1. 这题真正想考察的,是你对“原地修改”的理解深度
矩阵置零(Set Matrix Zeroes)这道题,几乎是每个刷题人都会遇到的老朋友。题目描述本身完全不复杂:给定一个 m x n 的矩阵,如果某个元素是 0,就把它所在的行和列全部置为 0。听起来就像是一个双重循环遍历、遇到 0 就横向纵向扫一遍的事。
但如果你真的这么写,就掉进坑里了。因为当你把某个格子置成 0 之后,它本来不是 0,却被当成了“原本就是 0”来处理,导致它所在的行和列也被错误地置零,最终整个矩阵被清零。
举个最简单的例子:
text复制[1, 0, 1]
[1, 1, 1]
[1, 1, 1]
如果直接遍历,先遇到 (0,1) 位置上的 0,于是把第 0 行和第 1 列全部标记为 0。等遍历到 (0,2) 时,这个格子已经被改成 0,你又把它当成原生的 0,去把第 0 列也清掉。最终结果变成全是 0 的矩阵,而正确答案是:
text复制[0, 0, 0]
[1, 0, 1]
[1, 0, 1]
所以这道题表面上是“找到 0、扩散清零”,实际上考察的是:你怎么在修改数组的过程中,区分哪些 0 是原始状态,哪些 0 是被你改出来的状态。
更进一步。这道题在 LeetCode 上被标记为 Medium,难度不在于找 0,而在于空间复杂度。题意里明确要求:需要你使用原地算法,也就是额外空间得做到 O(1)。如果你开一个新的矩阵来存结果,或者开两个数组分别记录要清零的行和列,思路一秒就能写出来,但这空间复杂度要么是 O(mn),要么是 O(m+n),都算不上最优。
换句话说,这道题真正想考的东西有两个:
- 你能不能把“先记录、后修改”的思维方式应用到二维数组上;
- 你能不能在没有辅助数组的情况下,把“记录”这件事本身嵌入到原矩阵里。
而后者,正是从普通水平走向“能拿捏这一类题”的关键一步。我自己在面试里也问过这道题,大多数候选人能写出 O(m+n) 的版本,但能做到 O(1) 的人明显少一截,而且其中相当一部分是背下来的,问一句“为什么第一行第一列可以当标记用,它自己不是也会被覆盖吗”就露馅了。
这篇文章我就把这道题从暴力解到最优解完整拆一遍,把所有你以为“理所当然”的细节摊开讲清楚。同时也聊聊这种“把状态存回原数组”的思路,在 LeetCode 上哪些题全是一路的,帮你形成一套真正属于自己的解题方法论。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从暴力解到辅助数组:先把“为什么空间多一层”搞清楚
在讲最终解法之前,我们得先把两条“不够优”的路线看清楚。不是浪费时间,而是只有把这两条路的本质理解了,你才真正懂 O(1) 解法的巧妙之处在哪里。
2.1 暴力解 O(mn):为什么直接复刻行不通
最直观的想法是:我再开一个同样大小的矩阵,遍历原矩阵时,如果发现某个位置是 0,就在新矩阵里把它所在的行和列全部置成 0。最后把新矩阵拷贝回原矩阵,任务完成。
这个做法空间复杂度 O(mn),代码还特别长,没有任何一个面试官会满意,但它有一个很重要的特点:**它从根上避免了“二次污染”问题。**因为你在新矩阵上做修改,原矩阵一个格子都没动,所有 0 都是原始状态,不存在“这个 0 是我刚改出来的”这种迷惑。
我见过很多刚开始刷题的同学,卡住的原因不是想不到开新矩阵,而是想不到“为什么一定要开新矩阵”。说到底就是没意识到:修改动作和判断动作如果作用在同一份数据上,就存在动作之间的耦合。开新矩阵,本质上是把读和写分开,让它们不互相干扰。
这个“读写分离”的思想,在后面所有优化版本里都是一个底层逻辑,只是表现形式不同而已。
2.2 O(m+n) 辅助数组:把二维标记压缩成一维
既然问题出在“读和写不能分离”,而我们又不想开一个完整的 m x n 矩阵,那就只记录“需要清零的行号”和“需要清零的列号”,这个信息量就小多了。
具体做法是:
- 初始化两个数组 row[m] 和 col[n],初始均为 false;
- 遍历整个矩阵,如果 matrix[i][j] == 0,就把 row[i] 和 col[j] 都标记为 true;
- 再遍历一遍矩阵,如果当前格子的行号或列号被标记了,就把 matrix[i][j] 置成 0。
这个版本的时间复杂度是 O(m x n),空间复杂度 O(m + n),已经能通过绝大多数要求了。代码在这里:
python复制def set_zeroes_with_arrays(matrix):
m, n = len(matrix), len(matrix[0])
row = [False] * m
col = [False] * n
for i in range(m):
for j in range(n):
if matrix[i][j] == 0:
row[i] = True
col[j] = True
for i in range(m):
for j in range(n):
if row[i] or col[j]:
matrix[i][j] = 0
这个解法的核心思想,就是“先记录,后修改”。遍历两遍是刻意为之的:第一遍只读不写,收集所有 0 的位置信息;第二遍只写不读,根据记录的状态完成清零。
为什么第一遍不能边扫边改?因为你不知道后面还有没有更多的 0。如果在扫到第一个 0 时就把整行清零,后面本来不是 0 的格子也被你改成 0 了,它们就会作为“假 0”被记录进辅助数组,导致错误。一句话:记录不能中途停下来。
这个版本有一个很自然的优化空间——那两个数组能不能省掉?答案是可以,但需要动点脑筋。这也就是我们下一节要聊的 O(1) 解法。
3. 第一行第一列标记法:把辅助数组“折叠”进矩阵本身
3.1 核心思路:用矩阵自己当“记事本”
如果你仔细观察 O(m+n) 版本,你会发现那两个辅助数组里的信息,本质上只是一个布尔值:某一行是否需要被清零,某一列是否需要被清零。这些信息非常稀疏,没有必要独占一整片内存。
那么问题来了:矩阵里有没有哪块“地方”天然就适合存放这种行级别、列级别的标记?
答案就是第一行和第一列。
第一行有 n 个格子,恰好对应 n 列;第一列有 m 个格子,恰好对应 m 行。这就像是矩阵自带的一块“告示栏”。我们用第一行的每个格子记录“这一列是否需要清零”,用第一列的每个格子记录“这一行是否需要清零”。
听起来是不是很简单?直接用 matrix[0][j] 表示第 j 列是否需要清零,用 matrix[i][0] 表示第 i 行是否需要清零。而矩阵内部的 (i, j) 区域,则可以完全腾出来作为数据区。
整个流程是这样的:
- 先遍历整个矩阵,如果某个内部位置 (i, j) 是 0,就把它的“行标记”写到 matrix[i][0],把“列标记”写到 matrix[0][j];
- 遍历完所有内部区域后,根据第一行和第一列的标记,把内部区域清零;
- 最后再根据预先保存好的状态,处理第一行和第一列本身。
你可能已经意识到一个问题:如果第一行或第一列本来就有 0,那我们应该把它们也清零,可如果直接修改 matrix[0][0] 作为标记,原始信息不就丢了吗?
这就引出了两个“防御性变量”的用途。
3.2 为什么需要两个额外变量来保护第一行第一列
先想清楚一件事:第一行和第一列既是我们选定的“标记区域”,同时它们本身也要参与运算——如果第一行里出现一个原生 0,这一行就要被全部清零;如果第一列里出现一个原生 0,这一列也要被全部清零。
这就有冲突了。比如 matrix[0][3] == 0,说明第 0 行原本就该被清零,同时也说明第 3 列应该被标记为清零。如果你直接用 matrix[0][3] = 0 当列标记,没问题,反正这一行最后也是要被清零的。但如果第 0 行本来没有 0,只是因为某些内部格子需要把列的标记写到第一行,你把它置成 0 了,那么最终处理时,你会误以为第一行本身有原生 0 要清掉整行。
比如原矩阵是:
text复制[1, 1, 1]
[0, 1, 1]
[1, 1, 1]
按规则,内部 (1,0) 位置有 0,所以在第一列上标记 matrix[1][0] = 0(第一列第 1 行),同时 matrix[0][0] 也该标记为 0。但 matrix[0][0] 被改写成 0 之后,你怎么知道第一列原本有没有 0?如果最终根据 matrix[0][0] == 0 就把第一列清零,那就把第一列“误杀”了。
所以,在正式使用第一行第一列做标记之前,必须先把它们的原始状态保存下来。这里需要两个布尔值:
- row_zero_flag:第一行是否原本就包含 0;
- col_zero_flag:第一列是否原本就包含 0。
有了这两个变量,后面无论矩阵[0][0]被改写成什么,都不会影响第一行第一列的最终处理。
代码结构大致是这样的:
python复制def set_zeroes_o1(matrix):
m, n = len(matrix), len(matrix[0])
# 提前记录第一行第一列是否包含原生 0
first_row_has_zero = any(matrix[0][j] == 0 for j in range(n))
first_col_has_zero = any(matrix[i][0] == 0 for i in range(m))
# 用第一行第一列作为标记区域
for i in range(1, m):
for j in range(1, n):
if matrix[i][j] == 0:
matrix[i][0] = 0
matrix[0][j] = 0
# 根据标记清零内部数据区
for i in range(1, m):
for j in range(1, n):
if matrix[i][0] == 0 or matrix[0][j] == 0:
matrix[i][j] = 0
# 处理第一行第一列本身
if first_row_has_zero:
for j in range(n):
matrix[0][j] = 0
if first_col_has_zero:
for i in range(m):
matrix[i][0] = 0
这段代码就是最优解的完整形态。时间上仍然是 O(m x n) 的两次遍历,空间上只用到了两个布尔变量,做到了真正的 O(1)。
3.3 为什么第一行第一列的信息不会“互相污染”
有个细节值得单独拿出来讲:在处理内部数据区时,我们用的判断条件是 matrix[i][0] == 0 or matrix[0][j] == 0。这里会不会出现一种情况——第一列第 i 行本来没被标记,但第一行第 0 列(也就是 matrix[0][0])被标记了,导致整个第一列被误清?
实际上不会。因为我们在遍历内部数据区的时候,是从 i=1, j=1 开始的,第 0 列和第 0 行的标记只用在判断条件里,不会反向修改 matrix[i][0] 或 matrix[0][j] 本身是否清零。判断条件里的 matrix[0][j] 是第 j 列的列标记,而不是“第一行的格子要不要清零”的标记。matrix[i][0] 是第 i 行的行标记,而不是“第一列的格子要不要清零”的标记。
但这里确实有一个容易脑子绕晕的地方:matrix[0][0] 这一个格子,同时充当了“第 0 行的行标记”和“第 0 列的列标记”。如果内部某个格子 (i, j) 是 0,我们会把 matrix[i][0] 和 matrix[0][j] 各自置为 0。如果 i=0 或 j=0,那操作的就是 matrix[0][0] 本身。好在第一行和第一列我们都会用额外的布尔变量保护起来,最后统一处理,所以不会出错。
不过有一种情况值得留个心眼:如果 matrix[0][0] 本身就是 0,那么 first_row_has_zero 和 first_col_has_zero 都会被标记为 True,最终第一行和第一列都会被清零,这是正确的。如果 matrix[0][0] 不是 0,但因为内部某个位置需要标记而把 matrix[0][0] 改成了 0,那么最终处理时,第一行是否清零取决于 first_row_has_zero,第一列是否清零取决于 first_col_has_zero,而不是 matrix[0][0] 当前的值。这正是防御性变量的意义所在。
4. 实现细节与边界条件:真正拉开差距的地方
代码核心逻辑就这么多,但实际写的时候,有很多边界条件和细节会让你的代码在 ACM 模式下“翻车”。我把这些年踩过的坑和总结的经验列一下。
4.1 三个最容易出错的细节
第一个坑:先清零再标记,等于白标记。
有人可能会想,能不能在遍历内部区域时,遇到 0 直接把整行整列清零,然后再顺便把标记信息记下来?这样做的问题是:当你把某些格子提前清零后,后面遍历到这些格子时,它们已经是 0 了,你会误以为原矩阵这里有 0,从而把多余的行列也标记清零。所以遍历顺序必须是:第一遍只做标记,第二遍才做清除。两次遍历之间不能有任何交集。
第二个坑:单行矩阵或单列矩阵。
如果 m == 1 或 n == 1,第二层循环是 range(1, 1),直接不执行。这时候矩阵里只有一个方向上的信息,处理逻辑同样适用,因为 first_row_has_zero 或 first_col_has_zero 会正确捕获状态。但如果你在写代码时不小心,比如把 matrix[0][j] 和 matrix[i][0] 混用了,单行单列的情况会让你瞬间出错。我自己的习惯是写完代码先脑内跑一遍 m=1, n=1 的用例,比如 [[0]] 和 [[1]],非常管用。
第三个坑:矩阵元素可能是负数或者很大的值。
题目通常没有规定矩阵元素取值范围。所以不要依赖“特殊值标记法”——比如把需要清零的行列标成 -1 或者某个不可能出现的数,最后再统一处理。如果输入里本来就允许出现这个特殊值,就等着出错吧。第一行第一列标记法好在它没有引入任何新的“哨兵值”,存的全是 0/非0 语义,对输入内容零额外假设。
4.2 一版更精简的写法(不引入额外变量)
虽然额外变量不影响空间复杂度(两个布尔变量是 O(1)),但有的题目还要求“不允许使用额外变量”。实际上我们最多只需要一个额外布尔变量就够了,因为 matrix[0][0] 本身可以兼作一个标记位。
思路是:
- 先用一个布尔变量记录第一列是否有 0,matrix[0][0] 则用来记录第一行是否有 0;
- 遍历内部区域时,遇到 0 就把 matrix[i][0] 和 matrix[0][j] 置为 0,此时 matrix[0][0] 如果被置为 0,说明第一行需要被清零;
- 在处理时,先从最后一行往前处理(必须倒序),依次根据 matrix[i][0] 和 matrix[0][j] 清零;
- 最后处理第一列。
为什么这里要倒序处理?因为如果正序处理,在操作第 i 行时可能会修改 matrix[0][j],影响后面行对列标记的判断。倒序遍历就能保证标记信息不被破坏。
参考代码(C++ 版):
cpp复制class Solution {
public:
void setZeroes(vector<vector<int>>& matrix) {
int m = matrix.size(), n = matrix[0].size();
bool first_col_has_zero = false;
for (int i = 0; i < m; i++) {
if (matrix[i][0] == 0) first_col_has_zero = true;
for (int j = 1; j < n; j++) {
if (matrix[i][j] == 0) {
matrix[i][0] = 0;
matrix[0][j] = 0;
}
}
}
// 倒序处理,避免破坏列标记
for (int i = m - 1; i >= 0; i--) {
for (int j = n - 1; j >= 1; j--) {
if (matrix[i][0] == 0 || matrix[0][j] == 0) {
matrix[i][j] = 0;
}
}
if (first_col_has_zero) matrix[i][0] = 0;
}
}
};
这个版本的微妙之处在于:第一行的处理其实包含在倒序循环里。当 i=0 时,如果 matrix[0][0] 是 0(说明第一行需要清零),那么第 0 行整行都会被清零,注意这时候 j 的循环是从 n-1 到 1,所以 matrix[0][0] 也会被置为 0,这是符合预期的。但这里有个隐含风险:如果矩阵中第一个元素 matrix[0][0] 本来就是 0,那么 first_col_has_zero 可能已经为 true,matrix[0][0] 也会被正确清掉。逻辑是自洽的。
从工程角度讲,我其实更推荐第一种带两个布尔变量的写法。一方面它更直观易读,另一方面它不依赖“倒序遍历”这种魔法操作,未来维护起来不容易出错。面试时,清晰比炫技重要。
4.3 复杂度分析:别只说 O(mn),要能解释清楚为什么是“两遍”
时间上,我们遍历了两次整个矩阵(一次标记、一次清零),所以是 O(2mn) = O(mn)。空间上只有两个布尔变量,O(1)。
但有些人会把 any() 函数的时间忽略掉。比如 Python 代码里写 any(matrix[0][j] == 0 for j in range(n)),这本身也是一次 O(n) 的遍历。如果矩阵特别大,加上第一列判断,额外就是 O(m + n) 的时间,虽然不是时间复杂度的大头,但在追求极致性能的场合,可以合并到第一次遍历里一并计算。
更好的做法是,在同一个循环里先把第一行第一列的状态检查完。比如先扫描第一行、第一列,然后再扫描内部区域。代码会稍微长一点,但每一步都干净利落。
5. 从矩阵置零想开去:这一类“原地修改二维数组”题的通用套路
矩阵置零不是孤例。LeetCode 上有一整类题目,都要求你“不借助额外空间去修改二维数组”,解法思路一脉相承。我把这类题的核心套路总结成下面这几步,你在做题时可以直接套用。
第一步:区分“原始信息”和“修改信息”。
如果修改动作会覆盖原始信息,那么你就必须在修改前把关键信息“存下来”。存到哪里?数组本身就是天然的存储介质。把状态写回数组的某些固定位置,这就是原地算法的基础。
第二步:寻找矩阵里的“结构冗余”。
二维数组里,边界行、边界列、某个角落,往往具备“未被完全利用”的性质。比如第一行第一列,既可以是数据区,也可以是标记区。你到底划哪块区域来当标记区,取决于这个区域本身的信息量是否刚好能覆盖你需要的“问题规模”。
矩阵置零里,问题规模是“每行是否需要清”+“每列是否需要清”,恰好 m+n 个布尔值。第一行有 n 个格子 + 第一列有 m 个格子,中间有个 matrix[0][0] 重叠,加上一个额外变量就能完美匹配。这是设计上的巧思,不是瞎蒙的。
第三步:想清楚“最终处理顺序”。
标记信息写入之后,清空动作必须是从数据区到标记区、或者从标记区到数据区有明确的先后次序。任何一步如果可能反过来影响判断条件,就会出错。最典型的就是“倒序遍历”,许多原地题里都会用到这个技巧。
第四步:遇到扩展场景灵活变形。
比如 LeetCode 289 生命游戏,要求原地更新每个格子的状态。它把“当前活 + 下一轮死”的状态记为 -1,“当前死 + 下一轮活”的状态记为 2,用特殊值编码多种状态。这和矩阵置零的思路在底层是一致的:在被覆盖之前,先把新旧信息“折叠”进同一个格子。
再比如 LeetCode 48 旋转图像,要求原地旋转 90 度。它通过“先上下翻转 + 再沿对角线翻转”来避免开新矩阵,本质上也是利用了两个变换过程的顺序来避免中间状态丢失。
多刷几道这类题目你就会发现,矩阵置零其实是“用数组本身存储额外状态”这一大主题下最简单、最纯粹的一份教材。把它的每个细节吃透,后面再看那些复杂的变形题,都会有豁然开朗的感觉。
根据我个人的实际经验,这类题目非常值得在纸上完整推导三到五遍,而不是只盯着代码看。你可以在草稿纸上画一个 3x4 的矩阵,手动模拟每一步,把每一轮遍历后矩阵里每个格子的值都写出来。坚持做上三题,你就会对“哪些信息会被覆盖、哪些标记需要提前保存、处理顺序如何影响正确性”产生肌肉记忆。以后再遇到二维数组原地修改的题,第一反应就不是查题解,而是自己开始在草稿纸上推演了。
