Files
leetcode-go/矩阵/18-矩阵置零.md
T

476 lines
16 KiB
Markdown
Raw Normal View History

2026-05-17 09:38:35 +08:00
---
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["第一遍扫描<br/>建立 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["① 先检查首行首列<br/>记录 firstRowHasZero / firstColHasZero"]
CheckFirstRow --> ScanMatrix["② 遍历 matrix[1..m-1][1..n-1]<br/>遇到 0 则在首行首列做标记"]
ScanMatrix --> MarkRowsCols["③ 根据首行首列的标记<br/>清零对应的行和列<br/>(先清列再清行,避免冲突)"]
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) 空间矩阵题目中快速出手。