一、题目描述
给定一个 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) 的额外空间,但这仍然不是最好的解决方案
- 你能想出一个仅使用常量空间的解决方案吗
二、解题思路总览
核心问题: 如何记录哪些行和列需要置零,同时不破坏原矩阵?
解决方案:两次遍历 + 额外数组记录
| 遍历 | 操作 |
|---|---|
| 第一次遍历 | 扫描整个矩阵,遇到 0 就记录该行该列需要置零 |
| 第二次遍历 | 根据记录,将对应行和列的所有元素置零 |
| 方案 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 暴力解(每个0都遍历行列) | O(mn * (m+n)) | O(1) |
| 额外数组记录(本题) | O(mn) | O(m+n) |
| 原地算法(进阶) | O(mn) | O(1) |
三、完整代码
cpp
class Solution {
public:
void setZeroes(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
vector<int> row(m, 0); // 记录哪些行需要置零
vector<int> col(n, 0); // 记录哪些列需要置零
// 第一次遍历:扫描矩阵,记录需要置零的行和列
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (matrix[i][j] == 0) {
row[i] = 1;
col[j] = 1;
}
}
}
// 第二次遍历:根据记录,将对应行和列置零
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (row[i] || col[j]) {
matrix[i][j] = 0;
}
}
}
}
};
四、算法流程图
4.1 整体流程
输入:matrix(m x n 矩阵)
[Step 1] 获取矩阵尺寸
m = matrix.size()
n = matrix[0].size()
|
v
[Step 2] 创建辅助数组
row = vector<int>(m, 0)
col = vector<int>(n, 0)
|
v
[Step 3] 第一次遍历:扫描并标记
|
v
for i = 0 to m-1:
for j = 0 to n-1:
matrix[i][j] == 0 ?
|是 |否
v v
row[i] = 1 继续
col[j] = 1 |
| v
+<---- 继续内层循环 --+
|
v
内层循环结束
|
v
回到外层循环或下一步
|
v
[Step 4] 第二次遍历:置零
|
v
for i = 0 to m-1:
for j = 0 to n-1:
row[i] || col[j] ?
|是 |否
v v
matrix[i][j] = 0 继续(不变)
| |
v v
【返回】 【返回】
4.2 第一次遍历(标记)详细流程
外层循环:i = 0 to m-1
内层循环:j = 0 to n-1
|
v
matrix[i][j] == 0 ?
|否
v
继续 j++
|是
v
row[i] = 1
col[j] = 1
|
v
继续 j++
内层循环 j 结束后
|
v
i++ 继续外层循环
所有遍历完成后:
row 数组标记了所有含 0 的行
col 数组标记了所有含 0 的列
4.3 第二次遍历(置零)详细流程
外层循环:i = 0 to m-1
内层循环:j = 0 to n-1
|
v
row[i] == 1 或 col[j] == 1 ?
|是
v
matrix[i][j] = 0
|
v
继续 j++
|否(该位置不变)
|
v
继续 j++
内层循环 j 结束后
|
v
i++ 继续外层循环
4.4 具体示例执行流程
输入矩阵:
[[1, 1, 1],
[1, 0, 1],
[1, 1, 1]]
m = 3, n = 3
row = [0, 0, 0]
col = [0, 0, 0]
第一次遍历(标记):
i=0, j=0: matrix[0][0]=1 != 0 → 跳过
i=0, j=1: matrix[0][1]=1 != 0 → 跳过
i=0, j=2: matrix[0][2]=1 != 0 → 跳过
i=1, j=0: matrix[1][0]=1 != 0 → 跳过
i=1, j=1: matrix[1][1]=0 == 0 → row[1]=1, col[1]=1
i=1, j=2: matrix[1][2]=1 != 0 → 跳过
i=2, j=0: matrix[2][0]=1 != 0 → 跳过
i=2, j=1: matrix[2][1]=1 != 0 → 跳过
i=2, j=2: matrix[2][2]=1 != 0 → 跳过
标记结果:
row = [0, 1, 0]
col = [0, 1, 0]
第二次遍历(置零):
i=0, j=0: row[0]=0 且 col[0]=0 → 保持 1
i=0, j=1: row[0]=0 但 col[1]=1 → 置零
i=0, j=2: row[0]=0 且 col[2]=0 → 保持 1
i=1, j=0: row[1]=1 → 置零
i=1, j=1: row[1]=1 且 col[1]=1 → 置零
i=1, j=2: row[1]=1 → 置零
i=2, j=0: row[2]=0 且 col[0]=0 → 保持 1
i=2, j=1: row[2]=0 但 col[1]=1 → 置零
i=2, j=2: row[2]=0 且 col[2]=0 → 保持 1
输出矩阵:
[[1, 0, 1],
[0, 0, 0],
[1, 0, 1]]
五、逐行解析
5.1 创建辅助数组
cpp
vector<int> row(m, 0); // 记录哪些行需要置零,初始化全为 0
vector<int> col(n, 0); // 记录哪些列需要置零,初始化全为 0
原理: 用两个一维数组分别记录需要置零的行和列。数组下标对应行号或列号,值为 1 表示需要置零。
空间复杂度: O(m + n)
5.2 第一次遍历:标记
cpp
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (matrix[i][j] == 0) {
row[i] = 1;
col[j] = 1;
}
}
}
原理: 遍历整个矩阵,找到所有值为 0 的元素,将其所在行和列标记。
时间复杂度: O(m * n)
5.3 第二次遍历:置零
cpp
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (row[i] || col[j]) {
matrix[i][j] = 0;
}
}
}
原理: 再次遍历矩阵,如果当前元素所在行或列被标记过(row[i] || col[j] 为真),则将该元素置零。
逻辑: 只要行被标记 OR 列被标记,该元素就需要置零。
六、进阶:原地算法 O(1) 空间
6.1 核心思想
用矩阵的第一行和第一列作为标记数组,代替 row 和 col 的作用。
问题: 如何区分「原本就是 0」和「被置零后变成 0」?
解决方案: 在遍历前,先判断第一行和第一列是否需要置零,然后用它们作为标记。
6.2 进阶代码
cpp
class Solution {
public:
void setZeroes(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
// 判断第一行和第一列是否需要置零
bool firstRowZero = false;
bool firstColZero = false;
for (int j = 0; j < n; j++) {
if (matrix[0][j] == 0) {
firstRowZero = true;
break;
}
}
for (int i = 0; i < m; i++) {
if (matrix[i][0] == 0) {
firstColZero = true;
break;
}
}
// 用第一行和第一列作为标记
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (matrix[i][j] == 0) {
matrix[i][0] = 0; // 标记第 i 行
matrix[0][j] = 0; // 标记第 j 列
}
}
}
// 根据标记置零(跳过第一行和第一列)
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (matrix[i][0] == 0 || matrix[0][j] == 0) {
matrix[i][j] = 0;
}
}
}
// 处理第一行和第一列
if (firstRowZero) {
for (int j = 0; j < n; j++) {
matrix[0][j] = 0;
}
}
if (firstColZero) {
for (int i = 0; i < m; i++) {
matrix[i][0] = 0;
}
}
}
};
6.3 进阶流程图
[Step 1] 判断第一行是否含 0
firstRowZero = matrix[0][j] == 0 ?
|
v
[Step 2] 判断第一列是否含 0
firstColZero = matrix[i][0] == 0 ?
|
v
[Step 3] 用第一行和第一列作为标记数组
for i = 1 to m-1:
for j = 1 to n-1:
matrix[i][j] == 0 ?
|是
v
matrix[i][0] = 0 // 标记行
matrix[0][j] = 0 // 标记列
|
v
[Step 4] 根据标记置零(排除第一行第一列)
for i = 1 to m-1:
for j = 1 to n-1:
matrix[i][0] == 0 或 matrix[0][j] == 0 ?
|是
v
matrix[i][j] = 0
|
v
[Step 5] 处理第一行
firstRowZero ?
|是
v
matrix[0][j] = 0 for all j
|
v
[Step 6] 处理第一列
firstColZero ?
|是
v
matrix[i][0] = 0 for all i
6.4 三种方案对比
| 方案 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 暴力解 | O(mn * (m+n)) | O(1) | 每个0都遍历行列,时间太慢 |
| 额外数组(本题) | O(mn) | O(m+n) | 两遍遍历,空间稍大 |
| 原地算法(进阶) | O(mn) | O(1) | 用第一行/列作为标记,最优 |
七、复杂度分析
7.1 本题解法
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(m * n) | 两次遍历矩阵 |
| 空间复杂度 | O(m + n) | row 数组 m 个,col 数组 n 个 |
7.2 进阶原地算法
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(m * n) | 三次遍历矩阵 |
| 空间复杂度 | O(1) | 只用几个布尔变量 |
八、面试追问
| 问题 | 回答要点 |
|---|---|
| 为什么需要两次遍历? | 第一次标记(记录哪些行/列要置零),第二次执行置零。如果边标记边置零,会导致后续判断出错 |
| row[i] | |
| 空间复杂度能进一步优化吗? | 可以,用矩阵第一行和第一列作为标记数组,实现 O(1) 空间 |
| 原地算法中 firstRowZero 的作用? | 记录第一行本身是否需要置零(因为第一行会被用作标记,不能直接置零) |
| 为什么原地算法要跳过第一行第一列? | 第一行和第一列被用作标记数组,如果对它们执行置零操作,会丢失标记信息 |
| 原地算法如何恢复第一行第一列? | 最后根据 firstRowZero 和 firstColZero 单独处理第一行和第一列 |
| 这个题有没有其他解法? | 还有一种方案是设置一个 sentinel 值(如 INFINITY)来标记,但会改变矩阵范围外的状态,不推荐 |
九、相关题目
| 题号 | 题目 | 关键点 |
|---|---|---|
| 73 | 矩阵置零 | 本题 |
| 48 | 旋转图像 | 矩阵旋转 |
| 54 | 螺旋矩阵 | 矩阵遍历 |
| 59 | 螺旋矩阵 II | 矩阵生成 |
| 289 | 生命游戏 | 原地算法,用位操作标记 |