1. 从一道迷宫题说起:BFS凭什么能解最短路
如果你刚接触算法竞赛或者刷题圈,大概率听过这句话:无权图上的最短路,用BFS。但很多人只是把这句话背下来,真到了做题时——第一步就卡住了:为什么要用BFS?DFS不行吗?什么算“无权图”?队列里到底该存什么?
先说结论:在边权全部为1(或者看作一步等于一个代价)的图里,BFS天然具备“按层扩散”的特性,先到达某个点的层数一定是最小的。换句话说,队列先进先出的机制保证了首次出队访问时,一定走的是最短路径。这不是什么高深推论,而是队列顺序的自然结果——你从起点出发,先处理所有一步能到的点,再处理两步能到的点,依此类推。
这篇文章我会带你完整过一遍BFS求最短路的整个思考链路:从模板代码到变式题型,从数组初始化到调试技巧,把我踩过的坑和排查思路一并写出来。无论你是刚接触图论的萌新,还是想系统梳理这类题型的参赛选手,相信都会有用。
2. 核心思路拆解:为什么“先进先出”能保证最短
2.1 无权图上的层级扩散模型
先明确一个前提:BFS求最短路,适用范围是无权图(或者所有边权相等)。如果是带权图,比如边权分别为2、5、10,那BFS就不适用了,得换Dijkstra。为什么?因为权重不同时,步数少的路径未必代价小。比如从A到C,一步直达代价100,绕路两步代价3,BFS按层扩散会先走到C就结束,但实际最短代价是绕路那条。这在有权图里是不成立的。
而无权图就好办:每条边代价都是1,那么路径长度就等于边的数量,也等于步数。步数少就是路径短,没有例外。
BFS的做法是:从起点开始,先走所有能一步到达的邻居,这些邻居的“距离”就是1。然后从这第一批邻居出发,找它们的邻居中还没被访问过的点,距离标记为2。整个过程像水波扩散,一圈圈往外推。
这里的关键是:拿到某个点时,我怎么知道它的距离?答案是它的距离 = 前一个点的距离 + 1。因为你从某个点走一步到新点,步数自然多1。
2.2 场景代入:地图、迷宫与状态图
BFS最短路的经典场景首先是迷宫/地图类问题。一个二维网格,0表示空地、1表示障碍物,从左上角走到右下角最少需要几步。这种题在各大刷题平台多到成灾,但确实是把BFS讲透的最佳案例。
其次还有一类容易忽略的场景:状态转换图。比如一个密码锁,每次可以旋转一位数字,问从初始状态到目标状态最少操作多少次;又比如一个木板水位问题,倒水、倒掉、加水,最少几步到指定状态。这种问题里,“每个合法的状态”就是一个节点,“一次操作”就是一条边,而且每次操作的代价相同,所以同样能用BFS。
还有多源BFS——比如一片区域里有多个火源同时向四周蔓延,问某点最早什么时候被烧到;或者在一排座位中有多个目标点,问所有座位到最近的目标点的距离。这类问题其实也是BFS,只是起点不止一个。
把这些场景放在一起,你会发现共同点非常清晰:
| 特征 | 说明 |
|---|---|
| 节点 | 位置坐标 / 状态表示 |
| 边 | 一次合法移动 / 一次操作 |
| 权值 | 均为1(一步一代价) |
| 目标 | 最小步数 / 最短路径长度 |
抓住这个抽象模型,很多看起来不像图论的题也能迎刃而解。
2.3 为什么DFS不适合求最短路
很多初学者会问:DFS也能找到终点,为什么不用它?原因在于:DFS走的是“一条路走到黑”,第一次找到终点的那条路径,未必是最短的。你要得到最短路,就得把所有路径都遍历完再比较,复杂度指数级爆炸。而且DFS天然配合栈(递归),适合“是否存在路径”“枚举所有可能方案”这类问题;BFS配合队列,是按层同步推进,适合“最少步数”。
这里打个比方:DFS像一个在迷宫里闷头走的人,走不通就原路退回换一条路再试;BFS则像一张渔网从起点均匀铺开,所有方向同步向前推进。渔网最先碰到的目标,走的距离自然最短——因为渔网各个方向拓展速度是一致的。
3. BFS最短路模板与实现细节
3.1 完整模板代码(Python + C++)
先亮出最通用的一版模板,拿网格迷宫举例。我用Python写一版,再给C++一版,大家各取所需。
from collections import deque def bfs_min_steps(maze, start, end): """ maze: 二维网格,0可走,1障碍 start: (x, y) 终点: (x, y) return: 最少步数,不可达返回 -1 """ if not maze or not maze[0]: return -1 rows, cols = len(maze), len(maze[0]) # 四个方向 directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] # 判空起点和终点合法性 if maze[start[0]][start[1]] == 1 or maze[end[0]][end[1]] == 1: return -1 visited = [[False] * cols for _ in range(rows)] # 其实可以用 dist 数组并初始化为 -1 # visited 和 dist 各有取好,见下文 dist = [[-1] * cols for _ in range(rows)] q = deque() q.append(start) dist[start[0]][start[1]] = 0 while q: x, y = q.popleft() if (x, y) == end: return dist[x][y] for dx, dy in directions: nx, ny = x + dx, y + dy if 0 <= nx < rows and 0 <= ny < cols: if maze[nx][ny] == 0 and dist[nx][ny] == -1: dist[nx][ny] = dist[x][y] + 1 q.append((nx, ny)) return -1C++版本:
#include <bits/stdc++.h> using namespace std; const int dx[4] = {-1, 1, 0, 0}; const int dy[4] = {0, 0, -1, 1}; int bfs(vector<vector<int>>& maze, pair<int,int> start, pair<int,int> end) { int rows = maze.size(), cols = maze[0].size(); if (maze[start.first][start.second] || maze[end.first][end.second]) return -1; vector<vector<int>> dist(rows, vector<int>(cols, -1)); queue<pair<int,int>> q; q.push(start); dist[start.first][start.second] = 0; while (!q.empty()) { auto [x, y] = q.front(); q.pop(); if (make_pair(x, y) == end) return dist[x][y]; for (int i = 0; i < 4; i++) { int nx = x + dx[i], ny = y + dy[i]; if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && maze[nx][ny] == 0 && dist[nx][ny] == -1) { dist[nx][ny] = dist[x][y] + 1; q.push({nx, ny}); } } } return -1; }3.2 每一步的意义:从变量到循环
拆开讲几个关键点。
队列是整个BFS的发动机。每次从队头取出一个点来处理,再把新发现且未访问的点加入队尾。因为队列先进先出,先被发现的点先被处理,这才造就了“按层遍历”的效果。
visited / dist 数组用来防止重复访问。没有它,BFS会陷入无限循环——因为图里可能有环,BFS会反复进入同一个点。用dist数组初始化为-1,同时起到了“是否访问过”和“记录步数”两个作用。访问时dist为-1说明还没来过,直接更新;不为-1说明已经来过,跳过。这种写法比单独开一个visited数组更省事。
方向数组是网格题目的常用技巧。dx、dy分别表示上下左右四个方向的坐标变化,循环一次就等于尝试了四个邻居。要注意在二维数组中,“上下”对应行坐标减1或加1,“左右”对应列坐标减1或加1,别搞混。
边界检查是最容易被新手忽略的地方。访问邻居前,必须先确认新坐标没有超出网格范围,否则会数组越界。我见过不少同学在正规刷题平台上提交时遇到运行时错误,检查半天发现是越界。
入队时同步更新dist,而不是出队时更新。这个细节很关键。如果入队时不更新,等出队时才设置距离,那么一个点可能被多个邻居同时发现,在队列里挤进好多次,dist判断就失灵了。正确做法是:一旦发现这个点未访问,立刻把它标记为“已发现”并设定距离,再入队。
3.3 记录路径:从dist数组反推
如果题目不只要求最短步数,还要求输出具体路径,怎么办?很简单,在更新dist的同时,用一个parent二维数组记录“我是从哪个点过来的”。
parent = [[None] * cols for _ in range(rows)] # 入队时: parent[nx][ny] = (x, y) # 到达终点后,从终点沿parent一路回溯到起点代码如下:
def bfs_with_path(maze, start, end): # ... 类似逻辑,额外维护 parent # 到达 end 后 path = [] cur = end while cur != start: path.append(cur) cur = parent[cur[0]][cur[1]] path.append(start) path.reverse() return path # 从起点到终点的坐标列表注意:反推出来的路径是倒序的,必须反转一次。另外,这种方式只适用于父节点唯一的情况——BFS中每个节点第一次被发现的父节点是唯一的,所以路径唯一。不过严格说的话,BFS找到的最短路可能有多条,这种回溯只记录其中一条。
3.4 时间与空间复杂度
BFS最短路的时间复杂度,在网格图上是O(R×C),在通用图上(V个顶点、E条边)就是O(V+E)。因为每个顶点最多入队一次,每条边都会被检查一次。
空间复杂度与复杂度同级,主要是队列和dist/visited数组的开销,也是O(V+E)。对绝大多数刷题场景来说,这个开销完全可接受。
注意:如果你用了邻接矩阵来存图,复杂度里的E会被V²替代——稀疏图用邻接表更划算,稠密图用邻接矩阵反而更快。网格题本身的“邻接关系”是天然的四方向结构,不需要显式建图。
4. 五种常见变式与题目应对策略
4.1 网格迷宫类:二维数组BFS
这类题目是BFS最基本的应用。二维网格、四方向移动、统计最少步数。模板直接套,唯一要留意的是“起点就是终点”这种边界情况,直接返回0。
还有一个小变种叫“最短路径中的转向次数”——比如地铁换乘最少换几次线。此时“点”还是位置坐标,但“边”的含义变了:在同一线路上的相邻站移动不算换乘,换线路才算。这类问题要么把状态包装成“当前所在线路”,要么把换乘看成一种“特殊步骤”加入BFS状态,后面会讲到状态BFS的思路。
4.2 最少步数类:状态BFS与状态压缩
有些题目不是“走地图”,走的是一张“状态图”。典型例子是“翻硬币”:一排硬币,每次可以翻转相邻的若干枚,问从初始状态翻到目标状态最少需要多少次。
这里每个状态就是一个“当前硬币的正反排列”,一次性翻转操作就是一条边。BFS直接从初始状态出发,一层层生成所有可达状态,直到命中目标状态。
关键问题在于状态如何表示。如果硬币有N枚,状态就有2^N种。N≤20时,可以用一个int的二进制位表示硬币的正反(1表示正面,0表示反面),状态转移就是按规则翻转二进制位。这种“用一个整数压住一个状态”的技巧叫状态压缩,配合BFS就是“状压BFS”。
实际写代码时,队列里存int状态,visited用一个bool数组下标就是状态值。从一个状态生成邻居时,按翻转操作位运算即可。这里我提供一个伪代码框架:
def bfs_state(start_state, target_state, n): # 状态最大值 2^n - 1 visited = [False] * (1 << n) q = deque([(start_state, 0)]) visited[start_state] = True while q: state, step = q.popleft() if state == target_state: return step for op in operations: # 每种翻转操作 new_state = apply(state, op) if not visited[new_state]: visited[new_state] = True q.append((new_state, step + 1)) return -1这类题的关键除了BFS本身,还有状态编码。编码设计得好,代码就清晰;编码混乱,一个状态转移就能把人绕晕。我建议先用小规模数据把状态转移逻辑理清楚,再上复杂度优化。
4.3 多源BFS:多个起点同时扩展
多个起点同时向外扩散,问某个位置最早被哪个起点覆盖。最经典的应用是“矩阵中每个格子到最近的0的距离”(曼哈顿距离):
- 把所有值为0的格子作为初始起点全部入队
- 让它们同时开始BFS扩散
- 每个格子第一次被访问时,dist就是到最近0的距离
这一步的实现很直接:初始时把多个起点全部放进队列,dist初始为0。BFS开始后,所有起点就像比赛起跑一样同时向外扩散,谁先“抢”到某个格子,谁就赋予它距离。因为所有方向速度相同,先到达的起点一定最近。
多源BFS的另一个常用场景是“洪水蔓延”:多个水源、多个火源同时扩散,求某个区域被覆盖的时间。把多个源点同时入队的思路完全不变。
很多人问:多源BFS和普通BFS不一样吗?本质上完全一样,只是“起点”不是一个而是一组。可以把多源BFS看成在起点集合之前再加一个虚拟超级源点,超级源点到所有起点有一条权值为0的边。但实现时不需要真的建这个点,只需把所有起点初始入队。
4.4 无权图上的最短路:邻接表 + BFS
当你面对的不是网格,而是抽象图——比如社交关系、课程依赖——时,就得先把图存起来,再跑BFS。
# 邻接表建图 from collections import defaultdict, deque def shortest_path_undirected(n, edges, start, target): graph = defaultdict(list) for u, v in edges: graph[u].append(v) graph[v].append(u) dist = [-1] * n dist[start] = 0 q = deque([start]) while q: cur = q.popleft() if cur == target: return dist[cur] for nxt in graph[cur]: if dist[nxt] == -1: dist[nxt] = dist[cur] + 1 q.append(nxt) return -1这里的关键是建图时别漏掉双向边——无向图要加两次。有向图则只加一次。
4.5 0-1 BFS扩展:带着权值的BFS变种
一个很有意思的扩展是“0-1 BFS”:图的边权只有0和1两种。此时不能直接用普通BFS,但也不用上Dijkstra。技巧是:遇到权值为0的边,把节点插入队首;遇到权值为1的边,插入队尾。
因为0权边不会增加代价,相当于和当前节点一样的层数;1权边增加一层,要放到下一层处理。使用双端队列(deque),就能在O(V+E)内解决这类问题。
from collections import deque def bfs_01(graph, start, n): dist = [float('inf')] * n dist[start] = 0 dq = deque([start]) while dq: u = dq.popleft() for v, w in graph[u]: nd = dist[u] + w if nd < dist[v]: dist[v] = nd if w == 0: dq.appendleft(v) else: dq.append(v) return dist这个变种面试和竞赛都会碰到,务必备着。它比Dijkstra更容易实现,又比普通BFS适用范围更广。
5. 一遍遍试错:我踩过的那些坑
5.1 队列忘记导出队头
写Python时经常忘了q.popleft()的返回值,或者写成了q.pop()。必须注意:队列取元素是从队头取,列表用pop()默认从队尾取,会变成“栈”——DFS行为,完全不对。C++里倒是std::queue的front+pop组合比较明确,不太会弄错。
5.2 visited标记时机不当
有两种常见错误:
- 出队时再标visited:同一个点可能被多个邻居同时发现,导致重复入队,队列膨胀,距离判断出错。更糟糕的是,可能A邻居先入队了,但B邻居后入队时发现dist还没被更新,又把该点入队一次。处理顺序一乱,最短路径就可能被覆盖掉。排查方法:在入队时更新dist并标记,绝不拖延。
- 入队时标visited:这是对的。我知道有些教材用出队标记的写法,但在很多题目里那种写法会导致一个点被多次入队,严重时直接TLE(超时)。所以统一用“入队即标记”。
5.3 dir方向的顺序搞混
上下左右对应的坐标增量很多人记不清。二维数组maze[x][y]中,x是行下标,y是列下标。所以:
- 上:
(-1, 0) - 下:
(1, 0) - 左:
(0, -1) - 右:
(0, 1)
如果把y当成行,就会得到“左右”变“上下”的诡异行为。调试时优先检查方向数组。
5.4 边界条件漏判
起点就是终点、起点是障碍物、终点是障碍物、地图只有一行一列、地图为空——这些边界条件看起来没必要,但恰恰是隐藏评测点。凡事先把边界条件写完,再跑主逻辑,能省掉大量返工。
5.5 路径长度从0开始还是从1开始
很多人在“初始距离”上犯浑。坐标起点到自身的距离是0,这一步很明确。但有的题目把“从起点出发算一步”,比如走迷宫问“至少移动几次”,起点位置移动已经算一次。遇到这种题要能在模板的基础上微调:把起点的dist初始化为1,或者返回时加1。建议做题前先花10秒理清题意,别急着写代码。
5.6 大数据输入时用sys.stdin加速
在刷题平台上,如果图特别大、输入行特别多,Python默认的input()很容易TLE。建议开头直接:
import sys input = sys.stdin.readline这一行往往能把TLE变成AC。C++选手则直接用ios::sync_with_stdio(false); cin.tie(0);加速输入输出。
6. 调试BFS:一套实操排查流程
BFS写错了,最常见的就是“答案不对”和“超时”两个极端。我的调试顺序一般是这样:
第一步,用小样例肉眼跑一遍。用一个3×3或4×4的地图,打印整个dist数组。只要dist数组肉眼看起来是对称、逐层递增的,基本问题不大。打印方式:
for row in dist: print(row)第二步,检查队列长度。如果队列长度快速增长、没有收敛趋势,多半是visited标记时机不对导致大量重复入队。这时候回看“入队时标记”一条——八成是它。
第三步,验证方向。单独跑一个从起点到某个近邻点的BFS,看看路径是否符合预期。方向错了,近邻点就找不到。
第四步,测边界用例。把起点设成终点、地图全空、地图全是障碍物、起点在角落,这类用例单独测,能暴露出大部分索引和初始化问题。
第五步,对比暴力解法。对非常小的数据,写一个DFS穷举或直接BFS打印所有路径长度,和你的BFS答案对比。如果两者一致,说明逻辑没问题;不一致,在全排列一样的逻辑里找差异。
第六步,检查变量类型。Python还好,C++选手要留意int溢出问题——网格很大的时候,dist值可能超int范围。不过常规题目int没问题,但数据规模大的题建议直接用long long,一劳永逸。
7. BFS与其他最短路算法的对视
把BFS放进整个最短路算法家族里看,站在全局角度能帮你更快判断该用什么算法。
| 算法 | 适用图 | 时间复杂度 | 实现难度 |
|---|---|---|---|
| BFS | 无权图(边权全等) | O(V+E) | 简单 |
| 0-1 BFS | 边权∈{0,1} | O(V+E) | 中等 |
| Dijkstra | 非负权图 | O(E log V) | 中等 |
| Bellman-Ford | 有负权(无负环) | O(VE) | 中等偏难 |
| Floyd | 全源最短路 | O(V³) | 简单 |
做题时的判断口诀:边权全是1,直接BFS;边权只有0和1,0-1 BFS;边权非负,Dijkstra;有负数,Bellman-Ford或SPFA;要求所有点对最短路,Floyd或V次Dijkstra。
最后再分享一个小技巧:我习惯在写BFS之前先画一张“层数示意图”。把起点标为第0层,所有一步到达的标第1层,两步到达的标第2层,画完这张图,代码的核心逻辑基本就出来了。算法题卡住的很多时候不是代码能力,而是没有把“图是什么”想清楚。BFS就是这么个好东西——一旦想通了一层层的扩散逻辑,剩下的一切都是水到渠成。