假设你是一名刚上岗的跑腿小哥,手里拿着城市地图,要从仓库出发给散落在各处的顾客送餐。地图上每条马路都标了预计耗时,你的任务是找到从仓库到每个顾客家最快的走法。最笨的办法是把所有走法都试一遍,但路口一多,组合数量会爆炸到连计算机都扛不住。今天要讲的 Dijkstra 和 Floyd,就是两种“聪明的地图算法”:Dijkstra 像一位急性子跑腿小哥,永远先处理眼前预计耗时最短的路口;Floyd 像一张全城速查表,把每个路口轮流当成中转站试一遍。还有一位 Bellman-Ford 像夜间巡逻车,专门识破“越走越便宜”的倒贴路段骗局。读完这篇,你会理解导航软件、网络路由和物流调度背后到底在算什么。
对 AI 从业者来说,最短路算法是路径规划、物流调度、网络优化等智能系统的地基。能根据数据规模与约束正确选型,是算法工程师必备的决策力。
目录
可视化演示
Dijkstra贪心逐步确定单源最短路径,Floyd动态规划更新全源距离矩阵,直观对比两种经典图算法的执行过程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Dijkstra单源最短路径 绿色=已确定最短路径的节点 黄色=待确定,显示当前估计距离 贪心策略:每次选最小距离的未确定节点 优先队列优化复杂度O((V+E)logV)
最短路问题
跑腿小哥小张的地图其实可以抽象成一张“带权图”:路口是图的顶点,道路是边,道路上的预计耗时就是边的权重。如果一个路口和另一个路口之间没有直接的路,我们就认为它们之间没有边;如果道路是单行道,就把它画成有方向的边。这样,任何一条送餐路线都对应图里的一条路径,路径的总耗时就是把沿途每条边的权重加起来。
最短路问题要回答的就是:在所有从起点 s 到终点 v 的路径中,哪一条的总权重最小?这个最小总权重记作 d(s,v)。
如果每条边的权重都正好是 1,那问题就退化成无权图最短路,直接用广度优先搜索 BFS 就能在 O(V+E) 时间内解决。但真实地图中每条路耗时不同,所以需要更通用的算法。先弄清楚是只求一个起点到所有点,还是要求任意两点之间,才能决定该请哪一位算法出场。
- 最短距离符号
d(s,v)代表从起点 s 到顶点 v 的候选最短距离,初始时只有 s 是 0,其他都是无穷大。 - 路径集合
\mathcal{P}(s,v)是所有从 s 到 v 的可行路径的集合,每条路径可能经过不同的中转路口。 - 路径总权把路径 P 上每条边的权重 w(e) 累加起来,就得到这条路径的总耗时。
- 取最小值在所有可行路径里挑总权重最小的那一个,这就是最短路。
贪心策略:Dijkstra 的核心
小张的直觉很朴素:既然每条路耗时都不会是负数,那么已经被确认最短距离的那个路口,以后无论怎么绕路都不可能变得更短。所以他准备了一张距离表,起点写 0,其他路口暂时写“无穷大”;每次从还没确认的路口里挑距离最小的那个,把它从“待定”改成“确定”,然后沿着它的每条出路,看看能否让邻居的距离变得更小。这一步在算法里叫作“松弛”。
Dijkstra 的贪心之处,就是永远相信“当前距离最小的待定节点”已经拿到了最终答案。这个直觉只在边权非负时成立,因为任何绕路都会增加总耗时,不可能偷出更短的路径。
小张按这个规则一步步处理下去,等所有路口都变成“确定”状态时,距离表里就是仓库到全城每个路口的最短耗时。贪心策略虽然简单,但胜在每一步都建立在“边权非负”这个坚实前提上。
- 当前距离
d[u]是已经确定的起点到 u 的最短距离,d[v]是 v 当前的候选距离。 - 经过 u 的新距离从 u 出发经过边 (u,v) 走到 v,总耗时是 d[u] 加上这条边的权重。
- 取较小者把新方案和旧的 d[v] 比较,只保留更小的值,这就是一次松弛。
- 写回距离表若新方案更短,就用新值覆盖 d[v],同时把 v 的候选状态更新到待处理队列中。
堆优化:让 Dijkstra 跑得更快
如果不做任何优化,小张每次都要在所有“待定”路口中扫描一遍找最小值,这就像把整个电话簿从头翻到尾只为找一个电话号码。当城市路口很多时,这种 O(V^2) 的做法会明显变慢。堆优化就是给小张配一个“叫号机”:所有待定路口按距离排成小顶堆,堆顶永远是最小值。
优先队列保存的是“候选距离 + 路口编号”。每松弛一条边,就把新的候选距离压进堆里;同一个路口可能被压入多次,所以取出堆顶后要先检查一下:如果取出的距离比距离表里当前记录的值大,说明这条记录已经过期,直接丢弃。这个技巧叫“惰性删除”。
经过堆优化后,Dijkstra 的时间复杂度从朴素版的 O(V^2+E) 变成 O((V+E)\log V)。在道路稀疏的大城市里,E 远小于 V^2,这种优化能让导航系统从“慢吞吞查电话簿”变成“秒响应叫号机”。
- 候选集合
Q是所有尚未正式确认最短距离的路口集合。 - argmin 取最小
\arg\min表示“能够使 d[v] 最小的那个 v”,也就是小张下一秒要去处理的路口。 - 堆实现工程上用最小堆维护 (d[v], v),堆顶自然就是当前最小距离的节点。
动态规划:Floyd 的全源速查表
公司看小张效率高,又给他派了个新任务:做一张全城速查表,让任意两个路口之间都能直接查到最短耗时。这时 Dijkstra 虽然也能做,但每次只能从一个起点出发;如果路口很多,反复跑 Dijkstra 也很贵。Floyd 算法的思路是用动态规划,把每个路口轮流当作“临时中转站”。
设 D_k[i][j] 表示“只允许经过前 k 个路口作为中转”时,i 到 j 的最短距离。考虑第 k 个路口时,从 i 到 j 要么完全不经过 k,要么经过 i→k→j。于是可以不断用中转点 k 来刷新整张表。
实现时用邻接矩阵初始化:i 到 i 的距离是 0,有直接边的点对填权重,没边就填无穷大。外层循环必须按 k 从小到大进行,让每个中转点只“上任”一次。等所有 k 都处理完,矩阵里就是任意两点之间的最短距离。Floyd 可以处理负权边,但不能有负权环;如果最后发现 dist[i][i] 变成负数,说明图里藏着一个“倒贴钱”的循环。
- 子问题定义
D_k[i][j]表示只允许编号 1 到 k 的路口作为中转时,i 到 j 的最短距离。 - 不经过 k原来不用 k 做中转时的答案就是
D_{k-1}[i][j]。 - 经过 k如果绕道 k,需要把 i 到 k 和 k 到 j 两段距离加起来。
- 取最小值两种方案中选更短的那个,作为允许前 k 个中转点时的最优答案。
负权检测:Bellman-Ford 的补救
小张某天遇到一条奇怪的打折路段:经过它不扣时间,反而倒找时间。普通地图当然不会有这种路,但在金融套利、游戏数值等场景中,负权边是真实存在的。边权为负时,Dijkstra 的贪心结论会失效,因为一个路口可能先被确定距离,之后又从一条负权小路绕回来变得更短。
Bellman-Ford 算法不搞“每次选最近”的贪心,而是像夜班巡逻车一样,老老实实把每条边都扫一遍,连续扫 V-1 轮。为什么是 V-1 轮?因为一条不绕圈的简单路径最多包含 V-1 条边,信息沿着路径最多传播 V-1 次就能到达所有点。
如果完成 V-1 轮之后,再扫一遍所有边仍能让某个 d[v] 变小,那就说明存在负权环——因为在这个环里每绕一圈总耗时都会下降,最短路径根本不存在。Bellman-Ford 的时间复杂度是 O(VE),虽然比 Dijkstra 慢,但它能接手 Dijkstra 不敢碰的负权场景。
- 轮数上标
d^{(k)}[v]表示经过 k 轮扫描后,起点到 v 的当前最短距离。 - 所有入边对 v 的每一条入边 (u,v),考虑从 u 过来是否更短。
- 新旧比较把“绕道 u 再到 v”和上一轮的旧值比较,留下更小者。
复杂度对比:三个方案的性价比
现在小张手里有三套方案,选哪一套要看图的规模。Dijkstra 适合单源、非负、稀疏图;Floyd 适合全源、点数较少或边很密的图;Bellman-Ford 适合需要处理负权边或检测负环的场景。
空间上,Dijkstra 和 Bellman-Ford 只需要保存距离表 O(V),Floyd 需要保存整个二维矩阵 O(V^2)。所以 Floyd 虽然能直接给出全源答案,但顶点数上千时内存也会很紧张。选算法不是选“最聪明的”,而是选“性价比最高的”。
- V 和 E
V是顶点数,E是边数,这是衡量图规模的两个基本单位。 - log V 的来源堆优化 Dijkstra 每次取出和插入堆都要花费 O(log V)。
- O(V^3) 与 O(VE)Floyd 的三重循环导致 O(V^3);Bellman-Ford 是 V-1 轮乘上 E 条边,所以是 O(VE)。
应用选型:现实中怎么选
回到小张的公司:如果只是从仓库给顾客送餐,那就跑一趟 Dijkstra;如果想知道全国所有城市中转站两两之间的最低运输成本,那就用 Floyd;如果某些路线允许“补贴价”导致负权,那就必须让 Bellman-Ford 先检查负环。选择算法就像选交通工具:去楼下便利店走路就行,跨城要坐高铁,运重货得叫卡车。
导航系统里常用的 A* 算法就是在 Dijkstra 的基础上加了一个“朝着终点方向优先扩展”的启发式,让搜索更有方向感。而在物流系统中,如果城市数量不多但关系很密,Floyd 的一次矩阵计算能省下大量重复查询时间。选型的关键从来不是“背复杂度”,而是先回答:单源还是全源?有没有负权?图是稀疏还是稠密?
- 单源场景只从一个起点出发计算最短路,优先选择堆优化的 Dijkstra。
- 全源场景要求任意两点之间的最短距离,且顶点数不大时,Floyd 简单直接。
- 负权场景存在负权边时用 Bellman-Ford,并先用最后一轮检查是否出现负环。
AI 应用场景
地图导航与外卖调度
阶段:路径规划与实时决策
解决的问题:传统穷举所有路线在路口多时会指数爆炸,AI 系统中的导航模块需要毫秒级计算从当前位置到目标点的最优路线。Dijkstra 搭配优先队列和启发式搜索,可以在实时路况下快速重算路线。
具体案例:外卖平台在高峰期接到新订单后,系统会把路网建模成带权图,每条路的权重来自实时车速和红绿灯等待时间,然后运行 Dijkstra 或 A* 找到骑手与商家、顾客之间的最短路线。
网络路由协议
阶段:链路状态感知与网络自动收敛
解决的问题:互联网路由器需要快速知道“把数据包从当前节点送到目标节点最短路径是哪条”。传统手工配置无法适应拓扑变化,需要分布式算法自动更新路由表。
具体案例:OSPF 是典型的链路状态协议,每个路由器收集全网拓扑后用 Dijkstra 计算最短路径树;RIP 则用类似 Bellman-Ford 的思路不断交换距离向量,二者共同支撑着企业网络和数据中心的自愈能力。
物流配送的全源成本表
阶段:全局运筹优化
解决的问题:物流公司有几十个中转场,希望提前算好任意两个中转场之间的最低运输成本。如果每次都单独跑单源算法,查询会重复且低效;Floyd 一次性生成全源距离表更适合这种稠密小规模网络。
具体案例:某快递公司把全国 50 个核心中转场的运输时效整理成邻接矩阵,用 Floyd 计算出两两之间最短时效表,后续做干线调度和运费报价时直接查表,查询时间降到 O(1)。
Python 演示
import heapq def dijkstra(graph, src): n = len(graph) dist = [float('inf')] * n dist[src] = 0 pq = [(0, src)] while pq: d, u = heapq.heappop(pq) if d > dist[u]: continue for v, w in graph[u]: nd = d + w if nd < dist[v]: dist[v] = nd heapq.heappush(pq, (nd, v)) return dist def floyd(graph): n = len(graph) INF = float('inf') dist = [[INF] * n for _ in range(n)] for i in range(n): dist[i][i] = 0 for v, w in graph[i]: dist[i][v] = w 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 if __name__ == "__main__": graph = [ [(1, 2), (2, 5)], [(2, 1), (3, 6)], [(4, 3)], [(4, 2), (5, 3)], [(5, 1)], [] ] print("Dijkstra from 0:", dijkstra(graph, 0)) all_pairs = floyd(graph) print("Floyd dist matrix:") for row in all_pairs: print(row)
dist = [float('inf')] * n初始化距离表,把所有顶点到起点的距离先设为无穷大。pq = [(0, src)]优先队列从起点开始,起点距离为 0。if d > dist[u]: continue这是惰性删除,跳过已经过期的堆记录。nd = d + w计算经过 u 再到 v 的候选新距离。for k in range(n):Floyd 外层循环,k 是当前允许使用的中转点。if dist[i][k] + dist[k][j] < dist[i][j]:检查绕道 k 是否比 i 到 j 原来的路更短。
常见误区
误区既然 Dijkstra 能快速求出最短路径,那遇到负权边时应该也能用,只是结果可能不太准,但大体上没问题。
正解Dijkstra 的贪心正确性完全依赖“边权非负”。一旦出现负权边,一个已经被标记为确定的路口,可能被一条负权小路绕回来变得更短。比如 s→a 耗时 5,s→b 耗时 5,b→a 耗时 -10,那么先确认 a 就会得到错误答案。所以只要存在负权边,就必须改用 Bellman-Ford 或 Floyd。
为何常见:很多人先学 Dijkstra,习惯了“每次选最近点”的直觉,没有意识到这个直觉建立在非负权重的隐含假设上。
误区Floyd 就是一个三重循环,外层是 i 还是 k 应该无所谓,反正最后都会把矩阵刷一遍。
正解外层循环必须是“中转点 k”,不能换成 i 或 j。因为 D_k 的定义是“只允许前 k 个点中转”,如果 k 不是最外层,就可能在一个中转点还没被完整考虑时提前使用它,导致结果漏掉某些路径。形象地说,你不能让一家还没开业的商场作为中转站。
为何常见:Floyd 的代码看起来只是三个 for 嵌套,初学者容易忽略动态规划的阶段顺序,只记住“三重循环”这个表面特征。
误区Bellman-Ford 既然支持负权,那负权环应该也只是让答案更小,算法照样能跑完。
正解负权环会让最短路失去定义。只要进入这个环,每绕一圈总耗时都变小,理论上可以无限循环下去。Bellman-Ford 的做法不是“求出答案”,而是检测并报告“负权环存在”。因此在实际系统中,发现负权环后需要先修正数据或调整业务规则。
为何常见:把“算法能跑完”误当成“算法有正确输出”,忽略了无界下降在数学上根本没有最小值。
误区最短路就是经过路口最少的路,所以 BFS 就能解决所有最短路问题。
正解最短路比较的是“总权重”,不是“边数”。一条绕一点的高速路可能比一条直穿老城区的路快得多。只有当所有权重都为 1 时,最短路才等价于最少边数,这时才适合用 BFS。
为何常见:日常生活中人们常说“走最近的路”,容易把“路程近”和“路边少”混为一谈,忽略了每条路的权重并不相同。
面试题
Q1. Dijkstra 算法最适合解决哪类问题?
- A.单源最短路径,且边权非负← 正确答案
- B.全源最短路径,且边权可为负
- C.带负权环的最短路径
- D.任意含负权边的图
答案:A。Dijkstra 依赖贪心策略,只有在所有边权非负时,才能保证一旦确定某个节点的距离就不会再被更新。负权边和负权环会让贪心失效,所以选 A。
Q2. Dijkstra 每一步选择哪个节点进行扩展?
- A.当前距离起点最近且尚未固定的节点← 正确答案
- B.距离终点最近的节点
- C.当前边权最小的边
- D.入度最大的节点
答案:A。Dijkstra 从“待定集合”中选出 d 值最小的节点固定。它选的是“当前已知距起点最近”的节点,而不是离终点最近,也不是边权最小的边。
Q3. Floyd 算法的核心思想是什么?
- A.动态规划,用中转点逐步松弛所有点对← 正确答案
- B.贪心,每次选当前最短边
- C.分治递归
- D.随机采样逼近
答案:A。Floyd 用 D_k[i][j] 表示只允许前 k 个点中转时的最短路,每轮用一个新中转点更新矩阵。它属于动态规划,不是贪心或分治。
Q4. 一个图存在负权边但不存在负权环,要求单源最短路径,应该选哪个算法?
- A.Dijkstra
- B.Floyd
- C.Bellman-Ford← 正确答案
- D.BFS
答案:C。Dijkstra 不能处理负权边;BFS 只适合无权图;Floyd 适合全源。Bellman-Ford 可以处理负权边并在最后检测负权环,所以选 C。
Q5. Bellman-Ford 检测负权环的时机是?
- A.初始化距离表之前
- B.完成 V-1 轮松弛后,再扫描一遍所有边查看是否还能松弛← 正确答案
- C.每轮松弛开始时
- D.优先队列为空时
答案:B。简单路径最多有 V-1 条边,因此 V-1 轮后距离应稳定。若第 V 轮仍能松弛,说明存在负权环,Bellman-Ford 在此时报告检测结果。
Q6. 使用优先队列优化后的 Dijkstra 时间复杂度是?
- A.O(V^3)
- B.O(VE)
- C.O((V+E)log V)← 正确答案
- D.O(V+E)
答案:C。堆优化让每次取最小和入堆都耗 O(log V),每条边最多松弛一次,每个节点可能多次入堆,所以总复杂度是 O((V+E)log V)。
Q7. 在无负权边的稀疏图上求全源最短路径,最合适的做法是?
- A.直接运行一次 Floyd
- B.对每个节点运行堆优化 Dijkstra← 正确答案
- C.对每个节点运行 Bellman-Ford
- D.直接运行 BFS
答案:B。稀疏图 E 接近 V,Floyd 的 O(V^3) 不划算;每点跑 Dijkstra 约为 O(V E log V),在稀疏图上明显更优。Bellman-Ford 更慢,BFS 不能处理带权图。
Q8. 关于 Floyd 算法,下列说法正确的是?
- A.外层循环 k 必须是中转点← 正确答案
- B.初始化时把 dist[i][i] 设为 0← 正确答案
- C.能正确处理含负权环的图
- D.空间复杂度是 O(V^2)← 正确答案
答案:ABD。Floyd 的动态规划阶段要求 k 在最外层,对角线距离为 0,矩阵空间 O(V^2)。它不能“求出”负权环上的最短路,但可用对角线负数检测负环,因此 C 错误。
Q9. 哪些情况下不能直接使用 Dijkstra?
- A.存在负权边← 正确答案
- B.存在负权环← 正确答案
- C.稀疏无权图
- D.图中没有任何边
答案:AB。Dijkstra 正确性依赖边权非负,因此负权边和负权环都不能用。稀疏无权图可以用 BFS,但 Dijkstra 也能跑;无边图所有距离都是无穷大,同样可以处理。
Q10. 关于 Bellman-Ford 算法,正确的描述有?
- A.每一轮都要扫描图中所有边← 正确答案
- B.简单最短路径最多包含 V-1 条边← 正确答案
- C.第 V 轮仍可松弛说明存在负权环← 正确答案
- D.时间复杂度是 O(V^3)
答案:ABC。Bellman-Ford 每轮扫描所有边,共 V-1 轮;因为简单路径不可能超过 V-1 条边。第 V 轮仍能松弛表示有负环。其复杂度是 O(VE),不是 O(V^3),故 D 错误。