第 5.4 章:最短路径
在节点间寻找最短路径是图论中最基础的问题之一。它出现在 GPS 导航、网络路由、游戏 AI 中,对我们来说最重要的是——USACO 题目。本章涵盖四种算法(Dijkstra、Bellman-Ford、Floyd-Warshall、SPFA),并解释何时使用哪种。
5.4.1 问题定义
单源最短路径(SSSP)
给定加权图 G = (V, E) 和源节点 s,找从 s 到所有其他节点的最短距离。
从源点 A:
dist[A] = 0dist[B] = 1dist[C] = 5dist[D] = 5(A→B→D = 1+4)dist[E] = 8(A→B→D→E = 1+4+3)
全对最短路径(APSP)
找所有节点对之间的最短距离。
为什么不直接用 BFS?
BFS 找无权图的最短路径(每条边 = 距离 1)。有了权重:
- 有些路径有很多短权重的边
- 另一些有少量大权重的边
- BFS 完全忽略权重 → 答案错误
5.4.2 Dijkstra 算法
最重要的最短路径算法。 用于约 90% 涉及加权最短路径的 USACO 题目。
核心思想:贪心 + 优先队列
Dijkstra 是一个贪心算法:
- 维护一个「已确定」节点集合(最短距离已最终确定)
- 每次处理当前距离最小的未访问节点
- 处理节点时,尝试松弛其邻居(如果找到更短路径则更新距离)
为什么贪心有效: 若所有边权非负,当前距离最小的节点不可能通过其他节点得到改善(所有替代路径 ≥ 当前距离)。
逐步追踪
起点: 节点 0 | 初始: dist = [0, ∞, ∞, ∞, ∞]
| 步骤 | 处理节点 | 松弛操作 | dist 数组 | 队列 |
|---|---|---|---|---|
| 1 | 节点 0(dist=0) | 0→1: min(∞, 0+4)=4; 0→2: min(∞, 0+2)=2; 0→3: min(∞, 0+5)=5 | [0, 4, 2, 5, ∞] | {(2,2),(4,1),(5,3)} |
| 2 | 节点 2(dist=2) | 2→3: min(5, 2+1)=3 ← 改善! | [0, 4, 2, 3, ∞] | {(3,3),(4,1),(5,3_旧)} |
| 3 | 节点 3(dist=3) | 3→1: min(4, 3+1)=4(无变化); 3→4: min(∞, 3+3)=6 | [0, 4, 2, 3, 6] | {(4,1),(6,4),(5,3_旧)} |
| 4 | 节点 1(dist=4) | 无可松弛 | [0, 4, 2, 3, 6] | {(6,4)} |
| 5 | 节点 4(dist=6) | 完成! | [0, 4, 2, 3, 6] | {} |
最终: dist = [0, 4, 2, 3, 6]
完整 Dijkstra 实现
📄 查看代码:完整 Dijkstra 实现
// Dijkstra 算法(优先队列)— O((V+E) log V)
#include <bits/stdc++.h>
using namespace std;
typedef pair<int, int> pii; // {距离, 节点}
typedef long long ll;
const ll INF = 1e18; // 使用 long long 避免 int 溢出!
const int MAXN = 100005;
// 邻接表:adj[u] = {权重, v} 列表
vector<pii> adj[MAXN];
vector<ll> dijkstra(int src, int n) {
vector<ll> dist(n + 1, INF); // dist[i] = 到节点 i 的最短距离
dist[src] = 0;
// 最小堆:{距离, 节点}
priority_queue<pii, vector<pii>, greater<pii>> pq;
pq.push({0, src});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop(); // 取距离最小的节点
// 关键:若已找到到 u 的更好路径则跳过(过期条目)
if (d > dist[u]) continue;
// 松弛 u 的所有邻居
for (auto [w, v] : adj[u]) {
ll newDist = dist[u] + w;
if (newDist < dist[v]) {
dist[v] = newDist; // 更新距离
pq.push({newDist, v}); // 将更新后的条目加入队列
}
}
}
return dist;
}
Dijkstra 的关键要点
🚫 关键:Dijkstra 对负边权不起作用! 若存在负边权,Dijkstra 可能产生错误结果。算法正确性依赖于贪心假设:一旦节点从优先队列弹出(已确定),其距离就是最终的——负边破坏这个假设。对含负边权的图,改用 Bellman-Ford 或 SPFA。
- 只对非负权重有效。 负边破坏贪心假设。
- 当边权较大时,距离用
long long。dist[u] + w可能溢出int。 - 用
greater<pii>让priority_queue成为最小堆。 if (d > dist[u]) continue;检查对正确性和性能至关重要。
5.4.3 Bellman-Ford 算法
单源最短路径算法,比 Dijkstra 慢,但支持负权边,还能检测负环。
核心思想
Bellman-Ford 只做一件事,反复做:
遍历所有边,如果经过边 u→v 能让 dist[v] 变小,就更新 dist[v]。 这个操作叫松弛。
if (dist[u] != INF && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w; // 松弛
}
松弛很简单:已知到 u 的距离,尝试"先到 u 再走到 v",如果比当前到 v 更短就更新。
为什么重复 V-1 轮?
一轮松弛只能把已知距离沿边向外传播一层:
- 第 1 轮后:找到所有最多用 1 条边能到的最短路
- 第 2 轮后:找到所有最多用 2 条边能到的最短路
- 第 k 轮后:找到所有最多用 k 条边能到的最短路
没有负环时,最短路径不会重复经过节点,最多使用 V-1 条边。所以 V-1 轮一定够。
为什么不用 Dijkstra?
Dijkstra 一旦确定某个点的距离就不再修改。但负权边意味着"绕远路可能更短",这个假设被打破。
A → B 权重 10,A → C 权重 5,C → B 权重 -3
A 到 B 的最短路是 A→C→B = 2,而不是直接的 A→B = 10。Dijkstra 可能提前锁定 B=10,错过更短路线。
Bellman-Ford 从不提前锁定任何点,每轮都重新检查所有边,因此能正确处理负权。
逐步追踪
边:A→B(4), A→C(2), A→F(15), C→B(1), B→D(5), C→D(8), B→E(7), D→E(-6), D→F(4), E→F(2),源点 A。
| 轮次 | dist[A] | dist[B] | dist[C] | dist[D] | dist[E] | dist[F] | 关键变化 |
|---|---|---|---|---|---|---|---|
| 初始 | 0 | ∞ | ∞ | ∞ | ∞ | ∞ | — |
| 第1轮 | 0 | 4 | 2 | ∞ | ∞ | 15 | 直达 B,C,F |
| 第2轮 | 0 | 3 | 2 | 9 | 11 | 15 | C→B 改进 B |
| 第3轮 | 0 | 3 | 2 | 8 | 2 | 13 | B→D, D→E(-6) 连锁 |
| 第4轮 | 0 | 3 | 2 | 8 | 2 | 4 | E→F 最终改进 |
| 第5轮 | 0 | 3 | 2 | 8 | 2 | 4 | 无变化,收敛 |
最终最短路:A→C→B→D→E→F = 2+1+5+(-6)+2 = 4
要点:
- 直达不代表最短:F 初始为 15,最终变成 4
- 负边放大改进:D 变短后,D→E(-6) 让 E 大幅下降
- V-1 轮是上限:6 个节点最多 5 轮,实际第 4 轮就收敛了
算法流程
Bellman-Ford(src):
1. dist[src] = 0, 其余 = ∞
2. 重复 V-1 轮:遍历所有边,尝试松弛
3. 第 V 次检查:如果还能松弛 → 存在负环
负环检测
负环是总权重为负的环,每绕一圈距离就减小,最短路不存在。
A→B(2), B→C(-5), C→A(1) → 环总权 = -2
检测方法:V-1 轮后所有最短路应已稳定。如果第 V 次遍历还能松弛,说明存在从源点可达的负环。
for (auto& [u, v, w] : edges) {
if (dist[u] != INF && dist[u] + w < dist[v]) {
return {}; // 负环!
}
}
⚠️ 此法只检测从源点可达的负环。要检测全图负环,需用"超级源点"技巧(见 SPFA 章节)。
完整实现
📄 查看代码:Bellman-Ford 实现
// Bellman-Ford 算法 — O(V * E)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef tuple<int, int, int> Edge; // {起点, 终点, 权重}
const ll INF = 1e18;
// 返回最短距离数组,若检测到负环则返回空数组
vector<ll> bellmanFord(int src, int n, vector<Edge>& edges) {
vector<ll> dist(n + 1, INF);
dist[src] = 0;
for (int iter = 0; iter < n - 1; iter++) {
bool updated = false;
for (auto& [u, v, w] : edges) {
if (dist[u] != INF && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
updated = true;
}
}
if (!updated) break; // 提前收敛
}
// 第 V 次检查:检测从源点可达的负环
for (auto& [u, v, w] : edges) {
if (dist[u] != INF && dist[u] + w < dist[v]) {
return {}; // 存在负环
}
}
return dist;
}
| 关键代码 | 作用 |
|---|---|
dist[u] != INF | 只从已可达节点扩展,避免 INF + w 溢出 |
updated 标记 | 本轮无更新则提前终止 |
| 第 V 次遍历 | 还能松弛 = 存在负环 |
什么时候用 Bellman-Ford?
- 没有负边 → Dijkstra 更快
- 有负边但无负环,求单源最短路 → Bellman-Ford 或 SPFA
- 需要检测从源点可达的负环 → Bellman-Ford
- 图很大 → O(VE) 可能 TLE,考虑 SPFA 或其他方法
💡 一句话总结: 每轮遍历所有边做松弛,第 k 轮保证找到最多用 k 条边的最短路,V-1 轮一定收敛;第 V 轮还能松弛则存在负环。
5.4.4 Floyd-Warshall 算法
一次算出所有节点对之间的最短路径。
核心思想:试试每个节点当中转站
想象你要查全国所有城市之间的最短路线。最朴素的想法:对每对城市 (i, j),直接看有没有边相连。但这只考虑了直达路线。
关键洞察:i 到 j 的最短路,要么直达,要么经过某个中转站 k:
最短路 i→j = min( 直达 i→j, 经过中转 i→k→j )
Floyd-Warshall 就把这个想法系统化:依次尝试每个节点 k 当中转站,看能不能让 i→j 更短。
if (dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j]; // 经过 k 更短,更新
}
逐步追踪
4 个节点(0-3),5 条无向边:0-1(3), 0-2(8), 1-2(2), 1-3(5), 2-3(1)
初始距离矩阵(只填直达边,无直达 = ∞):
| dist | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 0 | 0 | 3 | 8 | ∞ |
| 1 | 3 | 0 | 2 | 5 |
| 2 | 8 | 2 | 0 | 1 |
| 3 | ∞ | 5 | 1 | 0 |
k=0:让节点 0 当中转站。检查所有 (i, j),看 i→0→j 是否比 i→j 更短。
| i | j | i→0→j | 当前 dist[i][j] | 结果 |
|---|---|---|---|---|
| 1 | 2 | 3+8=11 | 2 | 11 ≥ 2,不更新 |
| 2 | 1 | 8+3=11 | 2 | 11 ≥ 2,不更新 |
| 其他 | — | — | — | 节点 3 到 0 不可达,跳过 |
k=0 无更新。当前矩阵不变。
k=1:让节点 1 当中转站。
| i | j | i→1→j | 当前 dist[i][j] | 结果 |
|---|---|---|---|---|
| 0 | 2 | 3+2=5 | 8 | 5 < 8,更新! dist[0][2]=5 |
| 0 | 3 | 3+5=8 | ∞ | 8 < ∞,更新! dist[0][3]=8 |
| 2 | 0 | 2+3=5 | 8 | 更新(对称) |
| 3 | 0 | 5+3=8 | ∞ | 更新(对称) |
| 2 | 3 | 2+5=7 | 1 | 7 ≥ 1,不更新 |
k=1 后矩阵:
| dist | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 0 | 0 | 3 | 5 | 8 |
| 1 | 3 | 0 | 2 | 5 |
| 2 | 5 | 2 | 0 | 1 |
| 3 | 8 | 5 | 1 | 0 |
k=2:让节点 2 当中转站。
| i | j | i→2→j | 当前 dist[i][j] | 结果 |
|---|---|---|---|---|
| 0 | 3 | 5+1=6 | 8 | 6 < 8,更新! dist[0][3]=6 |
| 1 | 3 | 2+1=3 | 5 | 3 < 5,更新! dist[1][3]=3 |
| 0 | 1 | 5+2=7 | 3 | 7 ≥ 3,不更新 |
k=2 后矩阵:
| dist | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 0 | 0 | 3 | 5 | 6 |
| 1 | 3 | 0 | 2 | 3 |
| 2 | 5 | 2 | 0 | 1 |
| 3 | 6 | 3 | 1 | 0 |
k=3:让节点 3 当中转站。所有检查都没有更短的路径,矩阵不变。
最终结果:dist[0][3]=6(路径 0→1→2→3),dist[1][3]=3(路径 1→2→3)
为什么 k 必须是最外层循环?
这是 Floyd-Warshall 最常见的实现错误。
理解关键:当处理中间节点 k 时,dist[i][k] 和 dist[k][j] 必须是还没尝试过 k 当中转站时的值(即只用 {0..k-1} 当中转站算出的最短路)。
- k 在最外层 ✅:处理 k 时,i 和 j 的双层循环只读取
dist[i][k]和dist[k][j],这两个值在当前 k 这轮不会被修改(因为 k 行和 k 列的值只取决于 {0..k-1},不受新的更新影响),所以是安全的 - k 在最内层 ❌:同一个 k 轮中,前面刚更新的
dist[i][k]会被后面的 (i', j') 读取,相当于"同一轮内重复使用 k 当中转站",结果不对
一句话记忆:k 在最外层,i 和 j 在内层——顺序不能换!
完整实现
📄 查看代码:Floyd-Warshall 实现
// Floyd-Warshall 全对最短路径 — O(V^3)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = 1e18;
const int MAXV = 505;
ll dist[MAXV][MAXV];
void floydWarshall(int n) {
// 初始化:dist[i][i]=0, 有边设为边权, 无边设为 INF
// ...(读入时已完成)
// ⚠️ 关键:k 必须是最外层循环!
for (int k = 1; k <= n; k++) { // 中转站
for (int i = 1; i <= n; i++) { // 起点
for (int j = 1; j <= n; j++) { // 终点
if (dist[i][k] != INF && dist[k][j] != INF) {
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
}
// 若 dist[i][i] < 0,则节点 i 在负环上
}
| 关键代码 | 作用 |
|---|---|
k 在最外层 | 保证 DP 不变量:处理 k 时只用 {1..k-1} 当中转站的结果 |
dist[i][k] != INF | 防止 INF + w 溢出 |
dist[i][i] < 0 | 负环检测:正常情况自己到自己距离为 0,< 0 说明有负环 |
什么时候用 Floyd-Warshall?
- 需要所有节点对之间最短路径,且 V ≤ 500(V³ ≈ 1.25×10⁸ 勉强可行)
- V > 500 时,对每个节点跑 Dijkstra 更快:O(V × (V+E) log V)
- 需要检测全图负环:跑完后检查
dist[i][i] < 0 - 代码极短:只有 5 行核心逻辑,适合 V 小的题目
💡 一句话总结: 依次让每个节点 k 当中转站,更新所有 (i,j) 对——如果 i→k→j 比当前 i→j 更短就更新。三重循环,k 必须在最外层。
5.4.5 算法对比表
| 算法 | 时间复杂度 | 负边 | 负环检测 | 多源 | 最适合 |
|---|---|---|---|---|---|
| BFS | O(V + E) | ✗ 否 | ✗ 否 | ✓ 是(多源 BFS) | 无权图 |
| Dijkstra | O((V+E) log V) | ✗ 否 | ✗ 否 | ✗(每源运行一次) | 加权非负边图 |
| Bellman-Ford | O(V × E) | ✓ 是 | ✓ 是 | ✗ | 负边、检测负环 |
| SPFA | 最坏 O(V × E),平均 O(E) | ✓ 是 | ✓ 是 | ✗ | 稀疏图含负边 |
| Floyd-Warshall | O(V³) | ✓ 是 | ✓ 是(对角线) | ✓ 是(全对) | 稠密图、全对查询 |
如何选择?
图有负边吗?
├── 有 → Bellman-Ford、SPFA(或全对用 Floyd-Warshall)
└── 没有 → V ≤ 500 且需要全对最短路?
├── 是 → Floyd-Warshall O(V³)
└── 否 → 无权图(所有边 = 1)?
├── 是 → BFS O(V+E)
└── 否 → 边权只有 0 或 1?
├── 是 → 0-1 BFS O(V+E)
└── 否 → Dijkstra O((V+E) log V)
5.4.6 SPFA——带队列优化的 Bellman-Ford
上一节学完了 Bellman-Ford:每轮扫一遍所有边,重复 V-1 轮。你可能会想——大多数边一轮下来根本不会产生松弛,扫它们纯属浪费。SPFA 就是来解决这个问题。
从 Bellman-Ford 到 SPFA:一个直觉
Bellman-Ford 每轮做的事:遍历所有 E 条边,看哪些能松弛。
但想想看:只有 dist[u] 刚刚变小的节点 u,它的出边才有可能松弛邻居 v。如果 dist[u] 这一轮没变,那它的邻居们也不会因此变好。
SPFA 的核心思路: 谁的距离刚更新,就把谁加入队列;从队列中取出节点时,只松弛它的邻居。没更新的节点根本不用管。
左边 Bellman-Ford 每轮扫 6 条边,5 轮共 30 次检查;右边 SPFA 只处理更新节点的邻居,总共约 12 次检查——结果完全相同,效率提升显著。
队列的工作流程
SPFA 的结构与 BFS 几乎一模一样,唯一的区别是:BFS 的节点只进队一次,而 SPFA 的节点可以反复进队(因为距离可能被多次改善)。
关键细节:
| 要点 | 说明 |
|---|---|
| 队列里放什么 | 距离刚被更新的节点 |
| 出队后做什么 | 遍历它的所有出边,尝试松弛 |
inQueue 标记 | 如果节点已在队列中,就不重复入队(但 dist 已经更新了,出队时自然使用新值) |
| 为什么可以反复入队 | 因为负边可能让同一条路多次变短;不同于 Dijkstra "锁定"节点,SPFA 允许反复改进 |
逐步追踪
下面用和 Bellman-Ford 完全相同的图来跑 SPFA,方便你对比两种算法的过程。
| 出队 | 松弛操作 | dist 数组 | 队列 |
|---|---|---|---|
| — | 初始:dist[A]=0,A 入队 | A=0, B=∞, C=∞, D=∞, E=∞, F=∞ | {A} |
| A | A→B(4)✓ A→C(2)✓ A→F(15)✓ | A=0, B=4, C=2, D=∞, E=∞, F=15 | {C, B, F} |
| C | C→B(1)✓ B:4→3; C→D(8)✓ | A=0, B=3, C=2, D=10, E=∞, F=15 | {B, F, D} |
| B | B→D(5)✓ D:10→8; B→E(7)✓ | A=0, B=3, C=2, D=8, E=10, F=15 | {F, D, E} |
| F | F 的邻居无法改善 | 不变 | {D, E} |
| D | D→E(-6)✓ E:10→2; D→F(4)✓ F:15→12 | A=0, B=3, C=2, D=8, E=2, F=12 | {E, F} |
| E | E→F(2)✓ F:12→4 | A=0, B=3, C=2, D=8, E=2, F=4 | {F} |
| F | 无改善 | 不变 | {} ← 空,结束! |
最终结果: A→C→B→D→E→F = 2+1+5+(-6)+2 = 4 ——与 Bellman-Ford 完全一致。
💡 对比 Bellman-Ford:BF 跑了 5 轮 × 10 条边 = 50 次边检查;SPFA 只做了约 18 次。节点越多、图越稀疏,SPFA 的优势越明显。
负环检测
和 Bellman-Ford 类似,SPFA 也能检测负环:
- 原理:没有负环时,一个节点最多进队 V-1 次(对应最短路最多 V-1 条边)
- 判据:如果某节点进队次数 ≥ V,说明存在负环
cnt[v]++;
if (cnt[v] >= n) return {}; // 负环!
⚠️ 上面代码检测的是从 src 出发可达的负环。若要判断全图是否存在负环(包含从 src 不可达的部分),需建立超级源点——见下方。
完整实现
📄 C++ 完整代码
// SPFA(Bellman-Ford + 队列优化)
vector<ll> spfa(int src, int n) {
vector<ll> dist(n + 1, INF);
vector<bool> inQueue(n + 1, false);
vector<int> cnt(n + 1, 0); // cnt[v] = v 进入队列的次数
queue<int> q;
dist[src] = 0;
q.push(src);
inQueue[src] = true;
while (!q.empty()) {
int u = q.front(); q.pop();
inQueue[u] = false;
for (auto [w, v] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
if (!inQueue[v]) {
q.push(v);
inQueue[v] = true;
cnt[v]++;
// 负环检测:若节点进入队列 >= n 次
if (cnt[v] >= n) return {}; // 负环!
}
}
}
}
return dist;
}
| 关键代码 | 作用 |
|---|---|
inQueue 数组 | 避免同一节点重复入队,保持队列精简 |
cnt[v]++ | 统计入队次数,用于负环检测 |
inQueue[u] = false | 出队时清除标记,允许后续再次入队 |
负环检测:判断全图是否存在负环
上面的 SPFA 检测的是从 src 出发可达的负环。若要判断全图是否存在负环(包含从 src 不可达的部分),需建立超级源点:
📄 C++ 完整代码
// 判断全图负环(包含不可达部分)
// 建立超级源点 0,向所有节点连 0 权边,从 0 跑 Bellman-Ford
bool hasNegativeCycle(int n) {
// 原图节点 1..n,超级源点 0
vector<ll> dist(n + 1, 0); // 全部初始化为 0(等价于超级源点到所有节点距离为 0)
vector<bool> inQueue(n + 1, true);
vector<int> cnt(n + 1, 0);
queue<int> q;
// 所有节点入队(超级源点效果)
for (int i = 1; i <= n; i++) q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
inQueue[u] = false;
for (auto [w, v] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
if (!inQueue[v]) {
q.push(v); inQueue[v] = true;
if (++cnt[v] >= n) return true; // 负环!
}
}
}
}
return false;
}
何时使用 SPFA
- 所有边非负 → Dijkstra 更稳定
- 有负边、无负环、单源最短路 → SPFA 是首选(比 Bellman-Ford 快得多)
- 需要检测可达负环 → SPFA(入队次数判据)
- 需要检测全图负环 → 超级源点 + SPFA
⚠️ SPFA 最坏情况: 最坏时间复杂度是 O(V × E)——与朴素 Bellman-Ford 相同。在精心构造的图(竞赛中常见的「反 SPFA」数据)上,SPFA 会退化并 TLE。所以:能用 Dijkstra 就用 Dijkstra;只有存在负边时才考虑 SPFA。
💡 一句话总结: Bellman-Ford 每轮扫所有边,SPFA 只松弛"刚更新节点的邻居"——用队列跳过无用边,结果相同,通常快得多。
5.4.7 Johnson 算法——全源最短路
Floyd-Warshall 是 O(N³),跑 N 次 Bellman-Ford 是 O(N²M)。Johnson 算法通过重新标注边权,将 N 次 Dijkstra 应用于无负权的图,复杂度 O(NM log M),在稀疏图上优于 Floyd。
算法步骤
- 建超级源点 0,向所有节点连 0 权边
- 用 Bellman-Ford 求 0 到所有点的最短路
h[i](若存在负环则无解) - 重新标注边权:
w'(u,v) = w(u,v) + h[u] - h[v](保证非负) - 以每个点为源点跑 N 次 Dijkstra
- 还原答案: 实际最短路 = Dijkstra 结果 -
h[s]+h[t]
📄 5. **还原答案:** 实际最短路 = Dijkstra 结果 - `h[s]` + `h[t]`
// Johnson 全源最短路 — O(NM log M)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = 1e18;
int n, m;
// adj[u] = {v, w},edges 存所有边用于 Bellman-Ford
vector<pair<int,ll>> adj[505];
struct Edge { int u, v; ll w; };
vector<Edge> edges;
vector<ll> bellman_ford(int s) {
vector<ll> dist(n + 1, INF);
dist[s] = 0;
for (int i = 0; i < n; i++) {
for (auto [u, v, w] : edges) {
if (dist[u] != INF && dist[u] + w < dist[v])
dist[v] = dist[u] + w;
}
}
return dist;
}
vector<ll> dijkstra(int s, int n) {
vector<ll> dist(n + 1, INF);
priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<>> pq;
dist[s] = 0; pq.push({0, s});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue;
for (auto [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
return dist;
}
// 返回 dist[i][j] = i 到 j 的真实最短路
// 若 i 到 j 不可达返回 INF
vector<vector<ll>> johnson() {
// 超级源点 n+1 向所有节点连 0 权边
for (int i = 1; i <= n; i++)
edges.push_back({n + 1, i, 0});
// Bellman-Ford 求势函数 h[]
vector<ll> h = bellman_ford(n + 1);
// 重新标注边权(保证非负)
for (auto& e : edges) {
if (e.u != n + 1) // 不处理超级源点的虚拟边
e.w += h[e.u] - h[e.v];
}
// 同步更新邻接表
for (int u = 1; u <= n; u++)
for (auto& [v, w] : adj[u])
w += h[u] - h[v];
// N 次 Dijkstra
vector<vector<ll>> ans(n + 1, vector<ll>(n + 1, INF));
for (int s = 1; s <= n; s++) {
auto d = dijkstra(s, n);
for (int t = 1; t <= n; t++) {
if (d[t] != INF)
ans[s][t] = d[t] - h[s] + h[t]; // 还原真实距离
}
}
return ans;
}
💡 为什么重标后边权非负? 三角不等式保证
h[v] ≤ h[u] + w(u,v),因此w'(u,v) = w(u,v) + h[u] - h[v] ≥ 0。
5.4.8 输出最短路径方案
用 pre[] 数组记录前驱节点,松弛时同步更新:
📄 用 `pre[]` 数组记录前驱节点,松弛时同步更新:
// Dijkstra 带路径输出
vector<int> dijkstra_with_path(int src, int dst, int n) {
vector<ll> dist(n + 1, INF);
vector<int> pre(n + 1, -1); // pre[v] = v 的前驱
priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<>> pq;
dist[src] = 0; pq.push({0, src});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue;
for (auto [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pre[v] = u; // 记录前驱
pq.push({dist[v], v});
}
}
}
// 根据 pre[] 还原路径(逆序)
vector<int> path;
for (int v = dst; v != -1; v = pre[v])
path.push_back(v);
reverse(path.begin(), path.end());
// 检查路径是否从 src 出发
if (path.empty() || path[0] != src) return {}; // 不可达
return path; // src → ... → dst
}
当所有边权为 1(无权图)时,BFS 就是用简单队列的 Dijkstra。
0-1 BFS: 当边权只有 0 或 1 时,用双端队列代替队列的强力技巧:
双端队列:[队首 → 距离最小 ... → 队尾 → 距离最大]
松弛邻居 v(经由权重 w 的边 u→v):
w = 0 → push_front(v) (与 u 距离相同——放在前面)
w = 1 → push_back(v) (多一步——放在后面)
💡 效率: 0-1 BFS 运行 O(V+E)——比 Dijkstra 的 O((V+E) log V) 更快。当边权只有 0 和 1 时,始终优先用 0-1 BFS。
📄 C++ 完整代码
// 0-1 BFS — O(V + E),处理 {0,1} 权重边
vector<int> bfs01(int src, int n) {
vector<int> dist(n + 1, INT_MAX);
deque<int> dq;
dist[src] = 0;
dq.push_front(src);
while (!dq.empty()) {
int u = dq.front(); dq.pop_front();
for (auto [w, v] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
if (w == 0) dq.push_front(v); // 0 权重:加到前面
else dq.push_back(v); // 1 权重:加到后面
}
}
}
return dist;
}
5.4.8 网格上的 Dijkstra
许多 USACO 题目涉及网格最短路径,图是隐式的:
📄 许多 USACO 题目涉及网格最短路径,图是隐式的:
// 网格 Dijkstra — 找从 (0,0) 到 (R-1,C-1) 的最短路径
// 每个格子有进入代价
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef tuple<ll,int,int> tli;
const ll INF = 1e18;
int dx[] = {0,0,1,-1};
int dy[] = {1,-1,0,0};
ll dijkstraGrid(vector<vector<int>>& grid) {
int R = grid.size(), C = grid[0].size();
vector<vector<ll>> dist(R, vector<ll>(C, INF));
priority_queue<tli, vector<tli>, greater<tli>> pq;
dist[0][0] = grid[0][0];
pq.push({grid[0][0], 0, 0});
while (!pq.empty()) {
auto [d, r, c] = pq.top(); pq.pop();
if (d > dist[r][c]) continue;
for (int k = 0; k < 4; k++) {
int nr = r + dx[k], nc = c + dy[k];
if (nr < 0 || nr >= R || nc < 0 || nc >= C) continue;
ll newDist = dist[r][c] + grid[nr][nc];
if (newDist < dist[nr][nc]) {
dist[nr][nc] = newDist;
pq.push({newDist, nr, nc});
}
}
}
return dist[R-1][C-1];
}
5.4.9 USACO 真题训练:先判断边权,再选择最短路工具
最短路题的第一步不是写 Dijkstra,而是判断图的边权结构:
| 边权类型 | 首选算法 | 典型信号 |
|---|---|---|
| 所有边权相同 | BFS | 最少步数、无权道路 |
| 非负不同权重 | Dijkstra | 路径代价、时间、距离不同 |
| 只有 0/1 权重 | 0-1 BFS | 免费边/付费边、翻转代价 0 或 1 |
| 有负边 | Bellman-Ford/SPFA | 返利、负代价、差分约束 |
| 多源最近距离 | 多源 BFS / Dijkstra | 最近出口、最近危险点 |
真题 1:Piggy Back(USACO 2014 December Silver)— 三次 BFS 拼出最优会合点
题目链接: USACO 2014 December Silver P1: Piggy Back
对应模式: 无权图最短路 + 枚举会合点
难度定位: Silver 标准
题干解读
Bessie 从节点 1 出发,Elsie 从节点 2 出发,目标都是到达节点 N。她们可以单独走,也可以在某个节点会合后一起走。单独走和一起走的单位边代价不同,要求最小总代价。
关键条件:
- 图是无权图,边表示一步道路。
- 会合点未知,但可以枚举。
- 需要知道三个来源到每个点的最短步数:1、2、N。
思路分析
分别从 1、2、N 做 BFS:
d1[x] = Bessie 从 1 到 x 的最少边数
d2[x] = Elsie 从 2 到 x 的最少边数
dn[x] = 从 x 到 N 的最少边数
若在点 x 会合,总代价为:
B * d1[x] + E * d2[x] + P * dn[x]
枚举所有 x 取最小值即可。
CPP 完整代码
✅ 完整代码:Piggy Back
#include <bits/stdc++.h>
using namespace std;
const int INF = 1e9;
vector<int> bfs(int start, const vector<vector<int>>& adj) {
int n = (int)adj.size() - 1;
vector<int> dist(n + 1, INF);
queue<int> q;
dist[start] = 0;
q.push(start);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : adj[u]) {
if (dist[v] == INF) {
dist[v] = dist[u] + 1;
q.push(v);
}
}
}
return dist;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
// USACO 官方文件 I/O 可按需打开:
// freopen("piggyback.in", "r", stdin);
// freopen("piggyback.out", "w", stdout);
long long bCost, eCost, togetherCost;
int n, m;
cin >> bCost >> eCost >> togetherCost >> n >> m;
vector<vector<int>> adj(n + 1);
for (int i = 0; i < m; i++) {
int a, b;
cin >> a >> b;
adj[a].push_back(b);
adj[b].push_back(a);
}
vector<int> d1 = bfs(1, adj);
vector<int> d2 = bfs(2, adj);
vector<int> dn = bfs(n, adj);
long long answer = LLONG_MAX;
for (int meet = 1; meet <= n; meet++) {
if (d1[meet] == INF || d2[meet] == INF || dn[meet] == INF) continue;
long long cost = bCost * d1[meet] + eCost * d2[meet] + togetherCost * dn[meet];
answer = min(answer, cost);
}
cout << answer << "\n";
return 0;
}
复杂度: 三次 BFS,总时间 O(N+M),空间 O(N+M)。
易错点提醒
- 用 Dijkstra 过度复杂化。 本题所有边长度相同,BFS 更简单更快。
- 只枚举两人路径上的交点。 最优会合点可能不是你直观看到的某条路径节点,直接枚举所有点最稳。
- 忘记不可达判断。 若某个距离是 INF,不能参与计算。
- 总代价用 int。 边数和费用相乘,建议用
long long。
拓展思考
这道题体现一个重要技巧:多次最短路 + 枚举中转点。在加权图中,同样思路可以改为从多个源跑 Dijkstra。
真题 2:Shortcut(USACO 2019 January Gold)— Dijkstra 后构造最短路树
题目链接: USACO 2019 January Gold P3: Shortcut
对应模式: Dijkstra + 最短路树 + 子树贡献
难度定位: Gold
题干解读
农场是带权无向图,所有奶牛每天都沿着到 1 号谷仓的最短路回家。你可以在某个节点修一条到谷仓的捷径,耗时固定为 T,问最多能减少多少总通勤时间。
关键条件:
- 需要先知道每个节点到 1 的最短距离。
- 若有多条最短路,题目要求按编号较小父节点确定路径树。
- 某个节点修捷径影响的是其最短路树子树中的所有奶牛。
思路分析
步骤:
- 从节点 1 跑 Dijkstra,得到
dist[x]。 - 同时记录最短路树父节点
parent[x]:若距离更短,更新;若距离相同,选编号更小的父节点。 - 按
parent建树。 - DFS 统计每个节点子树内奶牛数量
subtreeCows[x]。 - 若在 x 修捷径,节省为:
(dist[x] - T) * subtreeCows[x]
取最大值。
CPP 完整代码
✅ 完整代码:Shortcut
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll INF = (ll)4e18;
struct Edge {
int to;
int w;
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
// USACO 官方文件 I/O 可按需打开:
// freopen("shortcut.in", "r", stdin);
// freopen("shortcut.out", "w", stdout);
int n, m;
ll t;
cin >> n >> m >> t;
vector<ll> cows(n + 1);
for (int i = 1; i <= n; i++) cin >> cows[i];
vector<vector<Edge>> adj(n + 1);
for (int i = 0; i < m; i++) {
int a, b, w;
cin >> a >> b >> w;
adj[a].push_back({b, w});
adj[b].push_back({a, w});
}
vector<ll> dist(n + 1, INF);
vector<int> parent(n + 1, 0);
priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<pair<ll, int>>> pq;
dist[1] = 0;
parent[1] = 0;
pq.push({0, 1});
while (!pq.empty()) {
auto [d, u] = pq.top();
pq.pop();
if (d != dist[u]) continue;
for (const Edge &e : adj[u]) {
int v = e.to;
ll nd = d + e.w;
if (nd < dist[v] || (nd == dist[v] && u < parent[v])) {
dist[v] = nd;
parent[v] = u;
pq.push({nd, v});
}
}
}
vector<vector<int>> tree(n + 1);
for (int v = 2; v <= n; v++) {
tree[parent[v]].push_back(v);
}
vector<ll> subtreeCows = cows;
vector<int> order;
order.reserve(n);
stack<int> st;
st.push(1);
while (!st.empty()) {
int u = st.top();
st.pop();
order.push_back(u);
for (int v : tree[u]) st.push(v);
}
for (int i = (int)order.size() - 1; i >= 0; i--) {
int u = order[i];
for (int v : tree[u]) {
subtreeCows[u] += subtreeCows[v];
}
}
ll answer = 0;
for (int u = 1; u <= n; u++) {
answer = max(answer, (dist[u] - t) * subtreeCows[u]);
}
cout << answer << "\n";
return 0;
}
复杂度: Dijkstra O((N+M) log N),建树和统计 O(N),空间 O(N+M)。
易错点提醒
- 忽略字典序父节点。 距离相同时必须选编号更小的父节点,否则最短路树错误。
- 只看单个节点奶牛数。 捷径影响整棵子树中的奶牛,不只是当前节点。
- 递归 DFS 栈风险。
N可较大,用迭代顺序统计更稳。 - 节省值可能为负。 取
max时从 0 开始即可,不修捷径相当于节省 0。
拓展思考
Shortcut 是 Gold 中典型的「最短路结果不是终点,而是后续结构的输入」。跑完 Dijkstra 后,还要把最短路关系转成树,再在树上做统计。
⚠️ 五大经典 Dijkstra Bug
- 用
int而不是long long—— 距离和溢出 → 静默的错误答案 - 最大堆而非最小堆 —— 忘记
greater<pii>→ 优先处理错误的节点 - 缺少过期条目检查(
if (d > dist[u]) continue)—— 不是错误但慢约 10 倍 - 忘记
dist[src] = 0—— 所有距离保持为 INF - 对负边用 Dijkstra —— 未定义行为,可能无限循环或给出错误答案
本章总结
📌 核心要点
| 算法 | 复杂度 | 处理负边 | 使用场景 |
|---|---|---|---|
| BFS | O(V+E) | ✗ | 无权图 |
| Dijkstra | O((V+E) log V) | ✗ | 非负权重加权 SSSP |
| Bellman-Ford | O(VE) | ✓ | 负边、检测负环 |
| SPFA | 最坏 O(VE),平均快 | ✓ | 稀疏图含负边 |
| Floyd-Warshall | O(V³) | ✓ | 全对、V ≤ 500 |
| 0-1 BFS | O(V+E) | 不适用 | 只有 0 或 1 权重的边 |
❓ 常见问题
Q1:为什么 Dijkstra 对负边不起作用?
A:Dijkstra 的贪心假设是「当前距离最短的节点不能通过后续路径改善」。有了负边,这个假设失败——通过负边的较长路径最终可能更短。结论:有负边必须用 Bellman-Ford(O(VE))或 SPFA(平均 O(E),最坏 O(VE))。
Q2:SPFA 和 Bellman-Ford 有什么区别?
A:SPFA 是队列优化版的 Bellman-Ford。Bellman-Ford 每轮遍历所有边;SPFA 只更新距离被改善的节点的邻居,用队列追踪哪些节点需要处理。实践中 SPFA 快得多(平均 O(E)),但理论最坏情况相同(O(VE))。
Q3:Floyd-Warshall 中为什么 k 循环必须在最外层?
A:这是最常见的 Floyd-Warshall 实现错误! DP 不变量是:处理第 k 次外层循环后,
dist[i][j]表示只用 {1, 2, ..., k} 作中间节点时从 i 到 j 的最短路径。处理中间节点 k 时,dist[i][k]和dist[k][j]必须已经基于 {1..k-1} 完整计算好。若 k 在内层,dist[i][k]可能在同一轮刚被更新,导致错误结果。记住:k 在最外层,i 和 j 在内层——顺序很重要!
Q4:USACO 题目如何判断用 Dijkstra 还是 BFS?
A:关键问题:边是否有权重?
- 无权图(边权=1 或求最少边数)→ BFS,O(V+E),更快更简单
- 加权图(不同的非负权重)→ Dijkstra
- 边权只有 0 或 1 → 0-1 BFS(比 Dijkstra 快,O(V+E))
- 有负边 → Bellman-Ford/SPFA
Q5:什么时候用 Floyd-Warshall?
A:需要所有节点对之间的最短距离,且 V ≤ 500(
O(V³)≈ 1.25×10^8 在 V=500 时勉强可行)。典型场景:给定多个源和目标,查询任意对之间的距离。V > 500 时,对每个节点运行 Dijkstra(O(V × (V+E) log V))更快。
🔗 与其他章节的联系
- 第 5.2 章(BFS 与 DFS):BFS 是「无权图的 Dijkstra」;本章是 BFS 的直接扩展
- 第 5.5 章(二叉树与树算法):树上的最短路径就是唯一的根到节点路径(DFS/BFS 够用)
- 第 6.1 章(DP 入门):Floyd-Warshall 本质上是 DP(状态 = 「使用前 k 个节点」);很多最短路变体可以用 DP 建模
- USACO Gold:最短路 + DP 的组合、最短路 + 二分搜索、最短路 + 数据结构优化
练习题
题目 5.4.1 — 经典 Dijkstra 🟢 简单
题目: 给定 N 座城市和 M 条双向道路,各有行驶时间。找从城市 1 到城市 N 的最短行驶时间,若不可达输出 −1。
样例输入 1:
5 6
1 2 2
1 3 4
2 3 1
2 4 7
3 5 3
4 5 1
样例输出 1: 6(最短路:1→2→3→5,代价 2+1+3=6)
样例输入 2: 3 个城市,节点 3 不可达 → 输出: -1
💡 提示
从节点 1 做标准 Dijkstra。距离用 long long——最大路径 = N × 最大权重 = 10^5 × 10^9 = 10^14,int 会溢出。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<ll,int> pli;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
vector<vector<pair<int,int>>> adj(n + 1);
for (int i = 0; i < m; i++) {
int u, v, w; cin >> u >> v >> w;
adj[u].push_back({v, w});
adj[v].push_back({u, w});
}
vector<ll> dist(n + 1, LLONG_MAX);
priority_queue<pli, vector<pli>, greater<pli>> pq;
dist[1] = 0;
pq.push({0, 1});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue;
for (auto [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
cout << (dist[n] == LLONG_MAX ? -1 : dist[n]) << "\n";
return 0;
}
// 时间:O((N + M) log N),空间:O(N + M)
题目 5.4.2 — 网格 BFS 🟢 简单
题目: 机器人从 R×C 网格的格子 (0,0) 出发,部分格子是墙(#),其余可通行(.)。找到达 (R-1, C-1) 的最少步数,不可能时输出 −1。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int R, C;
cin >> R >> C;
vector<string> grid(R);
for (auto& row : grid) cin >> row;
vector<vector<int>> dist(R, vector<int>(C, -1));
queue<pair<int,int>> q;
if (grid[0][0] != '#') {
dist[0][0] = 0;
q.push({0, 0});
}
int dr[] = {-1, 1, 0, 0};
int dc[] = {0, 0, -1, 1};
while (!q.empty()) {
auto [r, c] = q.front(); q.pop();
for (int d = 0; d < 4; d++) {
int nr = r + dr[d], nc = c + dc[d];
if (nr >= 0 && nr < R && nc >= 0 && nc < C
&& grid[nr][nc] != '#' && dist[nr][nc] == -1) {
dist[nr][nc] = dist[r][c] + 1;
q.push({nr, nc});
}
}
}
cout << dist[R-1][C-1] << "\n";
return 0;
}
题目 5.4.3 — 负边检测 🟡 中等
题目: 给定有 N 个节点、M 条边(可能有负权重)的有向图,找从节点 1 到节点 N 的最短距离。若存在可从节点 1 到达且能到达节点 N 的负环,输出 "NEGATIVE CYCLE"。若节点 N 不可达,输出 "UNREACHABLE"。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
vector<tuple<int,int,ll>> edges(m);
for (auto& [u, v, w] : edges) cin >> u >> v >> w;
const ll INF = 1e18;
vector<ll> dist(n + 1, INF);
dist[1] = 0;
// Bellman-Ford:V-1 遍
for (int iter = 0; iter < n - 1; iter++) {
for (auto [u, v, w] : edges) {
if (dist[u] != INF && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
}
}
}
// 第 V 遍:检测负环
vector<bool> inNegCycle(n + 1, false);
for (int iter = 0; iter < n; iter++) {
for (auto [u, v, w] : edges) {
if (dist[u] != INF && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
inNegCycle[v] = true;
}
if (inNegCycle[u]) inNegCycle[v] = true;
}
}
if (dist[n] == INF) cout << "UNREACHABLE\n";
else if (inNegCycle[n]) cout << "NEGATIVE CYCLE\n";
else cout << dist[n] << "\n";
return 0;
}
题目 5.4.4 — 多源 BFS:僵尸爆发 🟡 中等
题目: K 座已感染城市同时爆发僵尸。每个时间单位,僵尸扩散到所有相邻(未感染)城市。找僵尸到达每座可达城市的最少时间,永远无法到达的城市输出 −1。
💡 提示
多源 BFS:将所有 K 座感染城市以时间 0 加入队列。然后正常运行 BFS。这等价于添加一个虚拟「超级源点」通过 0 代价边连接到所有 K 座城市。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m, k;
cin >> n >> m >> k;
vector<vector<int>> adj(n + 1);
for (int i = 0; i < m; i++) {
int u, v; cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
vector<int> dist(n + 1, -1);
queue<int> q;
// 将所有 K 个僵尸源以时间 0 压入
for (int i = 0; i < k; i++) {
int z; cin >> z;
dist[z] = 0;
q.push(z);
}
// 从所有源同时做标准 BFS
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
q.push(v);
}
}
}
for (int u = 1; u <= n; u++) {
cout << dist[u];
if (u < n) cout << " ";
}
cout << "\n";
return 0;
}
题目 5.4.5 — Floyd 全对最短路 🟡 中等
题目: 给定 N 座城市(N ≤ 300)和 M 条道路,回答 Q 次查询:「城市 u 到城市 v 的距离在 D 以内吗?」
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
const ll INF = 1e18;
vector<vector<ll>> dist(n + 1, vector<ll>(n + 1, INF));
for (int i = 1; i <= n; i++) dist[i][i] = 0;
for (int i = 0; i < m; i++) {
int u, v; ll w;
cin >> u >> v >> w;
dist[u][v] = min(dist[u][v], w);
dist[v][u] = min(dist[v][u], w);
}
// Floyd-Warshall:O(N³)
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (dist[i][k] != INF && dist[k][j] != INF)
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
int q;
cin >> q;
while (q--) {
int u, v; ll D;
cin >> u >> v >> D;
cout << (dist[u][v] <= D ? "YES" : "NO") << "\n";
}
return 0;
}
// 时间:O(N³ + Q),空间:O(N²)
题目 5.4.6 — 最大瓶颈路径 🔴 困难
题目: 给定 N 座城市和 M 条道路,每条道路有重量限制(能通过的最大货物重量)。找从城市 1 到城市 N 最大化路径最小边权的路径——即一趟能运送的最重货物。
💡 提示
修改版 Dijkstra: 不是最小化总代价,而是最大化瓶颈。dist[v] = 到 v 的任意路径的最大最小边权。用最大堆。松弛:dist[v] = max(dist[v], min(dist[u], weight(u,v)))。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef pair<int,int> pii;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
vector<vector<pii>> adj(n + 1);
for (int i = 0; i < m; i++) {
int u, v, w; cin >> u >> v >> w;
adj[u].push_back({v, w});
adj[v].push_back({u, w});
}
// 修改版 Dijkstra:最大化瓶颈
vector<int> dist(n + 1, 0);
priority_queue<pii> pq; // 最大堆:{瓶颈, 节点}
dist[1] = INT_MAX;
pq.push({INT_MAX, 1});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d < dist[u]) continue;
for (auto [v, w] : adj[u]) {
int newBottleneck = min(dist[u], w); // ← 关键:取路径上的最小值
if (newBottleneck > dist[v]) {
dist[v] = newBottleneck;
pq.push({dist[v], v});
}
}
}
cout << dist[n] << "\n";
return 0;
}
// 时间:O((N + M) log N),空间:O(N + M)
第 5.4 章结束 — 下一章:第 6.1 章:动态规划入门