前段时间有位同事问了我一道很经典的矩阵题,就是 LeetCode 73 的“矩阵置零”。他一开始觉得特别简单,说“遍历到 0 就把整行整列清零呗”,结果一写就翻车:同一个 0 触发多次清理、标记又被覆盖、第一行第一列作为标记空间时怎么都绕不过去。我告诉他,这道题真正的考点不是“会不会用标记数组”,而是“能不能在不申请额外大空间的前提下,把标记和数据区复用起来”。这篇文章就把我完整踩过、调试过的思路写下来,顺便把边界情况和各种实现方式的坑都讲透。
1. 先把题面嚼碎:90%的人在第一句话就理解偏了
题目原文不长,大致是:给定一个 m x n 的整数矩阵,如果某个元素是 0,就把它所在的行和列上所有元素都改成 0。进阶要求是尽量使用 O(1) 的额外空间,也就是常说的“原地完成”。
这句话看起来人畜无害,但里面藏了两个理解偏差。
第一个偏差是“连坐”的范围。有人一看到 0 就想把当前行、当前列全部置零,但忽略了如果同一行里有两个不同的 0,或者一个 0 所在的行和另一个 0 所在的列交叉,交叉点会怎样处理。其实规则是“只要行里有 0,整行变 0;只要列里有 0,整列变 0”,跟这个位置原本是不是 0 没有关系。反复执行“有 0 就清掉整行整列”和一次性按原始 0 的位置清,结果可能不一样,所以必须先固定“原始 0 的位置”作为依据。
举个例子,矩阵长这样:
1 0 1 1 1 1 1 1 1如果只是遍历到 (0,1) 的 0,就先把第 0 行清掉,再把第 1 列清掉,结果是对的。但如果矩阵里再增加一个 0,情况就变了:
1 0 1 1 1 0 1 1 1你如果一边遍历一边立刻清理,遍历到 (0,1) 时清掉了第 0 行和第 1 列,此时原 (1,2) 的 0 可能已经被你亲手改成 0 了?不会,因为清第 0 行只影响第一行,清第 1 列只影响第二列,(1,2) 不会被碰。可是当你遍历到 (1,2) 时,这个位置依然还是 0,于是你又清掉了第 1 行和第 2 列。看起来还算运气好。可如果先有一个 0 把另一个 0 所在的行覆盖了,比如:
0 1 1 1 1 1 1 1 0遍历到 (0,0) 时,第 0 行和第 0 列清零,这不会把 (2,2) 改掉。继续遍历到 (2,2),它仍然是 0,再次清第 2 行和第 2 列,最终结果依然正确。那什么时候会出错?答案是当清理动作反向污染了“尚未遍历到的原始 0 的判断依据”时,你要么漏掉一个原本该清的行或列,要么多清掉一个不该清的区域。最典型的场景是:
1 1 1 1 0 0 1 1 1如果从 (1,1) 开始遍历,先清理第 1 行和第 1 列,第 1 列被清成 0 后,(2,1) 变成了 0。继续遍历到 (2,1) 时发现它是 0,于是又去清第 2 行和第 1 列。但这第二波操作里,第 2 行原本并不该被清,因为原始矩阵里第 2 行没有 0。这就是“边遍历边清”的灾难来源——你亲手制造了新的 0,又把它当成原始条件去触发更多清理。
第二个偏差是“原地”到底指什么。原地不是说不能用任何临时变量,而是说额外空间不能随矩阵尺寸增长。用一个 int 临时变量、几个循环下标都不算额外空间;但新建一个一模一样的矩阵,或者开两个长度分别为 m 和 n 的布尔数组,就是 O(m+n) 的额外空间。LeetCode 73 的真正区分度在于把这个额外空间压到常数。
还有个容易被忽略的边界:矩阵可能只有一行,也可能只有一列,甚至 m 或 n 为 0。在只有一行的矩阵里,只要遇到一个 0,整行都是 0;而 m 或 n 为 0 时,理论上根本没有元素,算法里的“第一行第一列”都不存在,必须在入口处单独判掉。很多跑测试用例栽跟头的人,基本都栽在这些极端尺寸上。
所以建议先把问题翻译成人话:先在地图上把“哪几行有 0、哪几列有 0”登记下来,然后根据登记结果整行整列清除。难点只是“登记”这件事用什么空间来做。
2. 从最直白的做法开始:复制矩阵法为什么“虽蠢但稳”
既然核心是登记,最直白的做法就是复制一份原矩阵,然后遍历副本。遍历过程中只要发现副本里某个位置是 0,就在原矩阵上把对应整行、整列全部清零。用副本而不是原矩阵来做判断,是因为副本上的数据始终保持进入循环那一刻的状态,不会因为“先清掉了某一行”而导致后续判断出错。
以 Python 为例,代码大概是这样的:
def set_zeroes_copy(matrix): if not matrix: return rows, cols = len(matrix), len(matrix[0]) copy_matrix = [row[:] for row in matrix] for i in range(rows): for j in range(cols): if copy_matrix[i][j] == 0: for r in range(rows): matrix[r][j] = 0 for c in range(cols): matrix[i][c] = 0复制矩阵法让代码逻辑变得极其简单:判断 0 的来源是备份,清零的目标是原来的矩阵,两套数据互不干扰。这样在处理“多个 0 互相交叉”的时候完全不用动脑,因为所有 0 的位置在开始时就已经凝固了。时间复杂度是 O(mn × (m+n)),因为最坏情况下每个 0 都可能触发一次整行和整列的扫描;空间复杂度是 O(mn),因为复制了完整矩阵。在手动测试小矩阵时,这是最不容易写错的方案。
但它的缺点也很明显。首先,空间翻倍,对于一个 1000×1000 的矩阵就是额外 100 万个整数,在内存敏感的环境里并不理想。其次,每个 0 都去扫整行整列,最坏时间会退化到立方级别。例如一个矩阵里有一半位置是 0,每个 0 都触发 O(m+n) 的清除,总的操作次数就是 O(mn × (m+n))。虽然在线评测通常不会把它卡到极限,但这显然不是面试官想看到的答案。
在做复制矩阵法的时候有个很隐蔽的坑:如果直接用if copy_matrix[i][j] == 0:作为条件,然后调用清行、清列的函数,清列函数可能把原矩阵的很多位置改成 0,但这个操作并不会反向影响副本,所以判断结果始终正确。这个特性是“复制一份”方案的优势,不需要像某些变量跟踪方案那样做额外快照。但要注意,Python 里如果写成copy_matrix = matrix,那只是复制了引用,你修改原矩阵时副本也会跟着变,整个“不变量”就被破坏了。必须用逐行复制(row[:])或者copy.deepcopy才能真正隔离数据。
复制矩阵这个概念引出接下来的核心问题:我们能不能只记录“某行需要清零”“某列需要清零”,而不复制完整矩阵?这就有了第二种方法。
3. 两个布尔数组:空间从 O(mn) 降到 O(m+n)
复制矩阵浪费的空间在于我们复制了整个二维数据,但实际上真正需要记住的只有“第 i 行是否存在 0”和“第 j 列是否存在 0”。前者只需要 m 个布尔位,后者只需要 n 个布尔位。
所以第二次迭代的做法是:先建立两个布尔数组 rowFlag(m) 和 colFlag(n)。第一遍扫描原矩阵,如果 matrix[i][j] == 0,就把 rowFlag[i] 和 colFlag[j] 都置为 true。这个阶段只做标记,不改动任何矩阵元素。第二遍再根据标记统一清零:如果 rowFlag[i] 为 true,就把第 i 行全部元素写成 0;如果 colFlag[j] 为 true,就把第 j 列全部元素写成 0。
代码实现大概是:
def set_zeroes_two_flags(matrix): if not matrix: return rows, cols = len(matrix), len(matrix[0]) zero_rows = [False] * rows zero_cols = [False] * cols for i in range(rows): for j in range(cols): if matrix[i][j] == 0: zero_rows[i] = True zero_cols[j] = True for i in range(rows): if zero_rows[i]: for j in range(cols): matrix[i][j] = 0 for j in range(cols): if zero_cols[j]: for i in range(rows): matrix[i][j] = 0这种方法为什么比复制矩阵法更“正确”?因为它把“判断依据”和“修改动作”在时间上彻底分离了。第一轮扫描结束后,矩阵可能还是原样,但所有需要的标记已经存在;第二轮修改时,依赖的是标记而不是矩阵里某个“当时还是 0 的值”,所以无论先清行还是先清列,无论行列交叉多少次,结果都唯一。这也是矩阵置零的核心思想:标记先行,执行置后。
两个数组方法的时间复杂度是 O(mn),空间复杂度是 O(m+n)。按理说这已经满足绝大多数实际业务场景:一张图片矩阵、一个稀疏表格,额外存 m+n 个标志位通常可以接受。但真正有挑战性的问题在于:能不能把空间压到 O(1),也就是“用矩阵自己的边界去承载这些标记”。
在展开 O(1) 解法前,我需要泼一盆冷水:这里的 rowFlag 和 colFlag 必须分开存储,不能只用一个一维数组。有人想“反正 0 的位置又不多,我用一个列表存所有 (i, j) 坐标不就行了”,可以,但最坏情况下所有位置都是 0,这个坐标列表的规模会退化到 O(mn),等于回到了复制矩阵的量级。另一个常见错误是:把 rowFlag 和 colFlag 合并成一个长度为 m+n 的数组,结果分不清索引到底代表行还是列。尤其是当 m 和 n 不同且恰好某些数值相等时,很容易把行标志写进列标志的位置。实际写代码时我强烈建议一开始就命名成zeroRows和zeroCols,不要用含义模糊的名字。
4. 原地算法的关键视角:让第一行和第一列兼任标记板
“用矩阵自身当标记”这件事,很多第一次见的读者会疑惑:你标记的时候不就是修改矩阵吗?那修改之后的矩阵还能叫原矩阵吗?这里需要扭转一个观念:我们不是像前面那样把标记存在额外的地方,而是把“第一行有哪些列需要清空”“第一列有哪些行需要清空”这两类信息,分别写进矩阵的边界行和边界列里。矩阵内部的数据在第二轮之前不动。
具体约定如下:
- 用 matrix[0][j] 是否为 0 来表示“第 j 列是否需要被清零”。注意这里说的是除了第 0 列之外的所有列。
- 用 matrix[i][0] 是否为 0 来表示“第 i 行是否需要被清零”。注意这里说的是除了第 0 行之外的所有行。
- 但第一行本身是否要被清零、第一列本身是否要被清零,单靠上面两条没办法区分,因为 matrix[0][0] 同时位于第一行和第一列,它既可能表示“第 0 行需要清零”,也可能表示“第 0 列需要清零”,还可能两者都要。所以需要两个独立的布尔变量:
firstRowHasZero、firstColHasZero,在扫描初始数据时记录原始状态。
于是整个算法分为四步。
第一步:扫描整个矩阵,先把第一行、第一列的原始 0 情况记录下来。注意这个“先”很关键,如果你在扫描的过程中顺手就把第一行的某个位置改掉,后面判断“第 j 列是否要清”就会失真。
第二步:从 (1,1) 开始扫描内部区域。只要发现 matrix[i][j] == 0,就在标记位置落笔:把 matrix[i][0] 设为 0,表示“第 i 行需要清”;把 matrix[0][j] 设为 0,表示“第 j 列需要清”。这一步没有清掉任何内部元素,只是把标记写到边界上。
第三步:根据第一列上的 0 标记,把对应的整行(从第 2 列到最后一列)清成 0;根据第一行上的 0 标记,把对应的整列(从第 2 行到最后一行)清成 0。注意这里不要碰第一行和第一列,因为第一行和第一列是否清零要由第一步的布尔变量最终决定。
第四步:根据第一步记录的 firstRowHasZero 和 firstColHasZero,决定是否把整个第一行、整个第一列清零。
很多文章会把这三步讲得很快,仿佛“用第一行第一列标记”就是全部奥秘。但实际的难点并不是“标记”本身,而是扫描顺序。一个很经典的错误是:你从 (1,1) 开始扫描,遇到一个 0,把 matrix[0][j] 写成 0,结果后面又扫描到第一行或第一列上的某个已经变成 0 的位置,把它误认为“本来就有 0”,然后做出错误的标记。这就要靠第一步的“先保存布尔变量”来挡掉,同时第二步严格只扫描 i>=1、j>=1 的区域,才能保证内部数据的判断只发生在真实数据上,而不是发生在标记数据上。换句话说,标记写入边界之后,绝对不能反过来成为内部循环的判定依据。
5. 原地算法的完整实现与逐行读码
下面是 Python 版的原地实现,我把每一步都写了注释:
def set_zeroes_in_place(matrix): if not matrix: return rows = len(matrix) cols = len(matrix[0]) # 第一步:先看第一行、第一列本身有没有原始 0 first_row_has_zero = False first_col_has_zero = False for j in range(cols): if matrix[0][j] == 0: first_row_has_zero = True break for i in range(rows): if matrix[i][0] == 0: first_col_has_zero = True break # 第二步:遍历内部区域,用第一行第一列记录行列标记 for i in range(1, rows): for j in range(1, cols): if matrix[i][j] == 0: matrix[i][0] = 0 matrix[0][j] = 0 # 第三步:根据标记清理内部区域 for i in range(1, rows): if matrix[i][0] == 0: for j in range(1, cols): matrix[i][j] = 0 for j in range(1, cols): if matrix[0][j] == 0: for i in range(1, rows): matrix[i][j] = 0 # 第四步:最后处理第一行和第一列 if first_row_has_zero: for j in range(cols): matrix[0][j] = 0 if first_col_has_zero: for i in range(rows): matrix[i][0] = 0这版代码有几点值得单独说。
第一,第一步为什么分两个循环分别看第一行、第一列,而不是合并成一个循环?因为合并会让逻辑变复杂。比如我先遍历第一行,发现 (0,0) 是 0,那它同时代表第一行和第一列都有 0;但如果我在同一个循环里用同一个变量记录,接下来遍历第一列时又发现 (0,0) 已经不是原始信息,还是刚才我记录的那一个。结论或许碰巧正确,但可读性和稳定性都差。分开写,一眼就能看出“第一行是否有原始 0”和“第一列是否有原始 0”是两个独立判断。
第二,第三步里两次清理的顺序可以交换吗?可以。因为第三步只依赖边界标记,不依赖内部区域的变化。先清行再清列,还是先清列再清行,最终内部区域都会变成“该清的都清了”。这也是这套设计最优雅的地方:边界标记和内部数据在第二轮彻底解耦。
第三,当矩阵只有一行时,第二步的for i in range(1, rows)是空循环,但第一步已经记录了first_row_has_zero,第四步会把整行清掉,所以结果是正确的。当矩阵只有一列时同理。你可以自己推一遍[[0, 1, 2]]:first_row_has_zero 为 true,first_col_has_zero 为 false,内部区域为空,第三步什么都不做,第四步把第一行全清成 0,结果变成[[0, 0, 0]],完全正确。
这里有一个非常关键的工程习惯:不要在高维数组里盲目相信“自己只改边界不碰数据”。如果你在第二步的扫描范围不小心写成range(0, rows),你就会把第一行本身也当成内部数据来判断,而此时第一行可能已经被你自己写入了标记。比如矩阵内部有个 0 导致 matrix[0][2] 被标记为 0,接下来你又遍历到 (0,2),误以为第一行本来就该清。结果可能没错,但你会把原本不该清的列也标记成该清,因为第一行上的标记位被反复加工。这就是“边标记边判断”造成的脏读。
为了减少这种风险,我建议在实现里把三个阶段的 action 分开写,不要试图在一个双重循环里同时完成“标记 + 行清理 + 列清理”。哪怕多写几个循环,性能也不会差——它们都是 O(mn) 的线性叠加。
6. 三种实现的复杂度对比与面试追问逻辑
到了这一步,代码能跑通还只是开始。矩阵置零之所以被反复拿出来当面试题,是因为它太适合做复杂度讨论了。我把三种方式的整理成一张表:
| 方案 | 额外空间 | 时间复杂度 | 最易出错点 |
|---|---|---|---|
| 复制矩阵 | O(mn) | O(mn(m+n)) | 浅拷贝导致副本被连带修改 |
| 两个标记数组 | O(m+n) | O(mn) | 行列标记数组混淆 |
| 原地边界标记 | O(1) | O(mn) | 第一行/列原始信息丢失 |
你写出 O(m+n) 空间的版本之后,面试官大概率会顺着问三件事。
第一问:“如果让你把空间压到 O(1),你打算怎么做?”这时候你要给出的答案就是“复用第一行和第一列”。不要急着背代码,先把思路讲清楚:第一行映射到列标记,第一列映射到行标记,额外再用两个布尔量兜住第一行、第一列自己的命运。
第二问:“第一行和第一列本身也是数据,你用它们当标记,那它们原来的数据去哪儿了?”这就是要考察你有没有意识到“标记复用一个区域时,必须先保存这个区域的原始信息”。你把第一行和第一列当作计数器,就必须在程序开始时备份它们的原始状态。备份的形式是布尔值,而不是复制整行整列,因为边界行、列最终要么全变 0 要么不变,根本不需要保留完整元素。
第三问:“如果有 0 出现在第一行,你怎么知道是原始数据还是你自己写的标记?”这个问题直接指向 firstRowHasZero 和 firstColHasZero 的作用。如果第一行本来就有一个 0,那第一行最后必然全清零;如果你不知道这个信息,可能会漏掉对第一行的处理。如果你不知道第一列本来有 0,同理会漏掉第一列的清理。
在回答这三连问时,最能体现理解深度的一句话是:“我先把第一行、第一列是否包含原始 0 记录成两个布尔量,这样后续用它们做标记时,即使边界被改动,我也能在最后恢复正确的清除范围。”这句话一说,基本就默认你已经理解“先保存、后标记、再清理、最后恢复边界”的完整闭环。
时间复杂度也要能解释清楚。三种方法都至少需要遍历每个元素一次才知道哪里是 0,所以下界是 O(mn)。复制矩阵法如果实现成“遇到一个 0 立刻清行清列”,最坏情况下每个 0 都导致 O(m+n) 的写操作,所以总复杂度会退化到 O(mn(m+n))。但这不是算法思想的问题,而是实现方式的问题:如果你先收集所有 0 的位置,再统一清除,时间依然可以做到 O(mn)。所以面试时给出的代码,应该明确采用“两轮遍历”而不是“每遇 0 就立刻清除”。
关于空间复杂度,我想特别强调一个概念:原地算法里的“原地”不是“不用额外空间”,而是“额外空间是常数级 O(1)”。两个布尔变量、几个循环变量、一个临时交换值,全都算 O(1)。一些较真的面试官会问递归栈空间、临时申请的大数组等,但在矩阵置零这道题里,标准答案就是通过复用矩阵边界实现 O(1) 额外空间。
7. 边界测试用例与现场调试思路
不管你用哪种实现,提交前建议至少跑这几组用例,都是实际踩过的坑。
第一组:2×2 矩阵,全部元素都是 0。
0 0 0 0这种全零矩阵能直接检验你是否能正确处理 firstRowHasZero 和 firstColHasZero 同时为 true 的情况。在这个用例里,第一步的两个布尔量都为 true,第二步把 (1,1) 标记到 matrix[1][0] 和 matrix[0][1],但第一行第一列原本就是 0,边界标记和原始标记混在一起,最终结果依然是全零。表面上没什么区别,但如果你在第二步中错误地依赖“matrix[0][0] 原本是 0”去推断第一行和第一列,不会立刻报错,却会在另一个用例里暴露问题。
第二组:矩阵只有一行,比如[[0, 1, 2]]。期望结果是[[0, 0, 0]]。如果你在代码里仍然进入第二步从 (1,1) 扫描,那么 range(1, 1) 是空的,边界处理会不会漏掉 j=0 这一列?正确实现里 firstRowHasZero 会为 true,最终把整行清掉,所以结果正确。但如果你的边界逻辑写得太复杂,这一行测试用例跑下来很容易发现你忘掉了某个方向的清零。
第三组:矩阵里只有一个 0,位于正中间。比如 3×3 矩阵,把中心位置设为 0。期望结果是中心所在行和所在列全变 0,其余四个角保留。这个用例可以同时检验“标记是否隔离”和“内部数据是否在第二轮才被修改”。
第四组:第一行有一个 0,但第一列没有 0;以及第一列有 0 但第一行没有 0。这两类用例能检查最先保存的两个布尔变量是否被正确使用。很多错误版本把 firstRowHasZero 和 firstColHasZero 写反了,导致 90 度的行/列清错。比如矩阵:
0 1 1 1 1 1 1 1 1期望结果是第一行全零。只要 firstRowHasZero 为 true,这个用例就能通过。但如果代码写反成 firstColHasZero,第一列会被清成 0,而正确结果里第一列除了 (0,0) 本身为 0,其余位置都应该是 1,于是用例直接失败。
调试的时候还有一个很实用的习惯:在每一步标记之后把矩阵打印出来看看。标记阶段的矩阵应该呈现出“内部数据原封不动,只有第一行和第一列某些位置变成 0”的状态;如果你发现内部也被改了,说明你的扫描范围越界了。清理阶段则应该是“内部数据大片变成 0,第一行和第一列的标记数据保持不变”;如果第一行第一列也被动了,说明你把边界也当成普通行来清了,最终很可能把矩阵搞成全零。
我举个例子,假设输入:
1 2 3 4 0 6 7 8 9标记阶段后,矩阵应该变成:
1 0 3 0 0 6 7 8 9注意 matrix[0][1] 变成了 0,表示第 1 列需要清;matrix[1][0] 变成了 0,表示第 1 行需要清。内部区域的 (1,2) 还是 6,因为标记阶段不修改内部数据。很多人这一步没做好,看到 matrix[0][1] 是 0,就急着把第 1 列整列清了,导致 (0,2)=3 被误清,后面又得补逻辑。其实完全没必要急,等所有标记完成再统一清。
经过清理后,矩阵会变成:
1 0 3 0 0 0 7 0 9然后再看 firstRowHasZero 和 firstColHasZero。这里第一行原本没有 0,所以 matrix[0][1] 这个 0 是标记产生的,不能据此把第一行全清;第一列原本也没有 0,所以 matrix[1][0] 这个 0 也不能触发第一列全清。最终结果保留第一行和第一列的非零元素,只有内部中间行和中间列被清掉。这一套推演走完,才能算真正理解了“标记与执行分离”。
8. 变体思考:从“整行整列清零”到更大的矩阵场景
矩阵置零的解法思路可以横向迁移到很多类似问题。最常见的变体是“如果矩阵非常大,但 0 非常稀疏,能不能用更快的方法”。这时 O(mn) 的最优时间已经固定,但你可以从空间上做文章:用一个坐标列表只记录 0 的位置,而不是记录所有行列标志。当 0 的数量 k 远小于 min(m,n) 时,额外空间是 O(k),虽然它不是严格的原地算法,但在实际业务环境里往往比 O(m+n) 更经济。
另一种变体是“感染”类问题:给定一个矩阵,如果某个格子里有污染物,把相邻格子都污染掉。这时候 0 的传播是有方向的、逐轮扩散的,不能用“整行整列清零”来套,但核心思路仍然是一致的:标记先行、分轮执行。先做一个访问标记或者队列,避免同一轮里新污染的区域继续传播,因为这一轮新污染的区域应该算作下一轮的开始,否则会出现“一步污染全图”的错乱。
还有一类常被放在同一专题下的问题:只清除包含 0 的行,不清除列。这种简化版很容易,但面试官可能故意让你把所有列都清掉再问你“你如何避免重复清零”。重复清零不会改变最终结果,却能暴露你对幂等操作的理解,所以回答时可以说“置零操作是幂等的,执行两次和一次没有区别,但为了效率和避免边界混乱,我仍然标记去重”。
从工程角度说,矩阵类问题不要一上来就套 BFS/DFS,先看规则是否具有“全局连坐”的性质。全局连坐(行、列整段变 0)和局部传播(相邻才扩散)对应的最优工具完全不同。前者用标志数组或边界复用,后者才需要队列、并查集或拓扑。LeetCode 73 属于前者,所以核心是“标记空间如何压缩”,而不是“扩散顺序如何模拟”。
再延伸一下,如果你把矩阵想象成一张表格,把 0 想象成某些行和列上的“特殊标记”,那这道题其实是在教你:先扫描所有需要修改的行列索引,再一次性地写入;不要边扫边写,否则你的扫描结果会被自己的写入污染。这个原则在数据库批量更新、图像像素处理、表格去重等场景里一脉相承。我见过不少人在处理二维数组时写出一堆“边遍历边删除”的代码,最后不是漏数据就是多删,原因都是没有把“读阶段”和“写阶段”分开。
回到 LeetCode 73 这道题,它教会我的其实是“先保存现场,再改数据”的工程习惯。很多人写矩阵算法时最怕的就是“边判断边修改导致后续判断失真”。在真实业务里,比如导出表格、图像处理、数据清洗,这种坑同样普遍:你在遍历一个数组的时候删除了它,可能跳过了元素;你在上行更新时依赖下行旧值,可能产生脏数据。解决办法从来不是“小心一点”,而是像这道题一样,把读取阶段和写入阶段彻底分开,必要时用一个很小的标记结构记录哪些位置需要变更。
另外,做这道题时不要一上来就背“设两个布尔变量然后原地标记”的答案。先把复制矩阵、行数列数组、原地边界标记三步依次写出来,每一版都跑通边界用例。你会发现从第一版到第三版,变化的不是“技巧”,而是对“哪些信息必须保留、哪些信息可以覆写”的理解。这个理解的深度,才是面试官在意的东西,也是这道题被翻来覆去考的原因。
如果时间有限,建议至少把几组边界用例(单行、单列、2×2 全零、只有内部一个 0)记住,它们能覆盖近乎所有实现细节。下次再遇到类似的“原地修改二维数组”问题,比如矩阵旋转、螺旋遍历这类牵一发而动全身的场景,你也会习惯性地问自己一句:修改时,还有没有被覆盖但后面还要用的信息?
这就是我反复刷这道题最大的收获。希望你也能在写完代码之后,多花两分钟把“为什么先存布尔变量”这件事讲给旁边的人听,讲明白了,这道题才算真正会了。