本节专注于最短路径,从情景开始引出三个经典算法。最终你会发现它们是同一个微观操作在三种调度策略下的产物。
引子:一次配送任务
小镇上有
1 3 2 1
A ──────────► B ──────────► D ──────────► E ──────────► F
│ │ ▲ ▲ ▲
│ │ │ │ │
4 │ 8 │ 1 │ 5 │ │
▼ ▼ │ │ │
└─────► C ────┴─────────────┴─────────────┘ │
└────────────────────────── 10 ─────────────────────────┘delivery-map.txt为了不依赖示意图的绘制精度,下面给出权威的边表(本页所有算法都依赖这张图,希望能够烂熟于心): 注:边表就是把图中所有路线整理成一张清单,每行写明起点、终点和耗时(即权重)
| 起点 | 终点 | 耗时 | 起点 | 终点 | 耗时 | |
|---|---|---|---|---|---|---|
骑手的问题:从
DEF定义 · 路径、路径长度与最短路径
在图
DEF定义 · 问题的两种规模
- 单源最短路径(Single-Source Shortest Path,SSSP):给定源点
,求所有 的 ——骑手只关心"从餐厅出发到每个地点多久"。 - 全源最短路径(All-Pairs Shortest Path,APSP):求所有点对
之间的 ——平台想预计算一张任意两点间的运费表。
在学习算法之前,先找到提纲挈领的关键。
统一的主线:松弛
想象你手里维护着一张距离估计表 dist[]:dist[v] 是你当前对
DEF定义 · 松弛操作
对一条边
简单地说:"如果绕道
松弛之所以成为算法的核心,靠两个不变量:
PROP性质 · 松弛的两条不变量
- 上界性:任何时刻,
dist[v]要么是 ,要么等于某条真实 路径的长度。因此恒有 ——估计永远不会低于真相。 - 单调性:
dist[v]只会减小,不会增大。每次生效的松弛都严格压低了某个估计。
第 1 条的证明是两行归纳:初始 dist[s]=0 是真实路径(零条边的路径);每次生效的松弛令 dist[v] = dist[u] + w(u,v),由归纳假设 dist[u] 对应一条真实路径,再接上边
而"压到什么时候才算完",依赖一条更深的结构性质:
THM定理 · 最优子结构(子路径最优性)
设
PROOF证明
反设存在更短的
这条定理是全文的隐形骨架:Dijkstra 靠它保证"确定顶点的最短路只经过更早确定的顶点",Floyd 靠它把一条路径从中转点处干净地切成两半。
接下来全部问题简化成一句话:按什么顺序施加松弛? 三种答案,对应本页三幕:
| 算法 | 松弛的调度策略 | 思想阵营 | 适用 |
|---|---|---|---|
| BFS | 按层:先松弛离源点近的边 | 按层自然有序 | 无权图 |
| Dijkstra | 每轮挑当前 dist 最小的顶点,松弛其所有出边 | 贪心 | 非负权 |
| Floyd | 按中转点:第 | 动态规划 | 全源、可有负边 |
| Bellman-Ford(拓展) | 无差别:全部边松弛 | 迭代收敛 | 有负权、可检测负环 |
第一幕 · 无权图:BFS 为什么天然正确
先考察最简单的版本:假设骑手只关心经过的路段数(每条路耗时相同),即无权图。这个问题 7.1 的 BFS 已经能解,但在接受答案之前,先复盘两个"想当然"的方案是怎么被pass掉的。
两条歧路
歧路一:每步走眼前最短的边。 以从
ANTI反例 · 局部最优 ≠ 全局最优
"每步选当前最优"的贪心在图上没有免疫力:一条便宜的开局边可能把你引向一片昂贵的区域。后面 Dijkstra 的成功恰恰在于它不是这样贪心的——它贪心的是"整个顶点的当前总距离",而非"单条边"。
歧路二:枚举所有路径,取最短。 数学上无懈可击,工程上是天方夜谭:
转机:距离是"分层"的
无权图有一个带权图没有的美妙性质:距离为
// graph[u] = {u 的所有出边邻居};返回 s 到各顶点的边数距离(不可达为 -1)
std::vector<int> bfs(const std::vector<std::vector<int>>& graph, int s) {
int n = static_cast<int>(graph.size());
std::vector<int> dist(n, -1); // -1 兼作"未访问"标记
std::queue<int> q;
dist[s] = 0; // 入队即赋距离
q.push(s);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : graph[u])
if (dist[v] == -1) { // 只在第一次到达时处理
dist[v] = dist[u] + 1; // 本质:relax(u, v),且必生效
q.push(v);
}
}
return dist;
}bfs.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
注意第 12 行正是松弛:dist[u] + 1 < dist[v](此处 dist[v] 为 dist[u] 已是最终值。
在例图上运行(邻居按字母序):
| 层 | 顶点 | 本层新发现(来源) |
|---|---|---|
| — | ||
即跳数意义上
论证 BFS 的正确性
THM定理 · BFS 的正确性(归纳证明)
无权图中,BFS 结束时
PROOF证明
对出队次序归纳,证明更强的命题:顶点按
先看队列的结构:任何时刻队列中只存在距离为
再看赋值的正确性。注意 BFS 中每个顶点的 dist 只在入队时被赋值一次、此后不再改变。对出队次序归纳,归纳假设是"此前每次出队所引发的赋值都正确"。基础:
- 若
,取 最短路径上 的前驱 ,则 。由出队次序非降, 在 之前出队;而 此刻仍未访问,在 出队时必然也未访问,故当时就该被标记——矛盾。 - 若
,同样取前驱 , , 在 之前出队, 当时未访问,同样矛盾。 - 故
,赋值 正确。
归纳完成。
本幕理论概要
| 结论 | 数学依据 |
|---|---|
| BFS 求出的就是无权最短路 | 归纳法:出队序 = 距离非降序;层 |
dist 始终是真实路径长度(上界) | 松弛不变量 1,与边权符号无关 |
| 每条边至多检查一次, | 入队即标记,每个顶点至多产生一轮出边扫描 |
| 贪"单条边"不可行 | 反例: |
| 穷举不可行 | 简单路径数指数级;含环则路径无穷 |
第二幕 · 非负权图:Dijkstra 的贪心
崩溃现场
路段耗时不尽相同——这才是现实。第一幕的结论在此处作废:
ANTI反例 · BFS 在带权图上失效
例图中
问题在于:跳数的层序等于距离的序,只当每条边权相等时才成立。边权一旦不均匀,"先访问"就不再意味着"更近"。
从"按层"到"按距离"
修补的思路是直面崩溃的原因:既然层序失效,就改成按当前估计距离排序——每一轮,把估计表中数字最小且尚未敲定的顶点找出来,宣布它的估计就是最终答案,然后用它的出边做一轮松弛。这就是 Dijkstra 算法。
它的算法正确性由一条不变量支撑:
PROP性质 · Dijkstra 的核心不变量
边权非负时,每当顶点 dist[u] 被选出,必有
// graph[u] = {(邻居, 边权), ...},边权非负;prev 记录前驱用于还原路径
std::vector<int> dijkstra(int s, const std::vector<std::vector<std::pair<int,int>>>& graph,
std::vector<int>& prev) {
int n = static_cast<int>(graph.size());
prev.assign(n, -1);
const int INF = std::numeric_limits<int>::max();
std::vector<int> dist(n, INF);
std::vector<bool> done(n, false); // done[u]:u 是否已确定
using P = std::pair<int,int>; // (dist, vertex),小根堆
std::priority_queue<P, std::vector<P>, std::greater<P>> pq;
dist[s] = 0;
pq.push({0, s});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (done[u]) continue; // 堆里的过期记录,丢弃
done[u] = true; // 此刻 dist[u] 即最终答案
for (auto [v, w] : graph[u])
if (!done[v] && dist[u] + w < dist[v]) { // relax(u, v)
dist[v] = dist[u] + w;
prev[v] = u; // 记住"是谁把我压短的"
pq.push({dist[v], v});
}
}
return dist;
}dijkstra.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
两个细节值得关注:
- 第 15 行跳过
done[u]的记录:懒惰删除的堆里同一个顶点可能有多条过期记录,只有等于当前dist[u]的那条是新鲜的。确定即永不再动。 - 第 18 行对已确定顶点不再松弛——保证(并要求)不变量成立。
手算 Dijkstra:例图全程
源点
| 轮次 | 出队(确定) | dist[B] | dist[C] | dist[D] | dist[E] | dist[F] | 本轮松弛记录 |
|---|---|---|---|---|---|---|---|
| 初始 | — | ||||||
| 1 | 初始化三条出边 | ||||||
| 2 | |||||||
| 3 | |||||||
| 4 | |||||||
| 5 | |||||||
| 6 | — | — | — | — | — | 全部确定,算法结束 |
三轮细节各藏一个教学点:
- 估计可以反复压低:
的估计先被 压到 ,再被 压到 ——松弛不是依赖一次性判决,而是持续逼近。 - 并列不更新:第 4 轮
给出的 与当前值并列,松弛不生效。若写成 ,路径记录会被无意义地覆盖。 - 迟来的翻盘:直达边
( )从第 1 轮起就躺在表里,但直到第 5 轮才被 ( )淘汰。贪心选的是顶点的总距离,所以"开局便宜但后劲不足"的路径骗不了它——这正是它与第一幕"歧路一"的本质区别。
由 prev 链
O(·)复杂度
邻接表 + 二叉堆:每个可达顶点至多被确定一次,确定时扫描其全部出边,因此每条边在整个算法中至多被扫描一次。每次生效的松弛会产生一条新堆记录,最多
论证 Dijkstra 的正确性
THM定理 · Dijkstra 的正确性(非负权前提)
所有边权
PROOF证明
反证法。设
取一条真正的最短路径
在 之前确定且确定无误,故 ; 确定时松弛过 ,因此 ,恰是 上 前缀的长度 ;- 前缀不超过整条路径:
,这一步用到 的 尾段权非负; 是本轮未确定顶点中dist最小者: 。
串起来:
请特别注意非负权在哪里上场:仅在"前缀
它也有软肋:负权边
ANTI反例 · Dijkstra 遇负边不可用
三点图:!done[v] 拦截。最终答案
对照上节证明:
本幕理论概要
| 结论 | 数学依据 |
|---|---|
| 出队即最终(核心不变量) | 反证法 + "第一个错误顶点"论证;非负权保证路径前缀 |
dist 始终 | 松弛的两条不变量 |
| 已确定顶点的最短路只经过更早确定的顶点 | 最优子结构:最短路径的每个前缀也是最短的 |
| 平列( | 松弛定义取严格小于 |
| 负边下失效,且失效点可精确定位 | 反例 + 证明中唯一使用非负性的一步 |
| 堆操作计数;稠密时堆反成负担 |
第三幕 · 全源:Floyd 的动态规划
新问法
平台要把生意做大:预计算任意两个地点之间的最短耗时,生成一张
与其选边,不如选中转点
前两幕的松弛都沿着边推进。Floyd 换了枚举的维度:不问"下一条边选谁",而问"允许谁当中转站"。
DEF定义 · Floyd 的状态
记
THM定理 · Floyd 递推式
PROOF证明
对最优路径是否经过
- 不经过
:中间顶点仍全部落在 ,答案即 ; - 经过
:由子路径最优性,从 处把路径切成 与 两半,两半都是各自端点间的最短路,且中间顶点只能来自前 个(一条无负环的最短路不会两次经过 ),故长度为 。
两者取小即得。
代码极短:
// d 初始为邻接矩阵(无边处 INF,对角线 0);nxt 记录后继用于还原路径
void floyd(std::vector<std::vector<int>>& d, std::vector<std::vector<int>>& nxt, int n) {
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j)
nxt[i][j] = (i == j || d[i][j] == INF) ? -1 : j;
for (int k = 0; k < n; ++k) // 中转点必须是【最外层】
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j)
if (d[i][k] < INF && d[k][j] < INF &&
d[i][k] + d[k][j] < d[i][j]) { // relax:经 k 中转
d[i][j] = d[i][k] + d[k][j];
nxt[i][j] = nxt[i][k]; // 路径第一跳改为 i 的去向
}
}floyd.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
两个细节:
WARN易错点 · 三个细节
必须在最外层。递推式从 推到 ,把 放进内层等于在同一轮里混用新旧状态,某些需要"先经 中转两段、再拼起来"的路径会算错。 两层可以互换, 不行。- 二维数组为什么够:按理需要三维
。但第 轮里想修改 ,唯一的候选是"经 自己中转",即要求 ,等价于 ——这意味着存在一条过 的负环,无负环的图中不可能发生。 同理。因此第 轮原地读到的旧值与"上一轮的值"完全一致,第三维可以安全滚掉。 nxt[i][j] = nxt[i][k]而非= k:后继矩阵记录的是"下一步去哪"。改经 中转后,第一步走的是 原本通往 的第一步,不是 本身。
手算 Floyd:例图全程
为与代码的下标对应,约定顶点编号
六轮迭代中只有四轮有更新(这也提示:Floyd 对"稀疏无环味的图"做了很多无用功,见终幕选型表):
| 中转点 | 生效的松弛 | 说明 |
|---|---|---|
| 无 | 没有任何边指向 | |
| 发现 | ||
| 经 | ||
| 大丰收; | ||
| 每行都经 | ||
| 无 |
最终矩阵
交叉验证:加粗的
负权与负环
Floyd 的递推式不要求边权非负——它单纯枚举所有中转组合,负边带来的"更短"会被正常捕捉。但它有一个死穴:
WARN易错点 · 负环
若图中存在总权为负的环,绕环每圈都更短,最短路径趋于
本幕理论概要
| 结论 | 数学依据 |
|---|---|
| 递推式 | DP:按"是否经 |
| 三维可滚动为二维 | 第 |
| 状态转移要求"先备齐前 | |
| 允许负边、不能有负环 | 反例即机理:负环使 |
| 三重定长循环、二维矩阵 |
终幕 · 选型:挑装备
三个算法同源于松弛,分流于调度。选型的第一步是看清图的性质:
边权情况?── 无权 ──────────────► BFS O(n+m)
├── 非负 + 单源 ─────────► Dijkstra O((n+m)log n)(稠密 O(n²))
├── 含负权 + 单源 ────────► Bellman-Ford O(nm)(拓展阅读)
└── 全源(任意点对)──────► Floyd O(n³)(顶点少时最省心)decision.txt| 维度 | BFS | Dijkstra | Floyd | Bellman-Ford |
|---|---|---|---|---|
| 解决的问题 | 无权单源 | 非负权单源 | 全源 | 任意权单源 |
| 思想 | 按层松弛 | 贪心(按距离选顶点) | DP(按中转点) | 全边迭代收敛 |
| 复杂度 | ||||
| 负边 | —(无权) | 不允许 | 允许 | 允许 |
| 负环检测 | — | — | 第 | |
| 典型场景 | 社交关系度数 | 地图导航、网络路由 | 带折扣/返利的费用图 |
回到骑手:例图同源三问的答案放在同一张表里对照——
| 问题 | 算法 | 答案 |
|---|---|---|
| BFS | ||
| Dijkstra | ||
| 任意两地的耗时表? | Floyd |
小结
- 松弛是全文的主线:
dist[v] = min(dist[v], dist[u] + w)这一个操作,配上"上界、单调"两条不变量,构成了所有最短路算法的公共内核;算法之间真正的差别只是施加松弛的顺序。 - BFS 的正确性来自无权图的分层结构(归纳证明:出队序即距离非降序)。
- Dijkstra 的正确性来自非负权下的"前缀
全长"(反证法:第一个被错确定的顶点导致整条不等式链闭合为等号);它贪心的是顶点总距离,不是单条边。 - Floyd 的正确性来自动态规划:按中转点扩张可选集,递推式由"是否经过
"的分类与子路径最优性保证; 在最外层、二维滚动安全、负环可由对角线检测。 - 选型先看图:权值有无与符号、单源还是全源、稠密还是稀疏——先回答这三个问题,算法基本就选定了。
练习与自测
(答案已折叠)
1. 改权再算 Dijkstra
把例图的
点击展开答案
只有初始状态与最后两轮发生变化:
| 轮次 | 出队(确定) | dist[B] | dist[C] | dist[D] | dist[E] | dist[F] | 本轮松弛记录 |
|---|---|---|---|---|---|---|---|
| 初始 | — | 直达边权改为 | |||||
| 1 | 初始化三条出边 | ||||||
| 2 | |||||||
| 3 | |||||||
| 4 | |||||||
| 5 | |||||||
| 6 | — | — | — | — | — | 全部确定 |
2. 构造反例
构造一个只有
点击展开答案
(1) 跳数最少
(2) 每步走当前最短边失败:取顶点
两个反例的共同病根:单条边的便宜不代表整条路径的便宜——这正是第二幕 Dijkstra"按顶点总距离贪心"要修正的对象。
3. 定位非负权上场的唯一一步
复述 Dijkstra 正确性证明中唯一用到"边权非负"的一步,并解释为什么去掉这个前提该步不成立(配一个 3 顶点反例图)。
点击展开答案
唯一的一步是:"路径前缀不超过整条路径",即
反例(3 顶点):
4. 放进内层的代价
把 Floyd 的三重循环改为
点击展开答案
取 4 个顶点
按
病根:
5. 给例图加一条负边
例图中加入一条
点击展开答案
有负环:
- Dijkstra:不可用。它根本无法处理负权边(见第二幕反例),何况负环。
- Floyd:可以照常运行,但结果"没有意义"——它不会报错停止,而是忠实地把能绕负环的格子越压越低。运行结束后检查对角线,会发现
,据此判定负环存在;这就是 Floyd 作为负环探测器的用法。 :不再有有限值。每多绕一圈 ,从 出发到 的花费就再降 ,路径长度趋于 ——"最短路径"这个问题本身失去了定义。
6. nxt 记录的是"下一跳",不是"中转点"
为什么 nxt[i][j] = nxt[i][k] 而不能写 nxt[i][j] = k?画图说明两种写法各自还原出的路径。
点击展开答案
nxt[i][j] 的语义是"从 nxt[i][k],而不是
用例图验证
- 正确写法:更新链为
,最终 。还原时反复查表: ,正确。 - 错误写法:最后一次生效松弛发生在第
轮(中转 ),会记 。还原时从 直接"瞬移"到 ,得到 ——但 根本不是边,这段路走不通;即使按矩阵里的 计价,它描述的也只是" 经 到 "的路径,却被错误地当成了可直达的一步。
7. 证明:第 轮里第 行、第 列不动
证明:Floyd 第
点击展开答案
第
无负环时:对角线恒有
有负环时:若
参考资料
- 王道《数据结构》考研复习指导:图的应用(最短路径:BFS、Dijkstra、Floyd)
- 严蔚敏《数据结构》(C 语言版):图的最短路径
- 《算法导论》:第 24 章单源最短路径、第 25 章每对顶点间的最短路径(松弛性质与 Dijkstra 证明的严格版本)