Files

16 KiB
Raw Permalink Blame History

tags, create time
tags create time
LeetCode
矩阵
中等
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] 为真就清零对应行列。

这个方法简单粗暴,但完全浪费空间——每个布尔位只表示一个二元状态(是零还是不是),却占了一个完整字节甚至更多。

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
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] 完整算法流程

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:

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 隐式使用调用栈代替显式栈

核心心法是:如果某块数据在本次计算完成后不再需要保留原貌,它就可以在过程中充当临时容器。


代码

// 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) 空间矩阵题目中快速出手。