算法讲解 · 技术文档

地铁网络最短路算法讲解

从单源最短路到多目标巡回与碰头点选址

项目说明
文档类型技术讲解文档
技术栈C++17 / Qt 6.8
目标读者具备基础数据结构知识的开发者或学生
预计阅读约 15 分钟

TL;DR

同一张地铁网络图上,「最短路径」并不是一个算法,而是一族算法。本文按推进方式把项目里实现的五种算法串起来:Dijkstra 按距离从小到大逐个「确定」节点,Bellman-Ford 按轮次一圈圈向外铺,SPFA 是 Bellman-Ford 的队列优化,Floyd 则用「逐个解禁中转站」的办法一次性算出全源距离。在这之上,项目还叠加了两个把算法变成生活工具的扩展:多目标巡回(先用最短路算距离矩阵,再用状态压缩 DP 求访问顺序)与碰头点选址(同一张图,换一个目标函数,答案就完全不同)。

一、问题背景与数据模型

这一章先交代清楚两件事:我们在什么图上计算,以及算出来的数字到底代表什么。后面所有算法的讨论,都建立在这两个前提之上。

(一)要解决什么

给定一张城市地铁网络(站点 + 区间 + 区间耗时),回答三类问题:

  1. 单对最短路:从 A 站到 B 站最少要多久,走哪条线。
  2. 多目标巡回:从起点出发,把若干个目标站都走一遍,总耗时最小,顺序是什么。
  3. 碰头点选址:几个人从不同位置出发,约在哪个站见面最合适。

三类问题的内核是同一个——图上的最短路径。区别只在于「在最短路上优化什么」。

(二)图模型:邻接表与邻接矩阵双存

项目用 Graph 类同时维护两份存储:

用 INF 表示不可达,用 alive_ 标记点是否还存在(删点后不重建整张表)。读取数据时会丢弃自环、重边与越界编号,丢弃条数记在 dropped_ 里,供界面提示。

  设计取舍:双存带来约两倍内存,换来的是「遍历快」和「查询快」两种访问模式都不必妥协。地铁网络点数在几百量级,这个代价完全可以接受。

(三)边权的真实含义

边权不是直线距离,而是区间通行耗时(分钟)。实际计算前,基础耗时会乘上拥堵系数,组成 gWork_:

gWork_ = 基础耗时 × 拥堵系数      (封路的边权置为 INF)

所以算法输出的 dis 就是真实路上的分钟数。这一点很关键:它意味着后面所有「最优解」都是时间意义上的最优,而不是几何意义上的最近。

二、单源最短路:三种推进方式

单源最短路要回答的是「从 s 出发到每一个点的最小耗时」。三种算法的目标相同,区别在于用什么顺序去确定一个点的最终距离。

(一)Dijkstra:按距离从小到大逐个确定

Dijkstra 是最基础的贪心版本,它的全部威力来自一条不变式。下面先说明这条不变式为什么成立,再看它的两种实现。

1. 核心不变式

维护一个「已确定集合」S。每一轮从未确定的点里取出 dis 最小的那个 u,把它加入 S,然后用 u 去松弛它的所有邻居:

dis[v] = min(dis[v], dis[u] + w(u, v))

为什么可以「一旦确定就不再更改」:因为所有边权非负。如果 u 是剩余点里距离最小的,那么任何「绕一圈再回到 u」的路径都不可能更短——绕路只会让距离增加。

  前提条件:边权必须非负。这也是后面还要实现 Bellman-Ford 和 SPFA 的原因。

2. 朴素版与堆优化版

朴素版每轮用一次线性扫描找出当前最小距离的点:

for i in 0 .. n-1:
    u = 未确定点中 dis 最小的       // 这一步 O(n)
    vis[u] = 1
    for 每条 u -> v 的边: 松弛

总复杂度 O(n² + m),适合稠密图或点数很小的场景,写起来最直观。

堆优化版把「找最小」交给优先队列:

while 堆非空:
    (d, u) = 堆顶弹出
    if vis[u]: continue            // 过期项,丢弃
    vis[u] = 1
    for 每条 u -> v 的边:
        if dis[u] + w < dis[v]:
            dis[v] = dis[u] + w
            压入 (dis[v], v)

复杂度降到 O((n + m) log n),是项目里实际计算与算法对比时默认使用的版本。

3. 惰性删除:vis 判断为什么不能省

堆优化版没有实现「修改堆中元素的优先级」,而是允许同一个点被多次压入堆。这就带来一个问题:堆里可能同时存在 (dis_old, v) 和 (dis_new, v) 两项,其中 dis_old > dis_new。

if vis[u]: continue 就是用来丢弃这类过期项的。当 (dis_new, v) 先被弹出并处理时 vis[v] 被置 1,之后 (dis_old, v) 弹出时直接被跳过。

如果省掉这一行,会用已经过时的较大距离去松弛邻居,在稠密图上会产出偏大的错误结果。这正是「惰性删除(lazy deletion)」这个技巧存在的意义。

4. 记录型:为动画重放服务

dijkstraRecorded 的逻辑与堆优化版完全一致,只多输出一个 order 数组,记录节点被确定的先后顺序。界面上的算法动画就是按这个数组逐帧回放的——动画放出来的顺序,就是算法真实的确定顺序,而不是事后编排的视觉效果。

(二)Bellman-Ford:一轮一轮地铺

Bellman-Ford 不去挑「当前最近的点」,而是每一轮把所有边都扫一遍:

dis[s] = 0
for round in 1 .. n-1:
    changed = false
    for 每条边 (u, v, w):
        if dis[u] + w < dis[v]:
            dis[v] = dis[u] + w
            changed = true
    if not changed: break          // 提前收敛

为什么最多 n-1 轮:任何一条最短路径最多包含 n-1 条边(不含环)。第 i 轮结束时,已经能正确求出「边数不超过 i」的最短路。所以 n-1 轮之后必然全部收敛。

changed 标志是纯粹的优化:某一轮没有任何改进,说明已经收敛,可以直接跳出。

复杂度 O(n·m),比堆优化 Dijkstra 慢,但换来两个能力:

  1. 不要求边权非负,负权边也能正确处理。
  2. 可以检测负环:如果跑完 n-1 轮后再扫一遍还能松弛,说明存在可以无限绕圈变短的负环,此时最短路径无定义。

记录型版本额外输出 roundInfo(每轮的松弛次数),使得动画能表现 Bellman-Ford 特有的「一轮一轮向外铺开」——这是它与 Dijkstra 在视觉上最直观的区别。

(三)SPFA:Bellman-Ford 的队列优化

观察 Bellman-Ford 可以发现:一轮里绝大多数边其实是无效扫描。只有上一轮距离真正变小的那些点,才可能带动邻居更新。

SPFA 用队列维护「距离刚被更新的点」:

dis[s] = 0; 队列 = [s]; inq[s] = 1
while 队列非空:
    u = 队首出队; inq[u] = 0
    for 每条 u -> v 的边:
        if dis[u] + w < dis[v]:
            dis[v] = dis[u] + w
            if not inq[v]:
                入队; inq[v] = 1
                cnt[v] += 1
                if cnt[v] >= n: 存在负环

inq 保证同一个点不会在队列里重复出现,cnt 统计入队次数——一个点入队达到 n 次,说明它在被负环反复缩短。

  复杂度要诚实地说:SPFA 的期望复杂度是 O(k·m)(k 为平均入队次数,通常很小),但最坏可以退化到 O(n·m),与 Bellman-Ford 相同。它在网格图等特定结构上会被构造数据卡到最坏情况,因此不建议作为生产的默认选择。项目保留它,主要是为了对比教学——「队列优化在平均情况下有多快、在最坏情况下有多脆」本身就是很好的教材。

(四)三者的关系与选择

场景推荐理由
边权全非负,单源最短路堆优化 DijkstraO((n+m)log n),稳定且快
存在负权边Bellman-Ford唯一能正确处理负权单源
存在负权边但想更快SPFA平均更快,但要接受被卡最坏情况
只需要确定顺序做动画记录型版本与主算法逻辑一致,仅多一个输出

三、全源最短路:Floyd

Floyd 一次性求出所有点对之间的最短距离,代码只有三层循环。

(一)状态定义

把「中转站」逐个解禁。第 k 轮的含义是:只允许使用编号 0..k 的点作为中转。

for k in 0 .. n-1:                 // 第 1 层:本轮允许把 k 当中转站
    for i in 0 .. n-1:             // 第 2 层:起点
        for j in 0 .. n-1:         // 第 3 层:终点
            dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j])

(二)为什么 k 必须是最外层

这是 Floyd 最容易被写错的地方。把 k 放到内层,等价于只允许使用一个中转站——而真实的最短路径可能需要连续中转多次。

举例:若答案是 A → B → C → D,需要同时经过 B、C 两个中转点。只有 k 在最外层、逐个解禁,dis[A][C] 才会在 B 解禁时先被更新,dis[A][D] 才能在 C 解禁时利用到已经更新过的 dis[A][C]。

  错误不会报错,只会静默算出偏大的距离。这是它危险的地方。

(三)路径还原:next 矩阵

只记录长度无法还原路径,所以额外维护 next[i][j]——「从 i 到 j 的最短路上,i 的下一个点是谁」。更新距离时同步更新它:

if dis[i][k] + dis[k][j] < dis[i][j]:
    dis[i][j] = dis[i][k] + dis[k][j]
    next[i][j] = next[i][k]

复原时从 i 开始沿 next 一路跳到 j 即可。

(四)负环检测

Floyd 的收尾检查非常优雅:跑完后如果某个 dis[i][i] < 0,说明从 i 出发绕一圈回到 i 反而更短,即存在负环。

四、算法对比总表

表 1 五种算法横向对比
算法每步做什么时间复杂度边权要求输出项目中的角色
Dijkstra 朴素线性扫描找最小O(n² + m)非负单源教学基准
Dijkstra 堆优化优先队列取最小O((n+m) log n)非负单源实际计算默认
Dijkstra 记录型同上 + 记录顺序O((n+m) log n)非负单源 + 顺序动画回放
SPFA出队才松弛邻边O(k·m) 期望 / O(n·m) 最坏可负单源对比对象
Bellman-Ford每轮扫全部边O(n·m)可负单源负权与负环
Floyd三重循环,k 中转O(n³)可负(无负环)全源全源距离与路径

(一)一个实测结论

在 411 个节点、5 个目标站的路网上跑巡回求解,几组策略的实测表现差异极大:

表 2 巡回求解在不同策略组合下的实测表现
距离矩阵策略顺序策略耗时总耗时结果
k 次 Dijkstra状态压缩 DP0.79 ms102 分钟
k 次 Dijkstra最近邻贪心0.72 ms114 分钟
k 次 SPFA最近邻贪心0.23 ms114 分钟
一次 Floyd状态压缩 DP643 ms102 分钟

这张表说明了两件事。第一,Floyd 在这里慢得没有道理:它执行了约 800 倍的耗时,结果却和 k 次 Dijkstra 完全一致——因为目标只有 5 个(k = 6),Floyd 却把整个 411×411 的全源矩阵都算了。第二,贪心不是最优:它比精确 DP 少算了 12 分钟。

五、从单对到多目标:巡回问题

巡回看起来是一个全新的问题,但它并不需要新的最短路算法——它把已经实现的单源算法当成零件,换个方式组合起来。

(一)把问题拆成两步

「从起点出发按最优顺序走完所有目标」听起来比单对最短路复杂得多,但它可以干净地拆成两个独立子问题:

  1. 距离矩阵:求出「起点 + 各目标点」两两之间的最短距离。
  2. 顺序求解:在这个小规模距离矩阵上,决定访问顺序。

拆开的好处是两步可以各自换算法,组合出不同的性能/精度取舍。

(二)第一步:距离矩阵

设目标数为 m,节点表大小为 k = m + 1(含起点)。三种底座可选:

取舍点很清晰:k 小时多次 Dijkstra 划算;k 接近 n 时 Floyd 更优。上一节的实测正是这个结论的极端案例——k 很小时,Floyd 的巨大固定开销完全无法摊薄。

(三)第二步:顺序求解

距离矩阵备好之后,剩下的就是一个纯粹的组合优化问题:在 m 个目标上找一条代价最小的访问顺序。规模不同,解法也不同。

1. 状态压缩 DP

有了距离矩阵 D,问题变成「以最小总代价访问所有目标」。用状态压缩 DP(Held-Karp):

dp[mask][i] = 已访问集合为 mask、当前停在目标 i 的最小总耗时
dp[1 << j][j] = D[起点][j]                              // 初始:从起点直达第一个目标
dp[mask | (1 << j)][j] = min( dp[mask][i] + D[i][j] )   // 从 i 走到未访问的 j

mask 的每一位表示一个目标是否已访问。逐层扩展后,取 dp[全集][i] 的最小值即最优总耗时,再顺着父指针回溯即可得到访问顺序。

注意「已访问 {A, B} 且停在 B」和「已访问 {A, B} 且停在 A」是两个不同状态——因为下一步能走的边不同。这正是需要 mask 和 i 两个维度共同描述状态的原因。

2. 最近邻贪心

目标数变多时,DP 的 2m 规模会爆炸。此时退化为最近邻贪心:每一步都走向「最近的未访问目标」。

它是近似解,不保证最优——上一节实测中它比精确解多花了 12 分钟。但当 m 超过 12 时,它是唯一可行的选择。

3. 规模边界

目标数 m状态数 2^m策略
≤ 12≤ 4096自动选精确 DP
13 ~ 208192 ~ 1048576自动退化为贪心
> 20> 100 万若显式要求精确解则直接放弃并标注「不可用」

这一边界是显式写死在代码里的,而不是「跑跑看会不会超时」。显式声明「此配置下不可用」,比让用户等待一个可能几分钟都不返回的计算更诚实。

六、换个目标函数:碰头点选址

碰头点是把最短路「用出去」的典型场景。算法内核没有任何新东西,新的只是判断标准——而恰恰是这个标准决定了答案。

(一)问题与算法内核

三个人从不同位置出发,约在哪个站见面。算法上,对每个出发点各跑一次单源最短路,得到「每个人到每个候选点的耗时」,然后在候选点上选一个。选的标准就是目标函数。

(二)两种目标函数

设第 k 个人到候选点 v 的耗时为 d_k(v):

实现上只需要在遍历候选点时维护两个量:sum 与 mx,分别取最小即可。若有人到不了该候选点,直接淘汰这个候选点。

(三)两个答案为什么不同

用一个具体场景说明。A、B 两个人在同一片区,C 在城另一头。

表 3 两种准则在同一场景下的选址结果
候选点A 耗时B 耗时C 耗时总和最大值
靠 A、B 一侧400330370330
位于中间185145185515185

两个答案完全不同,而且都不是错的——它们只是优化了不同的东西。

  这个例子也是整个项目最想说明的一点:最短路的结果,取决于你要优化什么。换一个目标函数,同一张图上会得出完全不同的「最优解」。

七、让权重更真实:拥堵系数建模

前面所有算法的输入都依赖边权。如果边权本身不真实,算法再快也只是更快地给出一个不准的答案。这一章专门处理输入的来源。

(一)为什么要建模

项目自带的部分城市数据只有三列(起点、终点、耗时),没有拥堵系数。这意味着「按拥堵计算」在这些数据上等于没算——系数恒为 1.0,所有边等比放大,最短路结果完全不变。

(二)用真实经纬度与拓扑推算系数

真随机数是没有说服力的,所以系数由三个有现实依据的因素推算:

factor = 1.0 + 0.55 * centrality + 0.25 * hub - 0.08 * long_ratio

centrality = (1 - rel) ^ 2      // rel 为该区间中点到城市质心的归一化距离
hub                             // 该区间端点是否为换乘站(度数 >= 3)
long_ratio                      // 该区间长度占全市最长区间的比例

三个因素各有物理含义:

(三)为什么衰减指数必须大于 1

这是整个建模里最关键的一个决策。centrality 使用 (1 - rel) ^ 2 而不是线性衰减:

指数大于 1 带来的不是「更好看」,而是空间差异真正被拉开,拥堵才会真的改变路径选择。

(四)一个诚实的反例

值得一提的是:光有真实的拥堵系数,碰头点往往还是不变。原因是各人来的方向不同,但都经过核心区,来路的平均拥堵程度接近,耦合后互相抵消。

真正让选址发生移动的,是叠加高峰模拟(按站点度数模拟高峰期拥堵)之后。这个反例本身很有价值——它说明「数据更真实」和「结论会改变」是两件事,中间还隔着「模型是否对结果敏感」。

八、总结:什么时候用哪个

表 4 按需求选择算法
需求用什么一句话理由
单对最短路、边权非负堆优化 Dijkstra稳定、快,工程默认
需要动画/可视化重放记录型 Dijkstra直接给出真实的确定顺序
存在负权边Bellman-Ford唯一能正确处理负权的单源算法
需要检测负环Bellman-Ford 或 Floyd收尾多扫一轮即可判定
需要全源距离Floyd一次 O(n³) 拿下所有点对
目标点少(≤ 12)的巡回k 次 Dijkstra + 状态压缩 DP精确最优,且 k 小时远比 Floyd 划算
目标点多(> 12)的巡回k 次 Dijkstra + 最近邻贪心DP 规模爆炸后唯一可行
多人碰头各自一次 Dijkstra + 目标函数算法内核不变,只换优化目标

最后一条也许是整个项目最值得记住的:前七个问题问的是「怎么算得更快」,只有最后一个问的是「到底要算什么」。而在真实场景里,后者的价值常常更高。

元信息