网格图 · 多源 BFS
从 LC 994 腐烂的橘子开始:所有起点一次性入队,BFS 按"距离层"扩张,O(mn) 求出到任意起点的最短距离。
1. 核心原理
多源 BFS(multi-source BFS)是单源 BFS 的推广:单源 BFS 从一个起点 s 出发求 s 到所有可达点的最短距离;多源 BFS 输入一组起点 S = {s₁, s₂, …, sₖ},求每个点 v 到最近某个起点的距离。
形式上要计算的是:
dist(v) = min over s in S of bfs_dist(s, v)朴素做法对每个起点跑一遍 BFS,O(k · mn),当 k 很大时不可行。多源 BFS 把这件事压缩到 O(mn)。
等价构造:虚拟超级源点
多源 BFS 的标准解释:新建一个虚拟节点 s*,向所有 sᵢ 连一条长度为 0 的边——从 s* 出发跑一次单源 BFS,得到的 dist(s*, v) 就等于 min over sᵢ of dist(sᵢ, v)。
s* (虚拟超级源点)
/|\ \
0 0 0 0 (零费用边)
| | | |
s₁ s₂ s₃ s₄ (真实起点)代码上不必真的建虚拟节点。直接把所有 sᵢ 一次性塞进队列、距离都记 0 就行——队列里的"层"天然就是距离层,和先经过 s* 完全等价。
关键不变量
BFS 队列里始终保持"待扩张层"的所有节点,且这些节点到(最近的)起点的距离相同。出队顺序按距离非递减。
这条不变量保证了"出队即定距"——一个节点第一次入队时记下的距离就是最终答案,后续不再更新。无权图(每步代价 1)的 BFS 正确性都建立在这条不变量上。
单源 vs 多源 对照
| 单源 BFS | 多源 BFS | |
|---|---|---|
| 起点数 | 1 | k(k ≥ 1) |
| 队列初始 | [s] | [s₁, s₂, …, sₖ] |
| 距离初始化 | dist[s] = 0,其他 ∞ | 所有 sᵢ 的 dist[sᵢ] = 0,其他 ∞ |
| 答案语义 | 从 s 到 v 的最短距离 | v 到最近某个起点的最短距离 |
| 复杂度 | O(V + E) | O(V + E)(k 不影响渐进) |
第三行很关键——多源初始化时所有起点距离都是 0,不是按"先后顺序"递增。这跟"虚拟超级源点零费用接到所有 sᵢ"的几何含义一致。
适用场景
只要题目问的是"网格(或一般图)上每个点到最近的某类目标的距离",且每一步代价是 1(无权图),就是多源 BFS。本章三道例题分别是:
- LC 994 腐烂的橘子:起点是所有腐烂橘子,求最远新鲜橘子的距离。
- LC 542 01 矩阵:起点是所有 0,求每个 1 到最近 0 的距离矩阵。
- LC 1162 离海洋最远的陆地:起点是所有陆地,求所有海洋格子里到最近陆地距离的最大值。
不适用的两类情况:
- 每步代价不全是 1(带权图)→ 上 Dijkstra 或 0-1 BFS。
- 要求"从一个起点到目标的路径"而非"任一起点到该点的距离" → 仍是单源 BFS。
2. 通用模板
下面是多源 BFS 的"求网格上每个点到最近起点距离"骨架。返回二维 dist 数组:dist[i][j] 是 (i, j) 到最近某个起点的距离,不可达保留 -1。
from collections import deque
def multi_source_bfs(grid: list[list[int]], is_source, can_walk) -> list[list[int]]:
"""
多源 BFS 通用骨架。
is_source(grid, i, j):(i, j) 是否是起点
can_walk(grid, i, j):(i, j) 是否可走(非障碍)
返回 dist 二维数组;不可达记 -1。
"""
m, n = len(grid), len(grid[0])
dist = [[-1] * n for _ in range(m)]
q = deque()
# 1. 第一遍扫整张网格:所有起点一次性入队,距离 0
for i in range(m):
for j in range(n):
if is_source(grid, i, j):
dist[i][j] = 0
q.append((i, j))
# 2. 标准 BFS 按层扩张
while q:
i, j = q.popleft()
for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ni, nj = i + di, j + dj
if (0 <= ni < m and 0 <= nj < n
and dist[ni][nj] == -1 # 3. 入队即定距,未访问才进
and can_walk(grid, ni, nj)):
dist[ni][nj] = dist[i][j] + 1 # 4. 邻居距离 = 当前 + 1
q.append((ni, nj))
return distimport java.util.*;
public int[][] multiSourceBFS(int[][] grid,
java.util.function.BiPredicate<Integer, Integer> isSource,
java.util.function.BiPredicate<Integer, Integer> canWalk) {
int m = grid.length, n = grid[0].length;
int[][] dist = new int[m][n];
for (int[] row : dist) Arrays.fill(row, -1);
Deque<int[]> q = new ArrayDeque<>();
// 1. 起点一次性入队
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (isSource.test(i, j)) {
dist[i][j] = 0;
q.offer(new int[]{i, j});
}
}
}
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
// 2. 标准 BFS 按层扩张
while (!q.isEmpty()) {
int[] cur = q.poll();
int i = cur[0], j = cur[1];
for (int[] d : dirs) {
int ni = i + d[0], nj = j + d[1];
if (ni >= 0 && ni < m && nj >= 0 && nj < n
&& dist[ni][nj] == -1 // 3. 入队即定距
&& canWalk.test(ni, nj)) {
dist[ni][nj] = dist[i][j] + 1; // 4. 邻居距离 = 当前 + 1
q.offer(new int[]{ni, nj});
}
}
}
return dist;
}vector<vector<int>> multiSourceBFS(vector<vector<int>>& grid,
function<bool(int, int)> isSource,
function<bool(int, int)> canWalk) {
int m = grid.size(), n = grid[0].size();
vector<vector<int>> dist(m, vector<int>(n, -1));
queue<pair<int, int>> q;
// 1. 起点一次性入队
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (isSource(i, j)) {
dist[i][j] = 0;
q.push({i, j});
}
}
}
int dirs[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
// 2. 标准 BFS 按层扩张
while (!q.empty()) {
auto [i, j] = q.front();
q.pop();
for (auto& d : dirs) {
int ni = i + d[0], nj = j + d[1];
if (ni >= 0 && ni < m && nj >= 0 && nj < n &&
dist[ni][nj] == -1 && // 3. 入队即定距
canWalk(ni, nj)) {
dist[ni][nj] = dist[i][j] + 1; // 4. 邻居距离 = 当前 + 1
q.push({ni, nj});
}
}
}
return dist;
}复杂度:时间 O(mn),每个格子至多入队一次出队一次;空间 O(mn),dist 矩阵 + 队列最坏装下整张网格。
模板要点:
- 入队即定距:一个格子第一次入队就记下最终距离,后面再也不更新。判断"是否已访问"用
dist[ni][nj] == -1(−1 当哨兵),不需要单独的visited数组。 - 两层初始化扫描
O(mn)+ BFSO(mn),总O(mn),与起点数量 k 无关。 is_source和can_walk分开:起点也是"可走"的,但不是所有"可走"的都是起点。比如 LC 994 起点是grid == 2,可走是grid != 0(空格不可走)。- 染色时机在入队前:
q.append((ni, nj))之前先dist[ni][nj] = dist[i][j] + 1,避免同一格被多次入队。
3. 母题精讲
LeetCode 994. Rotting Oranges
链接:LeetCode 994
题意:给一个 m × n 的网格 grid,每一格的值是下面三个之一:
0空格1新鲜橘子2腐烂橘子
每过 1 分钟,所有腐烂橘子会让它上下左右相邻的新鲜橘子也变腐烂。返回让所有新鲜橘子都变腐烂所需的最少分钟数;如果有新鲜橘子永远不会腐烂,返回 −1。
数据范围:
m, n:1 到 10grid[i][j]:0、1 或 2
示例:
输入:grid = [
[2, 1, 1],
[1, 1, 0],
[0, 1, 1]
]
输出:4
解释:
分钟 0 分钟 1 分钟 2 分钟 3 分钟 4
2 1 1 2 2 1 2 2 2 2 2 2 2 2 2
1 1 0 2 1 0 2 2 0 2 2 0 2 2 0
0 1 1 0 1 1 0 1 1 0 2 1 0 2 2暴力解:每分钟扫一遍整个网格
最直觉的写法是按时间步进——每一分钟扫一遍整张网格,找出"上一分钟刚变腐烂"的橘子,让它们的相邻新鲜橘子在下一分钟腐烂。重复直到没有新增腐烂为止:
class Solution:
def orangesRotting_brute(self, grid: list[list[int]]) -> int:
m, n = len(grid), len(grid[0])
minutes = 0
while True:
to_rot = []
# 整张网格扫一遍找"上一分钟刚腐烂"的橘子,让相邻新鲜橘子变 2
for i in range(m):
for j in range(n):
if grid[i][j] == 2:
for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ni, nj = i + di, j + dj
if 0 <= ni < m and 0 <= nj < n and grid[ni][nj] == 1:
to_rot.append((ni, nj))
if not to_rot:
break
for i, j in to_rot:
grid[i][j] = 2
minutes += 1
# 还有新鲜橘子说明腐烂不完
for row in grid:
if 1 in row:
return -1
return minutes这份代码思路清晰,但有两个明显的浪费:
第一,每一分钟都扫一遍整张网格。设答案是 T 分钟,整体复杂度 O(T · mn)。m, n ≤ 10 时 T 最多 100,看起来不大,但同样的题型放到 LC 542(数据范围 10⁴ × 10⁴)就直接爆。
第二,已经腐烂的橘子会被反复扫描。一个橘子在第 1 分钟腐烂后,第 2、3、4 分钟还在被一遍遍扫,每次只是为了确认"它已经腐烂了"——这部分计算完全没必要。
更深一层的浪费在于:每个腐烂橘子并不需要知道当前是第几分钟,它只需要知道"我邻居里有哪些是新鲜的,给它们打上比我大 1 的时间戳"。这是 BFS 的天然模式——按层扩张。
关键观察:所有腐烂橘子在第 0 分钟同时入队
把"多个起点同时扩散"翻译成 BFS:
- 第 0 步:把所有腐烂橘子的坐标一次性塞进队列,距离记 0。
- 后续步:标准 BFS——出队、看四邻居、新鲜的入队并标记距离为当前 + 1。
- 队列空了之后,扫一遍网格,如果还有
1就返回 −1,否则返回最大距离。
每个橘子至多入队一次、出队一次,整体 O(mn),跟初始腐烂橘子的数量无关——这就是 §1 多源 BFS 的虚拟超级源点等价构造。
下面这个交互演示走一遍示例输入 [[2,1,1],[1,1,0],[0,1,1]] 的 BFS 过程(网格行优先展开成 9 格:(0,0)→idx 0,(0,1)→idx 1,… (2,2)→idx 8):
orangesRotting 多源 BFS 按层扩张
网格 [[2,1,1],[1,1,0],[0,1,1]] 行优先展开。值:2=已腐烂,1=新鲜,0=空。颜色:红=队首正在扩张,金=本层新入队,蓝=已处理
m × n ≤ 100 这道题感觉不出渐进区别,但同样的"多源同时启动"骨架放到 §4.2 LC 542 / §4.3 LC 1162 这种 10⁴ × 10⁴ 网格上,暴力版直接 TLE。
4. 例题
下面三道按"目标和数据规模递增"排:LC 994 求最大距离(小网格) → LC 542 输出全距离矩阵(大网格) → LC 1162 求矩阵里距离的最大值。每道带 follow-up 续到下一题。
4.1 LC 994 腐烂的橘子回头看
题目链接。题面见 §3。直接套 §2 多源 BFS 模板——起点是所有 2,可走是所有 != 0。三语言完整实现:
from collections import deque
class Solution:
def orangesRotting(self, grid: list[list[int]]) -> int:
m, n = len(grid), len(grid[0])
q = deque()
fresh = 0
# 第一遍扫一遍,腐烂橘子入队、统计新鲜橘子数
for i in range(m):
for j in range(n):
if grid[i][j] == 2:
q.append((i, j, 0)) # (i, j, 当前分钟)
elif grid[i][j] == 1:
fresh += 1
minutes = 0
while q:
i, j, t = q.popleft()
minutes = t
for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ni, nj = i + di, j + dj
if 0 <= ni < m and 0 <= nj < n and grid[ni][nj] == 1:
grid[ni][nj] = 2 # 入队即染色
fresh -= 1
q.append((ni, nj, t + 1))
return minutes if fresh == 0 else -1class Solution {
public int orangesRotting(int[][] grid) {
int m = grid.length, n = grid[0].length;
Deque<int[]> q = new ArrayDeque<>();
int fresh = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 2) q.offer(new int[]{i, j, 0});
else if (grid[i][j] == 1) fresh++;
}
}
int minutes = 0;
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
while (!q.isEmpty()) {
int[] cur = q.poll();
int i = cur[0], j = cur[1], t = cur[2];
minutes = t;
for (int[] d : dirs) {
int ni = i + d[0], nj = j + d[1];
if (ni >= 0 && ni < m && nj >= 0 && nj < n && grid[ni][nj] == 1) {
grid[ni][nj] = 2;
fresh--;
q.offer(new int[]{ni, nj, t + 1});
}
}
}
return fresh == 0 ? minutes : -1;
}
}class Solution {
public:
int orangesRotting(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
queue<tuple<int, int, int>> q;
int fresh = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 2) q.push({i, j, 0});
else if (grid[i][j] == 1) fresh++;
}
}
int minutes = 0;
int dirs[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
while (!q.empty()) {
auto [i, j, t] = q.front();
q.pop();
minutes = t;
for (auto& d : dirs) {
int ni = i + d[0], nj = j + d[1];
if (ni >= 0 && ni < m && nj >= 0 && nj < n && grid[ni][nj] == 1) {
grid[ni][nj] = 2; // 入队即染色
fresh--;
q.push({ni, nj, t + 1});
}
}
}
return fresh == 0 ? minutes : -1;
}
};复杂度:时间 O(mn)、空间 O(mn)(队列最坏装下整张网格)。
实现里几个工程细节值得拎出来:
第一,fresh 计数器代替最后扫一遍。BFS 结束后判断有没有"漏网"的新鲜橘子,直接看 fresh == 0 比再扫一遍网格干净。每次有新鲜橘子被腐烂时 fresh -= 1,初始扫描时累计。
第二,入队即染色——grid[ni][nj] = 2 在 q.append 之前完成。如果先 append 再染色,BFS 在多腐烂橘子相邻的情况下会让同一格被多次入队(最坏 4 次),复杂度退化。
第三,起点距离打成 0 而不是 1。一个常见 bug 是把初始腐烂橘子记距离 1,把 minutes = t - 1 来"修正"——这种打法 BFS 在第一层扩张时就会算错。更干净的做法是初始就 0,最后直接返回 t。
第四,全无腐烂橘子但有新鲜橘子怎么办。队列空、fresh > 0,返回 −1,逻辑正确(minutes 保留为 0,但被 fresh != 0 拦住)。
第五,全无新鲜橘子(初始就赢)。fresh = 0,BFS 队列里只有起点,扩张一圈都没东西可腐烂,minutes 保留为 0。但要小心:如果队列里所有元素 t = 0,循环里 minutes = t = 0,正确。
Follow-up:上面这题问的是"全部腐烂的总时间"——只需要最大距离,不在乎每个新鲜橘子被腐烂的精确时刻。如果题目反过来——给一个 0/1 矩阵,要求输出每个 1 到最近 0 的距离矩阵呢?
4.2 LC 542 01 矩阵
链接:LeetCode 542
题意:给一个 m × n 的 0/1 矩阵 mat。对每个 mat[i][j],求它到最近的 0 的曼哈顿距离(即网格距离,4-邻接)。返回同尺寸的距离矩阵。
数据范围:
m, n:1 到 10⁴m × n ≤ 10⁴mat[i][j]:0 或 1- 至少有一个 0
示例:
输入:mat = [
[0, 0, 0],
[0, 1, 0],
[1, 1, 1]
]
输出:[
[0, 0, 0],
[0, 1, 0],
[1, 2, 1]
]
解释:每个 1 到最近 0 的曼哈顿距离。比如 (2,2)=1,它的上邻居 (1,2)=0,距离 1;中心 (1,1)=1 四面都被 0 包围(取 (0,1)),距离 1;(2,1)=1 的最近 0 是 (1,0) 或 (0,1),距离 2。思路上有两条主流路径:
思路 A:从每个 1 出发跑一次 BFS 找最近的 0——O((mn)²)。mn ≤ 10⁴ 时 10⁸ 次操作,秒级 TLE。这是"单源 BFS 做 mn 遍"的暴力。
思路 B:从所有 0 出发的多源 BFS——O(mn)。把所有 0 一次性入队,BFS 自然按距离层扩张,碰到 1 时记下当前距离。这是本章主线。
思路 C:两遍 DP 扫描——O(mn)。第一遍从左上扫到右下、第二遍从右下扫到左上,每个点用左 / 上 / 右 / 下邻居的 dp + 1 更新自己。这也是 O(mn) 但常数稍小。多源 BFS 思路更通用、更易迁移到其他题(LC 1162),所以这里只展开 BFS 版。
from collections import deque
class Solution:
def updateMatrix(self, mat: list[list[int]]) -> list[list[int]]:
m, n = len(mat), len(mat[0])
# dist[i][j] = -1 表示尚未确定距离;0 表示自己就是源
dist = [[-1] * n for _ in range(m)]
q = deque()
# 第一步:所有 0 一次性入队,距离 0
for i in range(m):
for j in range(n):
if mat[i][j] == 0:
dist[i][j] = 0
q.append((i, j))
# 第二步:标准 BFS 按层扩张
while q:
i, j = q.popleft()
for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ni, nj = i + di, j + dj
if 0 <= ni < m and 0 <= nj < n and dist[ni][nj] == -1:
dist[ni][nj] = dist[i][j] + 1
q.append((ni, nj))
return dist复杂度:时间 O(mn)、空间 O(mn)(dist 矩阵 + 队列)。
跟 LC 994 的差异只有两点:
- LC 994 起点是 2、扩张目标是 1;LC 542 起点是 0、扩张目标是 1。
- LC 994 只关心"最远距离";LC 542 要输出整张距离矩阵。
骨架完全一致。这就是多源 BFS 的通用性——把"什么算起点、什么算可走"换一下,剩下都是模板。
为什么入队判断 dist[ni][nj] == -1 等价于 visited:BFS 第一次到达一个点时距离一定最短(无权图不变量),所以一旦距离不再是 −1,就不必再考虑了。这等于"入队即定距 + 之后不再访问"。
Follow-up:LC 542 要的是每个 1 到最近 0 的距离的整张矩阵。如果题目反过来——给一个 0/1 网格,0 是海洋、1 是陆地,要求海洋格子到最近陆地距离的最大值呢?
4.3 LC 1162 离海洋最远的陆地(题意按 LeetCode 原版)
题意:给一个 n × n 的 0/1 网格 grid。0 表示海洋、1 表示陆地。在所有海洋格子中,找到离最近的陆地格子的曼哈顿距离最大的那个,返回这个最大值。如果整张图全是陆地或全是海洋(即没有可对比的情况),返回 −1。
数据范围:
n:1 到 100grid[i][j]:0 或 1
示例:
输入:grid = [
[1, 0, 1],
[0, 0, 0],
[1, 0, 1]
]
输出:2
解释:中心 (1,1) 是海洋,离最近陆地(四角)距离都是 2,所以答案 2。思路:跟 LC 542 几乎一模一样——多源 BFS 从所有陆地出发,扩张时给每个海洋记下"到最近陆地的距离",最后取所有海洋格子里的最大距离。LC 542 输出整张矩阵、LC 1162 只要矩阵里的最大值。
from collections import deque
class Solution:
def maxDistance(self, grid: list[list[int]]) -> int:
n = len(grid)
q = deque()
# 所有陆地入队,距离 0
for i in range(n):
for j in range(n):
if grid[i][j] == 1:
q.append((i, j, 0))
# 全陆地 / 全海洋的退化情况,按题意返回 -1
if not q or len(q) == n * n:
return -1
ans = 0
# BFS,沿途记录到达海洋时的最大距离
# 用 grid 自己标记 visited(陆地 1,访问过的海洋改成 1)
while q:
i, j, d = q.popleft()
ans = max(ans, d)
for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ni, nj = i + di, j + dj
if 0 <= ni < n and 0 <= nj < n and grid[ni][nj] == 0:
grid[ni][nj] = 1 # 入队即染色,避免重复入队
q.append((ni, nj, d + 1))
return ans复杂度:时间 O(n²)、空间 O(n²)。
几个工程细节:
第一,全陆地 / 全海洋判退。题目明确要求这两种情况返回 −1。len(q) == n * n 即全陆地(队列装满),not q 即全海洋(队列空)。这两种判断在循环开始前做一次即可。
第二,用 grid 自己当 visited。把走过的海洋格子改成 1,借助"陆地 = 障碍"语义跟入队条件统一。如果题目要求不能修改输入,开一个独立的 visited 数组即可。
第三,距离 0 也要参与 max——所有陆地起点距离 0,被 ans = max(ans, d) 处理一遍,但 ans = 0 不会被 0 更新(max 不变)。真正起作用的是后续扩张出来的海洋格子。
第四,ans 初值是 0 还是 −1?这道题如果有海洋格子能扩张到,最终 ans 一定 ≥ 1;如果到不了(不会发生,因为已经判退了全陆地 / 全海洋),ans 保留为 0。初值 0 是安全的,对应"刚开始还没找到任何海洋"的状态。
5. 边界、易错与复杂度
把三道题里常踩的坑合并讲。
入队即染色(不是出队时染色)
最常见的退化是"出队时才标 visited"——这会让同一个格子被多次入队(最坏 4 次,每个邻居一次),队列长度爆到 O(4mn),复杂度从 O(mn) 退化到 O(mn) 的 4 倍常数 + 内存浪费。极端情况会 MLE。
正确写法:q.append(...) 之前就把这一格的距离 / 状态写好。这样它再次被相邻格子尝试入队时,会被 dist == -1 或 grid[ni][nj] == 1 拦住。
起点距离记 0,不是 1
把起点距离打成 1 是新手常见 bug。dist[起点] = 0 是约定俗成的——起点到自己的距离当然是 0。BFS 扩张时 dist[邻居] = dist[当前] + 1,从 0 开始累加才是层数。
LC 994 题面说"经过 1 分钟后腐烂",导致有人把初始腐烂橘子记成"分钟 1"。其实"分钟 0 时所有初始腐烂橘子已经腐烂",所以记 0、最后返回最大的 t,对应"第 t 分钟才完成"。
区分"可走" vs "起点"
LC 994 中起点是 grid == 2,可走是 grid != 0(即 1 或 2)。新鲜橘子和腐烂橘子都算可达点,空格子是障碍。
LC 542 中起点是 grid == 0,扩张目标是 grid == 1。没有障碍。
LC 1162 中起点是 grid == 1(陆地),扩张目标是 grid == 0(海洋)。
写多源 BFS 时把这两件事分开想清楚:谁是起点(一次性入队)、谁是可走的(在扩张时被判进队列)。
队列元素:用元组还是 (i, j, dist)
两种风格:
dist[i][j]二维数组单独存距离:队列里只放(i, j)。代码更长但内存常数小。- 队列元素打成
(i, j, d):把距离塞进队列元素。代码紧凑,但内存多 33%。
LC 994 风格用了 (i, j, d),LC 542 / 1162 用了 dist 矩阵。两种都对,按个人习惯选。Python 里元组很便宜,差异不大;Java / Go 里 int[]{i, j, d} 比 int[]{i, j} + dist[][] 多一次堆分配但代码短。
Python 用 deque 不用 list
Python 的 list.pop(0) 是 O(n),BFS 用 list 会让整体退化到 O((mn)²)。必须 from collections import deque,用 q.popleft() O(1)。
Java 用 ArrayDeque、Go 用切片 + 偏移指针都可以;都不要用 LinkedList 这种链表实现,常数大。
起点不存在的退化
每道题都要单独考虑"没有起点 / 全是起点 / 没有目标"这几种情形:
- LC 994:没有腐烂橘子 + 有新鲜橘子 → −1;没新鲜橘子 → 0。
- LC 542:题目保证至少一个 0 → 不会退化。
- LC 1162:全陆地 / 全海洋 → −1。
写完主逻辑后,把这几种边界各跑一遍,保证返回的不是 NaN / 抛异常。
复杂度速查
| 题 | 时间 | 空间 |
|---|---|---|
| LC 994 腐烂的橘子 | O(mn) | O(mn) 队列 |
| LC 542 01 矩阵 | O(mn) | O(mn) dist + 队列 |
| LC 1162 离海洋最远的陆地 | O(n²) | O(n²) 队列 |
每道题都是"每个格子至多入队一次、出队一次",所以渐进时间和空间都被网格大小卡死。
7. 小结
多源 BFS = "所有起点一次性入队"。本质等价于建一个虚拟超级源点零费用连到所有真实起点,从虚拟源跑一次单源 BFS。复杂度 O(mn),跟起点数量无关。三件事记牢:
- 起点距离 0、入队即染色,避免重复入队。
is_source和can_walk分开,看清楚谁是起点、谁是障碍、谁是扩张目标。- 退化情形单独判:无起点、无目标、全网格都是起点等情况都要给出正确返回值。
下一节会把网格 BFS 推到"带权"场景——每一步代价不再都是 1,需要 0-1 BFS(双端队列)或 Dijkstra。LC 1631 最小努力路径、LC 1293 网格中的最短路径都属于这类。
对应灵神题单:网格图 / 多源 BFS / 网格上的最短路
进一步阅读:灵茶山艾府《网格图题单》多源 BFS 小节;LeetCode BFS 标签 高频网格题