Skip to content
Go back

LeetCode Hot 100——矩阵篇(原地标记与转置翻转)

LeetCode Hot 100 · 矩阵篇

矩阵类题目在面试手撕里属于「看着简单、一写就乱」的重灾区:边界下标、原地约束、遍历顺序,任何一处细节没想清楚就会写出死循环或越界。本分类四道题——73 矩阵置零、54 螺旋矩阵、48 旋转图像、240 搜索二维矩阵 II——分别训练四种高频套路:就地标记、边界收缩、转置翻转、Z 字搜索。吃透这四招,矩阵题基本可以横着走。


#73 矩阵置零

题意

给定一个 m×n 矩阵,若某个元素为 0,则把它所在的整行和整列全部置为 0,要求原地完成。

输入:matrix = [[1,1,1],[1,0,1],[1,1,1]]
输出:[[1,0,1],[0,0,0],[1,0,1]]

题目本质:利用第一行和第一列作为标记位,避免申请额外空间。先单独记录第一行/第一列本身是否含 0,再用第一行/列去记录「其余行列」的 0 信息,最后逆向处理。

现实类比:工厂质检标记——先在车间墙上(第一行/第一列)记下哪条流水线出了问题,全部排查完后再根据墙上的标记批量把对应行列清零。墙本身是否也要清零,得先单独留个纸条(r0/c0)记下来。

难点与易错点

  1. 不能边遍历边置零:如果发现 matrix[i][j]==0 就立刻把第 i 行、第 j 列清零,你会把后面尚未检查的元素也抹成 0,于是「新产生的 0」被误当成「原本的 0」,导致把不该清的行列也清了。必须先完整标记,再统一清零。
  2. 第一行和第一列会被污染:一旦用 matrix[i][0]matrix[0][j] 当标记位,第一行/列原来的 0 信息就被覆盖了。所以必须先单独记录 firstRowHasZerofirstColHasZero,放到最后再处理,顺序不能颠倒。
  3. 清零时要跳过第一行/列:根据标记清零内部区域时,循环从 1 开始,避免破坏标记本身;否则标记还在后面要用时已经被抹掉。
  4. 边界情况:单行(m=1)或单列(n=1)矩阵,matrix[0][0] 同时属于第一行和第一列,标记逻辑仍成立但要多想一步;空矩阵需提前判空。

解法一:暴力 / 直观(标记数组)

用一个行数组 + 一个列数组记录哪些行/列需要清零,第二次遍历统一执行。

class Solution {
    public void setZeroes(int[][] matrix) {
        int m = matrix.length, n = matrix[0].length;
        boolean[] zeroRow = new boolean[m]; // 记录哪些行需要置零
        boolean[] zeroCol = new boolean[n]; // 记录哪些列需要置零

        // 第一次遍历:只标记,不动手清零
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (matrix[i][j] == 0) {
                    zeroRow[i] = true;
                    zeroCol[j] = true;
                }
            }
        }

        // 第二次遍历:根据标记统一置零
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (zeroRow[i] || zeroCol[j]) {
                    matrix[i][j] = 0;
                }
            }
        }
    }
}

复杂度:两次遍历各 O(m×n),总时间 O(m×n);空间 O(m+n) 存两个标记数组。

瓶颈:额外 O(m+n) 空间。题目要求「原地」,若面试官追问「能不能做到 O(1) 额外空间」,就需要把标记信息塞回矩阵自身。

解法二:优化 / 最优(首行首列当标记,O(1) 空间)

不额外开数组,把第一行、第一列复用成标记位,再用两个布尔变量记录边界本身的状态。

class Solution {
    public void setZeroes(int[][] matrix) {
        int rows = matrix.length;
        int cols = matrix[0].length;
        boolean firstRowHasZero = false;  // 第一行本身是否含 0
        boolean firstColHasZero = false;  // 第一列本身是否含 0

        // 检查第一列是否含 0
        for (int i = 0; i < rows; i++) {
            if (matrix[i][0] == 0) {
                firstColHasZero = true;
                break;
            }
        }

        // 检查第一行是否含 0
        for (int j = 0; j < cols; j++) {
            if (matrix[0][j] == 0) {
                firstRowHasZero = true;
                break;
            }
        }

        // 用第一行/列记录 [1..m-1][1..n-1] 范围内的 0 信息
        for (int i = 1; i < rows; i++) {
            for (int j = 1; j < cols; j++) {
                if (matrix[i][j] == 0) {
                    matrix[i][0] = 0; // 第 i 行需要置零
                    matrix[0][j] = 0; // 第 j 列需要置零
                }
            }
        }

        // 根据第一列标记,将对应列置零(跳过第一行)
        for (int j = 1; j < cols; j++) {
            if (matrix[0][j] == 0) {
                for (int i = 1; i < rows; i++) {
                    matrix[i][j] = 0;
                }
            }
        }

        // 根据第一行标记,将对应行置零(跳过第一列)
        for (int i = 1; i < rows; i++) {
            if (matrix[i][0] == 0) {
                Arrays.fill(matrix[i], 0);
            }
        }

        // 最后处理第一行/列本身
        if (firstColHasZero) {
            for (int i = 0; i < rows; i++) {
                matrix[i][0] = 0;
            }
        }
        if (firstRowHasZero) {
            Arrays.fill(matrix[0], 0);
        }
    }
}

> **💭 思考**O(1) 空间怎么记录「哪些行列要置零」?一步步想——暴力用两个标记数组是 O(m+n) 空间,题目要求原地。想省空间,只能把标记信息**塞回矩阵自身**:既然第一行和第一列本来就要被清零,不如复用它们当「标记位」,记录其余行列的 0 信息。但这就带来一个坑:第一行/列自己原来有没有 0,一旦被当标记位就会覆盖掉,所以必须先单独记下 `firstRowHasZero` / `firstColHasZero`,放到最后再处理。这个「复用已有位置当标记 + 先记边界本身」的思想,正是 O(1) 空间的关键。

复杂度逐步推导

以上六段加起来,主项是 O(m×n),所以时间 O(m×n)。空间上,只申请了两个布尔变量,标记位复用了矩阵已有的第一行/第一列,额外空间 O(1)

多解法对比

解法时间空间适用场景
标记数组(暴力)O(m×n)O(m+n)思路直白,先写出来保底
首行首列标记(最优)O(m×n)O(1)面试要求原地、O(1) 空间的硬约束

本题约束明确要求「原地算法」,所以必须选 O(1) 空间的方案。暴力的时间其实已经无法再优化(每个元素至少要看一遍),能省的就是空间,而「复用矩阵自身的第一行第一列当标记」就是这题的核心考点——面试官等的就是这句。

CodeTop 变体


#54 螺旋矩阵

题意

给定一个 m×n 矩阵,按顺时针螺旋顺序返回其中所有元素。

输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出:[1,2,3,6,9,8,7,4,5]

题目本质四个边界 + 方向收缩问题。维护上下左右四条边界,依次遍历「上边 → 右边 → 下边 → 左边」,每遍历完一条边就收缩对应边界,直到边界交叉为止。

现实类比:蚊香盘绕——从最外圈开始顺时针绕,绕完一圈就把外圈「剥掉」,继续绕内圈,直到没有更多圈可绕。

难点与易错点

  1. 每遍历完一条边都要立刻检查边界:不检查就继续下一条边,在非方形矩阵(如 1×n、n×1、扁平矩阵)里会重复输出或越界。标准写法是每次移动边界后紧跟 if (++t > b) break; 这类判断。
  2. 「收缩」的时机和方向:上边遍历完 t++(上边界下移),右边遍历完 r--(右边界左移),下边 b--,左边 l++,四条边收缩方向不同,写反一条结果就错。
  3. 循环终止条件:不能用 l<=r && t<=b 简单包一层 for,因为每走完一条边边界都可能交叉,必须边遍历边判断;否则会多输出最后一行/列。
  4. 空矩阵matrix.length == 0matrix[0].length == 0 要提前返回,否则访问 matrix[0] 越界。

解法一:暴力 / 直观(访问标记 + 方向模拟)

用方向数组模拟「右→下→左→上」的转向,配合 visited 数组判断是否该拐弯。思路最接近「人在格子里走」,不容易错,但占空间。

class Solution {
    public List<Integer> spiralOrder(int[][] matrix) {
        int m = matrix.length, n = matrix[0].length;
        boolean[][] visited = new boolean[m][n];
        List<Integer> res = new ArrayList<>();
        // 方向顺序:右、下、左、上
        int[] dr = {0, 1, 0, -1};
        int[] dc = {1, 0, -1, 0};
        int r = 0, c = 0, dir = 0; // 当前位置与当前方向

        for (int k = 0; k < m * n; k++) {
            res.add(matrix[r][c]);
            visited[r][c] = true;
            int nr = r + dr[dir], nc = c + dc[dir];
            // 下一步越界或已访问,则顺时针转向
            if (nr < 0 || nr >= m || nc < 0 || nc >= n || visited[nr][nc]) {
                dir = (dir + 1) % 4;
                nr = r + dr[dir];
                nc = c + dc[dir];
            }
            r = nr;
            c = nc;
        }
        return res;
    }
}

复杂度:每个元素恰好访问一次,时间 O(m×n);visited 数组占 O(m×n)。

瓶颈:额外 O(m×n) 的访问标记数组。对于结果本身就是 O(m×n) 的题目,其实可以省掉这个数组,用「边界收缩」做到 O(1) 额外空间。

解法二:优化 / 最优(四边界收缩,O(1) 额外空间)

维护 l, r, t, b 四个边界,每走完一条边就收缩一条,边遍历边判越界。

class Solution {
    public List<Integer> spiralOrder(int[][] matrix) {
        int l = 0, r = matrix[0].length - 1; // 左右边界
        int t = 0, b = matrix.length - 1;    // 上下边界
        int total = (r + 1) * (b + 1);       // 元素总数
        Integer[] result = new Integer[total];
        int idx = 0;

        while (true) {
            // 遍历上边:从左到右
            for (int i = l; i <= r; i++) result[idx++] = matrix[t][i];
            if (++t > b) break; // 上边界下移,越界则结束

            // 遍历右边:从上到下
            for (int i = t; i <= b; i++) result[idx++] = matrix[i][r];
            if (--r < l) break; // 右边界左移

            // 遍历下边:从右到左
            for (int i = r; i >= l; i--) result[idx++] = matrix[b][i];
            if (--b < t) break; // 下边界上移

            // 遍历左边:从下到上
            for (int i = b; i >= t; i--) result[idx++] = matrix[i][l];
            if (++l > r) break; // 左边界右移
        }

        return Arrays.asList(result);
    }
}

复杂度逐步推导

多解法对比

解法时间空间适用场景
方向模拟 + visitedO(m×n)O(m×n)首次写、怕写乱边界时保底
四边界收缩(最优)O(m×n)O(1)面试标准答案,边界收缩是考点

本题时间上两个解法都是 O(m×n)(每个元素必须访问一次,无优化空间),比拼的就是空间与代码干净度。「四边界收缩」把空间压到 O(1),且是面试官最想看到的写法——它直观体现了「剥洋葱」的收缩思想。

CodeTop 变体


#48 旋转图像

题意

给定一个 n×n 的二维矩阵,将其顺时针旋转 90 度,要求原地修改,不能另开矩阵。

输入:[[1,2,3],[4,5,6],[7,8,9]]
输出:[[7,4,1],[8,5,2],[9,6,3]]

题目本质先沿主对角线转置,再水平翻转。转置让「行变列」,水平翻转完成顺时针 90° 旋转。两步都可原地完成,用异或交换避免临时变量。

现实类比:转动魔方——先沿对角线折叠(行列互换),再左右翻转,合起来就等价于把整个图像顺时针旋转了 90°。

难点与易错点

  1. 直接找下标映射容易晕:顺时针 90° 的映射是 matrix[i][j] → matrix[j][n-1-i],直接一轮轮套环旋转容易写错。「转置 + 翻转」两步把复杂映射拆成两个简单操作,是这题的精髓。
  2. 转置只遍历主对角线下三角:转置时内层循环必须 j < i(或 j > i),只交换对角线一侧;如果 j 从 0 遍历到 n,会交换两次又换回来,白做。
  3. 水平翻转只遍历前半列:翻转每行时内层 j < n/2,只交换左右对称的元素;遍历到 n 会重复翻转回到原样。
  4. 异或交换的坑a ^= b; b ^= a; a ^= bab同一个元素(同一内存位置)时会把值清零。本解法里转置有 j < i、翻转有 j < n/2,保证两个位置必然不同,所以安全;但面试时要能讲出这个前提。
  5. 逆时针 vs 顺时针别搞混:本题是顺时针;若面试官让逆时针 90°,则是「先转置再垂直翻转」(或「先水平翻转再转置」),方向不同。

解法一:暴力 / 直观(辅助矩阵)

按顺时针映射公式,把结果写进一个新矩阵,再拷贝回原矩阵。

class Solution {
    public void rotate(int[][] matrix) {
        int n = matrix.length;
        int[][] tmp = new int[n][n];
        // 顺时针 90°:新位置 (i, j) 来自原位置 (n-1-j, i)
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                tmp[i][j] = matrix[n - 1 - j][i];
            }
        }
        // 拷贝回原矩阵
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                matrix[i][j] = tmp[i][j];
            }
        }
    }
}

复杂度:一次映射 + 一次拷贝,各 O(n²),总时间 O(n²);辅助矩阵占 O(n²)。

瓶颈:额外 O(n²) 空间。题目要求「原地」,这个方案直接违反约束,只能作为推导映射公式的中间步骤,不能当最终答案。

解法二:优化 / 最优(转置 + 水平翻转,O(1) 空间)

先沿主对角线转置,再对每一行做水平翻转,全程原地。

class Solution {
    public void rotate(int[][] matrix) {
        int n = matrix.length;

        // 第一步:沿主对角线转置(交换 matrix[i][j] 和 matrix[j][i])
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < i; j++) {
                // 异或交换,无需临时变量(i != j,两个位置必不同)
                matrix[i][j] ^= matrix[j][i];
                matrix[j][i] ^= matrix[i][j];
                matrix[i][j] ^= matrix[j][i];
            }
        }

        // 第二步:水平翻转每一行(左右镜像)
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n / 2; j++) {
                matrix[i][j] ^= matrix[i][n - 1 - j];
                matrix[i][n - 1 - j] ^= matrix[i][j];
                matrix[i][j] ^= matrix[i][n - 1 - j];
            }
        }
    }
}

> **💭 思考**:为什么旋转 90° 要拆成「转置 + 水平翻转」两步,而不是直接套坐标映射?一步步想——直接映射 `matrix[i][j] → matrix[j][n-1-i]` 是对的,但一轮轮套环旋转极容易写错边界。而「转置」让行列互换、「水平翻转」完成左右镜像,这两步各自都是极简单的原地操作,合起来恰好等价于顺时针 90°——把一次复杂旋转拆成两个简单操作,边界清晰、不易翻车,这正是面试官想听到的「拆解」思路。

复杂度逐步推导

多解法对比

解法时间空间适用场景
辅助矩阵(暴力)O(n²)O(n²)推导下标映射、快速验证思路
转置 + 水平翻转(最优)O(n²)O(1)原地约束下的标准答案

本题的 n² 个元素每个都要动一次,时间 O(n²) 无法再降,真正的约束是原地。「转置 + 翻转」把一次复杂的坐标旋转,拆成两个各自简单、且都能原地完成的操作,空间降到 O(1),是面试官必问的标准解法。

CodeTop 变体


#240 搜索二维矩阵 II

题意

给定一个 m×n 矩阵,特性是每行从左到右升序、每列从上到下升序,要求编写高效算法判断目标值 target 是否存在。

输入:matrix = [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24],[18,21,23,26,30]], target = 5
输出:true

题目本质从左下角(或右上角)出发的 Z 字搜索。以右上角为例:该元素比同行左边都大、比同列下方都小,相当于一棵二叉搜索树的根节点——根据与 target 的大小关系,每次能排除一整行或一整列。

现实类比:地图寻宝——站在右上角,目标比当前值小就往左走,比当前值大就往下走,每一步都能排除一整行或一整列,直到命中或走出边界。

难点与易错点

  1. 出发角必须选对:只有右上角(或左下角)同时具备「一行最大、一列最小」的性质,才保证每次比较能确定性地排除一行或一列。左上角(最小)和右下角(最大)都做不到——比它大/小时无法判断该往哪走。
  2. 移动方向别写反:从右上角出发时,matrix[i][j] > target 说明当前值偏大,要上移 i--(排除当前行);< target右移 j++(排除当前列)。若从左下角出发,方向正好相反,混用必错。
  3. 越界条件while (i >= 0 && j < n) 两边都要写全,漏写一边会导致数组越界。
  4. 与「每行首元素大于上一行末元素」的矩阵(LeetCode 74)区分:本题的「行尾、行首之间没有严格大小关系」,所以不能像 74 那样把整个矩阵当成一维数组二分,只能按行/列逐次排除。

解法一:暴力 / 直观(逐元素 / 每行二分)

最直接的想法是双重循环扫描每个元素;稍加优化则利用「每行升序」对每一行做二分。

class Solution {
    public boolean searchMatrix(int[][] matrix, int target) {
        // 逐行二分:只利用「每行升序」,未利用「每列升序」
        for (int[] row : matrix) {
            int l = 0, r = row.length - 1;
            while (l <= r) {
                int mid = (l + r) >>> 1;
                if (row[mid] == target) return true;
                else if (row[mid] < target) l = mid + 1;
                else r = mid - 1;
            }
        }
        return false;
    }
}

(纯暴力双重循环则是 O(m×n),思路更直白但更慢,此处以每行二分为「直观优化」。)

复杂度:对 m 行各做一次二分,每行 O(log n),总时间 O(m log n);空间 O(1)。

瓶颈:只利用了「每行升序」,完全浪费了「每列升序」这个更关键的条件,因此还不是最优。既然每列也有序,就可以做到 O(m + n)。

解法二:优化 / 最优(右上角 Z 字搜索,O(m+n))

从右上角出发,根据大小关系逐次排除一行或一列。

class Solution {
    public boolean searchMatrix(int[][] matrix, int target) {
        // 从右上角出发:该位置是当前行最大值、当前列最小值
        int i = 0;                      // 行指针,从第一行开始向下
        int j = matrix[0].length - 1;   // 列指针,从最后一列开始向左

        while (i < matrix.length && j >= 0) {
            if (matrix[i][j] > target) {
                j--; // 当前值偏大,排除当前列,左移
            } else if (matrix[i][j] < target) {
                i++; // 当前值偏小,排除当前行,下移
            } else {
                return true; // 找到目标
            }
        }

        return false; // 越界,未找到
    }
}

> **💭 思考**:行列都有序,为什么是「从右上角出发」,而不是逐行二分或从左下/左上出发?一步步想——逐行二分只利用了「行有序」、浪费了「列有序」,O(m log n) 还不够;而要想一次比较就排除尽量多,需要找一个「同时具备行、列信息」的点:右上角元素是「当前行最大、当前列最小」,相当于二叉搜索树的根——比它大就排除整行下移、比它小就排除整列左移,每次比较都能砍掉一行或一列,于是 O(m + n)。左上角是最小值,比它大时既可能右移也可能下移,排除不了任何一行一列,所以出发角必须选对。

(注:源材料里给出的是「从左下角出发、行上移/列右移」的等价写法,两者对称,任选其一即可。)

复杂度逐步推导

多解法对比

解法时间空间适用场景
纯暴力双重循环O(m×n)O(1)无脑保底,必被追问优化
每行二分O(m log n)O(1)只想到行有序时的折中方案
右上角 Z 字搜索(最优)O(m + n)O(1)同时利用行、列有序的标准答案

在 m、n 都较大时,O(m + n) 远优于 O(m×n)O(m log n)。本题约束(行列各自有序)决定了「二分整块矩阵」不可行,而 Z 字搜索恰好能把每次比较的价值最大化——一次比较排除一整行或一整列,因此选它作为最优解。

CodeTop 变体


本分类小结

题号题目核心套路一句话心法
73矩阵置零就地标记用第一行第一列当标记位,先记边界本身,再逆向清零
54螺旋矩阵边界收缩四边界每走完一条就收缩一条,边遍历边判越界
48旋转图像转置 + 翻转把一次复杂旋转拆成两个简单原地操作
240搜索二维矩阵 IIZ 字搜索从右上角出发,每次比较排除一行或一列

矩阵题万变不离其宗:看清「边界」与「方向」,想清「每次操作能否确定性地推进」。把这四道题手撕到不看题面也能写出来,矩阵分类即可通关。

章末提问

  1. #73 为什么不能边遍历边置零,必须「先标记再统一清零」?——结论:因为边遍历边置零会把「新产生的 0」误当成「原本的 0」,把不该清的行列也清了。必须先完整记录所有 0 的位置,再统一执行。
  2. #48 为什么「转置 + 翻转」能实现顺时针 90°,直接套 matrix[i][j] → matrix[j][n-1-i] 不行吗?——结论:映射公式本身是对的,但一轮轮套环旋转极易写错;转置 + 翻转把一次复杂旋转拆成两个各自简单、且都能原地完成的操作,边界清晰、不易翻车。
  3. #240 为什么只能从右上角或左下角出发,从左上角出发为什么不行?——结论:因为右上角同时是「一行最大、一列最小」,每次比较能确定性地排除一行或一列;左上角是最小值,比它大时无法判断该右移还是下移,排除不了任何一行或一列。

Share this post on:

Previous Post
合并K个升序链表——小顶堆与分治归并
Next Post
单调栈与滑动窗口——三个模板解决80%的数组区间问题