目录

题目描述

LCR 107. 01 矩阵

题意分析

给一个只含 0 和 1 的 m × n 矩阵,对每一个格子求出它到最近的 0 的距离,距离按四方向相邻计算(即曼哈顿意义下的网格步数)。返回一个同样大小的矩阵。

要点在「每一个」和「最近」这两个词上。它不是问某一个点到某一个点的距离,而是所有点到「最近的某一个 0」的距离,本质上是一个多源最短路问题:把所有 0 看成同一个源点集合,求全图到这个集合的距离场。

边权全都是 1,这是最关键的算法信号。边权一致的最短路 = BFS,不需要 Dijkstra,也不需要优先队列。识别出这一点,整道题就只剩实现细节。

约束里 m * n ≤ 10^4,并且题目保证矩阵中至少有一个 0。后者很重要:它保证了每个格子都有解,不会出现「无法到达」的情形,省掉了对 -1 之类不可达值的处理。

「距离场」的语义还带来一条隐含性质:0 所在格子的答案恒为 0,1 所在格子的答案至少为 1。这可以当作写完代码后的自检。

边界:矩阵可能只有一行或一列,越界判断必须四边都写全;矩阵可能全是 0,此时答案就是原矩阵;1 的连片区域可能很大,距离最大能到 $m + n$ 量级,用 int 存绰绰有余。

解法:动态规划递推

核心思路

最直接的想法是对每个 1 单独做一次搜索找最近的 0,或者干脆对每个 1 枚举所有 0 取最小曼哈顿距离。前者是 $mn$ 次 BFS,后者是 $O((mn)^2)$ 的两两配对,在 $10^4$ 个格子的规模下都要退化到 $10^8$ 量级。

瓶颈在于把「多个源」当成了「多次单源」来做,同一片区域被反复扫过。而这些搜索之间高度重叠:一个格子只关心离它最近的那个 0,其余 0 的搜索对它是纯粹的浪费。

反过来想:与其从每个 1 出发去找 0,不如从所有 0 同时出发向外扩散。把全部 0 一次性放进队列作为第 0 层,然后一层一层地向外推进,某个格子第一次被触达时所处的层号,就是它到最近 0 的距离。这就是多源 BFS——它和单源 BFS 的唯一区别只是初始队列里有多个起点。

正确性来自 BFS 的层序性质。不变量是:队列中的元素按距离非递减排列,且任何格子被第一次赋值时得到的就是它的最终答案。因为所有源点同时以速度 1 向外扩散,最先碰到某个格子的那条波前,一定来自离它最近的那个 0。

于是状态就是答案矩阵本身:answer[i][j] 表示 (i,j) 到最近 0 的距离,同时兼作「是否已被访问」的标记。初始化时把它全部填成 -1 表示未访问,再把所有 0 的位置置 0 并入队。

转移:从队列取出 (x, y),对四个方向的邻居 (nx, ny),若 answer[nx][ny] == -1(尚未被任何波前触达),则赋值 answer[x][y] + 1 并入队。用 -1 而不是另开一个 visited 数组,是因为「已赋值」与「已访问」在这道题里是同一件事,一个矩阵同时承担两种语义可以省掉一半空间。

队列清空时,每个格子都已被赋值,直接返回答案矩阵。

解题步骤

  • 新建与输入同规模的 answer 矩阵并整体填 -1。为什么用 -1 而不是 0:0 是合法的距离值(0 所在的格子),拿它当「未访问」标记会与真实答案混淆;-1 不可能是任何格子的答案,天然可区分。
  • 扫描整个矩阵,把每个值为 0 的位置的 answer 置 0 并全部入队。为什么要一次性全放进去:这是多源 BFS 的核心,只有让所有源点同处第 0 层,后续的层号才等于「到最近源点的距离」。若只放一个 0 再逐个跑,就退化成多次单源搜索。
  • 准备方向数组 {-1, 0, 1, 0, -1}。为什么这么写:相邻两项构成一组偏移,滑动取出 (-1,0)(0,1)(1,0)(0,-1) 恰好四方向,比四段 if 更短且不会误引入对角线。
  • 循环出队直到队列为空。为什么用队列而不是栈:队列的先进先出保证了扩散按层推进;换成栈就变成 DFS,第一次触达不再是最短距离,答案会偏大。
  • 对每个邻居,先判越界,再判 answer[nx][ny] == -1,成立则赋 answer[x][y] + 1 并入队。为什么两个判断缺一不可:越界判断防止下标非法,未访问判断保证每个格子只被赋值一次——已经有值说明它被更早(也就是更近)的波前触达过,再改只会变大。
  • 必须在入队的同时完成赋值,而不是等出队时再赋。为什么:若只入队不标记,同一个格子会被四个邻居重复推入队列,队列规模膨胀,还可能被后到的、更长的路径覆盖。赋值即标记,是 BFS 去重的标准做法。
  • 返回 answer。为什么不需要额外检查:题目保证至少存在一个 0,所以初始队列非空、扩散能覆盖全图,不会有格子停留在 -1

mat = [[0,0,0],[0,1,0],[1,1,1]] 走一遍。

初始化后 answer 全为 -1。扫描发现 0 的位置有 (0,0)(0,1)(0,2)(1,0)(1,2),把它们的 answer 置 0 并按行优先顺序入队。此时 answer[[0,0,0],[0,-1,0],[-1,-1,-1]],队列里是这五个源点,构成第 0 层。

出队 (0,0):上方越界、左方越界;右邻 (0,1) 的值是 0 不是 -1,跳过;下邻 (1,0) 同理跳过。没有新增。

出队 (0,1):下邻 (1,1)-1,赋值 answer[0][1] + 1 = 1 并入队。其余邻居都已有值。

出队 (0,2):下邻 (1,2) 已是 0,跳过。

出队 (1,0):下邻 (2,0)-1,赋值 0 + 1 = 1 并入队;右邻 (1,1) 此刻已经是 1,跳过——这里正体现了「先到先得」,(1,1) 保留了来自 (0,1) 的更早赋值。

出队 (1,2):下邻 (2,2)-1,赋值 1 并入队。

第 0 层耗尽,队列里剩下 (1,1)(2,0)(2,2),都是距离 1 的格子,构成第 1 层。

出队 (1,1):下邻 (2,1)-1,赋值 answer[1][1] + 1 = 2 并入队。

出队 (2,0):右邻 (2,1) 此刻已是 2,跳过;上邻已有值。

出队 (2,2):左邻 (2,1) 已有值,跳过。

出队 (2,1):四邻全部有值,无新增。队列清空。

最终 answer = [[0,0,0],[0,1,0],[1,2,1]]。可以验证 (2,1) 到最近的 0((1,0)(1,2))确实要走两步,而它的两个横向邻居只需一步,与「层号即距离」完全吻合。

如果把队列换成栈,(2,1) 可能先沿着 (1,1) → (2,1) 之外的更长路径被触达,得到 3 而不是 2,答案就错了——这是理解 BFS 为什么必须用队列的最直观反例。

代码实现

class Solution {

    public int[][] updateMatrix(int[][] mat) {
        int m = mat.length, n = mat[0].length;
        int[][] answer = new int[m][n];
        // -1 表示尚未被任何波前触达,与合法距离 0 区分开。
        for (int i = 0; i < m; ++i) {
            Arrays.fill(answer[i], -1);
        }
        Deque<int[]> q = new LinkedList<>();
        // 所有 0 同时作为第 0 层入队,这是多源 BFS 的关键。
        for (int i = 0; i < m; ++i) {
            for (int j = 0; j < n; ++j) {
                if (mat[i][j] == 0) {
                    answer[i][j] = 0;
                    q.offer(new int[] {i, j});
                }
            }
        }
        int[] dirs = new int[] {-1, 0, 1, 0, -1};
        while (!q.isEmpty()) {
            int[] t = q.poll();
            for (int i = 0; i < 4; ++i) {
                int x = t[0] + dirs[i];
                int y = t[1] + dirs[i + 1];
                // 赋值即标记:第一次被触达时得到的就是最短距离。
                if (x >= 0 && x < m && y >= 0 && y < n && answer[x][y] == -1) {
                    answer[x][y] = answer[t[0]][t[1]] + 1;
                    q.offer(new int[] {x, y});
                }
            }
        }
        return answer;
    }
}
func updateMatrix(mat [][]int) [][]int {
    m, n := len(mat), len(mat[0])
    answer := make([][]int, m)
    // -1 表示尚未被任何波前触达,与合法距离 0 区分开。
    for i := range answer {
        answer[i] = make([]int, n)
        for j := range answer[i] {
            answer[i][j] = -1
        }
    }
    type pair struct{ x, y int }
    var q []pair
    // 所有 0 同时作为第 0 层入队,这是多源 BFS 的关键。
    for i, row := range mat {
        for j, v := range row {
            if v == 0 {
                answer[i][j] = 0
                q = append(q, pair{i, j})
            }
        }
    }
    dirs := []int{-1, 0, 1, 0, -1}
    for len(q) > 0 {
        p := q[0]
        q = q[1:]
        for i := 0; i < 4; i++ {
            x, y := p.x+dirs[i], p.y+dirs[i+1]
            // 赋值即标记:第一次被触达时得到的就是最短距离。
            if x >= 0 && x < m && y >= 0 && y < n && answer[x][y] == -1 {
                answer[x][y] = answer[p.x][p.y] + 1
                q = append(q, pair{x, y})
            }
        }
    }
    return answer
}

复杂度分析

  • 时间复杂度:$O(mn)$。凭什么:初始化与扫描各遍历一次全图;BFS 中每个格子最多入队一次、出队一次,出队时检查常数个(4 个)邻居,总操作量与格子数成正比。
  • 空间复杂度:$O(mn)$。凭什么:答案矩阵本身是 $O(mn)$ 且必须返回;队列在最坏情况(矩阵全是 0)下会一次性装下所有格子,同样是 $O(mn)$。没有使用额外的 visited 数组,因为答案矩阵已兼任访问标记。

关键点总结

  • 「所有点到最近的某类点的距离」是多源 BFS 的标准信号,做法是把所有源点一次性放进初始队列,而不是对每个源跑一次单源 BFS——这个转换能把 $O((mn)^2)$ 直接降到 $O(mn)$。
  • 边权全为 1 时 BFS 就是最短路,无需 Dijkstra;能当场说清「什么时候 BFS 够用、什么时候必须上优先队列」,是这类题的核心考点。
  • BFS 必须在入队时打标记而不是出队时,否则同一格子会被多次入队,既膨胀队列又可能被更长的路径覆盖。
  • 用答案矩阵的哨兵值(这里是 -1)兼作访问标记,可以省掉一整个 visited 数组;前提是哨兵值不可能是任何合法答案。
  • 方向数组的滑动窗口写法 {-1,0,1,0,-1} 是网格题的通用模板,四方向、八方向只需换一组常量,比堆叠 if 更不容易漏分支。
  • 面试视角:本题还有一个 $O(mn)$ 的两遍 DP 解法(先左上到右下、再右下到左上各扫一次,取邻居最小值加一)。能同时给出 BFS 与 DP 两条路,并说明 DP 版常数更小但只适用于曼哈顿距离场,会明显加分。

易错点总结

  • 只把一个 0 入队而不是全部mat = [[0,1],[1,0]] 中若只放 (0,0)(1,1) 会被算成 2,而正确答案是 1。
  • 用 0 当「未访问」标记mat = [[0,0],[0,0]] 中所有格子的正确答案都是 0,却会被当成未访问反复入队,队列永不清空导致死循环。
  • 出队时才打标记mat = [[0,1,1]](0,1) 会被 (0,0)(0,2) 的探测重复入队,规模较大时队列膨胀到 $O(mn)$ 倍,超时甚至内存溢出。
  • 把队列换成栈(写成 DFS)mat = [[0,0,0],[0,1,0],[1,1,1]](2,1) 可能沿更长路径先被触达而得到 3,正确答案是 2。
  • 越界判断漏写某一侧:只写 x < m && y < n 而漏掉非负判断,mat = [[0]] 在向上探测时访问 answer[-1][0],Java 抛越界异常、Go panic。
  • 赋值时写成 answer[x][y] = answer[x][y] + 1mat = [[0,1]](0,1) 的初值是 -1,会被赋成 0,与「1 的格子距离至少为 1」矛盾;正确的来源是当前出队格子的距离。
  • 先入队再赋值,且赋值依赖出队时读取mat = [[0,1,1],[1,1,1]] 中同一格子被多个邻居推入,后续出队时读到的父格距离不再是最小的,部分格子的结果偏大。
  • 直接在 mat 上原地改写距离mat = [[0,1,1]] 中把 (0,1) 改成 1 后,判断 (0,2) 时无法再区分「原本是 1」和「已被赋距离 1」,逻辑彻底混乱。
  • 担心矩阵全是 1 而加不可达处理:题目已保证至少有一个 0,多写的分支不会被触发,只会让白板代码变长。

相似题目

题目 难度 考察点
542. 01 矩阵 中等 与本题同题,可直接套用同一份代码
994. 腐烂的橘子 中等 同为多源 BFS,但要按层计数并在结束后检查是否还有新鲜橘子残留
1162. 地图分析 中等 多源 BFS 求的是距离场的最大值,且需处理全陆地或全海洋返回 -1
1091. 二进制矩阵中的最短路径 中等 单源单终点,八方向连通,起点终点被堵时要提前判否
127. 单词接龙 困难 状态是字符串而非坐标,邻居靠逐位换字母现场生成,需要哈希集合去重
752. 打开转盘锁 中等 状态图是隐式的,还多了「死亡数字」这类被禁止的节点
909. 蛇梯棋 中等 需要把蛇形编号映射回二维坐标,且一步的邻居是骰子的六个结果
1345. 跳跃游戏 IV 困难 同值下标之间互为邻居,必须在用过一次后清空该值的列表以免退化成平方
LCR 108. 单词接龙 困难 与 127 同题,可用来对照坐标状态与字符串状态在实现上的差异