☰
最短路径算法全解析:Dijkstra、Bellman-Ford与Floyd-Warshall实战
2026/10/17 8:39:29 网站建设 项目流程

1. 先搞懂最短路径到底在求什么

1.1 从一个出行问题说起

我最早接触“最短路径”这四个字,是某次帮朋友查从A城到B市的自驾路线。导航给的路线有好几条,有的里程短但堵车,有的全程高速但绕远,有的红绿灯少但限速低。当时我盯着屏幕突然意识到,所谓最短路径,本质上就是在给定的一张网络里,找出一条连接起点的点A和终点B的代价最小的路径。这里的代价可以是公里数、耗时、油耗,甚至可以是“红绿灯次数”“过路费”这些奇奇怪怪的维度。

如果你正在学算法,或者工作中需要写地图、物流、路由相关的代码,那你迟早要跟最短路径打交道。它是图论里最经典、也是工程上用得最多的问题之一:快递员派单要算最短路线,通信网络要算数据包转发路径,游戏里的NPC要从A点走到B点,甚至你在编译器里做代码依赖分析时,也能看到最短路径思想的影子。

从抽象层面看,最短路径问题的输入是一张有向图或无向图,每条边带一个权重,输出是起点到目标点的最小权值路径。最常见的变体是“单源最短路径”,也就是固定一个源点,求它到所有其他点的最短距离;还有“多源最短路径”,要求求出任意两点之间的最短距离。别觉得这两个说法晦涩,后面都是从这两个基础问题延伸出来的。

1.2 不同场景里的“最短”差别很大

同样叫最短路径,实际场景里对“最短”的解读并不统一,这直接决定了你选哪种算法。

  • 如果是地图导航,边的权重通常是非负的——你不可能开一段路,里程反而是负数。这时候Dijkstra算法就是默认选择,配合堆优化,几千万节点的道路网也能扛得住。
  • 如果是金融套利、物理系统分析之类带负权重的场景,Dijkstra会直接失效,得让Bellman-Ford算法上阵,它能识别负权环,也是最早一批能处理负权边的算法之一。
  • 如果你要算的不是“某个人到所有人”,而是“任意两个人之间”的最短路径,比如多个配送点互相调度,那Floyd-Warshall算法虽然在单次查询上比较“笨”,但胜在实现简单、代码短,适合点数量不特别大的场景。

一句话总结:没有哪本算法书能替你决定“用哪个最好”,你得先看清你的图长什么样、边权有多大的范围、查询模式是一对一还是一对多。后面我展开讲每个算法的思路和适用条件。

2. 算法选型:三个经典算法一次讲清楚

2.1 Dijkstra:正权单源图的默认答案

Dijkstra算法是最短路径里的“老大哥”。它的核心逻辑可以用一句话概括:每次从未确定的节点里,挑出当前距离最短的那个节点,然后尝试用它去松弛它的邻居。这里的“松弛”是指,如果通过当前节点到达某个邻居的距离比原来记录的距离更短,就更新记录。

这里有个面试常问的考点:为什么Dijkstra要求边权非负?我自己的理解是,它依赖“贪心”的正确性——一旦一个节点被选为“当前距离最短”,它的距离就彻底确定了,后面不可能再有更短路径。但如果存在负权边,后面某个节点的距离可能因为走了一条负权边而变小,导致之前“确定”的节点其实还可以被更新,整个贪心逻辑就崩了。

Dijkstra的实现有两种常见形态:朴素版本用数组找最小距离,复杂度是O(V²),适合稠密图;堆优化版本用优先队列维护“当前最短距离候选集”,复杂度是O((V+E)logV),适合稀疏图。工程上绝大多数场景都是稀疏图,所以堆优化版几乎是标配。

我手写过一个堆优化Dijkstra,Python代码如下:

import heapq def dijkstra(start, graph, n): # graph: 邻接表,graph[u] = [(v, weight), ...] dist = [float('inf')] * n dist[start] = 0 pq = [(0, start)] # (当前距离, 节点) while pq: d, u = heapq.heappop(pq) if d > dist[u]: continue # 懒删除:旧记录被跳过 for v, w in graph[u]: new_d = d + w if new_d < dist[v]: dist[v] = new_d heapq.heappush(pq, (new_d, v)) return dist

注意看这句if d > dist[u]: continue,它处理的是同一个节点被多次压入优先队列的情况。很多人第一次写会漏掉这个判断,导致队列里堆积大量过期的冗余记录,虽然结果还可能对,但性能会明显下降。这就是“懒删除”技巧,也是我建议你一定要写进代码里的习惯。

2.2 Bellman-Ford:能扛负权边的老实人

Bellman-Ford算法没有Dijkstra那么聪明,但它更“老实”。它的思路是:对所有边反复进行松弛操作,每轮松弛至少能确定一个节点的最短距离,所以最多需要V-1轮。如果V-1轮之后还能继续松弛,说明图里存在负权环。

这个算法的缺点是复杂度高:O(VE)。在稀疏图里,它依然会比Dijkstra慢不少。但它的价值恰恰在于负权边场景和“能否检测负权环”这个附加能力。

我在处理一个含有“优惠抵扣”的计费系统时用过Bellman-Ford。当时业务里有一条规则:某些套餐组合会让计费金额变成负数,我需要知道是否会出现“越绕越便宜”的环。Bellman-Ford第V轮如果还能更新距离,就可以直接判定有负权环,从而触发告警。这比自己去写一个DFS找环判断方便很多。

核心代码如下:

def bellman_ford(edges, n, start): dist = [float('inf')] * n dist[start] = 0 for _ in range(n - 1): updated = False for u, v, w in edges: if dist[u] != float('inf') and dist[u] + w < dist[v]: dist[v] = dist[u] + w updated = True if not updated: break # 提前结束 # 第V轮检测负权环 for u, v, w in edges: if dist[u] != float('inf') and dist[u] + w < dist[v]: return None # 表示存在负权环 return dist

注意我把“提前结束”的优化也加上了:如果某一轮没有任何边被松弛,说明所有节点的最短距离已经稳定,不需要再跑完V-1轮。这在边数量大、但正权重居多的场景里能节省不少时间。

2.3 Floyd-Warshall:多源最短路径的暴力美学

Floyd-Warshall算法是我个人觉得“最不讲道理”但最好写的最短路径算法。它用三重循环,遍历所有可能的中间节点k,并尝试用k去缩短任意点对之间的距离。复杂度O(V³),V稍微大一点(比如超过1000)就会很吃力,但只要V足够小,它的实现正确性非常好保证。

Floyd的核心递推式是:dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])。它把“从i到j经过k”和“不经过k”两种情况做了比较。因为循环顺序是“最外层枚举k”,所以它能保证每一个k都作为中间节点被考虑过,最终得到全局最优。

Floyd在工程上的一个典型用法是“查询频率极高但图规模不大”的场景。比如一个小型加油站调度系统里,只有几十个节点,但每分钟要查大量不同点对之间的最短路径。这时与其每次查询都跑Dijkstra,不如启动时跑一次Floyd,把所有结果算出来,后续查询直接查二维数组,耗时降到O(1)。这就是空间换时间。

def floyd(dist): n = len(dist) for k in range(n): for i in range(n): for j in range(n): if dist[i][k] + dist[k][j] < dist[i][j]: dist[i][j] = dist[i][k] + dist[k][j] return dist

这段代码短得惊人,但有几个坑:dist矩阵初始化时,不存在的边要设成极大值;自己到自己的距离要设为0;如果图是无向图,矩阵是对称的,但循环依然要照跑不误。用了Floyd之后,除非中间编辑了图结构,否则不需要重复计算。

2.4 还有几个“亲戚”算法

除了三大金刚,实际工程里还会遇到一些“亲戚”。SPFA本质上是Bellman-Ford的队列优化版,能处理负权边,实现简单,但在最坏情况下复杂度可能退化到O(VE)。它在竞赛里很受欢迎,我个人的态度是:自己写demo、题目练习可以玩,生产系统里能用Dijkstra就尽量别碰SPFA,因为它的执行时间不太可控。

A算法则是Dijkstra在“已知终点”时的加速版本,它引入一个启发函数来估算当前节点到终点的距离,从而优先扩展“看起来更有希望”的节点。游戏寻路、机器人路径规划大量使用A。它的正确性依赖启发函数不能高估真实距离,否则会丢失最优解。

我会在后面的工程实战部分,给一个“先用Dijkstra,再用A*优化”的具体例子,对比一下二者的效率差异。

3. 实操:自己实现最短路径计算的核心细节

3.1 图的存储怎么选

想动手实现最短路径,第一件事是把图存起来。图的存储方式直接决定代码的复杂度和运行效率。

  • 邻接矩阵:用二维数组dist[i][j]存边权,代码最简单,但空间占用是O(V²),V大于几万就基本不可用了。
  • 邻接表:每个节点存一个列表,列表里放它所有邻居和边权。空间复杂度O(V+E),适合绝大多数场景,是我的首选。
  • 链式前向星:竞赛圈常用,本质是用数组模拟链表,适合极端追求性能的场景。工程上没有必要,除非你真的在做几十亿边的超大规模图计算。

我平时写工程代码,90%都用邻接表。Python里可以直接用list[dict]或list[list[tuple]],Java用List<List<Edge>>,C++用vector<pair<int,int>>。下面是Python和C++的简单示例:

# Python 邻接表 n = 5 graph = [[] for _ in range(n)] def add_edge(u, v, w): graph[u].append((v, w)) graph[v].append((u, w)) # 无向图要加反向边
// C++ 邻接表 #include <vector> using namespace std; struct Edge { int to, weight; }; vector<vector<Edge>> graph(n); auto add_edge = [&](int u, int v, int w) { graph[u].push_back({v, w}); graph[v].push_back({u, w}); };

3.2 堆优化Dijkstra的完整实现

堆优化Dijkstra至今仍然是我写图论代码时用得最多的模板。原因很简单:它正确、高效、通用。我给的示例里已经把懒删除写进去了,这里再补充一个带路径记录的完整版本。

假设你要从节点0出发,求到所有节点的最短距离和路径。用prev数组记录每个节点在最短路径上的前驱节点。

import heapq def dijkstra_with_path(start, graph, n): dist = [float('inf')] * n prev = [-1] * n dist[start] = 0 pq = [(0, start)] while pq: d, u = heapq.heappop(pq) if d > dist[u]: continue for v, w in graph[u]: new_d = d + w if new_d < dist[v]: dist[v] = new_d prev[v] = u heapq.heappush(pq, (new_d, v)) return dist, prev def reconstruct_path(prev, start, end): path = [] cur = end while cur != -1: path.append(cur) if cur == start: break cur = prev[cur] path.reverse() return path

这段代码里prev[v] = u是关键,它记录的是“节点v是从哪个节点u松弛过来的”。回溯路径时,从终点一路往起点倒查,最后反转即可。

用这个模板时,我注意过几个细节:

  • dist初始值不能设成-1,因为路径可能为负,除非你确定没有负权边。
  • 如果节点编号不是从0开始,需要做一层映射,或者直接用字典。
  • 无向图添加边时要记得加两条边,否则算出来的路径会漏掉反向可达性。

3.3 路径回溯:从距离表到完整路线

很多人实现了最短距离,却栽在“怎么把路径打印出来”这一步。距离只是一个数字,路径才是有业务价值的东西。做导航时,用户要看到“走A街、右转B路、到C站”,而不是“全程12.4公里”。

只要你在松弛时记录了prev,回溯逻辑是非常固定的。需要注意三个边界:

  • 如果目标是不可达的,prev里可能为-1,循环会出问题,所以要提前判断dist[end] == inf。
  • 如果起点就是终点,路径应该只有一个节点,避免回溯时多跑一次循环。
  • 无向图和有向图在回溯时的逻辑完全一样,但无向图因为每条边都被存了两遍,不会出现“路径方向走反”的问题,反而更稳妥。

注意:prev记录的是“上一个经过的节点”,不是“上一条边的编号”。如果你需要知道经过的边到底是什么,比如“选择哪条路”,可以额外用edge_id数组记录松弛时用到的边,这在路网系统里很常见。

3.4 Bellman-Ford与Floyd的工程实现要点

Bellman-Ford的工程实现,我前面已经给过核心代码。这里补充两个容易被忽视的点:

  • 使用dist[u] != float('inf')判断可以避免无穷大加负权重导致的不正确更新。虽然数学上inf + (-5)还是inf,但在浮点运算里可能会出现inf + (-5) = inf但其实你还想保留更大的一个值的情况。
  • 如果图里边的数量特别大,Bellman-Ford的O(VE)会非常慢。一个实用技巧是提取“候选边”:每轮松弛只处理上一轮被成功松弛过的节点作为起点的边,这样能大幅减少无效遍历。这就是SPFA的基本思想,但比SPFA更可控。

Floyd的实现里,我建议在计算过程中直接原地更新dist矩阵,不需要额外拷贝,因为Floyd的循环顺序保证了你读到的是“当前k状态下”的最优值。不过在业务代码里,如果dist矩阵会被其他线程读取,就要考虑加锁或使用副本,不然会出现读到半更新状态的脏数据。

4. 工程落地:一个简化导航系统的路径计算

4.1 场景与数据准备

为了把前面的理论落到实操,我设计了一个简化导航项目:一个由小型道路网组成的模拟地图,节点代表路口,边代表道路,边权代表通过时间。目标是给用户一个起终点,返回最短时间和完整路线。

我选了6个节点,连接关系如下:

起点终点权重(分钟)
AB4
AC2
BC1
BD5
CD8
CE10
DE2
DF6
EF3

这里我故意让C到D的权重比较大,这样最短路径可能绕道B,而不是直接走C-D,方便后续验证算法的正确性。先用邻接表建图,然后跑堆优化Dijkstra。

4.2 计算过程与结果验证

假如从A到F,肉眼观察可能的路径有:

  1. A - C - D - F:2 + 8 + 6 = 16
  2. A - C - E - F:2 + 10 + 3 = 15
  3. A - B - C - E - F:4 + 1 + 10 + 3 = 18
  4. A - B - D - F:4 + 5 + 6 = 15
  5. A - B - D - E - F:4 + 5 + 2 + 3 = 14

最短的是最后一条,总耗时14分钟。用代码跑一遍Dijkstra,返回的dist[F]应该是14,路径回溯出来是A-B-D-E-F。每个节点的最短路距离如下:

节点ABCDEF
距离0427914

B到D只要5,C到D要8,所以D是通过B松弛得到的,这也解释了为什么路径不直接走C-D。这个例子充分说明,只看局部边权选路线很容易被带偏,用全局松弛才能拿到真正的最短路径。

4.3 性能评估与优化方向

小型demo看不出性能差别,我另外在模拟的大图上测过:10万个节点、大约50万条边,堆优化Dijkstra单源查询耗时约100毫秒(普通机器上)。如果做多源场景,比如要算1000个不同的起点,最笨的办法是跑1000次Dijkstra,也就是10万毫秒左右,差不多100秒,这个延迟在实时业务里不可接受。

优化方向有两个:

  • 如果图结构固定、查询量大,可以考虑构建反向图,用两次Dijkstra(正向和反向)做“双向搜索”,很多导航系统就是这么干的。
  • 如果是查“任意点对”,可以用Floyd预计算,但前提是节点数少于几千。节点数多了以后,得换用距离表或分层索引,这就是另一套“地图数据工程”的范畴了。

我实测的经验是:在稀疏图中,优先队列用得好不好,对整体性能影响极大。Python的heapq比自定义的list加sort要快非常多。C++里如果开std::priority_queue配合std::priority_queue<pair<int,int>, vector<...>, greater<...>>,效率也够用;但最极致的是手写二项堆或配对堆,不过工程里很少需要做到那一步。

5. 实战踩坑:这些坑我帮你踩过了

5.1 距离溢出问题

最开始我曾在C++里用int存边权和距离,处理一个节点数不多但边权很大的图时,出现了“溢出导致错误更新”的诡异现象。比如dist[u]是INT_MAX,加上一个较大的边权后变成负数,于是算法误以为“找到了更短的路径”,结果越算越乱。

这个坑的解法很简单:使用long long存储距离,或者把不存在的边设成一个足够大的数但不要大到溢出。在Java里用Long.MAX_VALUE,Python的int没有溢出问题,但要注意float('inf')与整数相加时会产生浮点数精度损失。如果你确定边权都是整数,就继续保持int/float类型一致性,不要混用。

另外,在比较dist[u] + w < dist[v]时,如果dist[u]本身是inf,inf + w仍然是inf,但在某些语言里会得到nan或异常。稳妥写法是先行判断dist[u] != inf。

5.2 负权环别让它卡死系统

如果图里存在负权环,Bellman-Ford会在正常松驰结束之后再发现“还能继续松弛”,这本身是好事。但我在实际项目中见过一种坏情况:某次调度系统把边权算错,出现一个负权环,当时没有检测机制,导致Dijkstra用的距离数组不断变小,最终触发一个奇怪的异常。

正确的处理方式是在系统层面增加“合法性校验”:每次更新图数据后,跑一次Bellman-Ford的检测阶段,若发现负权环,直接拒绝这次图更新并告警。生产环境里,图数据是动态维护的,不要假设它永远是健全的。

一个小建议:当你用Dijkstra时,如果发现某个节点的最短距离在计算过程中出现了“多次更新次数超过V-1”的情况,可以怀疑数据里有轻微的负权环或浮点误差。加上更新计数,比事后排查要省心得多。

5.3 大图的稀疏性利用

同样的算法,用邻接矩阵和邻接表跑出来的性能是天壤之别。我曾经接过一个任务是处理一个20000个节点的图,如果用邻接矩阵,光存储就要20000×20000×8字节,约3.2GB,直接内存爆炸;而改用邻接表后,因为大部分节点的度不超过10,实际内存只有不到几十MB,性能也完全能接受。

在堆优化Dijkstra里,遍历的耗时与边数成正比,所以边数越少越快。这也是为什么我强烈建议你在建图时尽量去掉重复边、自环这些冗余数据。数据清洗阶段多做一步,后面运行效率稳许多。

5.4 路径还原的边界问题

路径还原是我见过最多bug的地方。最常见的症状是“算出来的路径里有-1”或者“死循环”。前者多半是起点不可达,后者多半是prev数组更新时写错方向,回溯时没能回到起点。

我在项目里加过一道保险:回溯路径时设置一个最大迭代次数,比如while cur != -1 and len(path) < n+1,这样即使prev数组坏了,也不会触发真正的死循环。再加一个断言:最终一维数组的头必须是起点,否则说明算法实现有问题。

6. 常见问题速查表

问题可能原因解决方案
Dijkstra算出了“更短”的路径,但中间有负边Dijkstra不能处理负权边改用Bellman-Ford或SPFA
Bellman-Ford结果不正确未正确处理不可达节点松弛前判断dist[u] != inf
优先队列里堆了很多过期节点少了懒删除判断取堆顶后比较是否大于当前dist
图很大时内存爆炸使用邻接矩阵存储换用邻接表
路径回溯出现-1终点不可达先判断dist[end]是否为inf
路径回溯死循环prev数组更新错误增加最大迭代次数并校验起点
明明没有负权边,Dijkstra还是慢数据重复边过多建图时去重、清理自环
多源查询很慢每次查询都跑一次单源算法考虑Floyd预计算或双向Dijkstra

我做最短路径这么多年,最深的体会是:算法本身并不复杂,复杂的是你在工程现场能不能看清数据的真实结构和边界条件。很多看似“玄学”的问题,归根结底都是存储选型、溢出、不可达、负权环这些细节没处理好。Dijkstra的代码长得都一样,但优化过的和没优化过的可能差几十倍性能;Bellman-Ford写得规范与否,决定了它到底是个定时炸弹还是称职的守护工具。希望这篇文章里写到的这些实现细节和查坑经验,能让你在需要算最短路径的时候少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询