1. 为什么我把“矩阵置零”当成笨鸟起飞的第一根树枝
先交代一下背景。去年我开始认真刷 LeetCode,目标很朴素——跳槽时不在算法面上栽跟头。刷了不到两周就发现一个残酷的现实:题解区里那些大佬的写法,我很多连看都看不懂,更别提自己写出来。排序、双指针、动态规划,每个专题都像是另一门语言。
就在这个时候我遇到了第73题,Set Matrix Zeroes,中文叫矩阵置零。这道题我前后折腾了差不多一整个晚上,被几个隐藏的坑反复折磨,但恰恰是这一题让我悟到了一些比“会做一道题”更重要的东西。
如果你也是那种看题解需要盯着屏幕看半天、还经常看不懂的人,我觉得这一题特别适合作为你算法刷题路上的一个里程碑。题目本身不复杂:给定一个 m x n 的矩阵,如果某个元素是 0,就把这个元素所在的行和列全部置为 0,并且要求原地完成。听起来极其简单,对吧?我第一次看到题目的时候也是这么想的。
但真正动手之后才会发现,这道题背后藏着一个所有“原地修改”类问题都会遇到的经典矛盾:你要记录信息,但不能用额外的空间;你要修改数据,但又不能影响后续的判断。这个矛盾一旦想通,就不只是会做一道题的事,很多算法题的思路都会被彻底打开。
从这题开始,我给自己定了一个规矩:每刷一道题,先用自己的办法做出来,哪怕很笨、很慢、很丑,然后再去学最优解。这个方法我管它叫“先飞的笨鸟”——先飞,不是为了证明翅膀有多硬,而是为了在摔打中把路线摸清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 题目拆解:矩阵置零到底在考什么
2.1 原题描述与几个被忽略的细节
先看原题。给定一个 m x n 的矩阵,如果一个元素是 0,则将其所在行和列的所有元素都设为 0。你可以用一个额外的矩阵来完成吗?这是进阶要求。真正的限制是:希望你能用一个常量空间来解决。
举个例子:
code复制输入: matrix = [[1,1,1],
[1,0,1],
[1,1,1]]
输出: [[1,0,1],
[0,0,0],
[1,0,1]]
再看一个:
code复制输入: matrix = [[0,1,2,0],
[3,4,5,2],
[1,3,1,5]]
输出: [[0,0,0,0],
[0,4,5,0],
[0,3,1,0]]
第二个例子很关键,矩阵里出现了两个 0,一个在 (0,0),一个在 (0,3)。这两个 0 会把第一行整行变成 0,同时也会让第一列和第四列变成 0,于是 (2,0) 位置的 1 和 (2,3) 位置的 5 都被带成了 0。这里有个特别容易忽略的点:当一行和一列同时需要被置零时,它们交叉的那个格子,不管原本是什么值,最终都一定是 0。很多人写代码的时候会在交叉位置反复纠结,但实际上一旦行被打上标记,这个格子的命运就已经注定了,不需要额外判断。
2.2 为什么不能一边遍历一边改
很多人第一次写这道题都会走这条路:遍历矩阵,遇到 0 就把所在行和列全改成 0。看起来天经地义,但立刻就会出问题。
假设你遍历到 (1,1) 发现它是 0,于是把第一行和第一列全改成 0。改完之后,继续往后遍历时遇到 (1,2),这个位置本来是 1,但因为刚才整行被改成 0 了,所以它现在就等于 0。你的程序看到 0,又试图把第二行和第三列全部置零。这就引发连锁反应,最终整个矩阵都被置成 0,而这种污染是不可逆的。
这个问题的本质在于:你在修改数据的同时,也在用同一份数据当作判断依据。数据一旦被改过,就不再能代表原始状态了。所以任何原地修改类的问题,核心思路都是先把需要修改的“信息”记录下来,再在确认不会有误判的前提下进行修改。
那记录在哪里?这就要看题目的空间限制。
3. 第一版方案:用额外空间先把题做出来
3.1 复制矩阵法:最笨但最稳妥的“笨鸟”方案
我的第一版解法非常简单粗暴:复制一个一模一样的新矩阵,然后在新矩阵上操作,最后把结果复制回原数组。
思路是这样:新矩阵一开始是原始状态的完整快照。我遍历原始矩阵(而不是新矩阵),只要原始矩阵里某个位置是 0,就去操作新矩阵,把对应行和列全部置 0。因为我是根据原始矩阵来判断的,新矩阵怎么改都不会影响我的判断依据,所以永远不会出现上面说的连锁污染。
代码大概是这样的(Python 版本):
python复制class Solution:
def setZeroes(self, matrix: List[List[int]]) -> None:
m, n = len(matrix), len(matrix[0])
# 复制一份原始矩阵
snapshot = [row[:] for row in matrix]
for i in range(m):
for j in range(n):
if snapshot[i][j] == 0:
# 整行置零
for col in range(n):
matrix[i][col] = 0
# 整列置零
for row in range(m):
matrix[row][j] = 0
这个解法能通过,但空间复杂度是 O(mn),因为复制了整个矩阵。虽然能过题,但完全不符合进阶要求,而且在面试里这么写几乎肯定会被追问优化。
3.2 标记数组法:把空间降到 O(m+n)
复制矩阵的解法虽然不优雅,但它让我清醒地意识到一件事:这道题真正需要的不是“盲目修改”,而是“先记录,再修改”。于是很自然地想到了用两个数组来记录状态——一个长度为 m 的数组记录哪些行需要被置零,一个长度为 n 的数组记录哪些列需要被置零。
思路极其简单:
python复制class Solution:
def setZeroes(self, matrix: List[List[int]]) -> None:
m, n = len(matrix), len(matrix[0])
row_flag = [False] * m
col_flag = [False] * n
# 第一遍:只做标记,不修改
for i in range(m):
for j in range(n):
if matrix[i][j] == 0:
row_flag[i] = True
col_flag[j] = True
# 第二遍:根据标记统一修改
for i in range(m):
for j in range(n):
if row_flag[i] or col_flag[j]:
matrix[i][j] = 0
这个方案的时间复杂度是 O(mn),空间复杂度是 O(m+n)。从复制矩阵到这一步,空间消耗已经降了一个大台阶,而且代码清晰,逻辑一目了然。很多人会觉得这就够了,但题目进阶要求是“用常量空间”,所以还要继续压缩。
不过在做空间压缩之前,我想先说一句:别觉得用额外空间的解法就丢人。如果你能先写出这个版本的代码,你已经理解了这道题 80% 的核心逻辑,剩下的 20% 只是空间上的优化技巧。我自己就是先把这版写通,再回头琢磨怎么把空间压到 O(1) 的。
4. 原地算法:用第一行和第一列当“草稿纸”
4.1 O(1) 空间的核心:信息不消失,只是换了个地方存
从 O(m+n) 到 O(1),信息并没有减少——你依然要知道哪些行该置零、哪些列该置零。既然额外的数组不能用了,那就得在矩阵本身找地方存放这些信息。
很多人走到这里就卡住了,觉得矩阵里每个格子都是“有主之物”,随便占用会破坏数据。但换个角度想:如果某个格子最终会被置成 0,那它的原始值还有意义吗?没有了。既然没有意义,那它是不是就能当作临时标记位来用?
按照这个思路,最合适的标记位就是——第一行和第一列。
具体策略是:用 matrix[i][0] 来标记第 i 行是否需要置零,用 matrix[0][j] 来标记第 j 列是否需要置零。这样就不需要额外数组了。
但这里有一个陷阱:第一行本身的状态和第一列本身的状态也需要被记录。如果我直接把 matrix[0][j] 用作列标记,那第一行自身是否包含 0 这个信息不就丢了吗?同理,第一列自身是否包含 0 也会被覆盖。
所以需要两个额外的变量:row0_has_zero 和 col0_has_zero,分别记录第一行和第一列原本是否包含 0。这两个变量是 O(1) 的,不影响空间复杂度要求。
4.2 完整步骤拆解
整个算法分三步走,顺序问题非常讲究,错一步满盘皆输。
第一步:记录第一行和第一列原本是否含 0。
python复制m, n = len(matrix), len(matrix[0])
row0_has_zero = any(matrix[0][j] == 0 for j in range(n))
col0_has_zero = any(matrix[i][0] == 0 for i in range(m))
第二步:遍历除第一行和第一列之外的所有格子。如果发现 matrix[i][j] == 0,就把对应的行标记 matrix[i][0] 和列标记 matrix[0][j] 置为 0。这一步只是打标记,绝对不要修改其他任何格子。
python复制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
为什么要跳过第一行和第一列?因为第一行和第一列现在是“标记区”,里面的 0 可能是人工打上的标记,不代表原始数据。如果我在这一步也遍历它们,就会把标记也当成原始 0 来传播,造成错误。
第三步:根据标记回填。遍历除第一行和第一列之外的所有格子,如果 matrix[i][0] == 0 或者 matrix[0][j] == 0,就把 matrix[i][j] 置为 0。
python复制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
第四步:单独处理第一行和第一列。如果 row0_has_zero 为真,把第一行全部置 0;如果 col0_has_zero 为真,把第一列全部置 0。
python复制if row0_has_zero:
for j in range(n):
matrix[0][j] = 0
if col0_has_zero:
for i in range(m):
matrix[i][0] = 0
整体代码合在一起:
python复制class Solution:
def setZeroes(self, matrix: List[List[int]]) -> None:
m, n = len(matrix), len(matrix[0])
row0_has_zero = any(matrix[0][j] == 0 for j in range(n))
col0_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 row0_has_zero:
for j in range(n):
matrix[0][j] = 0
if col0_has_zero:
for i in range(m):
matrix[i][0] = 0
这个解法的时间复杂度仍然是 O(mn),空间复杂度是 O(1),满足题目的进阶要求。
4.3 为什么回填必须从第二行第二列开始
这个细节我想单独拿出来说一下,因为它在面试中经常被追问。
假设矩阵第一行在原始状态下就含有一个 0,那你在第二步的时候,会把这个 0 的信息存到哪里?row0_has_zero 这个变量会在第一步把它记下来。如果你在第二步遍历的时候没有跳过第一行,遍历到 matrix[0][j] == 0 时,你会把这个 0 当成一个普通的 0 来处理,然后试图去设置 matrix[0][k] 和 matrix[i][0] 的标记。这在某些情况下不会出错,但在边界情况下会导致严重误判。
举个例子:
code复制原始矩阵:
[0, 1]
[1, 1]
第一步记录:row0_has_zero = True,col0_has_zero = True。
第二步如果从 (0,0) 开始遍历,看到 matrix[0][0] = 0,于是设置 matrix[0][0] = 0(本来就是 0),但同时还可能因为遍历顺序不同而触发额外操作。
更严重的问题是:如果第二步遍历到 (0,1) 时发现 matrix[0][1] 被标记成了 0,就会误以为第二列也需要置零,从而把整个矩阵都污染了。
跳过第一行和第一列之后,所有标记的读写都只在标记区进行,不会把标记区里的 0 误当成原始数据来传播,安全得多。这个细节就是这道题最容易踩的坑。
5. 踩坑实录:从错误提交到真正理解原地修改
5.1 我第一次提交的“翻车”代码
我很坦诚地说,上面那个看起来清爽的代码,并不是我一次写对的。我第一版“自以为优化过”的代码长这样:
python复制class Solution:
def setZeroes(self, matrix: List[List[int]]) -> None:
m, n = len(matrix), len(matrix[0])
# 先把第一行和第一列当作标记区
for i in range(m):
for j in range(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
看起来好像没问题?但实际上错得很离谱。
第一,我没有保存第一行和第一列原本是否含 0,直接把标记写在 matrix[0][j] 上时,原始信息已经被覆盖掉了。等最后要处理第一行时,我根本不知道该不该把第一行整体置 0。
第二,更致命的是,第一遍遍历时我没有跳过第一行和第一列,导致在遍历过程中不断用新的 0 覆盖标记位,整个标记体系全乱套了。
我当时用题目给的例子一跑,发现输出完全不对,整个人都是懵的。后来我打印出每一步的矩阵状态,才慢慢看明白问题出在哪里。这个调试过程虽然痛苦,但让我真正理解了“信息保存”和“标记区保护”的意义。
5.2 边界情况:当矩阵只有一行或一列时
如果你直接用上面整理后的代码跑 [[1, 0]] 这样的单行矩阵,会怎样?
m = 1,所以 for i in range(1, m) 这个循环根本不会执行,那么中间的标记和回填逻辑全部跳过。剩下的逻辑只剩判断 row0_has_zero 和 col0_has_zero。对于一个一行两列的矩阵 [1, 0],row0_has_zero = True,于是第一行会被全部置 0,结果是 [0, 0]。没问题,因为原始矩阵里确实有一个 0,那么整行(也就是这一行)都应该变成 0。
但假如矩阵是 [[0], [1]],也就是一列两行,m = 2, n = 1。col0_has_zero = True,第一列会被全部置 0,结果是 [0, 0]。这个结果其实也是对的,因为第一行第一列的 0 会导致第一行和第一列都变成 0,那么整个 2x1 矩阵当然全 0。
奇怪的是,如果你拿一个只有一行且没有任何 0 的矩阵 [1, 2, 3] 去试,row0_has_zero = False,整个代码几乎什么都不做,结果还是 [1, 2, 3],这也正确。所以这个解法在极端边界下是安全的,但前提是你必须严格保留第一步的两个布尔变量。如果去掉它们,单行或单列的情况就会翻车。
5.3 用例子手推一遍,比什么都管用
如果你到现在还对回填顺序有疑虑,我建议你拿出笔,跟我一起把下面这个例子走一遍。
code复制原始矩阵:
[1, 2, 3]
[4, 0, 6]
[7, 8, 9]
第一步:row0_has_zero = False,col0_has_zero = False。
第二步:遍历 i=1,2 j=1,2。发现 matrix[1][1] == 0,于是设置 matrix[1][0] = 0,matrix[0][1] = 0。此时的矩阵变成:
code复制[1, 0, 3]
[0, 0, 6]
[7, 8, 9]
注意,matrix[1][1] 还是 0,不动它。其他格子也没动。
第三步:回填。遍历到 (1,2) 时,matrix[1][0] == 0,所以 matrix[1][2] 置 0。遍历到 (2,1) 时,matrix[0][1] == 0,所以 matrix[2][1] 置 0。遍历到 (2,2) 时,matrix[1][0] 和 matrix[0][1] 都是 0,所以 matrix[2][2] 置 0。
此时矩阵变成:
code复制[1, 0, 3]
[0, 0, 0]
[7, 0, 0]
等等,matrix[2][2] 被置成了 0?但 (2,2) 原本是 9,它所在的第二行和第二列都没有原始 0 啊?
这里一定要停下来仔细想。虽然 (2,2) 所在的第二行(从 0 开始数的话是第 2 行)原本没有 0,但 (1,2) 这个位置所在的第二列(列索引 2)含有 0,所以整个第二列都应该变成 0,(2,2) 是第二列的一部分,所以它被置 0 是完全正确的。别被“行标记”和“列标记”搞混了,一个格子只要行或列任意一个被标记,它就得变成 0。
接着第四步:row0_has_zero 和 col0_has_zero 都是 False,所以第一行和第一列不额外处理。最终结果:
code复制[1, 0, 3]
[0, 0, 0]
[7, 0, 0]
我自己手推了三遍,才彻底理解为什么 (2,2) 会被置 0,也才真正明白“行或列被标记就置 0”这个规则是完备的。如果你手推一遍还有疑问,建议找个小矩阵多推几次,这个时间花得非常值。
6. 从这题延伸出去:为什么“先飞的笨鸟”反而飞得更稳
6.1 面试官在意的不只是你能不能写出 O(1)
后来我在模拟面试里被人问过这道题,对方问了一个让我印象深刻的问题:“你能写出 O(1) 空间的解法,那你有没有想过为什么第一步必须先遍历整行整列来记录状态,而不是边遍历边修改?”
我当时的回答是:“因为修改会破坏后续判断的依据,必须先快照再操作。” 面试官点了点头,又问:“那你觉得复制矩阵的解法和标记数组的解法,在代码可读性和性能上各有什么优缺点?”
那一刻我突然意识到,面试官真正想考察的并不是你能不能背下最优解,而是你有没有真正理解每一步操作的动机。如果你能解释清楚“为什么这个标记不会被误用”“为什么回填要从 (1,1) 开始”,那比单纯写对代码更有说服力。
这也是“先飞的笨鸟”理论在面试里的价值:我先用笨办法做出来,再一步步优化,整个过程我的脑子里有完整的推理链条,而不是一个孤立的答案。当面试官追问时,我能把每个细节的来龙去脉都说清楚。
6.2 类似问题的举一反三:状态标记类的通用思路
矩阵置零这道题所属的类型,在 LeetCode 里有一个很经典的名字叫“原地哈希”或“状态标记”。它和很多其他题目共享同一个底层思维:在空间受限的情况下,利用已有的数据结构保存额外信息。
典型的例子是 289 题“生命游戏”。那个题里每个细胞的状态是 0 或 1,但要同时计算下一代状态,又不能额外开数组,所以就需要用一个技巧性的状态编码,比如用 2 表示“原来是 1 但下一代变成 0”,用 3 表示“原来是 0 但下一代变成 1”,最后再统一解码。这和矩阵置零里用第一行第一列当标记区,本质上是一模一样的思路。
还有 41 题“缺失的第一个正数”,它也要求 O(1) 空间,做法是把数组本身当作哈希表,用正负号来标记某个数字是否出现过。我第一次看这个题的时候觉得非常神奇,但后来发现套路都一样:数组本身就是你的额外空间,只是你需要小心地维护“原始信息”和“标记信息”的共存关系。
如果你能真正吃透矩阵置零里“先保护标记区,再统一回填”这种思路,再去碰其他原地标记类题目,会感觉到明显的降维打击。
6.3 笨鸟先飞的具体操作方式:我的刷题复盘法
最后分享一点我自己的刷题习惯,不是什么高深的技巧,只是很笨但很有效的一套流程。
第一步,拿到一道题后,先不管最优解,用最本能的方式做出来。哪怕空间复杂度是 O(n),只要逻辑对、能通过,就先提交一版。这个阶段的核心目标是建立对题目的直觉理解。
第二步,看题解区的最优解,但不要直接看代码,先看思路。如果看不懂,就找各种不同语言的题解对比,或者看别人的注释,直到想明白“为什么这个优化是可行的”。
第三步,关掉题解,自己从零写一遍最优解。写的过程中记录所有卡壳的地方,这些卡壳点就是你真正欠缺的知识点。
第四步,针对自己卡壳的点,找 3 到 5 道同类题做对比练习。比如一道矩阵置零,我会顺手把生命游戏、旋转图像、螺旋矩阵都刷一遍,形成一个小的知识模块。
这个方法看起来很慢,开始的几周我每天只能刷两三道题,比我那些一天刷十道题的朋友慢得多。但三个月之后,我发现自己面对新题时,能更快地识别题型、联想到对应的套路,而那些刷得快的朋友反而开始抱怨“怎么换了个问法就不会了”。
笨鸟先飞,飞的不是速度,而是对对每个知识点的深度理解。慢一点,反而走得远。矩阵置零这道题,就是我整套方法论的第一块基石。如果你也觉得自己在算法面前是只笨鸟,不妨从这道题开始,一点点搭自己的翅膀。
