多源BFS求解01矩阵到最近0距离:原理、代码与面试详解 前两天刷到一家出行科技公司的面试真题给一个只含 0 和 1 的矩阵要求算出每个格子到最近的 0 的矩阵距离。这题在在线题库上属于中等难度但现场做的时候很多人一上来就想对每个 1 跑一次 BFS结果复杂度直接爆炸。实际上这道题的标准解法是多源 BFS思路一句话就能讲完把所有 0 同时当成起点放进队列一层一层向外扩展。但真正妙的是为什么这样做能保证答案正确这篇文章我打算从暴力解开始把多源 BFS 的原理、代码、面试追问一次讲透最后再聊聊我在真实场景里对这个模型的理解。1. 问题还原与暴力困局1.1 题目到底要你算什么题目通常这样描述给定一个 m 行 n 列的 0-1 矩阵每个元素非 0 即 1。对于矩阵中的每一个格子 (i,j)定义它的“矩阵距离”为离它最近的 0 的曼哈顿距离也就是从 (i,j) 到任意一个值为 0 的格子 (r,c) 的最短步数只能上下左右走。要求返回一个同样大小的矩阵每个位置填上对应的距离。举个例子输入 0 0 0 0 1 0 1 1 1 输出 0 0 0 0 1 0 1 2 1右下角那个 1它到最近的 0 要走两步先到左边那个 0或者上方的 0所以填 2。注意这里距离不是欧氏距离而是步数每一步只能往上下左右四个方向走一格。这是题目的关键也是后面能用 BFS 的前提。1.2 暴力逐个BFS为什么不可行最朴素的想法对每个 1单独做一次 BFS找到最近的 0 就停下来记录步数。假设矩阵里有 K 个 1每个 1 在最坏情况下都要遍历整个矩阵比如矩阵里只有一个 0其他全是 1那么单个 BFS 是 O(mn)总复杂度就是 O(Kmn)。当矩阵里 0 很少、1 很多时K 接近 mn复杂度就是 O(m²n²)。在 1000x1000 的矩阵里这个量级几乎是不可接受的。而且这样写会造成大量重复计算相邻两个 1 的 BFS 过程高度重叠却互相不知道结果白白浪费。比如一个 2x2 的全 1 矩阵中间有一个 0左上角和右上角的 BFS 几乎访问同样的格子。面试时如果你先说暴力解面试官通常会追问“能不能更快”这时候就要想到多源 BFS 了。1.3 从重复计算中寻找突破口暴力解法慢在“每个点独立计算”但仔细想想相邻格子的距离是有关系的。一个格子 (i,j) 如果本身是 1那么它到最近 0 的距离一定等于它某个相邻格子到最近 0 的距离加 1。这个递推关系提示我们与其一个点一个点地求不如从所有 0 出发同时向外扩散像波纹一样覆盖整个矩阵。这样每个格子只被计算一次复用前面扩散到的结果。这种“多个起点同时向外走”的算法就是多源 BFS。2. 多源BFS核心思想把一堆起点当成一个虚拟起点2.1 普通BFS与多源BFS的差异普通 BFS 是从一个源点出发一层层扩展。多源 BFS 则是把所有源点一起放进队列同时扩展。放在这道题里所有值为 0 的格子就是源点。我们先把所有 0 入队dist 记为 0然后开始 BFS每次从队列里取出一个点尝试走到上下左右四个邻居如果邻居还没被访问过就更新它的距离为“当前点距离 1”并把它入队。这里的关键动作是“所有 0 同时入队”。如果只把一个 0 入队那就是普通单源 BFS只能算出离那一个 0 最近的距离其他区域的距离会被高估。多源 BFS 把每个源点的初始层数都视为 0然后统一扩展让所有源点公平竞争。2.2 虚拟超级源点的等价变换更严谨的理解是我们可以在所有 0 格子的上方虚拟一个超级源点 S在 S 和每个 0 之间连一条权值为 0 的边。那么“从每个 0 出发到其他点的最短距离”就等价于“从超级源点 S 出发到其他点的最短距离”。因为 S 到某个格子的距离 0 该格子到最近 0 的距离。这样一看多源 BFS 其实就是在这个加了虚拟源点的图上做了一次单源 BFS。队列初始化时先把所有 0 放进来的操作就相当于先把 S 的所有邻居入队只不过我们把 S 本身省略掉了。这个理解很关键面试时如果能把这一层说出来会显得你对 BFS 本质有很深的认识。2.3 为什么第一次访问就是最短距离BFS 天然具备“按层扩展”的性质。队列中先入队的节点层级低后入队的节点层级高。从虚拟源点出发第 1 步能到的点距离就是 1第 2 步能到的点距离就是 2。当某个格子第一次被弹出节点的邻居访问到时它所在的那一层必然是最短路径所在层因为如果有更短路径它应该更早被访问到。用生活类比想象地面上有多个水龙头同时开始放水水流以相同的速度向外蔓延。某个位置的地面什么时候被水浸湿一定是最接近它的那个水龙头最先流到的。多源 BFS 就相当于模拟了这个同时蔓延的过程谁先到谁就是最近。3. 手写代码从队列初始化到dist数组3.1 标准Python实现我习惯用 Python 写这种 BFS下面这段代码可以直接跑通在线题库的样例也是面试时最推荐的手写版本from collections import deque def update_matrix(matrix): m, n len(matrix), len(matrix[0]) dist [[-1] * n for _ in range(m)] q deque() # 阶段1所有0入队距离设为0 for i in range(m): for j in range(n): if matrix[i][j] 0: dist[i][j] 0 q.append((i, j)) # 阶段2BFS扩展 directions [(1, 0), (-1, 0), (0, 1), (0, -1)] while q: x, y q.popleft() for dx, dy in directions: nx, ny x dx, y dy if 0 nx m and 0 ny n and dist[nx][ny] -1: dist[nx][ny] dist[x][y] 1 q.append((nx, ny)) return dist这个实现里有两个细节值得说明。第一dist 初始化成 -1用它同时充当 visited 数组。-1 表示“还没有被访问过”一旦被访问就更新为非负距离这样不需要额外的 visited 布尔矩阵代码更简洁。第二所有 0 必须在第一轮全部入队如果一个一个入队或者边入队边扩展就会破坏“同一层同时扩展”的顺序导致结果错误。3.2 关键细节为什么dist用-1初始化如果用 0 初始化 dist那么矩阵里的 0 格子本身距离是 0但那些没有被访问到的 1 初始也是 0就无法区分“已经算出距离为 0”和“还没访问到”。一旦遇到全 1 矩阵且没有 0 的情况整个矩阵都会返回全 0这显然是错的。用 -1 初始化就避免了这个问题。-1 表示不可达或尚未访问0 表示源点本身正数表示到最近源点的步数。如果题目保证至少有一个 0那么 BFS 结束后 -1 的地方只可能是不存在的情况但保留这个初始化能让代码在边界输入下也更稳健。3.3 时间与空间复杂度分析每个节点最多入队一次、出队一次所以整个 BFS 的时间复杂度是 O(mn)。空间上dist 数组占 O(mn)队列最多同时容纳 O(mn) 个节点所以总空间复杂度也是 O(mn)。对于 1000x1000 的矩阵这个量级完全没问题跑起来通常几十毫秒级别。如果面试官继续问“能不能优化空间”可以提原地修改把原始矩阵里所有 1 先改成 -1把 0 保持为 0然后直接用这个矩阵当 distBFS 过程中把 -1 改为距离。这样省掉了额外数组代价是修改了输入数据。写代码前务必和面试官确认是否允许修改输入这既是礼貌也是职业习惯。提示在 C 或 Java 中队列分别推荐queue和ArrayDeque它们从两端操作都是 O(1)。Python 则一定要用deque.popleft()不要用list.pop(0)后者是 O(n)数据量大时会明显退化。4. 面试官追问与变形题4.1 没有0怎么办题目通常会保证至少有一个 0但面试官可能会故意问“如果没有 0 呢”这时距离矩阵应该怎么填一种答案是全部填无穷大或者约定为一个特殊值比如 -1 表示不可达。实现上只要把 dist 初始化为 -1扫描一遍后发现队列还是空的直接返回 dist就天然完成了这个逻辑。这也是为什么用 -1 初始化比用 0 初始化更稳健的原因。4.2 如果边有权重还能用BFS吗BFS 默认每条边的权值相同这里的权值就是“一步”。如果移动代价不同比如向左走一步花费 1向下走一步花费 3那么 BFS 就不适用了要改用多源 Dijkstra把所有 0 的距离初始化为 0放进优先队列弹出节点时用当前距离更新邻居的最小距离。当然如果权值有负数那连 Dijkstra 也不能用得考虑 SPFA 或 Bellman-Ford但网格题一般不会走到那一步。面试时能主动说出这个边界说明你清楚 BFS 的适用条件而不是只会背代码。4.3 为什么不是DFS有同学会觉得 DFS 也能遍历图找最近距离于是写了递归。但实际上 DFS 是一条路走到黑第一次访问到 0 的距离不一定是最短距离需要额外比较所有路径如果图很大递归深度可能超过栈上限。在多源最短路径的场景下DFS 天生没有“按层扩展”的性质很难保证每个点第一次被访问时就是最短路径。所以 DFS 在这里是错解。如果非要 DFS也可以先记录第一个 0 的位置然后用带剪枝的 DFS 搜最近距离但剪枝条件很麻烦效率通常也比 BFS 差。面试官想听的是“我知道 BFS 是按层走DFS 是按方向走”而不是真的让你现场写一个 DFS。4.4 动态规划能不能做这道题其实还有一种动态规划解法因为曼哈顿距离只可能从上下左右四个方向传递所以可以做四次扫描。第一次从上到下、从左到右第二次从上到下、从右到左第三次从下到上、从左到右第四次从下到上、从右到左每次用已经算出的邻居距离更新当前格子的最小值。这种方法时间复杂度也是 O(mn)不需要队列但状态转移方程需要仔细设计而且只能用于这种特殊定义的距离。面试时如果提到 DP可以简单说思路但没必要写完整。多源 BFS 更直观也更容易证明正确性因此是首选。5. 面试实战复盘5.1 拿到题目先确认三件事我的习惯是先确认三点距离定义是不是曼哈顿距离也就是只能上下左右走矩阵里是否至少有一个 0是否允许修改输入的矩阵。这三句话问完一方面避免自己后面写偏另一方面也向面试官展示边界意识。尤其最后一点很多人上来就原地修改导致面试官提醒“输入不能动”时才慌神。先问一句“可以修改原矩阵吗”会显得你很专业。5.2 从暴力到多源的表达思路面试时不要一上来就写最优解。可以先说“最直接的做法是对每个 1 单独 BFS复杂度 O(m²n²)不够好。我观察到所有 0 都是相同的起点如果同时从所有 0 开始 BFS那么队列每个节点第一次被访问时的步数就是从某个 0 到它的最短距离整体复杂度降到 O(mn)。”这段话把优化的动机、原理、复杂度全说清楚了面试官一般会点头。接着再补充虚拟超级源点的等价观点基本就稳了。5.3 手撕代码时的检查清单写代码时我会迅速在心里过一遍几个 check方向数组是否覆盖上下左右有没有漏掉一个方向。边界判断是不是写反了比如0 nx m写成了nx 0 nx m。dist 初始化是否用了 -1而不是 0。队列是否用了popleft()。是否把所有 0 都入队了有没有漏掉矩阵边缘的 0。写完代码后主动提出跑一个测试用例题目给的例子一个全 0 的例子一个只有左上角是 0 的例子。跑完再向面试官说明为什么答案正确比等面试官发问效果好得多。5.4 想不出多源BFS时的破局方法很多人其实会单源 BFS但看到“多个起点”就蒙了不知道能不能把起点合并。这时核心思路是把多源问题“降维”成单源问题想象一个超级源点连接所有 0权重 0然后做普通 BFS。一旦明白了这个等价关系以后再遇到“多个起点 最短距离”的题条件反射就是多源 BFS。如果面试官给了提示一定要顺着提示往下说。接受提示不代表能力差把提示转化为代码才是面试官想看到的。6. 真实场景应用与扩展思考6.1 占据栅格地图上的距离场计算这类矩阵距离问题在机器人领域很常见。比如自动驾驶或扫地机器人会构建一个占据栅格地图0 代表可通行区域1 代表障碍物。为了做路径规划需要计算每个可通行格子到最近障碍物的距离这个距离场可以用来判断当前可行空间的大小也可以用来做膨胀、避障或梯度计算。多源 BFS 在这里依然适用只不过所有障碍物格子是源点可通行区域是待计算目标。实际工程中栅格地图可能很大还要限制在几毫秒内刷新。多源 BFS 的 O(mn) 复杂度通常能满足需求而且实现简单、容易验证所以很多基础方案都会选它。如果你做过机器人或者图形学相关的项目在面试中提到这一层会很有说服力。6.2 多起点路径规划与连通性分析再比如地图上有多个救援点要计算每个格子到最近救援点的最短时间或者游戏里有多个生成点要计算地图上每个位置被怪物首次到达的时间。这些问题本质上都是“多点源最短距离”。只需要把所有源点放进初始队列后面的逻辑完全一样。有些面试题还会进一步问连通性如果某些格子是墙不能走如何判断两个区域是否连通。这时 BFS 用于连通块标记和多源距离的思路是互补的可以一并聊。6.3 从这道题出发的复盘总结一道面试题背后往往藏着一个更通用的模型。多源 BFS 不只是一个算法更是一种“把多个起点统一处理”的思维框架。掌握了虚拟超级源点这个技巧很多看似复杂的多起点问题都会变得清晰简单。这个框架还可以继续延伸如果源点和目标点都在变化可以反过来从目标点做多源 BFS如果只有部分格子允许走那就把障碍格排除在访问条件之外本质还是一样的。我自己刷题时有一个体会多源 BFS 的代码量不大但很多人挂在细节上——要么没把所有源点一次性入队要么没用 -1 初始化导致无法判断访问状态。如果你第一次看到这题没做出来不用沮丧我见过不少人现场能从暴力解被提示到多源 BFS靠的就是“所有源点等价”这个虚拟超级源点的心智模型。下次再遇到任何“多个起点求最近距离”的题先想想这个模型代码通常十行内就能写完。另一个实用小技巧如果允许原地修改可以把原数组的 0 保持不变把 1 改成 -1再当 dist 用这样可以省掉额外数组但面试时我还是建议先用额外数组把逻辑说清楚最后再提这个优化显得你更有余裕。