多源BFS算法精讲:从原理到实战解决网格最短路径问题
2026/9/16 17:23:40 网站建设 项目流程

1. 从“单点”到“多点”:为什么我们需要多源BFS?

在解决图论或网格类问题时,广度优先搜索(BFS)是我们最熟悉的武器之一。它的经典应用场景是寻找从单一“起点”到单一“终点”的最短路径,比如经典的迷宫问题。算法从起点出发,像水波一样一层层向外扩散,第一次到达终点时,所经历的层数就是最短步数。这个模型直观、高效,是算法入门必学。

但现实中的问题往往更复杂。想象一下这样的场景:在一片森林里,同时有多个地方发生了火灾,火势会以相同的速度向四周蔓延。我们关心的是,整片森林被完全烧毁需要多少时间?或者,地图上有多个你的“基地”,你需要派遣队伍去探索未知区域,队伍从所有基地同时出发,速度相同,那么地图上每个位置被首次探索到的时间是多少?再比如,在一个社交网络中,一个谣言从多个“源头用户”开始传播,每个时间步传给其所有好友,那么每个用户最早听到谣言的时间点是什么?

这些问题无法用传统的单源BFS来优雅地解决。如果你固执地分别从每个源头跑一次BFS,然后对每个位置取所有BFS结果的最小值,这在算法复杂度上是不可接受的,相当于问题规模乘以源头数量。这时,多源BFS(Multi-source BFS)就登场了。它的核心思想极其巧妙:在初始化BFS队列时,不是放入一个起点,而是将所有源头同时放入队列,并标记它们的初始距离(通常为0)。这样,BFS的第一层就包含了所有源头,接下来的扩散过程,就是所有“波”同时、同速地向外推进。当一个位置第一次被任何一波“触及”时,它所对应的步数,就是它离其最近源头的最短距离。

这个模型,我们称之为“最小步数模型”。这里的“最小步数”不是指从A到B,而是指地图上每个点,到达离它最近的源点所需要的最少步数。多源BFS在一次遍历中,就为整个地图的所有位置计算出了这个值,效率是单源BFS无法比拟的。它解决了一类“多起点,同速度,求最近距离”问题的共性。

2. 多源BFS的算法框架与核心实现细节

理解了思想,我们来看如何用代码实现。多源BFS在代码结构上与单源BFS几乎一致,唯一的区别就在于队列的初始化。

我们以一个经典的网格问题为例:给定一个n x m的矩阵,其中包含数字011代表“源点”(比如火灾起火点、基地、谣言源头),0代表普通区域。我们需要计算每个0位置,距离其最近的1的曼哈顿距离(即只能上下左右移动,每次移动算一步)。

2.1 算法步骤拆解

步骤一:初始化这是与单源BFS唯一不同的地方。我们创建一个队列q,和一个距离数组dist(大小与地图相同,初始化为一个特殊值,如-1INF,表示未访问)。 然后,我们遍历整个地图,将所有值为1的源点位置(i, j)

  1. 将其坐标加入队列q
  2. dist[i][j]中将其距离设置为0(源点到自己的距离为0)。

此时,队列里已经包含了所有“第0层”的节点。

步骤二:标准BFS遍历接下来就是标准的BFS过程:

  1. 当队列不为空时,取出队首节点(x, y)
  2. 遍历该节点的四个方向(上、下、左、右)的邻居(nx, ny)
  3. 检查邻居是否在地图范围内、是否未被访问过(即dist[nx][ny] == -1)。
  4. 如果满足条件,则:
    • 将邻居节点(nx, ny)加入队列。
    • 更新邻居的距离:dist[nx][ny] = dist[x][y] + 1。这一步是关键,它保证了每个点记录的是从最近源点出发的步数。

步骤三:输出结果BFS结束后,dist数组中就存储了每个位置距离最近源点的最短步数。对于源点自身,距离为0;对于原本就是0的区域,则得到了我们想要的结果。

2.2 代码模板(以C++为例)

#include <iostream> #include <queue> #include <cstring> using namespace std; typedef pair<int, int> PII; const int N = 1010; // 假设地图最大尺寸 int n, m; int g[N][N]; // 存储原始地图,1为源点,0为普通区域 int dist[N][N]; // 存储最短距离 queue<PII> q; // 方向数组:上,右,下,左 int dx[4] = {-1, 0, 1, 0}; int dy[4] = {0, 1, 0, -1}; void multiSourceBfs() { // 1. 初始化距离数组和队列 memset(dist, -1, sizeof dist); // -1表示未访问 for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (g[i][j] == 1) { // 找到源点 q.push({i, j}); dist[i][j] = 0; // 源点距离为0 } } } // 2. 标准BFS过程 while (!q.empty()) { auto t = q.front(); q.pop(); int x = t.first, y = t.second; for (int i = 0; i < 4; i++) { int nx = x + dx[i]; int ny = y + dy[i]; // 检查边界、是否可访问(这里判断是否为普通区域0且未访问) if (nx >= 0 && nx < n && ny >= 0 && ny < m && dist[nx][ny] == -1) { dist[nx][ny] = dist[x][y] + 1; q.push({nx, ny}); } } } } int main() { // 假设已读入 n, m 和地图 g[][] multiSourceBfs(); // 输出结果 for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { cout << dist[i][j] << " "; } cout << endl; } return 0; }

注意:在遍历邻居判断条件时,dist[nx][ny] == -1是最通用的“未访问”判断。有时题目可能对可移动区域有额外限制(比如只能是陆地0,不能是海洋-1),则需要同时判断g[nx][ny]是否符合要求。核心是:一个位置一旦被首次访问(即距离值被更新),其距离就是最小值,之后不应再被更新。这是BFS“首次到达即最短”的性质保证的。

3. 从模型到实战:三大经典问题剖析

掌握了模板,我们来看几个力扣(LeetCode)上的经典题目,它们都是多源BFS“最小步数模型”的直接或变形应用。通过这些问题,你能更深刻地理解模型的威力。

3.1 问题一:地图分析(LeetCode 1162)

题目简述:你现在手里有一张N x N的网格地图,每个格子要么是陆地(1)要么是海洋(0)。请找出一个海洋单元格,这个海洋单元格到离它最近的陆地单元格的距离是最大的。并返回这个最大距离。如果地图上只有陆地或者只有海洋,返回-1

问题转化:这几乎就是多源BFS最小步数模型的“标准描述”。只不过源点是所有陆地格子(1),目标是所有海洋格子(0)。我们需要对每个海洋格子,求出其到最近陆地的距离,然后取其中的最大值。

解题思路

  1. 初始化队列和距离数组。遍历地图,将所有陆地格子(1)作为源点入队,距离设为0。
  2. 执行多源BFS,计算每个格子(尤其是海洋格子)到最近陆地的距离。
  3. BFS结束后,遍历所有海洋格子(0),找出距离数组中的最大值。
  4. 需要处理全陆地或全海洋的特殊情况。

核心代码片段

int maxDistance(vector<vector<int>>& grid) { int n = grid.size(); vector<vector<int>> dist(n, vector<int>(n, -1)); queue<pair<int, int>> q; int lands = 0; // 多源初始化 for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (grid[i][j] == 1) { q.push({i, j}); dist[i][j] = 0; lands++; } } } if (lands == 0 || lands == n * n) return -1; // 全海或全陆 // BFS int dirs[4][2] = {{-1,0},{1,0},{0,-1},{0,1}}; int maxDist = 0; while (!q.empty()) { auto [x, y] = q.front(); q.pop(); for (auto& d : dirs) { int nx = x + d[0], ny = y + d[1]; if (nx>=0 && nx<n && ny>=0 && ny<n && dist[nx][ny]==-1) { dist[nx][ny] = dist[x][y] + 1; maxDist = max(maxDist, dist[nx][ny]); // 顺便更新最大值 q.push({nx, ny}); } } } return maxDist; }

实操心得:在BFS过程中,可以一边扩散一边更新最大距离,无需最后再遍历一次数组。这是一个常见的优化小技巧。另外,处理全陆地或全海洋的边界情况,是本题的一个易错点。

3.2 问题二:腐烂的橘子(LeetCode 994)

题目简述:在给定的m x n网格中,每个单元格可以有以下三个值之一:0代表空单元格,1代表新鲜橘子,2代表腐烂的橘子。每分钟,任何与腐烂橘子相邻(4个方向之一)的新鲜橘子都会腐烂。返回直到单元格中没有新鲜橘子为止所必须经过的最小分钟数。如果不可能,返回-1

问题转化:这里的“源点”是所有初始就腐烂的橘子(值为2)。腐烂过程等同于从这些源点同时开始的BFS扩散。每分钟对应BFS的一层。我们需要计算所有新鲜橘子(1)被“感染”所需的时间,也就是它被BFS访问到时所在的层数(距离)。最终答案是所有新鲜橘子被腐烂所需的最大时间(层数)。如果BFS结束后还有新鲜橘子未被访问到,则返回-1

解题思路

  1. 初始化队列,将所有腐烂橘子位置入队,并将其距离(时间)设为0。
  2. 同时,统计新鲜橘子的总数fresh
  3. 执行多源BFS。当从队列中取出一个腐烂橘子,遍历其四个方向,如果邻居是新鲜橘子(1),则将其腐烂(可以原地修改网格为2或使用独立的dist数组),距离为当前距离+1,并入队。每腐烂一个,fresh计数减1。
  4. BFS结束后,如果fresh为0,返回过程中记录的最大时间;否则返回-1

核心代码片段

int orangesRotting(vector<vector<int>>& grid) { int m = grid.size(), n = grid[0].size(); queue<pair<int, int>> q; int fresh = 0, minutes = 0; // 多源初始化:腐烂橘子入队,并统计新鲜橘子 for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (grid[i][j] == 2) q.push({i, j}); else if (grid[i][j] == 1) fresh++; } } if (fresh == 0) return 0; // 没有新鲜橘子 // BFS int dirs[4][2] = {{-1,0},{1,0},{0,-1},{0,1}}; while (!q.empty() && fresh > 0) { // fresh>0 可提前结束 int sz = q.size(); for (int i = 0; i < sz; i++) { // 分层处理,每分钟一层 auto [x, y] = q.front(); q.pop(); for (auto& d : dirs) { int nx = x + d[0], ny = y + d[1]; if (nx>=0 && nx<m && ny>=0 && ny<n && grid[nx][ny]==1) { grid[nx][ny] = 2; // 标记为腐烂 q.push({nx, ny}); fresh--; } } } minutes++; // 一层结束,时间+1 } return fresh == 0 ? minutes : -1; }

避坑指南:本题的关键在于分层BFS。因为我们要统计的是“分钟数”,而BFS中每一层就代表了一分钟的扩散。代码中int sz = q.size(); for (int i=0; i<sz; i++) {...}这个结构就是标准的分层遍历写法。在循环外minutes++,确保了时间计数的准确性。如果不分层,直接使用dist数组记录步数,最后取最大值也是可以的,但分层写法在逻辑上更贴合题意。

3.3 问题三:01矩阵(LeetCode 542)

题目简述:给定一个由01组成的矩阵mat,请输出一个大小相同的矩阵,其中每个格子是mat中对应位置元素到最近的0的距离(曼哈顿距离)。

问题转化:这是最小步数模型的“镜像”问题。之前的题目是求每个位置到最近的1的距离,这里是求每个位置到最近的0的距离。源点变成了所有的0

解题思路:与“地图分析”问题几乎一模一样,只是源点和目标互换。将所有0作为源点入队进行多源BFS,计算所有1到最近0的距离。

一个重要的思维扩展:为什么这个问题不用对每个1做单源BFS找0?假设矩阵大小为M x N,其中有K1。对每个1做BFS,最坏时间复杂度是O(K * M * N),而使用多源BFS(从所有0出发),时间复杂度是O(M * N)。当K很大时(比如矩阵中大部分是1),前者效率极低。多源BFS巧妙地转换了视角,将多个目标的搜索合并为一次从反方向源点出发的搜索,这是其效率提升的本质。

4. 进阶、变形与常见“坑点”

多源BFS模型并不总是直接套用。在实际问题中,它常常与其他概念结合,或者设置了一些“陷阱”。

4.1 结合状态压缩:多源BFS + 位运算

有些问题中,源点可能有不同的“类型”或“状态”。例如,需要计算每个位置到最近的任意类型源点的距离,但同时还需要知道它是被哪种类型源点最先到达的。或者,问题要求找到一条路径,需要收集所有类型的“钥匙”才能通过某些“锁”。

这类问题通常需要将“位置”和“当前持有的状态”组合成一个新的节点(x, y, state),然后进行BFS。这里的state可以用一个整数的二进制位来表示,这就是状态压缩。虽然这增加了BFS的维度,但核心的“多源初始化”思想依然适用——你可能需要将所有初始状态(如所有钥匙都未获取时的各个起点)加入队列。

经验之谈:遇到网格上有门、钥匙、多种物品的问题,要立刻联想到BFS + 状态压缩。多源初始化时,队列里放入的是(起点坐标, 初始状态)。判断新状态是否访问过,需要一个三维的vis[x][y][state]数组。

4.2 “超级源点”技巧

有时,题目中的源点并不是直接给出的网格点,而是一些抽象的点,或者源点之间本身有连接关系。我们可以引入一个虚拟的“超级源点”。将所有真实源点都与这个超级源点连接,且距离为0。然后从超级源点做一次单源BFS,其效果等同于从所有真实源点同时开始的多源BFS。

这在处理一些图论问题时特别有用,尤其是当源点列表是动态给出的时候。在代码实现上,它避免了手动初始化多个源点入队的操作,但在思维上不如直接的多源BFS直观。对于网格问题,我们通常直接使用多源初始化队列的方法。

4.3 易错点与调试技巧

  1. 距离数组初始化:一定要初始化为一个不可能出现的值(如-1INT_MAX),用于判断是否访问过。如果初始化为0,会导致无法区分未访问点和距离为0的源点。
  2. 边界条件处理:在遍历四个方向时,务必先判断新坐标(nx, ny)是否在地图范围内,再进行数组访问,否则会导致运行时错误(数组越界)。
  3. 访问标记的时机:必须在将新节点加入队列的同时就标记为已访问(或更新距离)。如果等从队列取出时再标记,可能会导致同一个节点被多次加入队列,造成逻辑错误和性能下降。这是BFS的一个通用原则。
  4. 结果的含义:明确dist数组最终的含义。它存储的是“从最近的源点出发的步数”。对于源点本身,距离是0。对于无法到达的点,距离保持初始值(如-1)。输出结果前要清楚题目要求如何处理这些特殊情况。
  5. 性能考量:多源BFS的时间复杂度和空间复杂度与单源BFS遍历整个图相同,都是O(V+E),对于网格是O(M*N)。这是其高效的原因。如果遇到TLE(超时),检查是否是重复访问节点,或者队列操作、条件判断写成了低效的形式。

调试时,可以打印出每一层BFS结束后的dist数组或队列状态,观察扩散过程是否符合预期。对于复杂问题,在纸上画一个小规模网格,手动模拟算法流程,是理解问题和排查bug最有效的方法。

多源BFS加最小步数模型,将看似复杂的多起点最短路径问题,化简为一次高效的遍历。它的核心魅力在于这种“同时开始,齐头并进”的思维转换。下次当你看到问题描述中出现“多个起点”、“同时扩散”、“最近距离”这些关键词时,你应该能会心一笑,知道该请出这位老朋友了。掌握它,不仅能解决一大类算法题,更能训练你化繁为简、转换问题视角的思维能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询