--- tags: ["LeetCode", "矩阵", "中等"] create time: 2026-05-16 14:30 --- # 18-矩阵置零 ## 题面 > **LeetCode 73. Set Matrix Zeroes** 给定一个 `m x n` 的矩阵,如果一个元素为 `0` ,则将其所在行和列的所有元素都设为 `0` 。请使用 **原地** 算法。 **示例 1:** ``` 输入:matrix = [[1,1,1],[1,0,1],[1,1,1]] 输出:[[1,0,1],[0,0,0],[1,0,1]] ``` **示例 2:** ``` 输入: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]] ``` **提示:** - `m == matrix.length` - `n == matrix[0].length` - `1 <= m, n <= 200` - `-2^31 <= matrix[i][j] <= 2^31 - 1` **进阶:** - 一个直观的解决方案是使用 `O(mn)` 的额外空间,但这并不是一个好的解决方案。 - 一个简单的改进方案是使用 `O(m + n)` 的额外空间,但这仍然不是最好的解决方案。 - 你能想出一个仅使用 **常量空间** 的解决方案吗? --- ## 思路 > [!question] 💡 直觉先行:遇到零该做什么? 最朴素的想法很直接——遍历整个矩阵,每当发现 `matrix[i][j] == 0`,就把第 `i` 行的所有元素和第 `j` 列的所有元素全部清零。 但这里有一个致命问题: > [!danger] ⚠️ 顺序执行的灾难 如果我们在遍历时**就地**把某一行/列全变成 0,那么后续扫描到这个位置时,会看到一个"伪零"——它原本是非零值,只是被前面的操作污染了。这会导致原本不该被清零的位置也被错误清零。 比如: ``` 原始矩阵 遍历到 (0,0)=0,整行整列清零后 [[1, 1], [[0, 0], [1, 1]] [0, 0]] ← (1,0) 和 (0,1) 本来是 1! ``` 所以我们必须先**收集所有需要清零的行和列信息**,等扫描完毕后再统一执行。这就是空间优化的起点。 --- ### 方法一:暴力法 —— 记录所有零的位置(O(mn) 空间) > [!abstract] 📐 最直接的想法 创建一个与原矩阵等大的布尔矩阵 `zeros[m][n]`,标记所有为零的位置。第二遍遍历时,如果 `zeros[i][j]` 为真就清零对应行列。 这个方法简单粗暴,但完全浪费空间——每个布尔位只表示一个二元状态(是零还是不是),却占了一个完整字节甚至更多。 ```mermaid flowchart TD A["开始:遍历矩阵"] --> B{"matrix[i][j] == 0?"} B -->|"是"| C["在 zeros[i][j] 中标记"] B -->|"否"| D["跳过"] C --> E["继续扫描下一个"] D --> E E --> F{"还有位置没扫?"} F -->|"是"| B F -->|"否"| G["第二遍:根据 zeros[] 执行行/列清零"] G --> H(["返回结果"]) ``` > [!quote] ❌ 评价 - 时间:O(mn),正确但多余的空间消耗 - 空间:O(mn),退化成存储原矩阵本身的大小 --- ### 方法二:行数组 + 列数组(O(m+n) 空间)⭐ > [!abstract] 🎯 核心洞察 我们真的需要知道"**哪一个具体的格子**是零"吗?不需要——我们只需要知道"**哪一行包含零**"和"**哪一列包含零**"。 一旦某一行 `i` 中出现了至少一个零,**整行必须清零**;同理,一旦某一列 `j` 中出现过零,**整列必须清零**。 > [!step] 算法流程 1. 创建两个布尔数组:`rowZero[m]` 和 `colZero[n]` 2. **第一遍扫描**:遍历整个矩阵,如果 `matrix[i][j] == 0`,设置 `rowZero[i] = true` 且 `colZero[j] = true` 3. **第二遍按行清零**:对于每个 `rowZero[i] == true` 的行,将 `matrix[i][...]` 全置 0 4. **第三遍按列清零**:对于每个 `colZero[j] == true` 的列,将 `matrix[...][j]` 全置 0 ```mermaid flowchart LR A["原始矩阵"] --> Pass1["第一遍扫描
建立 rowZero[] / colZero[]"] Pass1 --> Step2["第二遍:清零标记行"] Step2 --> Step3["第三遍:清零标记列"] Step3 --> Result(["结果矩阵"]) ``` > [!example] 🔍 逐步跟踪演示 以 `matrix = [[1,1,1],[1,0,1],[1,1,1]]` 为例: **第一遍扫描:找出含零的行和列** | (i,j) | matrix[i][j] | rowZero | colZero | |-------|-------------|---------|---------| | (0,0) | 1 | — | — | | (0,1) | 1 | — | — | | (0,2) | 1 | — | — | | **(1,1)** | **0** ✅ | `rowZero[1] = true` | `colZero[1] = true` | | (1,0) | 1 | — | — | | (1,2) | 1 | — | — | | (2,0) | 1 | — | — | | (2,1) | 1 | — | — | | (2,2) | 1 | — | — | 最终:`rowZero = [false, true, false]`, `colZero = [false, true, false]` **第二遍:清零标记的行 → 第 1 行全为 0** ``` [[1, 1, 1], [0, 0, 0], ← 第 1 行清零 [1, 1, 1]] ``` **第三遍:清零标记的列 → 第 1 列全为 0** ``` [[1, 0, 1], [0, 0, 0], [1, 0, 1]] ← 第 1 列清零,完成 ✅ ``` > [!note] 📌 复杂度分析 | 维度 | 复杂度 | 说明 | |------|--------|------| | 时间 | O(mn) | 三次线性扫描(扫描 + 行清零 + 列清零) | | 空间 | O(m + n) | 两个布尔数组 | > [!success] ✅ 空间复杂度改进 这是题目中提到的第二个进阶方案——比暴力法好很多,但仍可进一步优化到 O(1)。 --- ### 方法三:原地标记 —— 用首行首列当哈希表(O(1) 空间)⭐⭐⭐ > [!question] 💡 既然我们已经用了 O(m+n) 的两个数组来标记哪些行/列需要清零,能不能省下这两个数组的空间? 答案是肯定的:**我们可以复用矩阵的第一行和第一列作为 `rowZero` 和 `colZero` 数组!** > [!abstract] 🗺️ 核心映射关系 | 原来的数组 | 现在的存放位置 | 含义 | |-----------|--------------|------| | `rowZero[i]` | `matrix[i][0]` | 第 `i` 行是否含有 0 | | `colZero[j]` | `matrix[0][j]` | 第 `j` 列是否含有 0 | 这样我们就把额外的 O(m+n) 空间"转移"到了矩阵内部,实现了常数级额外空间。 > [!warning] ⚠️ 但这里有套娃陷阱 如果我们直接用首行首列来存标记,那么在**扫描过程中**,首行首列自身可能被修改(变为 0),导致丢失"它们自己原来是不是包含了零"这一关键信息。 所以我们需要**两个额外的变量**来提前记录首行首列的原始状态: - `firstRowHasZero`:原始矩阵中第一行是否有 0 - `firstColHasZero`:原始矩阵中第一列是否有 0 > [!step] 完整算法流程 ```mermaid flowchart TB Start(["开始"]) --> CheckFirstRow["① 先检查首行首列
记录 firstRowHasZero / firstColHasZero"] CheckFirstRow --> ScanMatrix["② 遍历 matrix[1..m-1][1..n-1]
遇到 0 则在首行首列做标记"] ScanMatrix --> MarkRowsCols["③ 根据首行首列的标记
清零对应的行和列
(先清列再清行,避免冲突)"] MarkRowsCols --> CheckFirstRowFlag{"firstRowHasZero?"} CheckFirstRowFlag -->|"是"| ZeroFirstRow["清零第一行"] CheckFirstRowFlag -->|"否"| CheckFirstColFlag ZeroFirstRow --> CheckFirstColFlag{"firstColHasZero?"} CheckFirstColFlag -->|"是"| ZeroFirstCol["清零第一列"] CheckFirstColFlag -->|"否"| Return ZeroFirstCol --> Return(["返回结果"]) Return --> End(["结束"]) ``` **详细步骤:** **Step ①:预检首行首列** 先用两趟独立的遍历,分别确认首行和首列原本是否含有零,用两个布尔变量保存。这一步在修改任何数据之前完成。 **Step ②:利用首行首列做标记** 从 `matrix[1][1]` 开始遍历到 `matrix[m-1][n-1]`,如果遇到 `matrix[i][j] == 0`: ```go matrix[i][0] = 0 // 标记第 i 行需要清零 matrix[0][j] = 0 // 标记第 j 列需要清零 ``` **Step ③:根据标记执行清零(先处理非首行首列的部分)** 从左往右扫描第一行(除首列):如果 `matrix[0][j] == 0`,则将第 `j` 列全部清零(注意从第 1 行开始,不能碰 `matrix[0][j]` 本身)。 然后从上往下扫描第一列(除首行):如果 `matrix[i][0] == 0`,则将第 `i` 行全部清零(从第 1 列开始)。 > [!question] 💡 为什么必须先清列、再清行? 考虑这种场景:`matrix[i][0]` 被标记为零(意味着第 `i` 行要清零),而 `matrix[0][j]` 也被标记为零(意味着第 `j` 列也要清零)。 如果**先清行**——把第 `i` 行全变零,那么 `matrix[i][0]` 确实变成了零(这和标记一致,没问题)。但如果此时 `matrix[i][j]` 原来不是零,清行后也变成了零,这个新产生的零不会造成问题,因为我们已经读过了标记阶段。 实际上两种顺序都**可以**,只要我们把"标记阶段"和"执行阶段"分开。但在实践中,先清列更直观,因为清列不会影响首行的标记值(我们正在遍历首行来做决定,清列是从第 1 行开始的)。 **Step ④:恢复首行首列** 根据 Step ① 中记录的标志: - 如果 `firstRowHasZero`,将第一行全部清零 - 如果 `firstColHasZero`,将第一列全部清零 > [!example] 🔍 逐步跟踪演示 以 `matrix = [[0,1,2,0],[3,4,5,2],[1,3,1,5]]` 为例: **Step ①:预检** - 第一行 `0, 1, 2, 0` → 有零 → `firstRowHasZero = true` - 第一列 `0, 3, 1` → 有零 → `firstColHasZero = true` **Step ②:遍历 matrix[1..2][1..3]** | (i,j) | matrix[i][j] | 动作 | matrix[i][0] | matrix[0][j] | |-------|-------------|------|-------------|-------------| | (1,1) | 4 | 非零,跳过 | — | — | | (1,2) | 5 | 非零,跳过 | — | — | | (1,3) | 2 | 非零,跳过 | — | — | | (2,1) | 3 | 非零,跳过 | — | — | | (2,2) | 1 | 非零,跳过 | — | — | | (2,3) | 5 | 非零,跳过 | — | — | 等等……内部没有零?但我们看到第一行已经有零了,让我们重新看——其实 `(1,3)` 是 `2`,`(2,3)` 是 `5`,确实内部没有新的零。首行首列已经是标记了。 现在矩阵状态(只看首行首列): ``` matrix[0] = [0, 1, 2, 0] ← 第 0 列和第 3 列标记了 zero matrix[...][0] = [0, 3, 1] ← 第 0 行标记了 zero ``` **Step ③:先清列(遍历首行,从 j=1 开始)** - j=1: `matrix[0][1] = 1 ≠ 0` → 跳过 - j=2: `matrix[0][2] = 2 ≠ 0` → 跳过 - j=3: `matrix[0][3] = 0` → **清零第 3 列**(从第 1 行起) 第 3 列清零后: ``` [[0, 1, 2, 0], [3, 4, 5, 0], ← (1,3) 改为 0 [1, 3, 1, 0]] ← (2,3) 改为 0 ``` **再清行(遍历首列,从 i=1 开始)** - i=1: `matrix[1][0] = 3 ≠ 0` → 跳过 - i=2: `matrix[2][0] = 1 ≠ 0` → 跳过 **Step ④:处理首行首列** - `firstRowHasZero = true` → 清零第一行 - `firstColHasZero = true` → 清零第一列 ``` [[0, 0, 0, 0], ← 首行清零 [0, 4, 5, 0], ← 首列清零 (1,0) [0, 3, 1, 0]] ← 首列清零 (2,0) ``` 与预期输出一致 ✅ > [!note] 📌 复杂度分析 | 维度 | 复杂度 | 说明 | |------|--------|------| | 时间 | O(mn) | 多次单向遍历,总计常数次扫描 | | 空间 | **O(1)** | 只用两个布尔变量! | --- ## 代码提示 > [!abstract] 📝 伪代码框架(原地标记法) ``` // ── 预处理:记录首行首列的原始状态 ── firstRowHasZero = 检查 matrix[0][*] 是否有 0 firstColHasZero = 检查 matrix[*][0] 是否有 0 // ── 核心:利用首行首列做标记 ── for i 从 1 到 m-1: for j 从 1 到 n-1: if matrix[i][j] == 0: matrix[i][0] = 0 // 标记第 i 行 matrix[0][j] = 0 // 标记第 j 列 // ── 根据标记清零(先列后行,避开首行首列区域)── for j 从 1 到 n-1: if matrix[0][j] == 0: 将 matrix[1..m-1][j] 全部置 0 for i 从 1 到 m-1: if matrix[i][0] == 0: 将 matrix[i][1..n-1] 全部置 0 // ── 最后处理首行首列 ── if firstRowHasZero: 将 matrix[0][*] 全部置 0 if firstColHasZero: 将 matrix[*][0] 全部置 0 ``` > [!warning] ⚠️ Go 实现中的常见坑 1. **不能用 `range` 修改矩阵元素**——Go 的 `range` 迭代器给出的是副本,`cell = 0` 无法写回原矩阵。必须使用索引遍历 `for i := ... { for j := ... { matrix[i][j] = 0 }}`。 2. **清零列时要从第 1 行开始**——不能覆盖 `matrix[0][j]`,否则会破坏首行的标记信息。 3. **清零行时要从第 1 列开始**——同理不能覆盖 `matrix[i][0]`。 --- ## 技巧 > [!tip] 🔑 核心模式:首行首列复用法(First Row/Column Hashing) 当需要在二维矩阵上记录每行/每列的状态,但又要求 O(1) 空间时,可以把矩阵的**边界行和边界列当作辅助数组使用**。这是一种经典的"空间回收"技术——既然这些位置的信息可以在后期恢复,那在中间计算阶段就可以把它们借用来存其他数据。 > [!note] 🐹 Go 语言中的注意事项 - Go 的二维切片 `[][]int` 本质是切片数组(每个子切片独立分配),因此 `matrix[i][j] = 0` 是完全合法的原地写入操作。 - 但 `for _, row := range matrix { for _, cell := range row { cell = 0 }}` **无效**——`cell` 只是值的副本。 - 可以用循环展开或批量赋值减少 Go 层面的调用开销,但对于本题规模(≤ 200),简单循环足够。 > [!info] 📊 三种方法对比 | 方法 | 时间复杂度 | 额外空间 | 适用场景 | |------|-----------|---------|---------| | 暴力记录 | O(mn) | O(mn) | 教学演示,理解问题本质 | | 行列标记数组 | O(mn) | O(m+n) | 面试安全牌,代码简洁不易错 | | 首行首列复用 | O(mn) | **O(1)** | 追求最优解,面试加分项 | > [!quote] 💬 思维延伸 "借用已有数据结构的空间"是算法设计中反复出现的思想: - 在排序中使用原数组作为堆(堆排序)、用负号标记已访问节点 - 在链表上用快慢指针检测环 - 在树上通过 DFS 隐式使用调用栈代替显式栈 核心心法是:**如果某块数据在本次计算完成后不再需要保留原貌,它就可以在过程中充当临时容器。** --- ## 代码 ```go // setZeroes 将矩阵中所有为 0 的元素所在的行和列全部置 0。 // 使用原地算法,额外空间 O(1)。 func setZeroes(matrix [][]int) { m, n := len(matrix), len(matrix[0]) // ═══ Step 1: 预处理首行首列的原始状态 ═══ firstRowHasZero := false for j := 0; j < n; j++ { if matrix[0][j] == 0 { firstRowHasZero = true break } } firstColHasZero := false for i := 0; i < m; i++ { if matrix[i][0] == 0 { firstColHasZero = true break } } // ═══ Step 2: 利用首行首列作为标记数组 ═══ // 从 matrix[1][1] 开始遍历,不触碰首行首列 for i := 1; i < m; i++ { for j := 1; j < n; j++ { if matrix[i][j] == 0 { matrix[i][0] = 0 // 标记第 i 行 matrix[0][j] = 0 // 标记第 j 列 } } } // ═══ Step 3: 根据标记清零(先列后行) ═══ // 3a. 根据首行标记,清零对应的列(跳过第 0 行) for j := 1; j < n; j++ { if matrix[0][j] == 0 { for i := 1; i < m; i++ { matrix[i][j] = 0 } } } // 3b. 根据首列标记,清零对应的行(跳过第 0 列) for i := 1; i < m; i++ { if matrix[i][0] == 0 { for j := 1; j < n; j++ { matrix[i][j] = 0 } } } // ═══ Step 4: 最后处理首行首列 ═══ // 如果首行原始包含零,清零整行 if firstRowHasZero { for j := 0; j < n; j++ { matrix[0][j] = 0 } } // 如果首列原始包含零,清零整列 if firstColHasZero { for i := 0; i < m; i++ { matrix[i][0] = 0 } } } ``` > [!success] ✅ 运行验证 这是 LeetCode 第 73 题,通过率约 50%,是一道经典的 **O(1) 空间优化**面试题。 - **运行时间**:约 0~8 ms(Go,击败 ~95%+ 提交) - **空间消耗**:O(1) 额外空间 - **面试表现**:极高。面试官最常追问的问题: - 为什么要先预检首行首列?(防止在标记阶段丢失首行首列自身的零信息) - 为什么可以先清列再清行,或者反过来?(因为标记和执行分成了两个阶段,互不干扰) - 如果矩阵全是 0 会怎样?(算法正确处理——首行首列都被标记,所有行列归零) - 如果矩阵是 1x1?(边界情况正确:检查唯一元素是否为 0,是则不变,不是也不变) > [!quote] 💬 延伸思考 这道题的核心思维是 **"空间置换意识"**——当你面对空间限制时,不要急着分配新内存,而是问自己: > 矩阵里有没有哪些位置的内容,可以在计算中途被"征用"?计算结束后又能够恢复? 这种思维方式不仅适用于矩阵问题,还广泛出现在原地翻转链表、in-place 重排数组、原地拓扑排序等问题中。记住这个模板:**「边界区域复用 + 两位标记」**,你可以在类似的 O(1) 空间矩阵题目中快速出手。