最短路径算法:BFS、Dijkstra 与 Floyd

最短路径算法: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。

理解松弛、优先队列、前驱节点和不可达状态后,不仅可以解决算法题,也能分析导航、网络路由、服务依赖和任务调度中的路径问题。

参考资料

  1. Python heapq 文档:https://docs.python.org/3/library/heapq.html
  2. Introduction to Algorithms:https://mitpress.mit.edu/9780262046305/introduction-to-algorithms/
  3. NetworkX 最短路径文档:https://networkx.org/documentation/stable/reference/algorithms/shortest_paths.html
相关推荐
梦帮科技2 小时前
Kali Linux Scapy 畸形报文构造学——深度解剖非法 TCP Flags 与反常序列号
linux·爬虫·神经网络·算法·数学建模·系统架构·模拟退火算法
智能直播3 小时前
RK3528 V4L2 框架 UVC 接口自动获取 H.264 / MJPEG / YUYV 格式:从内核采集到硬解分发
java·网络·实时音视频·视频编解码·h.264
171320330计算机毕设编程4 小时前
计算机毕业设计 基于YOLO改进算法的车辆特征识别系统设计与实现--lw
java·大数据·python·sql·算法
姜鱼问生4 小时前
Nginx gzip 压缩:一个配置让页面加载快 3 倍
后端·算法·架构
badhope4 小时前
Spring AI Alibaba停更:Java程序员的信任,还能被辜负几次?
java·spring
不会就选b4 小时前
算法日常・每日刷题--<动态规划>23
算法·动态规划
const_qiu4 小时前
第一章 1.1-1.2:基础模型调用与 TypeScript 入门 —— Java 开发者的视角
java·javascript·typescript
Wang's Blog4 小时前
Java 项目实战: 交友项目 - Dubbo 负载均衡策略与实战验证
java·dubbo·交友