3. 广度优先搜索(BFS):算法原理、队列实现、在无权图中寻找最短路径
大家好,我是你们的老朋友。今天我们来聊聊广度优先搜索,也就是 BFS。
说实话,BFS 是我个人最早接触的图搜索算法之一。那时候我刚入行,还在啃数据结构课本,觉得这玩意儿不就是「一层一层往外扩」嘛,有啥难的?结果后来在项目中栽了跟头,才真正理解了它的精髓。
嗯,咱们今天就把 BFS 掰开揉碎了讲清楚。你想想看,它凭什么能在无权图中找到最短路径?队列又是怎么帮它实现「层层推进」的?
3.1 BFS 的核心思想:像水波一样扩散
BFS 的原理,说白了就是「从起点出发,一层一层往外探索」。
想象一下你往平静的湖面扔了一颗石子。水波会一圈一圈地向外扩散,先碰到第一圈,再碰到第二圈,以此类推。BFS 就是这么干的——它先访问离起点最近的所有节点,然后再访问离起点第二近的节点,依此类推。
为什么会这样?因为 BFS 使用了一个先进先出的队列来管理待访问节点。每次从队列头部取出一个节点,然后把它的所有未访问邻居加入队列尾部。这样,先入队的节点(离起点近的)就会先被处理,后入队的节点(离起点远的)就会后被处理。
核心结论:在无权图中,BFS 第一次访问到目标节点时,所经过的路径就是最短路径。因为它是按「层」来推进的,每一层都比上一层多走一步。
3.2 队列实现:BFS 的发动机
我个人习惯用 Python 的 collections.deque 来实现 BFS 的队列。为什么不用普通的 list?因为 list 的 pop(0) 操作是 O(n) 的,而 deque 的 popleft() 是 O(1) 的。在节点数量大的时候,这个性能差距非常明显。
我记得有一次做地图路径规划,地图上有上万个节点。一开始我用 list 实现队列,结果程序跑起来慢得像蜗牛。换成 deque 之后,速度直接提升了十几倍。嗯,这就是细节决定成败。
来看一个标准的 BFS 模板代码:
from collections import deque
def bfs_shortest_path(graph, start, goal):
"""
在无权图中使用 BFS 寻找从 start 到 goal 的最短路径
graph: 邻接表表示的图,如 {0: [1, 2], 1: [0, 3], ...}
start: 起点
goal: 终点
返回: 最短路径的节点列表,如果不存在则返回 None
"""
# 队列中存储 (当前节点, 到达当前节点的路径)
queue = deque()
queue.append((start, [start]))
# 记录已访问节点,防止重复访问
visited = set()
visited.add(start)
while queue:
current_node, path = queue.popleft()
# 找到目标节点,返回路径
if current_node == goal:
return path
# 遍历当前节点的所有邻居
for neighbor in graph[current_node]:
if neighbor not in visited:
visited.add(neighbor)
# 新路径 = 旧路径 + 新节点
new_path = path + [neighbor]
queue.append((neighbor, new_path))
# 队列为空,说明没有路径
return None
小技巧:如果你只需要知道最短路径的长度,而不需要具体路径,可以只存节点本身,不存路径。用一个 distance 字典来记录每个节点到起点的距离即可。这样能节省大量内存。
3.3 BFS 在无权图中寻找最短路径的原理
为什么 BFS 能找到最短路径?我们来拆解一下。
假设起点是 A,终点是 F。BFS 的过程是这样的:
- 第一层:访问 A(距离 0)
- 第二层:访问 A 的所有邻居 B、C、D(距离 1)
- 第三层:访问 B、C、D 的未访问邻居 E、G(距离 2)
- 第四层:访问 E、G 的未访问邻居 F(距离 3)
当 BFS 第一次访问到 F 时,它走过的步数就是 3。因为 BFS 是按层推进的,它不可能跳过某一层直接到达更远的节点。所以第一次访问到目标节点时,一定是最短路径。
我曾经在项目中遇到过一个问题:用 BFS 找到了路径,但总觉得不是最短的。后来排查发现,是因为图中有环,而我没有正确处理已访问节点。BFS 一旦发现某个节点已经被访问过,就跳过它,这样才能保证每个节点只被访问一次,从而保证第一次访问就是最短路径。
避坑指南:我曾经在实现 BFS 时,把 visited 检查放在了「从队列取出节点之后」,而不是「加入队列之前」。结果导致同一个节点被多次加入队列,不仅浪费内存,还可能导致路径不是最短的。正确的做法是:在将邻居加入队列之前,就检查它是否已被访问。
3.4 图解 BFS 流程
光说理论不够直观,咱们画个图看看。下面这张 SVG 图展示了 BFS 从起点 S 到终点 T 的搜索过程。
从图中可以看到,BFS 先探索第 0 层的 S,然后第 1 层的 A 和 B,接着第 2 层的 C、D、E,最后在第 3 层找到 T。其中 S→B→D→T 这条路径被高亮显示,它就是最短路径。
3.5 时间复杂度与空间复杂度
咱们来聊聊性能。BFS 的时间复杂度是 O(V + E),其中 V 是节点数,E 是边数。为什么?因为每个节点入队出队一次,每条边被检查一次。
空间复杂度是 O(V),主要花在队列和 visited 集合上。如果图特别大,比如上百万个节点,内存消耗就很可观了。
| 指标 | 值 | 说明 |
|---|---|---|
| 时间复杂度 | O(V + E) | 每个节点和每条边都被访问一次 |
| 空间复杂度 | O(V) | 队列 + visited 集合 |
| 适用场景 | 无权图 | 边权值相同或没有权值 |
| 能否找最短路径 | 能 | 第一次访问到目标节点即为最短 |
经验之谈:如果你要处理的是带权图,别用 BFS,改用 Dijkstra 算法。BFS 只适用于所有边的权值都相等的情况。我在项目中就犯过这个错,用 BFS 处理带权图,结果找出来的路径根本不是最短的。
3.6 实际应用场景
BFS 的应用场景其实挺多的。我随便列几个:
- 社交网络中的好友推荐:比如「你可能认识的人」,就是通过 BFS 找二度、三度好友。
- 迷宫求解:从起点到终点的最短路径,BFS 天然适合。
- 网络广播:数据包在网络中扩散,BFS 的「一层一层」特性正好匹配。
- 垃圾回收算法:某些 GC 算法使用 BFS 遍历对象引用图。
我记得有一次做智能仓储机器人的路径规划,仓库地面画了网格,机器人要从 A 点走到 B 点。网格就是一张无权图,每个格子是一个节点,上下左右四个方向是边。用 BFS 一跑,最短路径秒出。嗯,那种感觉真的很爽。
3.7 常见误区与避坑
最后,我把自己踩过的坑分享给大家,希望能帮你们少走弯路。
我曾经犯过的错:
- 忘记标记已访问节点:导致死循环,程序卡死。尤其是在有环的图中,这个问题特别致命。
- 在取出节点时才标记 visited:导致同一个节点被多次加入队列,路径可能不是最短的。
- 用 list 实现队列:pop(0) 是 O(n) 操作,节点一多性能就崩了。
- 没有考虑图不连通的情况:如果起点和终点不在同一个连通分量里,BFS 会返回 None,要提前处理。
好了,关于 BFS 的内容就讲到这里。你想想看,其实 BFS 的核心就两点:队列控制顺序,visited 防止重复。掌握了这两点,BFS 就没什么神秘的了。