3. 广度优先搜索(BFS):从层层推进到最优路径
广度优先搜索,简称 BFS,是我个人非常喜欢的一种算法。为什么?因为它「老实」。它不像某些算法那样会走捷径,它是一层一层、踏踏实实地往外扩张。你想想看,这种性格在路径规划里其实特别有用——尤其是在栅格地图这种离散环境中。
我记得刚入行那会儿,有个项目需要在仓库地图里找一条最短路径。当时我第一反应就是 BFS。虽然现在有很多更花哨的算法,但 BFS 在「无权图」里找最短路径这件事上,依然是教科书级别的存在。
3.1 BFS 算法原理
BFS 的核心思想其实很简单:从起点出发,先访问所有距离为 1 的邻居,再访问所有距离为 2 的邻居,以此类推。说白了,就是「一圈一圈往外扩」。
它依赖一个关键数据结构——队列。先进先出,谁先来谁先走。这保证了我们总是先处理离起点最近的节点。
核心步骤:
- 将起点放入队列,标记为已访问
- 从队列取出一个节点,检查它是不是目标
- 如果是,结束搜索;如果不是,把它的未访问邻居全部入队
- 重复步骤 2-3,直到队列为空
为什么会保证找到最短路径?因为 BFS 是按「层」推进的。当你第一次遇到目标节点时,那一定是最短距离。这个性质在无权图中是铁律。
3.2 BFS 在栅格地图中的实现
栅格地图说白了就是一个二维数组。每个格子要么是空地(可通行),要么是障碍物(不可通行)。BFS 在这上面跑,其实就是在做「四方向」或「八方向」的邻居遍历。
我个人习惯用四方向,因为更符合直觉。上、下、左、右,四个邻居。当然,如果你允许斜着走,那就用八方向,但要注意斜对角穿墙的问题——这个坑我踩过。
下面是一个完整的 BFS 实现,我加了详细的注释:
def bfs(grid, start, goal):
"""
grid: 二维栅格地图,0表示空地,1表示障碍物
start: 起点坐标 (row, col)
goal: 终点坐标 (row, col)
返回: 路径列表,如果找不到返回空列表
"""
rows = len(grid)
cols = len(grid[0])
# 四方向邻居:上、下、左、右
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
# 队列:存储待访问节点
queue = deque()
queue.append(start)
# 记录每个节点的父节点,用于回溯路径
parent = {start: None}
# 记录已访问节点
visited = set()
visited.add(start)
while queue:
current = queue.popleft()
# 到达目标,回溯路径
if current == goal:
path = []
while current is not None:
path.append(current)
current = parent[current]
return path[::-1] # 反转得到从起点到终点的路径
# 遍历四个邻居
for dr, dc in directions:
nr, nc = current[0] + dr, current[1] + dc
# 检查边界和障碍物
if 0 <= nr < rows and 0 <= nc < cols:
if grid[nr][nc] == 0 and (nr, nc) not in visited:
queue.append((nr, nc))
visited.add((nr, nc))
parent[(nr, nc)] = current
# 队列为空,找不到路径
return []
小提示:我在项目中习惯把 visited 和 parent 分开记录。虽然可以用一个字典搞定,但分开写代码更清晰,调试也方便。尤其是当地图很大的时候,用 set 做 visited 查询是 O(1) 的,性能有保障。
3.3 BFS 的优缺点分析
没有银弹。BFS 虽然经典,但也不是万能的。咱们客观地聊聊它的优缺点。
| 优点 | 缺点 |
|---|---|
| 保证找到最短路径(无权图) | 空间复杂度高,需要存储大量节点 |
| 实现简单,逻辑清晰 | 搜索范围大,效率低 |
| 适用于小规模地图 | 不适用于有权图 |
| 可并行化处理 | 对大规模地图内存消耗大 |
嗯,这里要注意一点。BFS 的空间复杂度是 O(b^d),其中 b 是分支因子(栅格地图里通常是 4 或 8),d 是搜索深度。如果地图是 1000x1000,最坏情况下 BFS 可能要存上百万个节点。这个量级,普通机器还能扛,但如果地图再大一个数量级,那就得掂量掂量了。
避坑指南:我曾经在一个 5000x5000 的栅格地图上跑 BFS,结果内存直接爆了。后来改用双向 BFS,才把问题解决。所以,如果你遇到超大图,别硬上 BFS,考虑一下双向 BFS 或者 A* 算法。
3.4 BFS 实战案例
光说不练假把式。咱们来看一个具体的例子。
假设有一个 5x5 的栅格地图,0 表示空地,1 表示障碍物:
grid = [
[0, 0, 0, 0, 0],
[0, 1, 1, 0, 0],
[0, 0, 0, 0, 0],
[0, 1, 0, 1, 0],
[0, 0, 0, 0, 0]
]
start = (0, 0)
goal = (4, 4)
运行 BFS 后,得到的路径是:
[(0,0), (1,0), (2,0), (2,1), (2,2), (2,3), (3,3), (4,3), (4,4)]
你想想看,BFS 是怎么找到这条路径的?它先访问 (0,0) 的邻居,也就是 (0,1) 和 (1,0)。然后从队列里取出 (0,1),访问它的邻居... 一层一层推进,直到碰到 (4,4)。
这个过程中,BFS 其实「不知道」目标在哪,它只是机械地往外扩。但正是这种「笨办法」,保证了第一次碰到目标时,路径一定是最短的。
实战要点总结:
- BFS 适用于小规模、无权重的栅格地图
- 四方向 vs 八方向:根据实际场景选择,注意斜对角穿墙问题
- 内存优化:可以用位图或压缩存储来减少内存占用
- 双向 BFS:当起点和终点都知道时,可以大幅减少搜索空间
好了,BFS 的内容就聊到这儿。下一章咱们会讲 Dijkstra 算法,它解决了 BFS 不能处理权重的问题。但在此之前,我建议你把 BFS 的代码亲手敲一遍,跑几个例子感受一下。代码这东西,光看是学不会的。
我的习惯:每次学新算法,我都会先画一张小地图,然后手动画出 BFS 的搜索过程。画着画着,很多细节就通了。你也试试看?