
1. 题目概览两道二维矩阵的经典关卡1.1 力扣73题到底在考什么力扣73题叫做“矩阵置零”给定一个 m x n 的矩阵如果某个元素为 0则要求将该元素所在的行和列的所有元素都置为 0。这道题我第一次做的时候觉得很简单不就是遍历一遍找到所有 0然后再把对应行和列刷成 0 吗但真上手才发现坑不少。最核心的约束是题目要求使用原地算法也就是额外空间尽量少最好能控制到常数级。这就把很多“新建一个同样大小的矩阵做标记”的偷懒思路直接堵死了。这道题实际上考察两件事。第一你是否具备最基础的“标记-清理”两阶段思维第二你是否能通过复用矩阵自身的空间来省掉额外的存储。说白了就是在考察空间复杂度的压缩能力。很多初学者能轻松想到 O(mn) 空间的做法但面试官通常都会追问一句“能不能把额外空间降到 O(1)”这一步就把很多人卡住了。所以我建议刷这道题的时候不要只满足于提交通过要把空间优化的每个版本都亲手写一遍这才是这道题真正的价值所在。1.2 力扣74题为何值得单独写一篇力扣74题叫“搜索二维矩阵”题面也很朴素给你一个 m x n 的矩阵每一行从左到右升序排列且每一行的第一个整数都大于上一行的最后一个整数。也就是说整个矩阵从第一行第一列开始按行展开之后是一个完整的一维有序数组。现在要判断给定的 target 是否存在于矩阵中。这道题有两个经典解法一个是把二维坐标映射成一维下标直接做标准二分查找另一个是从右上角或者左下角出发像走迷宫一样按大小条件决定移动方向。前者时间复杂度是 O(log(mn))后者是 O(mn)。有意思的是很多人觉得 74 题比 73 题简单但实际面试时翻车率也不低。问题往往出在坐标映射的细节上——你在一维数组里二分到 mid 之后怎么正确地换算成 matrix[mid/numCols][mid%numCols]这个除法取模的坐标映射看着简单一旦行数列数对不上或者边界判断出错就会无限死循环或者漏掉目标值。所以我愿意花一整篇文章来说它就是因为这类二维转一维的思路在后续很多题目里都会反复出现值得彻底吃透。1.3 两道题组合起来的练习价值把 73 和 74 放一起看不是随手凑的。它们有个共同主题如何在不额外开辟大空间的约束下高效地遍历和修改二维矩阵。73 题是“二维矩阵的原地修改”74 题是“二维矩阵的降维搜索”。一个是写操作一个是读操作但都涉及对行、列坐标的精细控制。刷完这两道题基本上能把二维矩阵的索引操作练得比较扎实后面再做像“螺旋矩阵”“岛屿数量”“旋转图像”这类题起码坐标不会搞乱。2. 核心思路拆解从暴力到优雅2.1 73题的自然解法与隐藏陷阱先说说最直觉的暴力做法。很多人第一反应是遍历矩阵每遇到一个 0就立刻把整行整列都改成 0。这个方案看起来没问题但执行一下就会发现灾难性的后果你改完之后后续遍历会看到大量新产生的 0然后又开始扩散。最终整个矩阵可能全被刷成 0完全失控。就好比一间屋里有几处火源你发现一处就立刻用水泼结果把整栋楼都淹了反而造成更大面积的污染。正确的思路是先做标记再做清理。具体来说第一遍扫描矩阵记录哪些行需要置零、哪些列需要置零。记录的方式可以是用两个数组一个长度为 m 的布尔数组记录行一个长度为 n 的布尔数组记录列。第二遍再遍历矩阵只要所在行或所在列被标记了就把该位置改成 0。这样空间复杂度是 O(mn)时间复杂度 O(mn)。这个版本已经可以通过了但它还不是最优因为题目鼓励你继续压缩空间。这里有一个很容易被忽略的细节标记阶段应该只记录原始矩阵中本来就存在的 0 所对应的行号列号而不能在标记过程中去修改矩阵内容。修改操作必须完整推迟到第二遍才执行。我见过不少人把两阶段合并成一遍边遍历边置零结果就掉进 2.1 刚说的扩散陷阱里。因此记住一句话判断和修改必须解耦。2.2 常数空间的标记策略如果要做到额外空间 O(1)就不能再用两个辅助数组了。这时候一个经典的思路浮出来用矩阵的第一行和第一列本身来充当标记数组。具体操作是先扫描矩阵中除第一行、第一列之外的所有元素如果某个位置 [i][j] 是 0就把第一行的第 j 列标记为 0matrix[0][j] 0同时把第一列的第 i 行标记为 0matrix[i][0] 0。这样一来原始矩阵的“首行首列”就变成了“哪些列需要清空、哪些行需要清空”的记录载体。扫描结束之后再根据这两条边上的标记去清理内部区域。不过这还没完最容易被阴到的地方是第一行和第一列本身也可能包含 0那它们自己需不需要被整体置零呢这就必须提前用两个布尔变量记录好第一行是否含零、第一列是否含零。否则当你把第一行当成标记数组去改的时候原始信息已经被破坏了后面根本不知道第一行原来有没有 0。正确的顺序是先遍历第一行若存在 0记 rowZero true再遍历第一列若存在 0记 colZero true。跳过第一行和第一列扫描内部区域把标记写入首行首列。根据首行首列的标记清理内部区域从第 1 行第 1 列开始到 m-1 行 n-1 列。最后根据 rowZero 清空第一行根据 colZero 清空第一列。这个顺序不能乱。尤其第四步必须放在第三步之后因为第三步依赖首行首列的信息如果你提前把第一行清了标记就没了。2.3 74题的一维化思维74 题的题目条件非常特殊——每行的第一个元素大于上一行的最后一个元素。这句话翻译过来就是把整个矩阵按行拼接能形成一条完整的有序序列。既然是有序序列找 target 最快的方式自然是二分查找。二分查找需要你维护一个一维区间 [low, high]low 初始为 0high 初始为 m*n-1mid (low high) 1。关键是拿到 mid 之后需要把一维下标换算成二维坐标row mid / ncol mid % n。这里的 n 是列数千万别写成行数 m。我见过有人因为粗心把除数和取模对象搞反结果每次取的元素完全错位二分永远收敛不到正确答案。换算坐标之后拿 matrix[row][col] 和 target 比较进而收缩区间。最终要么找到 target返回 true要么 low 超过 high说明矩阵中不存在 target返回 false。这种思路之所以优雅是因为它直接复用了一维二分的所有成熟经验只是在下标换算上做了一层包装。我个人的建议是不要背代码要在草稿纸上画一个 3x4 的矩阵手动模拟几次二分过程把 mid、row、col 的对应关系彻底弄明白。这个坐标映射的肌肉记忆以后很多题目都要用。2.4 右上角出发的搜索路径除了二分74 题还有另一个高频解法从矩阵的右上角开始搜索。为什么选右上角因为这个位置的元素有一个很有趣的特性在同一行里它最大在同一列里它最小。你拿 matrix[row][col] 和 target 比如果相等直接返回 true如果 target 大于当前元素说明当前这一行所有元素都不够大那么 row 向下移动一行如果 target 小于当前元素说明当前这一列所有元素都太大那么 col 向左移动一列。这样每一步都能排除一行或者一列。最坏情况下从右上角走到左下角共走 mn 步所以时间复杂度是 O(mn)。这个方法的优点是完全不需要额外的空间而且代码特别短小。缺点是它依赖于矩阵的特定单调方向行递增且列递增。不过 74 题的矩阵恰好同时满足这两个性质因为题目额外规定了行首大于前行末尾这实际上保证了列方向也是严格递增的。所以这个方法在这道题上完全成立。我个人觉得从右上角出发的方法虽然时间复杂度比二分高一点点但在很多算法题中“沿着单调性排除区域”的思维非常常用比如“二维数组中的查找”“搜索二维矩阵II”都是同款思路。建议把二分和右上角两种方法都掌握因为面试官经常会在你写出一种之后追问“还有没有别的方法”这时候能立刻给出第二种解法很加分。3. 代码落地与边界处理3.1 73题的最终实现用常数空间实现矩阵置零我建议按下面的框架走。代码用 Java 写但换成 C、Python 都很容易。public void setZeroes(int[][] matrix) { int m matrix.length; int n matrix[0].length; boolean firstRowHasZero false; boolean firstColHasZero false; // 第一步检查第一行和第一列本身是否含0 for (int j 0; j n; j) { if (matrix[0][j] 0) { firstRowHasZero true; break; } } for (int i 0; i m; i) { if (matrix[i][0] 0) { firstColHasZero true; break; } } // 第二步用首行首列记录内部0的位置 for (int i 1; i m; i) { for (int j 1; j n; j) { if (matrix[i][j] 0) { matrix[i][0] 0; matrix[0][j] 0; } } } // 第三步根据标记清理内部区域 for (int i 1; i m; i) { for (int j 1; j n; j) { if (matrix[i][0] 0 || matrix[0][j] 0) { matrix[i][j] 0; } } } // 第四步最后清理第一行和第一列 if (firstRowHasZero) { for (int j 0; j n; j) { matrix[0][j] 0; } } if (firstColHasZero) { for (int i 0; i m; i) { matrix[i][0] 0; } } }这个实现里第二步只遍历 i 1 到 m-1、j 1 到 n-1 的内部矩形刻意避开第一行第一列就是为了防止把标记信息改乱。第三遍清理的时候同样是只清内部区域第一行和第一列留给第四步单独处理。这个“内外分离”的处理顺序是整道题最核心的技巧。实际写的时候还要注意一个点如果 m 或 n 等于 1也就是说矩阵只有一行或一列代码也能正确工作。因为第一步会扫描这唯一的一行或列然后内部循环的边界自然就不会进入最后再根据布尔变量清理。所以不需要额外做特判。3.2 74题二分实现再看 74 题的二分版本。核心就是坐标映射我直接贴一个可以运行通过的实现public boolean searchMatrix(int[][] matrix, int target) { int m matrix.length; int n matrix[0].length; int low 0; int high m * n - 1; while (low high) { int mid low (high - low) / 2; int row mid / n; int col mid % n; int value matrix[row][col]; if (value target) { return true; } else if (value target) { low mid 1; } else { high mid - 1; } } return false; }这里我特意写了low (high - low) / 2而不是(low high) / 2目的是防止 low 和 high 极大时相加溢出。虽然矩阵规模通常不会大到那种地步但这是个好习惯。还有一个细节int row mid / n;的除数必须是列数 n不是行数 m。原理很简单因为整个矩阵是按行优先排列存储概念上的序列每一行有 n 个元素所以从一维下标换算到行号时要看每行能装多少元素也就是列数 n。3.3 复杂度对比两个解法怎么选为了更直观地对比不同方案的差异我整理了一个表格把 73 题和 74 题的主要解法放在一起看题目解法思路时间复杂度空间复杂度适用场景73 矩阵置零辅助行/列标记数组O(mn)O(mn)面试时第一反应版本代码简单73 矩阵置零首行首列作为标记O(mn)O(1)最优解法满足进阶要求74 搜索二维矩阵一维二分查找O(log(mn))O(1)时间最优适合有序性强的矩阵74 搜索二维矩阵右上角/左下角移动O(mn)O(1)代码最直观适合扩展到更一般的二维查找表格里能明显看到73 题追求的是空间压缩而 74 题追求的是时间压缩。放在一起刷恰好能体会到不同题目对同一类数据结构的不同优化方向。4. 常见错误与高频问题实录4.1 我踩过的73题三个坑第一个坑就是前面反复提到的边扫描边置零。我最早写这题时就干过这种事写完发现矩阵全变成 0 了debug 半天才反应过来原来是我把标记和修改耦合了。后来我给自己立了个规矩凡是遇到“根据已有信息批量修改矩阵”的题一律先扫描记录再统一修改两步分离。这个规矩帮我避开了后面很多类似的坑。第二个坑是忘了保存第一行和第一列的原始状态。如果你直接用矩阵的第一行做标记但第一行本身也有 0而你没有提前记下来那么标记阶段就会覆盖掉第一行的原始信息。最后你完成了内部区域置零但第一行原本该不该置零已经无从判断了。我在代码里用firstRowHasZero和firstColHasZero两个布尔变量来保存这个细节非常容易漏。第三个坑比较隐蔽在某些语言里matrix的某一行可能是空数组。如果题目输入是matrix [[]]这种形态直接取matrix[0].length会正常返回 0但在后续遍历中就要小心越界。好在力扣的大部分测试用例不会给这种极端输入但在工程实践中防御性判断还是要加的比如先检查matrix null || matrix.length 0。4.2 74题容易忽略的边界74 题最常见的错误场景是二分区间写成了low high然后初始化high m * n - 1结果在目标值恰好位于最后一个位置时返回 false。原因是当low high时循环退出根本没有检查最后一个元素。正确写法是low high保证区间收缩到单个元素时还能做一次比较。另一个常见问题是只关注行方向的二分忽略了列的二分。有的同学尝试先在每一行分别做一次二分也就是对每一行调用Arrays.binarySearch理论上复杂度是 O(m log n)。如果 m 很大、n 很小这个方案勉强能接受但它没有利用“跨行也递增”这个额外条件。更好的策略是 3.2 里的整体二分时间复杂度 O(log(mn))。如果你在面试中先写了每一行二分面试官很可能会追问复杂度能否更优这时候就要能自然过渡到整体二分。4.3 面试追问的话术准备这两道题在面试中出现的频率不低而且面试官喜欢做延伸提问。关于 73 题常见的追问包括“如果矩阵非常大大到无法完整放入内存你会怎么处理”这属于大数据场景下的变形通常可以回答按分块读取逐块记录行列标记最后统一清理。核心还是标记与修改分离。“如果要求同时用最少的空间和最少的时间你怎么取舍”回答思路可以是空间上 O(1) 已经是极限时间上 O(mn) 也是必须的因为每个元素至少要访问一次所以这两者没法再压缩了能做的就是在常数因子层面优化。关于 74 题面试官可能会出变体去掉“每一行第一个元素大于上一行最后一个元素”这个条件只保证每行内部升序、每列内部升序。这时候一维二分就失效了正确解法是 2.4 里从右上角出发的线性搜索。所以一定把右上角方法练熟它才是这类“行列分别有序”矩阵的通用解法。5. 写在最后这两题带给我的一些体会刷完这两道题我心里最大的感受是二维矩阵题目的核心不是矩阵本身而是坐标与状态的抽象能力。73 题教我用首行首列做标记本质上是一种“借用已有资源记录额外信息”的思路这种思路在工程里也很有用——比如你在处理一张大表时不想额外申请内存就可以用第一行第一列存临时统计值。74 题则让我彻底理解了二维降一维的映射关系这之后我再遇到“螺旋矩阵”“对角线遍历”这些题坐标计算明显比之前流畅得多。如果你正在按顺序刷题我建议把 73 和 74 放在同一天完成。先做 73感受一下原地修改矩阵的边界控制再做 74体会一下二分和单调性搜索的切换。两题之间的共同点会帮你在脑子里搭起一张“二维矩阵题型地图”以后遇到类似问题至少知道该从哪个方向下手。最后再分享一个小技巧刷这类矩阵题别光在编辑器里对着测试用例跑拿笔在纸上画格子模拟几步很多一眼看不出来的 bug 在纸上根本藏不住。