📖 第 5.4 章 ⏱️ 约 80 分钟 🎯 进阶

第 5.4 章:最短路径

在节点间寻找最短路径是图论中最基础的问题之一。它出现在 GPS 导航、网络路由、游戏 AI 中,对我们来说最重要的是——USACO 题目。本章涵盖四种算法(Dijkstra、Bellman-Ford、Floyd-Warshall、SPFA),并解释何时使用哪种。


5.4.1 问题定义

单源最短路径(SSSP)

给定加权图 G = (V, E) 和源节点 s,找从 s所有其他节点的最短距离。

SSSP Example Graph

从源点 A:

  • dist[A] = 0
  • dist[B] = 1
  • dist[C] = 5
  • dist[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 题目。

时间
O((V+E) log V)
空间
O(V + E)
限制
非负权重
类型
单源

核心思想:贪心 + 优先队列

Dijkstra 是一个贪心算法

  1. 维护一个「已确定」节点集合(最短距离已最终确定)
  2. 每次处理当前距离最小的未访问节点
  3. 处理节点时,尝试松弛其邻居(如果找到更短路径则更新距离)

为什么贪心有效: 若所有边权非负,当前距离最小的节点不可能通过其他节点得到改善(所有替代路径 ≥ 当前距离)。

逐步追踪

Dijkstra Trace Graph

起点: 节点 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-FordSPFA

Dijkstra 负边权失败原因

  • 只对非负权重有效。 负边破坏贪心假设。
  • 当边权较大时,距离用 long longdist[u] + w 可能溢出 int
  • greater<pii>priority_queue 成为最小堆。
  • if (d > dist[u]) continue; 检查对正确性和性能至关重要。

5.4.3 Bellman-Ford 算法

单源最短路径算法,比 Dijkstra 慢,但支持负权边,还能检测负环

时间
O(V × E)
负边
✓ 支持
负环
✓ 可检测

核心思想

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 轮一定够

为什么需要 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 从不提前锁定任何点,每轮都重新检查所有边,因此能正确处理负权。

逐步追踪

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轮04215直达 B,C,F
第2轮03291115C→B 改进 B
第3轮0328213B→D, D→E(-6) 连锁
第4轮032824E→F 最终改进
第5轮032824无变化,收敛

最终最短路: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 算法

一次算出所有节点对之间的最短路径。

时间
O(V³)
空间
O(V²)
负边
✓ 支持
类型
全对

核心思想:试试每个节点当中转站

想象你要查全国所有城市之间的最短路线。最朴素的想法:对每对城市 (i, j),直接看有没有边相连。但这只考虑了直达路线。

关键洞察:i 到 j 的最短路,要么直达,要么经过某个中转站 k:

最短路 i→j = min( 直达 i→j,  经过中转 i→k→j )

Floyd-Warshall 就把这个想法系统化:依次尝试每个节点 k 当中转站,看能不能让 i→j 更短。

Floyd-Warshall DP 状态转移

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)

Floyd-Warshall 示例图

初始距离矩阵(只填直达边,无直达 = ∞):

dist0123
0038
13025
28201
3510

k=0:让节点 0 当中转站。检查所有 (i, j),看 i→0→j 是否比 i→j 更短。

iji→0→j当前 dist[i][j]结果
123+8=11211 ≥ 2,不更新
218+3=11211 ≥ 2,不更新
其他节点 3 到 0 不可达,跳过

k=0 无更新。当前矩阵不变。

k=1:让节点 1 当中转站。

iji→1→j当前 dist[i][j]结果
023+2=585 < 8,更新! dist[0][2]=5
033+5=88 < ∞,更新! dist[0][3]=8
202+3=58更新(对称)
305+3=8更新(对称)
232+5=717 ≥ 1,不更新

k=1 后矩阵:

dist0123
00358
13025
25201
38510

k=2:让节点 2 当中转站。

iji→2→j当前 dist[i][j]结果
035+1=686 < 8,更新! dist[0][3]=6
132+1=353 < 5,更新! dist[1][3]=3
015+2=737 ≥ 3,不更新

k=2 后矩阵:

dist0123
00356
13023
25201
36310

k=3:让节点 3 当中转站。所有检查都没有更短的路径,矩阵不变。

最终结果:dist[0][3]=6(路径 0→1→2→3),dist[1][3]=3(路径 1→2→3)

Floyd-Warshall 矩阵变化过程

为什么 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 算法对比表

算法时间复杂度负边负环检测多源最适合
BFSO(V + E)✗ 否✗ 否✓ 是(多源 BFS)无权图
DijkstraO((V+E) log V)✗ 否✗ 否✗(每源运行一次)加权非负边图
Bellman-FordO(V × E)✓ 是✓ 是负边、检测负环
SPFA最坏 O(V × E),平均 O(E)✓ 是✓ 是稀疏图含负边
Floyd-WarshallO(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 的核心思路: 谁的距离刚更新,就把谁加入队列;从队列中取出节点时,只松弛它的邻居。没更新的节点根本不用管。

SPFA vs Bellman-Ford

左边 Bellman-Ford 每轮扫 6 条边,5 轮共 30 次检查;右边 SPFA 只处理更新节点的邻居,总共约 12 次检查——结果完全相同,效率提升显著。

队列的工作流程

SPFA 的结构与 BFS 几乎一模一样,唯一的区别是:BFS 的节点只进队一次,而 SPFA 的节点可以反复进队(因为距离可能被多次改善)。

SPFA Queue Flow

关键细节:

要点说明
队列里放什么距离刚被更新的节点
出队后做什么遍历它的所有出边,尝试松弛
inQueue 标记如果节点已在队列中,就不重复入队(但 dist 已经更新了,出队时自然使用新值)
为什么可以反复入队因为负边可能让同一条路多次变短;不同于 Dijkstra "锁定"节点,SPFA 允许反复改进

逐步追踪

下面用和 Bellman-Ford 完全相同的图来跑 SPFA,方便你对比两种算法的过程。

SPFA Step Trace

出队松弛操作dist 数组队列
初始:dist[A]=0,A 入队A=0, B=∞, C=∞, D=∞, E=∞, F=∞{A}
AA→B(4)✓ A→C(2)✓ A→F(15)✓A=0, B=4, C=2, D=∞, E=∞, F=15{C, B, F}
CC→B(1)✓ B:4→3; C→D(8)✓A=0, B=3, C=2, D=10, E=∞, F=15{B, F, D}
BB→D(5)✓ D:10→8; B→E(7)✓A=0, B=3, C=2, D=8, E=10, F=15{F, D, E}
FF 的邻居无法改善不变{D, E}
DD→E(-6)✓ E:10→2; D→F(4)✓ F:15→12A=0, B=3, C=2, D=8, E=2, F=12{E, F}
EE→F(2)✓ F:12→4A=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。

算法步骤

  1. 建超级源点 0,向所有节点连 0 权边
  2. 用 Bellman-Ford 求 0 到所有点的最短路 h[i](若存在负环则无解)
  3. 重新标注边权: w'(u,v) = w(u,v) + h[u] - h[v](保证非负)
  4. 以每个点为源点跑 N 次 Dijkstra
  5. 还原答案: 实际最短路 = 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 时,用双端队列代替队列的强力技巧:

0-1 BFS 双端队列工作原理

双端队列:[队首 → 距离最小 ... → 队尾 → 距离最大]

松弛邻居 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)

易错点提醒

  1. 用 Dijkstra 过度复杂化。 本题所有边长度相同,BFS 更简单更快。
  2. 只枚举两人路径上的交点。 最优会合点可能不是你直观看到的某条路径节点,直接枚举所有点最稳。
  3. 忘记不可达判断。 若某个距离是 INF,不能参与计算。
  4. 总代价用 int。 边数和费用相乘,建议用 long long

拓展思考

这道题体现一个重要技巧:多次最短路 + 枚举中转点。在加权图中,同样思路可以改为从多个源跑 Dijkstra。


真题 2:Shortcut(USACO 2019 January Gold)— Dijkstra 后构造最短路树

题目链接: USACO 2019 January Gold P3: Shortcut
对应模式: Dijkstra + 最短路树 + 子树贡献
难度定位: Gold

题干解读

农场是带权无向图,所有奶牛每天都沿着到 1 号谷仓的最短路回家。你可以在某个节点修一条到谷仓的捷径,耗时固定为 T,问最多能减少多少总通勤时间。

关键条件:

  • 需要先知道每个节点到 1 的最短距离。
  • 若有多条最短路,题目要求按编号较小父节点确定路径树。
  • 某个节点修捷径影响的是其最短路树子树中的所有奶牛。

思路分析

步骤:

  1. 从节点 1 跑 Dijkstra,得到 dist[x]
  2. 同时记录最短路树父节点 parent[x]:若距离更短,更新;若距离相同,选编号更小的父节点。
  3. parent 建树。
  4. DFS 统计每个节点子树内奶牛数量 subtreeCows[x]
  5. 若在 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)

易错点提醒

  1. 忽略字典序父节点。 距离相同时必须选编号更小的父节点,否则最短路树错误。
  2. 只看单个节点奶牛数。 捷径影响整棵子树中的奶牛,不只是当前节点。
  3. 递归 DFS 栈风险。 N 可较大,用迭代顺序统计更稳。
  4. 节省值可能为负。max 时从 0 开始即可,不修捷径相当于节省 0。

拓展思考

Shortcut 是 Gold 中典型的「最短路结果不是终点,而是后续结构的输入」。跑完 Dijkstra 后,还要把最短路关系转成树,再在树上做统计。


⚠️ 五大经典 Dijkstra Bug

  1. int 而不是 long long —— 距离和溢出 → 静默的错误答案
  2. 最大堆而非最小堆 —— 忘记 greater<pii> → 优先处理错误的节点
  3. 缺少过期条目检查if (d > dist[u]) continue)—— 不是错误但慢约 10 倍
  4. 忘记 dist[src] = 0 —— 所有距离保持为 INF
  5. 对负边用 Dijkstra —— 未定义行为,可能无限循环或给出错误答案

本章总结

📌 核心要点

算法复杂度处理负边使用场景
BFSO(V+E)无权图
DijkstraO((V+E) log V)非负权重加权 SSSP
Bellman-FordO(VE)负边、检测负环
SPFA最坏 O(VE),平均快稀疏图含负边
Floyd-WarshallO(V³)全对、V ≤ 500
0-1 BFSO(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 章:动态规划入门