图搜索算法入门:图论基础与三大经典算法
大家好,我是你们的老朋友。今天咱们正式进入图搜索算法的世界。
说实话,我刚入行那会儿,觉得图论这东西特别抽象。什么节点、边、权重,听着就像数学课本里的概念。直到我第一次做机器人路径规划,才真正体会到——没有图论,你连地图都描述不清楚。
这一章,我会带你打好基础。咱们从图论的基本概念讲起,然后手撸BFS、DFS,最后搞定Dijkstra。嗯,都是硬货,但我会尽量讲得接地气。
图论基础:节点、边、权重
先问个问题:什么是图?
说白了,图就是一堆点(节点)和连接这些点的线(边)。你想想看,地图上的路口就是节点,道路就是边。就这么简单。
我习惯把图分成三种:
- 无向图:边没有方向。比如你和朋友的关系,是双向的。
- 有向图:边有方向。比如单行道,只能从一个方向走。
- 加权图:边上带数字(权重)。比如道路的长度、通行时间、油耗等。
权重这东西,我在项目中吃过亏。有一次做AGV调度,我默认所有路径的权重都是距离。结果发现,有些路段虽然短,但经常堵车。后来我改成用通行时间做权重,效果立竿见影。
核心概念速记
- 节点(Vertex):图中的基本单元,代表位置或状态
- 边(Edge):节点之间的连接,代表关系或路径
- 权重(Weight):边的代价,可以是距离、时间、成本等
- 邻接(Adjacency):两个节点之间有边直接相连
- 路径(Path):从一个节点到另一个节点经过的边序列
下面这张图,是我手绘的图结构示意。你可以看到节点、边和权重的关系。
图中A到B的权重是5,A到C是3,B到D是2,C到D是4。A还有一个自环,权重为1。这种结构,在路径规划中非常常见。
广度优先搜索(BFS)
BFS是什么?我打个比方:你往池塘里扔一块石头,水波一圈一圈往外扩散。BFS就是这种扩散式的搜索方式。
它的核心思想是:先访问离起点近的节点,再访问远的。用队列实现,先进先出。
我曾经用BFS解决过一个迷宫问题。机器人要从左上角走到右下角,障碍物随机分布。BFS保证能找到最短路径(步数最少),但不管权重。说白了,它只关心「几步能到」,不关心「这条路好不好走」。
BFS适用场景
- 所有边的权重相同(比如网格地图)
- 只需要最短步数,不考虑代价
- 社交网络中的好友推荐(六度空间理论)
来看代码实现。我习惯用Python写,清晰易懂。
from collections import deque
def bfs(graph, start):
visited = set()
queue = deque([start])
visited.add(start)
while queue:
node = queue.popleft()
print(f"访问节点: {node}")
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return visited
# 示例图(邻接表)
graph = {
'A': ['B', 'C'],
'B': ['A', 'D'],
'C': ['A', 'D'],
'D': ['B', 'C']
}
bfs(graph, 'A')
运行结果:A → B → C → D。注意,B和C的顺序取决于邻接表的定义。如果你把C放在B前面,结果就是A → C → B → D。
避坑指南
我曾经在BFS里忘记标记已访问节点,结果死循环了。嗯,那是在一个环形图里,队列永远清不空。所以记住:入队即标记,不要出队再标记。
深度优先搜索(DFS)
DFS和BFS正好相反。它是一条路走到黑,撞了南墙再回头。用栈实现,后进先出。
我刚开始学DFS时,总觉得它不如BFS实用。后来做拓扑排序和连通性检测,才发现DFS的妙处。比如检测一个图有没有环,DFS比BFS方便得多。
DFS有两种写法:递归和迭代。我个人习惯用递归,代码更简洁。但要注意递归深度,Python默认递归深度是1000,图大了会栈溢出。
def dfs_recursive(graph, node, visited=None):
if visited is None:
visited = set()
visited.add(node)
print(f"访问节点: {node}")
for neighbor in graph[node]:
if neighbor not in visited:
dfs_recursive(graph, neighbor, visited)
return visited
# 迭代版本
def dfs_iterative(graph, start):
visited = set()
stack = [start]
while stack:
node = stack.pop()
if node not in visited:
visited.add(node)
print(f"访问节点: {node}")
# 注意:为了保持和递归相同的顺序,需要反向入栈
for neighbor in reversed(graph[node]):
if neighbor not in visited:
stack.append(neighbor)
return visited
# 使用示例
graph = {
'A': ['B', 'C'],
'B': ['A', 'D'],
'C': ['A', 'D'],
'D': ['B', 'C']
}
print("递归DFS:")
dfs_recursive(graph, 'A')
print("\n迭代DFS:")
dfs_iterative(graph, 'A')
递归版本输出:A → B → D → C。迭代版本如果不用reversed,顺序会不同。你想想看,栈是后进先出,所以先压入的节点会后访问。
BFS vs DFS 对比
| 特性 | BFS | DFS |
|---|---|---|
| 数据结构 | 队列(Queue) | 栈(Stack) |
| 空间复杂度 | O(宽度),可能很大 | O(深度),通常较小 |
| 最短路径 | 能找到(无权图) | 不能保证 |
| 适用场景 | 最短路径、层级遍历 | 拓扑排序、连通性检测 |
Dijkstra算法原理与实现
终于到了重头戏。Dijkstra算法,说白了就是带权重的BFS。它解决了BFS的痛点:当边的权重不同时,步数最少不等于代价最小。
我记得第一次在项目中用Dijkstra,是在一个仓储机器人系统里。地图上有不同长度的走廊,还有电梯(权重特别大)。BFS给出的路径虽然步数少,但要走电梯,耗时反而更长。换成Dijkstra后,机器人自动绕开了电梯,走平路。
Dijkstra的核心思想:贪心 + 松弛。每次选择当前距离最小的节点,然后更新它的邻居。用优先队列(最小堆)实现。
import heapq
def dijkstra(graph, start):
# 初始化距离字典,起点为0,其他为无穷大
distances = {node: float('inf') for node in graph}
distances[start] = 0
# 优先队列:(距离, 节点)
pq = [(0, start)]
# 记录路径
previous = {node: None for node in graph}
while pq:
current_dist, current_node = heapq.heappop(pq)
# 如果当前距离大于已知距离,跳过(重要优化)
if current_dist > distances[current_node]:
continue
for neighbor, weight in graph[current_node].items():
distance = current_dist + weight
# 松弛操作
if distance < distances[neighbor]:
distances[neighbor] = distance
previous[neighbor] = current_node
heapq.heappush(pq, (distance, neighbor))
return distances, previous
def reconstruct_path(previous, start, end):
path = []
current = end
while current is not None:
path.append(current)
current = previous[current]
path.reverse()
return path if path[0] == start else []
# 示例图(带权重的邻接表)
graph = {
'A': {'B': 5, 'C': 3},
'B': {'A': 5, 'D': 2},
'C': {'A': 3, 'D': 4},
'D': {'B': 2, 'C': 4}
}
distances, previous = dijkstra(graph, 'A')
print(f"从A到各节点的最短距离: {distances}")
print(f"从A到D的最短路径: {reconstruct_path(previous, 'A', 'D')}")
输出结果:从A到D的最短距离是7(A→B→D,5+2),而不是A→C→D(3+4=7,一样)。如果权重不同,结果会变。
Dijkstra的局限性
- 不能处理负权边。如果有负权,算法会陷入死循环或给出错误结果。
- 需要知道所有节点信息。如果图是动态变化的,Dijkstra不适用。
- 时间复杂度O((V+E)logV),对于超大图可能不够快。
我曾经在项目中遇到负权边,Dijkstra跑出来的路径明显不对。后来换成Bellman-Ford才解决。所以,用之前一定要确认权重非负。
三种算法的选择建议
说了这么多,到底什么时候用哪个?我总结一下:
- 无权图,找最短步数 → BFS
- 需要遍历所有节点,或者检测连通性 → DFS
- 有权图,找最小代价路径 → Dijkstra
- 有权图,有负权边 → Bellman-Ford(下一章会讲)
你想想看,实际项目中90%的情况都是有权图。所以Dijkstra是重中之重。BFS和DFS虽然简单,但它们是理解更复杂算法的基础。
我的学习建议
别光看代码,动手画图。拿一张纸,画几个节点,手动模拟BFS、DFS、Dijkstra的执行过程。我当年就是这么学的,效果比看十遍视频都好。
好了,这一章的内容就到这儿。图论基础打牢了,后面学A*、RRT、PRM这些高级算法才会轻松。记住:算法是工具,理解原理才能用好它。
公众号:蓝海资料掘金营,微信deep3321