本节从一个配送场景引出 BFS、Dijkstra 与 Floyd。三种算法都使用松弛操作,但它们组织状态和安排松弛顺序的方式不同,适用条件也不同。
引子:一次配送任务
小镇上有
为了避免示意图布局造成误读,下面列出本页例图的完整边集。后续手算均使用这组边。
边表把每条路线的起点、终点和耗时(即权重)列为一行:
| 起点 | 终点 | 耗时 | 起点 | 终点 | 耗时 | |
|---|---|---|---|---|---|---|
骑手的问题:从
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 与 Floyd,并在拓展中补充 Bellman-Ford:
| 算法 | 松弛的调度策略 | 核心方法 | 适用条件 |
|---|---|---|---|
| BFS | 按层:先松弛离源点近的边 | 按层自然有序 | 无权图 |
| Dijkstra | 每轮挑当前 dist 最小的顶点,松弛其所有出边 | 贪心 | 非负权 |
| Floyd | 按中转点:第 | 动态规划 | 全源、可有负边 |
| Bellman-Ford(拓展) | 无差别:全部边松弛 | 迭代收敛 | 有负权、可检测负环 |
第一幕 · 无权图:BFS 为什么天然正确
先考察最简单的版本:只关心经过的路段数,也就是把每条边的代价都视为
两种不可行的直接做法
做法一:每步选择当前权值最小的边。 以从
ANTI反例 · 局部最小边不保证路径总权值最小
单条边权较小,不代表包含它的整条路径权值也较小。Dijkstra 比较的是从源点到候选顶点的当前总距离,而不是只比较下一条边的权值。
做法二:枚举所有路径后取最短。 这种定义直接但计算不可行:
BFS 利用距离分层
无权图满足:距离为
// graph[u] = {u 的所有出边邻居};dist 保存 s 到各顶点的边数距离(不可达为 -1)
std::vector<std::vector<int>> graph;
std::vector<int> dist;
void bfs(int s) {
int n = static_cast<int>(graph.size());
dist.assign(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);
}
}
}bfs.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
第 12 行对应一次松弛:对尚未访问的 v,其距离可从 dist[u] + 1。BFS 按层安排这些松弛;每个可达顶点只会出队一次,每条有向边或无向边的每条邻接记录只会被扫描一次。
在例图上运行(邻居按字母序):
| 层 | 顶点 | 本层新发现(来源) |
|---|---|---|
| — | ||
即跳数意义上
论证 BFS 的正确性
THM定理 · BFS 的正确性(归纳证明)
无权图中,BFS 结束时
PROOF证明
对出队次序归纳,证明更强的命题:顶点按
先看队列的结构:任何时刻队列中只存在距离为
再看赋值的正确性。注意 BFS 中每个顶点的 dist 只在入队时被赋值一次、此后不再改变。对出队次序归纳,归纳假设是"此前每次出队所引发的赋值都正确"。基础:
- 若
,取 最短路径上 的前驱 ,则 。由出队次序非降, 在 之前出队;而 此刻仍未访问,在 出队时必然也未访问,故当时就该被标记——矛盾。 - 若
,同样取前驱 , , 在 之前出队, 当时未访问,同样矛盾。
因此只能有
归纳完成。
本幕理论概要
| 结论 | 数学依据 |
|---|---|
| BFS 求出的就是无权最短路 | 归纳法:出队序 = 距离非降序;层 |
dist 始终是真实路径长度(上界) | 松弛不变量 1,与边权符号无关 |
| 每条邻接记录至多扫描一次, | 入队即标记,每个顶点至多产生一轮出边扫描 |
| 只比较“下一条边”的权值不可行 | 反例: |
| 穷举不可行 | 简单路径数指数级;含环则路径无穷 |
第二幕 · 非负权图:Dijkstra 的贪心
带权图上的反例
路段耗时不尽相同——这才是现实。第一幕的结论在此处作废:
ANTI反例 · BFS 在带权图上失效
例图中
问题在于:跳数的层序等于距离的序,只当每条边权相等时才成立。边权一旦不均匀,"先访问"就不再意味着"更近"。
从"按层"到"按距离"
Dijkstra 改为按当前估计距离选择顶点:每一轮找出 dist 最小且尚未确定的顶点,把它的当前估计确定为最终距离,再用它的出边执行松弛。边权非负时,这一步具有正确性保证。
它的算法正确性由一条不变量支撑:
PROP性质 · Dijkstra 的核心不变量
边权非负时,每当顶点 dist[u] 被选出,必有
// graph[u] = {(邻居, 边权), ...},边权非负;prev 记录前驱用于还原路径
std::vector<std::vector<std::pair<int, int>>> graph;
std::vector<int> dist, prev;
void dijkstra(int s) {
int n = static_cast<int>(graph.size());
prev.assign(n, -1); // prev[v]:最短路上的前一个顶点
const int INF = std::numeric_limits<int>::max();
dist.assign(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; // 记录使 dist[v] 变小的前驱
pq.push({dist[v], v});
}
}
}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
26
两个细节值得关注:
- 第 15 行跳过
done[u]的记录:采用懒惰删除时,同一个顶点可能在堆中保留多条旧记录;顶点第一次以当前最小距离弹出后即被确定,后续记录直接跳过。 - 第 18 行不再更新已确定顶点;这依赖边权非负时“已确定距离不会再变小”的不变量。
手算 Dijkstra:例图全程
源点
| 轮次 | 出队(确定) | dist[B] | dist[C] | dist[D] | dist[E] | dist[F] | 本轮松弛记录 |
|---|---|---|---|---|---|---|---|
| 初始 | — | ||||||
| 1 | 初始化三条出边 | ||||||
| 2 | |||||||
| 3 | |||||||
| 4 | |||||||
| 5 | |||||||
| 6 | — | — | — | — | — | 全部确定,算法结束 |
三轮细节各藏一个教学点:
- 估计可以多次降低:
的估计先经 更新为 ,再经 更新为 ;未确定顶点的距离估计会随着新路径被发现而逐步改进。 - 并列不更新:第 4 轮
给出的 与当前值并列,松弛不生效。若写成 ,路径记录会被无意义地覆盖。 - 直达候选后来被绕行路径改进:直达边
( )从第 1 轮起就在表中,但第 5 轮发现 的总权值为 ,于是更新dist[F]。Dijkstra 比较的是从源点到候选顶点的总距离,而不是当前单条边的权值——这正是它与前面“只比较当前边权”这一错误策略的本质区别。
由 prev 链
O(·)复杂度
邻接表 + 二叉堆:每个可达顶点至多被确定一次,确定时扫描其全部出边,因此每条边在整个算法中至多被扫描一次。每次生效的松弛会产生一条新堆记录,最多
论证 Dijkstra 的正确性
THM定理 · Dijkstra 的正确性(非负权前提)
所有边权
PROOF证明
反证法。设
取一条真正的最短路径
在 之前确定且确定无误,故 ; 确定时松弛过 ,因此 ,恰是 上 前缀的长度 ;- 前缀不超过整条路径:
,这一步用到 的 尾段权非负; 是本轮未确定顶点中dist最小者: 。
串起来:
请特别注意证明中使用非负权的位置:只有“路径前缀的权值不超过整条路径”这一步依赖它。若允许负边,这个不等式可能不成立,见下节反例。
负权边为何使 Dijkstra 失效
ANTI反例 · Dijkstra 遇负边不可用
三点图:done,更新被第 17 行的 !done[v] 拦截。最终答案为
对照上节证明:
本幕理论概要
| 结论 | 数学依据 |
|---|---|
| 出队即最终(核心不变量) | 反证法 + "第一个错误顶点"论证;非负权保证路径前缀 |
dist 始终 | 松弛的两条不变量 |
| 已确定顶点的最短路只经过更早确定的顶点 | 最优子结构:最短路径的每个前缀也是最短的 |
| 平列( | 松弛定义取严格小于 |
| 说明负权边为何使算法失效 | 反例 + 证明中使用非负性的关键一步 |
| 堆操作计数;稠密时堆反成负担 |
第三幕 · 全源:Floyd 的动态规划
新问法
平台要把生意做大:预计算任意两个地点之间的最短耗时,生成一张
与其选边,不如选中转点
前两幕的松弛都沿着边推进。Floyd 换了枚举的维度:不问"下一条边选谁",而问"允许谁当中转站"。
DEF定义 · Floyd 的状态
记
THM定理 · Floyd 递推式
PROOF证明
对最优路径是否经过
- 不经过
:中间顶点仍全部落在 ,答案即 ; - 经过
:在不存在相关负环时,总能选取一条不重复顶点的最短路径;若某条最短走法包含权值为零的回路,删去该回路后长度不变。选取这样的简单最短路径并从 处切成 与 两半,两半由子路径最优性可取为各自端点间的最短路,且中间顶点只来自前 个,故长度为 。
两者取小即得。
代码极短:
// d 初始为邻接矩阵(无边处 INF,对角线 0);nxt 记录后继用于还原路径
std::vector<std::vector<int>> dist, next_vertex;
int n;
void floyd() {
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j)
next_vertex[i][j] = (i == j || dist[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 (dist[i][k] < INF && dist[k][j] < INF &&
dist[i][k] + dist[k][j] < dist[i][j]) { // relax:经 k 中转
dist[i][j] = dist[i][k] + dist[k][j];
next_vertex[i][j] = next_vertex[i][k]; // 路径第一跳改为 i 的去向
}
}floyd.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
三个细节:
WARN易错点 · 三个细节
必须在最外层。递推式从 推到 ,把 放进内层等于在同一轮里混用新旧状态,某些需要"先经 中转两段、再拼起来"的路径会算错。 两层可以互换, 不行。- 二维数组为什么够:按理需要三维
。但第 轮里想修改 ,唯一的候选是"经 自己中转",即要求 ,等价于 ——这意味着存在一条过 的负环,无负环的图中不可能发生。 同理。因此第 轮读取第 行和第 列时,值与上一轮一致,可以复用同一个二维数组原地更新,而不必保存完整的三维状态。 nxt[i][j] = nxt[i][k]而非= k:后继矩阵记录的是"下一步去哪"。改经 中转后,第一步走的是 原本通往 的第一步,不是 本身。
手算 Floyd:例图全程
为与代码的下标对应,约定顶点编号
六轮迭代中只有四轮有更新(这也提示:Floyd 对"稀疏无环味的图"做了很多无用功,见终幕选型表):
| 中转点 | 生效的松弛 | 说明 |
|---|---|---|
| 无 | 没有任何边指向 | |
| 发现 | ||
| 经 | ||
| 本轮有五次更新; | ||
| 每行都经 | ||
| 无 |
最终矩阵
交叉验证:加粗的
负权与负环
Floyd 的递推式不要求边权非负:算法按中转点逐步扩展允许使用的路径集合,因此能正常处理负权边。但若相关路径可以经过负环,就不存在有限最短距离:
WARN易错点 · 负环
若从源点能够到达一个总权为负的环,并且从该环还能到达目标点,那么可在途中反复绕环,使这对点之间的路径长度趋于
本幕理论概要
| 结论 | 数学依据 |
|---|---|
| 递推式 | DP:按"是否经 |
| 三维可滚动为二维 | 第 |
| 状态转移要求"先备齐前 | |
| 允许负边、不能有负环 | 反例即机理:负环使 |
| 三重定长循环、二维矩阵 |
算法选型:根据约束选择方法
这四种方法都使用松弛,但状态组织、调度顺序与终止条件不同。选型时先看任务范围和边权条件:
这是一张入门选型表,不覆盖所有规模组合。例如,大型稀疏非负权图的全源问题可以对每个源点运行 Dijkstra;允许负权的大型稀疏图还可考虑 Johnson 算法。
| 维度 | BFS | Dijkstra | Floyd | Bellman-Ford |
|---|---|---|---|---|
| 解决的问题 | 无权单源 | 非负权单源 | 全源 | 可含负权的单源 |
| 思想 | 按层松弛 | 贪心(按距离选顶点) | DP(按中转点) | 全边迭代收敛 |
| 复杂度 | ||||
| 负边 | —(无权) | 不允许 | 允许 | 允许 |
| 负环检测 | — | — | 第 | |
| 典型场景 | 无权关系图的最少边数 | 地图导航、网络路由 | 带折扣或返利的费用图 |
回到骑手:例图同源三问的答案放在同一张表里对照——
| 问题 | 算法 | 答案 |
|---|---|---|
| BFS | ||
| Dijkstra | ||
| 任意两地的耗时表? | Floyd |
完整解题范例
范例一:网络延迟时间
题目解读
有 n 个网络节点和 m 条有向边,边 u v w 表示信号从 u 到 v 需要 w 毫秒,所有权值均为正。源点 s 在时刻 0 发出信号,节点收到后会并行转发。求全部节点都收到信号的最早时刻;若存在不可达节点,输出 -1。
“并行传播”不是把所有边权相加:节点 v 的首次收到时间是
算法分析
边权非负,使用堆优化 Dijkstra。dist[v] 保存源点到 v 的当前最短估计;松弛成功后把新状态压入小根堆。堆中可能同时存在同一顶点的旧记录,因此弹出 (d,u) 时若 d != dist[u] 就跳过。
算法结束后扫描 dist:存在无穷值则输出 -1,否则输出最大距离。时间为
伪代码
dist = 全 INF, dist[s] = 0
把 (0, s) 放入小根堆
while 堆非空:
(d, u) = 弹出最小项
if d != dist[u]: continue
for (v, w) in graph[u]:
if d + w < dist[v]:
dist[v] = d + w
压入 (dist[v], v)
if 存在 dist[v] == INF: 输出 -1
else 输出 max(dist)network-delay.pseudo最终代码
可编译 C++17 实现
#include <algorithm>
#include <functional>
#include <iostream>
#include <limits>
#include <queue>
#include <utility>
#include <vector>
struct Edge {
int to;
long long weight;
};
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n = 0, m = 0, source = 0;
if (!(std::cin >> n >> m >> source)) return 0;
std::vector<std::vector<Edge>> graph(n);
for (int i = 0; i < m; ++i) {
int u = 0, v = 0;
long long w = 0;
std::cin >> u >> v >> w;
graph[u].push_back({v, w});
}
const long long INF = std::numeric_limits<long long>::max() / 4;
std::vector<long long> dist(n, INF);
using Entry = std::pair<long long, int>;
std::priority_queue<Entry, std::vector<Entry>, std::greater<Entry>> ready;
dist[source] = 0;
ready.push({0, source});
while (!ready.empty()) {
auto [d, u] = ready.top();
ready.pop();
if (d != dist[u]) continue;
for (const Edge& edge : graph[u]) {
if (d + edge.weight >= dist[edge.to]) continue;
dist[edge.to] = d + edge.weight;
ready.push({dist[edge.to], edge.to});
}
}
long long answer = 0;
for (long long d : dist) {
if (d == INF) {
std::cout << -1 << '\n';
return 0;
}
answer = std::max(answer, d);
}
std::cout << answer << '\n';
}network-delay-example.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
拓展、思考与变式
- 若需要输出最晚收到信号的节点,可在扫描最大距离时同时维护编号;
- 若所有边权都为
1,可改用 BFS,把复杂度降为 ; - 若允许负权边,应改用 Bellman-Ford,不能继续使用 Dijkstra;
- 对应练习:T18 · 07E18 · 网络延迟时间;可先完成 T17 · 07E17 · 朴素 Dijkstra 与路径还原。
范例二:Floyd 回答多组最短路径查询
题目解读
给定顶点数较少的有向带权图以及多组查询 (s,t)。图允许负权边但不含负环;可能有自环、平行边和不可达点对。每次查询需要输出最短距离以及完整顶点路径,不可达时输出 -1。
多组查询意味着不能每次都从头搜索;n\le300 时可以用 Floyd 预计算所有点对距离。平行边初始化时取最小权,路径还原使用后继矩阵 nxt。
算法分析
初始化 d[i][i]=0,边 u→v 令 d[u][v] 取最小权、nxt[u][v]=v。Floyd 按中转点 k 逐轮松弛;若经 k 使 d[i][j] 变小,就把第一跳改为 nxt[i][k]。
查询时从 s 开始不断令 u=nxt[u][t],直到到达 t。预处理时间
伪代码
d[i][j] = INF, d[i][i] = 0, nxt[i][j] = -1
for 每条边 (u, v, w):
if w < d[u][v]:
d[u][v] = w
nxt[u][v] = v
for k = 0 .. n-1:
for i = 0 .. n-1:
for j = 0 .. n-1:
if i 能到 k 且 k 能到 j 且 d[i][k] + d[k][j] < d[i][j]:
d[i][j] = d[i][k] + d[k][j]
nxt[i][j] = nxt[i][k]
对每个查询 (s, t):
若不可达输出 -1
否则沿 nxt[*][t] 输出路径floyd-queries.pseudo最终代码
可编译 C++17 实现
#include <iostream>
#include <limits>
#include <vector>
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n = 0, m = 0, query_count = 0;
if (!(std::cin >> n >> m >> query_count)) return 0;
const long long INF = std::numeric_limits<long long>::max() / 4;
std::vector<std::vector<long long>> dist(
n, std::vector<long long>(n, INF));
std::vector<std::vector<int>> next(
n, std::vector<int>(n, -1));
for (int i = 0; i < n; ++i) dist[i][i] = 0;
for (int i = 0; i < m; ++i) {
int u = 0, v = 0;
long long w = 0;
std::cin >> u >> v >> w;
if (w < dist[u][v]) {
dist[u][v] = w;
next[u][v] = v;
}
}
for (int k = 0; k < n; ++k) {
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
if (dist[i][k] == INF || dist[k][j] == INF) continue;
long long candidate = dist[i][k] + dist[k][j];
if (candidate < dist[i][j]) {
dist[i][j] = candidate;
next[i][j] = next[i][k];
}
}
}
}
while (query_count-- > 0) {
int source = 0, target = 0;
std::cin >> source >> target;
if (source == target) {
std::cout << "0: " << source << '\n';
continue;
}
if (next[source][target] == -1) {
std::cout << -1 << '\n';
continue;
}
std::cout << dist[source][target] << ':';
int current = source;
while (current != target) {
std::cout << ' ' << current;
current = next[current][target];
}
std::cout << ' ' << target << '\n';
}
}floyd-queries-example.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
拓展、思考与变式
- 若只需要距离,不需要路径,可以删除
next矩阵; - 若查询很少、图稀疏且边权非负,对每个实际出现的源点运行一次 Dijkstra 可能更省;
- 若允许负环,应在 Floyd 后检查
dist[k][k] < 0,并进一步判断哪些查询点对受负环影响; - 对应练习:T20 · 07E20 · Floyd 全源最短路径。
小结
- 松弛是全文的主线:
dist[v] = min(dist[v], dist[u] + w)是 BFS、Dijkstra、Bellman-Ford 以及 Floyd 状态转移中的共同更新形式。算法不仅调度顺序不同,维护的状态、可用前提和终止条件也不同。 - BFS 的正确性来自无权图的分层结构(归纳证明:出队序即距离非降序)。
- Dijkstra 的正确性来自非负权下的"前缀
全长"(反证法:第一个被错确定的顶点导致整条不等式链闭合为等号);它贪心的是顶点总距离,不是单条边。 - Floyd 的正确性来自动态规划:按中转点扩张可选集,递推式由"是否经过
"的分类与子路径最优性保证; 在最外层、二维滚动安全、负环可由对角线检测。 - 选型先看图:权值有无与符号、单源还是全源、稠密还是稀疏——先回答这三个问题,算法基本就选定了。
练习与自测
(答案已折叠)
1. 改权再算 Dijkstra
把例图的
点击展开答案
第 1 轮起,
| 轮次 | 出队(确定) | 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 二维原地更新的条件
证明:不存在负环时,Floyd 第
点击展开答案
第
无负环时:对角线恒有
有负环时:若
参考资料
- 王道《数据结构》考研复习指导:图的应用(最短路径:BFS、Dijkstra、Floyd)
- 严蔚敏《数据结构》(C 语言版):图的最短路径
- 《算法导论》:第 24 章单源最短路径、第 25 章每对顶点间的最短路径(松弛性质与 Dijkstra 证明的严格版本)
代码题练习
按本节下方的 Lab 入口练习。下列入口使用稳定 Lab 编号。