LeetCode 542. 01 矩阵
题目描述
题意分析
给定一个只含 0 和 1 的矩阵,要求为每个格子算出它到最近的 0 的距离,距离按上下左右四方向相邻计一步。输出是一个同样大小的矩阵。
值得注意的是「最近的 0」并不指定是哪一个 0,也就是说每个 1 的答案由离它最近的那个 0 决定,而不同的 1 可以对应不同的 0。这提示答案不是围绕单个源点展开的。
约束里保证矩阵中至少有一个 0,所以不存在无解格子,不需要处理无穷大的输出。矩阵规模可达一万行一万列量级、但总格数有限,说明允许的总代价大致是把每个格子处理常数次。
边界情况有两类:格子本身是 0 时答案为 0;整个矩阵全是 0 时输出与输入同形且全零。另外要留意 1 的数量可能远多于 0,也可能远少于 0,解法不该对这两种分布有偏好。
解法:多源 BFS
核心思路
直觉解法是对每个值为 1 的格子单独做一次广度优先搜索,一路扩散直到碰到第一个 0。结果正确,但代价是「1 的个数」乘以「一次搜索的代价」,在几乎全是 1 的矩阵上会退化成格子数的平方级别。
瓶颈在于同一片区域被反复扫描:相邻的两个 1 走的扩散路径高度重合,却各算各的。反过来想,如果把搜索的方向倒过来——不是从 1 出发找 0,而是从所有 0 出发向外扩散——那么每个 0 的影响范围会自然拼接,整张图只需要扫一遍。
把所有 0 同时放进队列作为第 0 层,等价于在原图外面虚拟一个超级源点,它到每个 0 的边权为 0,再从这个超级源点做一次普通的广度优先搜索。由于每条边的权重都是 1,广度优先搜索按层扩散的性质保证了:一个格子第一次被弹出的时刻所记录的距离,就是它到最近 0 的最短距离,此后不会再被更短的路径改写。这就是本题的不变量。
具体到实现,用距离矩阵本身兼作访问标记:0 的位置初始化为 0,1 的位置初始化为 -1 表示未确定。这样就不需要额外的 visited 数组,判断「是否已定值」和「取距离」是同一次读取。
解题步骤
- 扫一遍矩阵,把所有 0 的坐标入队并把距离置 0,把 1 的距离置 -1。之所以要一次性全部入队,是因为这些 0 共同构成搜索的第 0 层,只有同时出发才能保证扩散的波前是按真实距离推进的。
- 用两个整型数组加头尾下标模拟队列。总入队次数不会超过格子总数,所以定长数组足够,省掉了链式队列的分配开销。
- 循环从队头取出坐标,用方向数组
{-1, 0, 1, 0, -1}的相邻两项组成四个方向的偏移。这种写法比写四个 if 更短,也不容易漏方向。- 对每个邻居先做越界判断,再看它的距离是否仍是 -1。只有仍是 -1 才更新为当前距离加一并入队;已经有值说明它在更早或同一层就被确定过,那个值只会更小或相等。
- 关键在于「赋值即入队」:更新距离的同时立刻入队,等价于在入队时刻就完成了访问标记。若把标记推迟到出队,同一个格子会被多个邻居重复推进队列。
- 队列排空后距离矩阵即为答案,直接返回,不需要额外一轮转换。
以
mat = [[0,0,0],[0,1,0],[1,1,1]]走一遍:初始化后dist为[[0,0,0],[0,-1,0],[-1,-1,-1]],队列依次装入 (0,0)、(0,1)、(0,2)、(1,0)、(1,2) 这五个 0。弹出 (0,0),四个邻居中 (1,0) 和 (0,1) 的距离都不是 -1,无事发生。弹出 (0,1),下方 (1,1) 距离为 -1,置为 $0 + 1 = 1$ 并入队。弹出 (0,2),下方 (1,2) 已是 0,跳过。弹出 (1,0),下方 (2,0) 为 -1,置为 $0 + 1 = 1$ 并入队;右方 (1,1) 此刻已是 1,跳过。弹出 (1,2),下方 (2,2) 为 -1,置为 1 并入队。
第一层处理完,队列里是 (1,1)、(2,0)、(2,2),距离都是 1。弹出 (1,1),下方 (2,1) 为 -1,置为 $1 + 1 = 2$ 并入队。弹出 (2,0),右方 (2,1) 已是 2,跳过。弹出 (2,2),左方 (2,1) 同样跳过。最后弹出 (2,1),四周再无 -1。
队列排空,
dist为[[0,0,0],[0,1,0],[1,2,1]]。注意 (2,1) 的答案 2 来自 (1,1) 这条路径,而 (2,0) 的答案 1 来自另一个 0,这正是多源扩散相对单源搜索的价值所在。
代码实现
class Solution {
// 如果从每个 1 分别 BFS,复杂度会乘以 1 的数量,双重遍历很重。
public int[][] updateMatrix(int[][] mat) {
int m = mat.length;
int n = mat[0].length;
int[][] dist = new int[m][n];
int[] qx = new int[m * n];
int[] qy = new int[m * n];
int head = 0;
int tail = 0;
int[] dirs = new int[] {-1, 0, 1, 0, -1};
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (mat[i][j] == 0) {
qx[tail] = i;
qy[tail] = j;
tail++;
dist[i][j] = 0;
} else {
dist[i][j] = -1;
}
}
}
while (head < tail) {
int x = qx[head];
int y = qy[head];
head++;
for (int d = 0; d < 4; d++) {
int nx = x + dirs[d];
int ny = y + dirs[d + 1];
if (nx < 0 || ny < 0 || nx >= m || ny >= n || dist[nx][ny] != -1) {
continue;
}
dist[nx][ny] = dist[x][y] + 1;
qx[tail] = nx;
qy[tail] = ny;
tail++;
}
}
return dist;
}
}
func updateMatrix(mat [][]int) [][]int {
// 如果从每个 1 分别 BFS,复杂度会乘以 1 的数量,双重遍历很重。
m := len(mat)
n := len(mat[0])
dist := make([][]int, m)
qx := make([]int, m*n)
qy := make([]int, m*n)
head, tail := 0, 0
dirs := []int{-1, 0, 1, 0, -1}
for i := 0; i < m; i++ {
dist[i] = make([]int, n)
for j := 0; j < n; j++ {
if mat[i][j] == 0 {
dist[i][j] = 0
qx[tail] = i
qy[tail] = j
tail++
} else {
dist[i][j] = -1
}
}
}
for head < tail {
x := qx[head]
y := qy[head]
head++
for d := 0; d < 4; d++ {
nx := x + dirs[d]
ny := y + dirs[d+1]
if nx < 0 || ny < 0 || nx >= m || ny >= n {
continue
}
if dist[nx][ny] != -1 {
continue
}
dist[nx][ny] = dist[x][y] + 1
qx[tail] = nx
qy[tail] = ny
tail++
}
}
return dist
}
复杂度分析
- 时间复杂度:$O(m \cdot n)$,每个格子只在距离从 -1 变为确定值的那一刻入队一次,出队后固定检查 4 个方向,所以总操作量是格子数的常数倍,与 0 和 1 的分布无关。
- 空间复杂度:$O(m \cdot n)$,距离矩阵是必须的输出,队列在最坏情况(矩阵全为 0)下会同时容纳所有格子,两者都是格子数量级。
关键点总结
- 「每个点到最近的某类点的距离」是多源最短路的标准信号,方向应当反转:从所有目标点同时出发,而不是从每个查询点各走一次。
- 多源广度优先搜索等价于加一个到所有源点边权为 0 的超级源点,理解了这个等价关系,就不必怀疑同时入队是否会破坏层序性质。
- 边权全为 1 是广度优先搜索能给出最短路的前提。一旦边权不等,就必须换成优先队列,这条界限在面试里经常被追问。
- 用结果数组的哨兵值(这里是 -1)兼作访问标记,既省一个数组,也让「已确定」和「取值」合并为一次读取,减少状态不一致的可能。
- 标记时机必须是入队时而非出队时。这是广度优先搜索最常见的效率与正确性分水岭,写多源版本时尤其容易忽略。
- 面试视角:先说出朴素解法及其复杂度上界,再点明「反向扩散」的观察,最后才写代码。能主动补一句「若把 0 和 1 的角色对调,或者改成八连通,代码只需改初始化和方向数组」,会显著提升评价。
易错点总结
- 错误写法:对每个值为 1 的格子各做一次广度优先搜索。
mat为 200×200 且只有右下角一个 0 → 近四万个格子各自扩散一遍,总操作量达到十亿量级,直接超时。- 错误写法:只把第一个遇到的 0 入队,然后再逐个补。
[[0,0,0],[0,1,0],[1,1,1]]→ 波前不再按真实距离推进,(2,0) 会先从远处的 0 拿到偏大的值并被锁定,输出[[0,0,0],[0,1,0],[2,2,1]]之类的错误结果。- 错误写法:把访问标记放在出队时。全 0 矩阵 → 每个格子被四个邻居各推一次,队列长度膨胀到格子数的四倍,预分配的定长队列数组越界。
- 错误写法:把 1 的初始距离设成 0 而不是 -1。
[[0,1]]→ 判断「是否已确定」时把真正未访问的格子误当成已确定,(0,1) 永远不会被更新,输出[[0,0]]。- 错误写法:用一个足够大的正数(如
Integer.MAX_VALUE)作未访问标记,更新时写成dist[nx][ny] = dist[x][y] + 1却不判断是否已访问。任何含 1 的用例 → 已确定的格子被后来的更长路径覆盖,答案偏大。- 错误写法:方向数组写成
{-1, 0, 1, 0}并按dirs[d]与dirs[d + 1]取偏移。任意矩阵 → 第四个方向读到下标 4 越界,或用取模绕回后重复了第一个方向而漏掉左方,最左侧的 1 拿不到正确距离。- 错误写法:越界判断写在读取
dist[nx][ny]之后。第 0 行任意格子 → 先访问dist[-1][y]就已经抛出下标异常。- 错误写法:假设矩阵是方阵,用同一个边长同时判断行和列。
[[0,0,0,0,1]]这类扁平矩阵 → 列方向的合法下标被误判为越界,右侧的 1 保持 -1 不变。- 错误写法:入队后忘记推进队尾下标,或出队后忘记推进队头下标。任意含 1 的用例 → 前者让新格子被后续写入覆盖导致漏搜,后者让循环永远处理同一个坐标而死循环。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 994. 腐烂的橘子 | 中等 | 同为多源扩散,但要的是总层数并判断是否有剩余 |
| 1162. 地图分析 | 中等 | 多源扩散后取所有距离的最大值 |
| 1091. 二进制矩阵中的最短路径 | 中等 | 单源八连通网格,答案是经过的格子数 |
| 1293. 网格中的最短路径 | 困难 | 状态要附加剩余消除次数,访问标记升到三维 |
| 909. 蛇梯棋 | 中等 | 棋盘一维化并处理蛇与梯子造成的跳转边 |
| 1129. 颜色交替的最短路径 | 中等 | 状态要记住上一条边的颜色,同点两份距离 |
| 127. 单词接龙 | 困难 | 隐式图,邻居靠逐位替换字符临时枚举 |
| 433. 最小基因变化 | 中等 | 字符集只有四个,可直接暴力枚举全部邻居 |
| 752. 打开转盘锁 | 中等 | 带禁止状态的转盘图,适合双向搜索 |
| 773. 滑动谜题 | 困难 | 把棋盘序列化成字符串当作图节点 |
| 854. 相似度为 K 的字符串 | 困难 | 邻居由交换生成,需剪掉无意义的交换 |
| 1345. 跳跃游戏 IV | 困难 | 等值下标之间的超级边用完必须清空 |
| 1654. 到家的最少跳跃次数 | 中等 | 一维无界坐标,需要先推导搜索范围上界 |
| LCP 09. 最小跳跃次数 | 困难 | 单向弹簧图,用已扩散前缀避免重复回退 |
| 1298. 你能从盒子里获得的最大糖果数 | 困难 | 队列驱动的可达性传播,钥匙与盒子互相解锁 |
| LCR 108. 单词接龙 | 困难 | 127 的 LCR 编号,可用来复核模板熟练度 |
| LCR 109. 打开转盘锁 | 中等 | 752 的 LCR 编号,注意起点即禁止态的边界 |