LeetCode Hot 100 刷到第 73 题“矩阵置零”,这道题我前后在不同时间段刷过三次,每次都有新的体会。题目本身一句话就能说清楚:如果矩阵中某个元素为 0,则将其所在行和列的所有元素都设为 0。但麻烦的是,题目要求用原地算法完成,额外空间只能是 O(1)。第一次刷的时候我根本没多想,直接开了一个布尔数组记录哪些行列需要清零,写完一提交发现空间复杂度 O(m+n),虽然能过,但总觉得这题没吃透。后来认真研究了一番,才明白这题真正考的不是“会不会扫描 + 置零”,而是“你怎么避免置零操作把原始信息覆盖掉”。这篇文章我打算把这条思考链路完整拆开,从暴力思路一步步推导到最优解,把 C++ 实现里那些容易踩的坑也一并列清楚。
1. 为什么这题不能“边扫边改”:核心矛盾拆解
1.1 直觉解法与它的致命问题
大部分第一次接触这道题的人,第一反应都是这样:遍历整个矩阵,遇到 0 就把整行整列改成 0。这个思路听起来非常直接,但只要你拿一个具体例子走一遍,立刻就会发现逻辑崩了。
假设输入矩阵是:
text复制1 1 1
1 0 1
1 1 1
从 (0,0) 开始扫描,一直扫到 (1,1) 才发现这里有一个 0。此时你准备把第 1 行和第 1 列全部置零。好,置零之后矩阵变成:
text复制1 0 1
0 0 0
1 0 1
接着你继续往下扫描,准备处理 (1,2) 这个位置。但问题来了——(1,2) 本来不是 0,它是第 1 行被置零之后才变成 0 的。如果你在遍历过程中以“当前值是否为 0”作为判断依据,那么这个格子就会被误判为“原始就是 0”,于是把第 2 列也置零。最终整个矩阵变成:
text复制1 0 0
0 0 0
1 0 0
这显然是错的,因为原始矩阵里 (0,0)、(0,2)、(2,0)、(2,2) 都是 1,不应该被清零。
这个反例说明了一个本质问题:置零操作会覆盖掉矩阵中的原始信息,而后续扫描又需要依赖这些原始信息来判断“这个位置是不是本来就为 0”。 一旦信息被覆盖,判断依据就失效了。这就像你在黑板上做题,刚写完一个数字就被人擦掉改成了别的数字,你回头再看黑板时,已经分不清哪些是原来写的、哪些是后来改的。
1.2 空间换时间:O(m+n) 的常规方案
既然“边扫边改”行不通,那自然想到一个办法:先不动矩阵本身,第一遍遍历只负责“记录”,把所有应该清零的行号和列号记下来;第二遍遍历再根据记录,把对应的行和列置零。这样判断依据就不会被破坏。
实现方式也很简单:开两个布尔数组 row[] 和 col[],分别标记哪些行、哪些列需要清零。
cpp复制class Solution {
public:
void setZeroes(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
vector<bool> row(m, false);
vector<bool> col(n, false);
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (matrix[i][j] == 0) {
row[i] = true;
col[j] = true;
}
}
}
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (row[i] || col[j]) {
matrix[i][j] = 0;
}
}
}
}
};
这个方法的时间复杂度是 O(m×n),空间复杂度是 O(m+n),也就是额外开两个数组的空间。这个方案本身没有错,也是一道正常合格的面试答案,面试官如果只是让你“把题做出来”,写到这里就够了。但如果他追问一句“能不能把空间压到 O(1)”,你就需要进入下一层思考了。
1.3 题目真正在考察什么
仔细想想,O(m+n) 的空间到底浪费在哪里?row[] 数组要记录 m 行的状态,col[] 数组要记录 n 列的状态。我们要的不就是“哪些行要清零”“哪些列要清零”这两组信息吗?这两组信息加起来,恰好是一个 m 行 n 列矩阵的“第 0 列”和“第 0 行”。
这就是整个题目的破局点:我们需要的标记信息,原本就可以存进矩阵自身的第 0 行和第 0 列里。 这样一来,额外空间可以由 O(m+n) 降到 O(1),代价是必须解决一个更棘手的问题——第 0 行和第 0 列本身也是矩阵的一部分,它们自身的值也可能需要被清零,如果把它们当成“草稿纸”来用,会不会把原始数据冲掉?
正是因为这一步处理起来有很多边界细节,LeetCode 才把这道题放进 Hot 100,用来考察你对“信息覆盖”和“边界条件”的理解。面试里能把这段讲清楚,效果远比你背下来一段代码好得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 原地算法的核心:把第 0 行和第 0 列当“草稿纸”用
2.1 为什么偏偏是第 0 行和第 0 列
先看一个很自然的替代方案:不用第 0 行和第 0 列,而是额外开两个变量,遍历的时候把需要清零的行号、列号压进集合里,最后再统一清零。这确实能做到 O(1) 额外空间(如果集合也算额外空间的话,其实不是,严格说集合是 O(k)),但严格来说,用哈希集合存储坐标,空间复杂度仍然和 0 的个数相关,并非严格意义的 O(1)。
那 O(1) 空间到底从哪来?答案就是复用矩阵本身的空间。矩阵有 m 行、n 列,行信息天然可以存放在第 0 列里(第 i 行是否需要清零,就写在 matrix[i][0]),列信息天然可以存放在第 0 行里(第 j 列是否需要清零,就写在 matrix[0][j])。这样,m + n 个布尔值的存储需求,被完美地塞进了矩阵自带的第一行和第一列里。
但这里有个冲突:matrix[0][0] 这一个位置,既属于第 0 行又属于第 0 列。如果第 0 行需要清零和第 0 列需要清零这两个条件不同时成立,那 matrix[0][0] 无法同时表达两种状态。所以必须额外用两个布尔变量来单独记住“第 0 行自身是否含有 0”和“第 0 列自身是否含有 0”。整体下来,额外空间只有这两个变量,仍然是 O(1)。
2.2 三步走:先备份、再标记、后还原
具体执行过程可以拆成四个阶段,顺序一个都不能乱。
第二步是扫描矩阵中除了第 0 行和第 0 列以外的所有格子,也就是从 (1,1) 开始遍历。只要发现 matrix[i][j] == 0,就把 matrix[i][0] 和 matrix[0][j] 都标记为 0。这一步的含义是:第 i 行需要清零、第 j 列需要清零。注意这个阶段只改“标记区”,不碰数据区。因为标记区存放的是“要不要清零”的布尔值,把它们改成 0 不影响原始数据区的判断。需要注意的是,第一遍扫描过程中,后续格子可能因为读到标记区被修改而产生干扰吗?不会,因为后续格子的判断只看自身 matrix[i][j] == 0,不看标记区。
第三步再扫描一次数据区,从 (1,1) 开始,如果发现 matrix[i][0] 是 0,说明第 i 行需要清零;如果发现 matrix[0][j] 是 0,说明第 j 列需要清零。只要满足任一条件,就把 matrix[i][j] 直接置成 0。此时数据区被清零不影响后面格子的判断,因为后面格子判断依据始终是标记区,而不是被修改后的数据区。
最后一步处理第 0 行和第 0 列本身。这时就要用上第一步备份的两个布尔变量了。如果 firstRowHasZero 为真,就把第 0 行全部清零;如果 firstColHasZero 为真,就把第 0 列全部清零。注意这里不能再看标记区,因为标记区和第 0 行第 0 列是重叠的,必须依靠备份的变量做最终判断。
这个顺序里最关键的是第一步和第二步的先后关系。如果你不先备份第 0 行和第 0 列是否含有 0,等到第二遍扫描时,matrix[0][j] 已经被改成了标记值,你根本分不清这个标记值是“第 0 行原来有 0”还是“第 j 列被标记了”。所以第一步必须是备份首行首列的原始状态。
2.3 一个完整示例推演
拿一个稍微复杂点的矩阵走一遍流程,比如:
text复制0 2 3 4
5 0 7 8
9 1 2 3
第一步,检查第 0 行:matrix[0][0] == 0,所以 firstRowHasZero = true。检查第 0 列:matrix[0][0] == 0,所以 firstColHasZero = true。
第二步,从 (1,1) 开始扫描。matrix[1][1] == 0,因此把 matrix[1][0] 和 matrix[0][1] 都置为 0。继续扫描,(1,2) 是 7,(1,3) 是 8,(2,1) 是 1,(2,2) 是 2,(2,3) 是 3,都没有 0。标记区更新后矩阵变成:
text复制0 0 3 4
0 0 7 8
9 1 2 3
第三步,重新扫描数据区。(1,1) 处,matrix[1][0] == 0,所以置零;(1,2) 处,matrix[0][2] 是 3,matrix[1][0] 是 0,所以也置零;(1,3) 同理置零。(2,1) 处,matrix[2][0] 是 9,matrix[0][1] 是 0,所以也置零;(2,2) 和 (2,3) 同理因为 matrix[0][2] 或 matrix[0][3] 都是 3、4,但 matrix[0][1] 已经是 0 了,所以第 2 行也会因为第 0 行的标记被清零?这里要小心,第三步判断条件是 matrix[i][0] == 0 || matrix[0][j] == 0。对于 (2,2) 来说,matrix[2][0] 是 9,matrix[0][2] 是 3,两个都不为 0,所以 (2,2) 不会被清零。但是 (2,1) 会被清零,因为 matrix[0][1] 是 0。这说明列标记是独立的,第 1 列需要清零,所以这一列所有格子都会清零,包括第 2 行的 (2,1)。而 (2,2)、(2,3) 所在列并没有被标记,所以保持原样。最终数据区变成:
text复制0 0 3 4
0 0 0 0
9 0 2 3
第四步,因为 firstRowHasZero 为 true,把第 0 行全部清零;firstColHasZero 也为 true,把第 0 列全部清零。最终结果:
text复制0 0 0 0
0 0 0 0
0 0 2 3
手动验证一下,原始矩阵中 0 出现在 (0,0) 和 (1,1),所以第 0 行、第 0 列、第 1 行、第 1 列都该清零。结果矩阵中,第 0 行全 0、第 1 行全 0、第 0 列全 0、第 1 列全 0,剩下的 (2,2) 和 (2,3) 保持 2、3 不变。完全正确。
3. C++ 实现与提交时的坑点清单
3.1 可提交的完整代码
前面铺垫了这么多,直接上一版完整可跑的 C++ 代码。这个版本在 LeetCode 上提交可以通过,时间和空间都比较稳定。
cpp复制class Solution {
public:
void setZeroes(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
bool firstRowHasZero = false;
bool firstColHasZero = false;
// 1. 备份第 0 行和第 0 列是否含有 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;
}
}
// 2. 利用第 0 行和第 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;
}
}
}
// 3. 根据标记区清零数据区
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;
}
}
}
// 4. 最后处理第 0 行和第 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;
}
}
}
};
这段代码的核心思路就是前面讲的:把 matrix[i][0] 当作第 i 行的标记位,把 matrix[0][j] 当作第 j 列的标记位。理解了标记位怎么用,代码就不需要死记硬背。
3.2 五个容易写错的细节
这题代码量不大,但坑非常多,我在本地测试和 LeetCode 提交时都踩过。下面几个细节是我认为最容易出问题的。
第一个坑:第二步扫描时,如果把“标记”和“清零”混在一起做。有人会在扫描过程中发现 matrix[i][j] == 0 后,顺手就把第 i 行和第 j 列的其他格子清零。这个操作是致命的,因为清零会影响后续格子的“原值是否为 0”的判断。整个算法的前提是:第一遍扫描只标记,不改动除标记区以外的任何数据。标记区改了没关系,因为标记区在后续判断中承担的是“记录”职责,数据区一旦被改,信息就丢了。
第二个坑:第二步扫描从 (0,0) 开始。如果从 (0,0) 开始,那么 matrix[0][j] 和 matrix[i][0] 可能会在扫描过程中被修改,而这些位置本身就是标记区的一部分。更麻烦的是,如果 matrix[0][0] 原本是 0,你在扫描数据区时会把第 0 行第 0 列同时标记为 0,但这并不能反映第 0 行和第 0 列自身的原始状态。所以正确做法是从 (1,1) 开始扫描,把第 0 行第 0 列留到最后单独处理。
第三个坑:第二步扫描过程中,有些版本的写法会顺便用 matrix[i][0] 或 matrix[0][j] 来更新行首列首之外的格子。比如有人会写成:如果 matrix[i][j] == 0,就把第 i 行整行标记,同时把第 j 列整列标记,但标记的过程中如果修改了 matrix[i][0],那么后续再看 matrix[i][0] 时已经无法区分是“第 i 行需要清零”还是“第 i 行原本就有 0”。因此标记和判断必须分阶段,不能在一次遍历里同时完成。
第四个坑:忘记特殊处理单行或单列矩阵。比如 matrix = [[1, 0]],m=1,n=2。第一步扫描第 0 行时会发现 matrix[0][1] == 0,所以 firstRowHasZero = true;扫描第 0 列时只会看 matrix[0][0] == 1,所以 firstColHasZero = false。第二步扫描从 (1,1) 开始,因为 m=1,外层循环根本不执行。第三步同理不执行。第四步把第 0 行全部清零。最终结果是 [[0, 0]],正确。如果代码里第二、三步的循环边界写错,比如从 0 开始,就可能在单行或单列矩阵中越界访问。好在 C++ 的 vector 越界不一定立即崩溃,但结果是未定义的,一定要小心。
第五个坑:使用 vector<vector<int>> 时,matrix[0] 是否一定存在?LeetCode 的条件是矩阵非空且至少有一行一列,所以 matrix[0] 一定存在。但在工程代码中,最好先判断矩阵空的情况。
3.3 复杂度分析与极端矩阵的验证
这个算法的时间复杂度是 O(m×n)。整个矩阵最多被完整遍历三次(第一步两次扫描首行首列、第二步一次全矩阵扫描、第三步一次全矩阵扫描、第四步两次扫描首行首列),但常数级的三遍扫描仍然是 O(m×n)。空间上只用了两个布尔变量,所以额外空间是 O(1)。
我建议你在本地跑一下这些极端用例,确保代码没问题:
| 输入矩阵 | 期望结果 | 说明 |
|---|---|---|
[[0]] |
[[0]] |
单元素矩阵,本身就是 0 |
[[1]] |
[[1]] |
单元素矩阵,无 0 |
[[1, 0]] |
[[0, 0]] |
单行矩阵 |
[[1], [0]] |
[[0], [0]] |
单列矩阵 |
[[0, 1], [1, 1]] |
[[0, 0], [0, 1]] |
首行首列有 0 |
[[1, 1], [1, 1]] |
[[1, 1], [1, 1]] |
完全无 0 |
这些用例覆盖了大部分边界情况。尤其是单行单列的矩阵,很多新手会在这里翻车。
4. 从这题延伸出去:标记复用思想与变式拓展
4.1 矩阵标记复用的通用思路
LeetCode 73 这题,表面上是考矩阵遍历和标记,本质上考的是“如何利用输入数据自身的结构来存储额外信息”,这种思想在算法题里非常常见。
一个很经典的例子是 LeetCode 289“生命游戏”。题目要求原地更新矩阵的下一状态,但因为每个格子的下一个状态需要依赖周围 8 个格子的当前状态,如果直接覆盖,后续格子就看不到原始值了。常规解法是开一个同样大小的新矩阵,但 O(mn) 的空间能不能省?能,用两位二进制位来存储状态,低位表示当前状态,高位表示下一个状态,这样遍历时读低位,更新时写高位,最后统一左移一位。这种“一个存储空间同时承载两个含义”的思路,和矩阵置零里把第 0 行第 0 列当标记区,本质上是一模一样的。
还有 LeetCode 48“旋转图像”,也是要求原地旋转矩阵。思路是在四条边之间循环交换元素,不需要额外空间,但你要非常小心边界索引。虽然它不涉及“信息覆盖”的问题,但同样是在教你怎么在矩阵的有限空间里玩出花来。
所以你可以把“标记复用”理解成一种元技巧:当题目要求 O(1) 空间时,先想想输入数据里有没有哪部分区域是“废弃”的,或者可以被覆盖而不影响最终结果的。在矩阵置零中,第 0 行和第 0 列最后反正都要清零,那在清零之前,它们天然就是一块免费的“草稿纸”。这是整道题最巧妙的地方。
4.2 面试官可能追问的变式
把这题刷明白之后,你还可以想想以下变式,大部分都是面试官喜欢深挖的问题。
第一个变式:如果题目允许你使用 O(m+n) 的额外空间,但要求“只能遍历一次矩阵”,你怎么做?这个就简单了,开两个布尔数组,一遍扫描记录标记,第二遍更新?不对,如果只能遍历一次,那就一边扫描一边更新,但要保证扫描到的格子不会因为之前的更新而干扰判断。其实做不到一次性更新所有格子,除非你记录标记的同时也记录坐标。所以这类变式通常会把“只能遍历一次”和“O(1) 空间”放在一起,用来堵死常规解法,考察你能不能想到用别的存储介质。
第二个变式:如果矩阵特别大,比如 10000×10000,但 0 的个数非常少,用 O(m+n) 的数组和用 O(1) 空间的方法,哪个更快?实际测试下来,O(1) 空间的方法需要完整遍历多次矩阵,而如果 0 非常稀疏,用坐标集合存储 0 的位置可能反而更快,因为后续清零时可以只处理含 0 的行和列。但代价是额外空间和 0 的个数相关,不再是严格的 O(1)。工程实践中通常会在“时间”和“空间”之间做取舍,没必要为了 O(1) 空间牺牲所有场景下的性能。
第三个变式:如果矩阵是流式输入,没办法全部读入内存,怎么办?那就得用外部排序或分块思想,一次处理一个分块,维护跨块的行列标记。这个问题已经超出 LeetCode 的考察范围,但能体现出你是否真正理解这个算法的本质:核心永远是要维护“哪一行需要清零”和“哪一列需要清零”这两组信息,存储介质是数组、集合还是标记位,都只是手段。
4.3 我的刷题经验与建议
刷这道题时,我建议你按照“暴力解法 → 数组标记法 → 原地标记法”的递进顺序来学习,不要直接背最优解。面试官问算法题,除了看你能不能做出来,更看你能不能讲清楚你是如何一步步优化到最优解的。很多人一上来就说“用第 0 行和第 0 列做标记”,面试官一听就知道你是背过的,因为正常思考路径一定是先想到数组标记,然后才想到用矩阵自身来复用空间。
我在给身边的人讲这道题时,习惯打一个比方:你要在一张满是数字的白纸上做标记,但不能用额外的便签纸。怎么办?找一个角落——比如左上角——把要记的内容写在上面,最后处理完之后,再用橡皮把角落擦掉。第 0 行和第 0 列就是这个“角落”,那两个布尔变量就是“橡皮擦”之后你还需要保留的记忆。
最后再提一个小的代码风格建议:第二步和第三步的扫描都用 for (int i = 1; i < m; i++) 加 for (int j = 1; j < n; j++),两个循环结构几乎一样,唯一的区别是一个是“标记”,一个是“清零”。写的时候可以把这两步分开注释清楚,方便自己和别人阅读。LeetCode 上有些高分题解会把第二、三步合并成一步,用三目表达式一次性改写 matrix[i][j],但我觉得那样可读性差,而且容易出错,还是分开写更稳。算法题的第一要务是正确,其次是可维护性,最后才是炫技。这个习惯放到工程代码里也一样适用。
