最短路径算法:BFS、Dijkstra 与 Floyd
摘要
最短路径问题用于计算图中两个节点或多个节点之间的最小代价路径。城市导航、网络路由、服务调用链、游戏地图和任务依赖都可以抽象为最短路径问题。
本文从无权图的 BFS 开始,介绍带非负权图的 Dijkstra 算法和多源最短路径的 Floyd 算法,并比较它们的适用条件、复杂度、路径恢复和常见错误。
一、背景与问题
最短路径中的"最短"不一定是距离,也可以是:
- 网络延迟最小。
- 费用最低。
- 风险最小。
- 经过节点最少。
- 总耗时最短。
一个带权图可以表示为:
text
A ── 4 ── B
│ │
2 1
│ │
C ── 3 ── D
从 A 到 D 可能存在多条路径,需要根据边权计算最小总成本。
不同图结构需要不同算法:
| 图类型 | 常用算法 |
|---|---|
| 无权图 | BFS |
| 边权非负 | Dijkstra |
| 边权可能为负 | Bellman-Ford |
| 任意两点最短路 | Floyd |
| DAG | 拓扑序动态规划 |
二、核心概念
1. 路径和距离
路径是节点序列,路径长度或代价是沿途边权之和。无权图中,每条边通常视为代价 1,因此最短路径就是边数最少的路径。
2. 松弛操作
最短路径算法的核心操作是松弛:
text
如果 dist[u] + weight(u, v) < dist[v]
就用更短的新路径更新 dist[v]
同时可以记录 previous[v] = u,用于恢复路径。
3. 单源与多源
- 单源最短路径:从一个起点到其他节点。
- 单点对最短路径:从一个起点到一个终点。
- 全源最短路径:任意两个节点之间的最短路径。
BFS 和 Dijkstra 通常解决单源问题,Floyd 直接计算全源结果。
4. 负权边与负环
Dijkstra 要求边权非负。存在负权边时,已经确定的最短距离可能被后续负边再次缩短,因此不能直接使用 Dijkstra。
负环是总权重为负的环,沿着负环重复行走可以让路径代价无限降低,最短路径可能不存在。
三、工作原理
1. BFS 最短路径
无权图中,BFS 按距离层级扩展:
text
距离 0:起点
距离 1:起点的邻居
距离 2:邻居的邻居
...
节点第一次被访问时,已经得到从起点出发的最少边数。
2. Dijkstra
Dijkstra 使用优先队列,每次选择当前距离最小的未处理节点:
text
初始化起点距离为 0
→ 取出当前距离最小节点
→ 松弛其所有邻居
→ 将被更新的邻居加入优先队列
→ 重复直到队列为空
由于边权非负,被取出的最小距离不会再被更短路径改变。
3. Floyd
Floyd 使用动态规划计算任意两点之间的最短距离。核心思想是逐步允许节点作为中间节点:
text
dist[i][j] =
min(
dist[i][j],
dist[i][k] + dist[k][j]
)
其中 k 表示当前允许作为中间点的节点。
4. 复杂度
| 算法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| BFS | O(V + E) |
O(V) |
无权图 |
| Dijkstra 堆优化 | O((V + E) log V) |
O(V) |
非负权稀疏图 |
| Floyd | O(V³) |
O(V²) |
节点较少、全源查询 |
选择算法时,需要结合节点数量、边数量、权重和查询次数。
四、实战示例
1. 无权图 BFS 最短距离
python
from collections import deque
def bfs_distances(
graph: dict[str, list[str]],
start: str,
) -> dict[str, int]:
distances = {start: 0}
queue = deque([start])
while queue:
node = queue.popleft()
for neighbor in graph.get(node, []):
if neighbor in distances:
continue
distances[neighbor] = distances[node] + 1
queue.append(neighbor)
return distances
distances 同时承担访问标记和距离记录的作用。
2. BFS 恢复路径
python
def bfs_shortest_path(
graph: dict[str, list[str]],
start: str,
target: str,
) -> list[str] | None:
queue = deque([start])
previous: dict[str, str | None] = {start: None}
while queue:
node = queue.popleft()
if node == target:
break
for neighbor in graph.get(node, []):
if neighbor not in previous:
previous[neighbor] = node
queue.append(neighbor)
if target not in previous:
return None
path: list[str] = []
current: str | None = target
while current is not None:
path.append(current)
current = previous[current]
return path[::-1]
3. 带权图表示
python
weighted_graph = {
"A": [("B", 4), ("C", 2)],
"B": [("D", 1)],
"C": [("B", 1), ("D", 3)],
"D": [],
}
每个邻居由 (节点, 权重) 表示。
4. Dijkstra 算法
python
import heapq
def dijkstra(
graph: dict[str, list[tuple[str, int]]],
start: str,
) -> tuple[
dict[str, int],
dict[str, str | None],
]:
distances = {start: 0}
previous: dict[str, str | None] = {start: None}
heap = [(0, start)]
while heap:
distance, node = heapq.heappop(heap)
if distance != distances.get(node):
continue
for neighbor, weight in graph.get(node, []):
if weight < 0:
raise ValueError(
"Dijkstra requires non-negative weights"
)
candidate = distance + weight
if candidate < distances.get(
neighbor,
float("inf"),
):
distances[neighbor] = candidate
previous[neighbor] = node
heapq.heappush(
heap,
(candidate, neighbor),
)
return distances, previous
堆中可能存在同一节点的多个旧距离,因此弹出时需要跳过过期记录。
5. 恢复 Dijkstra 路径
python
def restore_path(
previous: dict[str, str | None],
target: str,
) -> list[str] | None:
if target not in previous:
return None
path: list[str] = []
current: str | None = target
while current is not None:
path.append(current)
current = previous[current]
return path[::-1]
6. 运行 Dijkstra
python
distances, previous = dijkstra(
weighted_graph,
"A",
)
print(distances)
print(restore_path(previous, "D"))
对于示例图,A 到 D 的最短路径会经过 C 和 B,具体结果取决于边权。
7. Floyd 初始化
python
def floyd_warshall(
nodes: list[str],
edges: list[tuple[str, str, int]],
) -> dict[str, dict[str, int]]:
infinity = 10**18
distances = {
left: {
right: (0 if left == right else infinity)
for right in nodes
}
for left in nodes
}
for left, right, weight in edges:
distances[left][right] = min(
distances[left][right],
weight,
)
for middle in nodes:
for left in nodes:
for right in nodes:
candidate = (
distances[left][middle]
+ distances[middle][right]
)
if candidate < distances[left][right]:
distances[left][right] = candidate
return distances
初始化时自己到自己的距离为 0,没有直接连接的节点使用一个足够大的值表示不可达。
8. Floyd 检查负环
python
def has_negative_cycle(
distances: dict[str, dict[str, int]],
) -> bool:
return any(
distances[node][node] < 0
for node in distances
)
如果 Floyd 结束后存在 dist[node][node] < 0,说明图中存在负环。
9. 0-1 BFS
如果边权只有 0 和 1,可以使用双端队列优化:
python
from collections import deque
def zero_one_bfs(
graph: dict[str, list[tuple[str, int]]],
start: str,
) -> dict[str, int]:
distances = {start: 0}
queue = deque([start])
while queue:
node = queue.popleft()
for neighbor, weight in graph.get(node, []):
candidate = distances[node] + weight
if candidate >= distances.get(
neighbor,
float("inf"),
):
continue
distances[neighbor] = candidate
if weight == 0:
queue.appendleft(neighbor)
else:
queue.append(neighbor)
return distances
权重为 0 的边放到队首,权重为 1 的边放到队尾。
10. 网格最短路径
python
def shortest_grid_path(
grid: list[list[int]],
start: tuple[int, int],
target: tuple[int, int],
) -> int:
rows = len(grid)
cols = len(grid[0]) if rows else 0
queue = deque([(start[0], start[1], 0)])
visited = {start}
directions = [
(1, 0),
(-1, 0),
(0, 1),
(0, -1),
]
while queue:
row, col, distance = queue.popleft()
if (row, col) == target:
return distance
for row_delta, col_delta in directions:
next_row = row + row_delta
next_col = col + col_delta
point = (next_row, next_col)
if not (0 <= next_row < rows):
continue
if not (0 <= next_col < cols):
continue
if grid[next_row][next_col] == 1:
continue
if point in visited:
continue
visited.add(point)
queue.append((next_row, next_col, distance + 1))
return -1
无权网格可以直接使用 BFS;如果不同方向代价不同,需要改用带权算法。
五、常见问题与实践建议
1. Dijkstra 为什么不能处理负权边?
它依赖"当前取出的最小距离已经确定"这一性质。负权边可能在后续路径中把已确定的距离继续降低,从而破坏算法正确性。
2. BFS 能处理带权图吗?
只有所有边权相同,或者特殊的 0-1 权重可以使用 0-1 BFS。普通不同权重图不能直接用 BFS 判断最短代价。
3. Floyd 什么时候不适合?
节点数量很大时,O(V³) 的时间和 O(V²) 的空间成本很高。Floyd 更适合节点较少、需要频繁查询任意两点距离的场景。
4. 为什么 Dijkstra 要跳过过期堆元素?
使用 heapq 时,更新距离通常是把新记录再次压入堆,而不是直接修改旧记录。因此堆中可能存在旧距离,弹出时必须判断它是否仍等于当前最优值。
5. 不可达节点如何表示?
可以使用不存在的字典键、None 或一个足够大的无穷值。选择后要保证加法和比较不会产生错误。
六、进阶思考
1. 算法选择决策
text
边权全部相同
→ BFS
边权只有 0 和 1
→ 0-1 BFS
边权非负且图较稀疏
→ Dijkstra + 堆
节点较少且需要全源结果
→ Floyd
存在负权边
→ Bellman-Ford 或其他适配算法
2. 路径恢复与距离计算分离
很多应用不仅需要最短距离,还需要实际路径。建议单独维护 previous 或 next 矩阵,避免只返回一个数字后无法解释结果。
3. 动态图
网络拓扑、交通路线和服务依赖会变化。图更新后,缓存的最短路径可能失效,需要考虑增量更新、缓存过期和重新计算。
4. 多源最短路径
如果多个起点到其他节点的代价都需要计算,可以增加一个虚拟起点连接所有源节点,或初始化多源 BFS / Dijkstra。
5. 工程中的权重设计
权重可能是延迟、价格、风险和资源消耗的组合。设计权重时要明确单位、范围和是否允许负数,不能把不同量纲直接相加。
结论
最短路径问题需要根据图的权重和查询方式选择算法。无权图使用 BFS,非负权图使用 Dijkstra,节点较少且需要任意两点结果时可以使用 Floyd。
理解松弛、优先队列、前驱节点和不可达状态后,不仅可以解决算法题,也能分析导航、网络路由、服务依赖和任务调度中的路径问题。
参考资料
- Python
heapq文档:https://docs.python.org/3/library/heapq.html - Introduction to Algorithms:https://mitpress.mit.edu/9780262046305/introduction-to-algorithms/
- NetworkX 最短路径文档:https://networkx.org/documentation/stable/reference/algorithms/shortest_paths.html