03提高篇-09最短路
【图论2-2】最短路
16 道题 | 难度 3-6 | 约 160 分钟 | S 组提高
上节课回顾
📝 【图论2-1】树
| 知识点 | 核心思想 |
|---|---|
| 树的定义与存储 | \(n\) 个点 \(n-1\) 条边无环连通图,邻接表无向存储 |
| LCA(最近公共祖先) | 二叉倍增法:预处理 \(O(n\log n)\),查询 \(O(\log n)\) |
| 树的直径 | 两次 DFS/BFS——从任意点找最远点 \(u\),再从 \(u\) 找最远点 \(v\),\(u\!-\!v\) 即直径 |
| 树的重心 | 删除后剩余最大连通块 \(\le n/2\),一次 DFS 找 \(O(n)\) |
| 树形 DP | 后序遍历自底向上,状态从子节点向父节点转移 |
| 树链剖分 | 重链将树上路径操作转化为线段树区间操作 \(O(\log^2 n)\) |
树与最短路有着紧密的联系——树上的最短路就是 LCA 路径(两点之间唯一路径)。而 Dijkstra 算法在最短路问题中的地位,就像树链剖分在树上问题中的地位一样——都是解决路径问题的核心工具。从树上的唯一路径到图中的最短路,是图论学习的一次重要跃迁。
课程导入
最短路问题的核心是"如何高效地找到权值和最小的路径"。朴素做法(DFS 枚举所有路径)是指数级的,而通过松弛操作(Relaxation)反复逼近最优解,可以在多项式时间内完成。
最短路算法的选择:
| 场景 | 算法 | 复杂度 |
|---|---|---|
| 单源、非负权 | Dijkstra(堆优化) | \(O((n+m)\log n)\) |
| 单源、可有负权 | SPFA(队列优化 Bellman-Ford) | \(O(km)\) 平均 |
| 多源、小规模 | Floyd | \(O(n^3)\) |
| 判负环 | SPFA 入队次数统计 | \(O(nm)\) |
| 差分约束 | 最短路 / 最长路模型转化 | \(O(nm)\) |
本课学习路线: 单源最短路 → 分层图 → 反向建边 → 负环与 SPFA → 差分约束 → 次短路 → Floyd 多源最短路 → 传递闭包
本课知识地图
每个知识点均依托例题展开,核心概念在首次出现时先给出定义,再结合例题深入演示。
| 知识模块 | 核心概念 | 对应例题 |
|---|---|---|
| 单源最短路径 | Dijkstra · SPFA · 链式前向星 | 例1 P4779 |
| 分层图 | 多层次建图 · 免费飞行 | 例2 P4568 |
| 反向建边 | 正向反向各跑一次最短路 | 例3 P1629 |
| 负环与差分约束 | SPFA 判负环 · 不等式转边 | 例4 P3385 → 例5 P5960 |
| 次短路 | 同时维护最短路和次短路 | 例6 P2865 |
| 多源最短路与传递闭包 | Floyd · 可达性传递 | 例7 P2910 → 例8 B3611 |
例题精讲
例题 1:P4779 【模板】单源最短路径(标准版)
知识点: 单源最短路径 — Dijkstra 堆优化 + 链式前向星
题意
给定 \(n\) 个点 \(m\) 条边的有向图(所有边权非负),求从起点 \(s\) 到每个点的最短距离。
样例解释
| 输入 | 输出 | 解释 |
|---|---|---|
n=4, m=6, s=1 |
0 2 4 3 |
\(1\to2 = 2\),\(1\to4\to3 = 4\),\(1\to4 = 3\) |
边:1→2(2) 2→3(2) 2→4(1) 1→3(5) 3→4(3) 1→4(4) |
原图样例
核心概念:Dijkstra 算法
Dijkstra 算法(堆优化版)的核心是贪心 + 松弛:
- 用
d[]记录从起点到每个点的当前最短距离,初始d[s] = 0,其余为 \(\infty\)- 用小根堆维护当前"距离最小的未确定点" \(u\)
- 对 \(u\) 的所有出边 \((u, v, w)\) 执行松弛:若 \(d[u] + w < d[v]\),则更新 \(d[v]\) 并入堆
- 重复直到堆为空
正确性保证:因为边权非负,堆顶取出的点 \(u\) 的 \(d[u]\) 就是最终最短距离——不可能通过其他路径得到更小的值。
🎯 算法设计引导
Q1:暴力怎么做?
→ 枚举从 \(s\) 到所有点的所有路径,取最小值。指数级复杂度。
Q2:能否逐步逼近最优解?
→ 可以。先找离 \(s\) 最近的点(直接邻接),用它的信息更新其他点。这个更新动作叫松弛(Relaxation)。松弛一次不够?没关系,多松弛几轮直到稳定。
Q3:怎么保证效率?
→ 每次选"当前距离最小且未确定的点"来松弛——这就是 Dijkstra 的贪心策略。用小根堆快速取出最小值,堆优化后复杂度 \(O((n+m)\log n)\)。
Dijkstra 算法过程图解
以 \(s=1\) 为例,逐步执行:
Step 0: d = [0, ∞, ∞, ∞] 堆:{(0,1)}
↓ 取出 (0,1),松弛出边
Step 1: d = [0, 2, 5, 4] 堆:{(2,2), (4,4), (5,3)}
↓ 取出 (2,2),松弛出边
Step 2: d = [0, 2, 4, 3] 堆:{(3,4), (4,3), (5,3)} ← 注意到 3 通过 2→4→3 被更新为 3
↓ 取出 (3,4),松弛出边
Step 3: d = [0, 2, 4, 3] 堆:{(4,3)} ← 4 已确定,跳过
↓ 取出 (4,3),已标记,跳过
Final: d = [0, 2, 4, 3]
*表示该点的最短距离已确定
AC 代码
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
vector<pair<int, int>> g[N]; // 邻接表:pair<邻接点, 边权>
int n, m, s; // n=点数, m=边数, s=起点
int d[N], vis[N]; // d[i]=最短距离, vis[i]=是否已确定
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> q;
// 小根堆:pair<距离, 顶点编号>
void dj()
{
memset(d, 0x3f, sizeof(d)); // 初始化为无穷大
d[s] = 0; // 起点到自身距离为 0
q.push({0, s}); // 起点入堆
while (q.size())
{
auto [dis, u] = q.top(); q.pop();
if (vis[u]) continue; // 已确定最短距离,跳过
vis[u] = 1; // 标记为已确定
for (auto [v, w] : g[u]) // 遍历 u 的所有出边
{
if (d[u] + w < d[v]) // 松弛操作:经过 u 到 v 更短?
{
d[v] = d[u] + w;
q.push({d[v], v}); // 新距离入堆(即使 v 已在堆中,插入新值即可)
}
}
}
}
int main()
{
cin >> n >> m >> s;
for (int i = 1; i <= m; i++)
{
int x, y, w;
cin >> x >> y >> w;
g[x].push_back({y, w}); // 有向边 x→y,权为 w
}
dj();
for (int i = 1; i <= n; i++) cout << d[i] << " ";
return 0;
}
时间复杂度:\(O((n+m)\log n)\)——每个节点入堆一次,每次堆操作 \(O(\log n)\)
空间复杂度:\(O(n+m)\)——邻接表 + d/vis 数组 + 堆
总结与拓展
- Dijkstra 的堆优化是单源最短路的标准解法。不能处理负权边——贪心策略在负权下会失效。
- 若需要处理负权边,改用 SPFA(见例 4)。
- 若需要同时求出最短路径,用
pre[]数组记录前驱(类似挖地雷问题)。 - 拓展:稠密图可用未优化版 Dijkstra(\(O(n^2)\) 不建堆,每次暴力找最小),在 \(n \le 3000\) 时有时更快。
例题 2:P4568 [JLOI2011] 飞行路线(分层图)
知识点: 单源最短路径 — 分层图
题意
给定 \(n\) 个点 \(m\) 条边的无向图,有 \(k\) 次机会将一条边的花费变为 0(免费飞行)。求从 \(s\) 到 \(t\) 的最小花费。
样例解释
| 输入 | 输出 | 解释 |
|---|---|---|
n=5, m=6, k=1 |
8 |
路线:\(0\!\to\!2\)(免费飞行,原权 100)→ \(2\!\to\!3\)(权 3)→ \(3\!\to\!4\)(权 5),共 \(0+3+5=8\) |
s=0, t=4 |
||
0-1(5), 1-2(5), 2-3(5), 3-4(5) |
||
2-3(3), 0-2(100) |
原图样例
核心概念:分层图
分层图思想:将原图复制 \(k+1\) 层(0 层到 \(k\) 层),层内边权不变,层间用权值为 0 的边连接。
- 第 \(i\) 层的节点 \(u\) 用编号 \(u + i \times n\) 表示
- 层内边:\(u + i\cdot n \;\to\; v + i\cdot n\),权值 \(w\)(与普通边相同)
- 层间边(免费飞行):\(u + (i-1)\cdot n \;\to\; v + i\cdot n\),权值 \(0\)
这样,从第 0 层的 \(s\) 到第 \(k\) 层的 \(t\) 的最短路中,每跨越一层就表示"使用了一次免费飞行"——最多 \(k\) 次。
分层图示意图
以 n=5, k=1 为例,第 1 层的节点编号 = 原编号 + \(1 \times n\)(即 +5)。
第 0 层为原始图。第 1 层所有节点编号 = 原编号 \(+n\)(即 \(+5\))。带
0的箭头表示免费飞行(层间边,权值为 0)。每条原始边 \((x, y)\) 都会产生两条层间免费边:\(x \to y\!+\!5\) 和 \(y \to x\!+\!5\),共 10 条。另外终点 \(4 \to 9\) 的免费边表示可以不把免费机会用完就到达终点,对应代码中for (int i = 1; i <= k; i++) g[t + (i-1)*n].push_back({t + i*n, 0});。
🎯 算法设计引导
Q1:暴力怎么做?
→ 枚举哪些边免费、哪些不免费。\(C(m, k)\) 种组合,指数爆炸。
Q2:关键洞察是什么?
→ 将图"复制"成 \(k+1\) 层,每层代表"已经用了多少次免费机会"。层间用权值为 0 的边连接——这意味着"用一次免费机会"等价于"从当前层跳到下一层"。
Q3:建图后怎么做?
→ 建好 \(k+1\) 层图(共 \(n(k+1)\) 个节点),从第 0 层的 \(s\) 跑 Dijkstra 到第 \(k\) 层的 \(t\)。因为边权非负,Dijkstra 保证正确。
AC 代码
#include<bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5; // n*(k+1) 的最大节点数
vector<pair<int, int>> g[N];
int n, m, k, s, t;
int d[N], vis[N];
priority_queue<pair<int, int>> q; // 大根堆,存负数实现小根堆
void dj()
{
memset(d, 0x3f, sizeof(d));
d[s] = 0;
q.push({0, s});
while (q.size())
{
auto [tmp, u] = q.top(); q.pop();
if (vis[u]) continue;
vis[u] = 1;
for (auto [v, w] : g[u])
{
if (d[u] + w < d[v])
{
d[v] = d[u] + w;
q.push({-d[v], v}); // 取负放入大根堆 = 小根堆
}
}
}
}
int main()
{
cin >> n >> m >> k >> s >> t;
for (int i = 1; i <= m; i++)
{
int x, y, w;
cin >> x >> y >> w;
// 第 0 层:原始边
g[x].push_back({y, w});
g[y].push_back({x, w});
// 第 1..k 层:每层的内部边 + 层间免费边
for (int j = 1; j <= k; j++)
{
// 层内边:第 j 层的 x 与 y 相连
g[x + j * n].push_back({y + j * n, w});
g[y + j * n].push_back({x + j * n, w});
// 层间边:第 j-1 层到第 j 层(免费)
g[x + (j - 1) * n].push_back({y + j * n, 0});
g[y + (j - 1) * n].push_back({x + j * n, 0});
}
}
// 每个分层的 t 之间连免费边(可以不把免费机会用完)
for (int i = 1; i <= k; i++)
g[t + (i - 1) * n].push_back({t + i * n, 0});
dj();
cout << d[t + k * n]; // 输出第 k 层的 t 的最短距离
return 0;
}
时间复杂度:\(O((k(n+m))\log(kn))\)——\(k+1\) 层图的总点数和边数乘以 \(\log\)
空间复杂度:\(O(k(n+m))\)——邻接表存储 \(k+1\) 层图
总结与拓展
- 分层图适用于"有 \(k\) 次特殊操作"的问题,用状态维度的增加来刻画操作次数。
- 核心:建图时顺便把状态转移(免费飞行)建成边,然后直接跑最短路。
- 拓展:若 \(k\) 较大(\(k > 10^3\)),分层图会导致节点爆炸,需要用 DP + Dijkstra(最短路套 DP)的思路。
例题 3:P1629 邮递员送信(反向建边)
知识点: 单源最短路径 — 反向建边求往返最短路
题意
给定 \(n\) 个点 \(m\) 条边的有向图,邮递员从 \(1\) 出发,要到 \(2..n\) 每个点送信并返回 \(1\)。求全程最短总距离。
样例解释
| 输入 | 输出 | 解释 |
|---|---|---|
n=5, m=10 |
112 |
每个点去一次回一次,共 10 段路的总和最短 |
| 10 条有向边略 |
核心概念:反向建边
反向建边:要计算"从所有点到 \(1\) 的最短距离",等价于在反向图上从 \(1\) 出发跑最短路。
- 正向图:从 \(1\) 出发到各点的最短路 = 一次 Dijkstra
- 反向图:将每条边方向反过来,从 \(1\) 出发到各点的最短路 = 各点到 \(1\) 的最短路
正向图跑一次 Dijkstra 算出
dis1[i](1→i 的最短距离),反向图跑一次算出dis2[i](i→1 的最短距离)。答案 = \(\sum_{i=2}^n (dis1[i] + dis2[i])\)。
🎯 算法设计引导
Q1:暴力怎么做?
→ 对每个点 \(i\),分别求 \(1\to i\) 和 \(i\to 1\) 的最短路。共 \(2(n-1)\) 次最短路,太慢。
Q2:去程怎么优化?
→ 所有去程都是"从 1 出发到各点"——一次 Dijkstra 搞定。
Q3:回程怎么优化?
→ 回程是"从各点回到 1"。如果建反向图(所有边方向翻转),回程就变成了"从 1 出发到各点"——又是一次 Dijkstra!
Q4:怎么建反向图?
→ 存图时同时存正反两个邻接表,或者存边时存原始边,跑 Dijkstra 时分别遍历正向和反向邻接表。
AC 代码
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
vector<pair<int, int>> g1[N], g2[N]; // g1=正向图, g2=反向图
int n, m;
int d1[N], d2[N], vis[N];
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> q;
void dj(vector<pair<int, int>> g[], int d[])
{
fill(d + 1, d + n + 1, 0x3f3f3f3f);
memset(vis, 0, sizeof(vis));
d[1] = 0;
q.push({0, 1});
while (q.size())
{
auto [dis, u] = q.top(); q.pop();
if (vis[u]) continue;
vis[u] = 1;
for (auto [v, w] : g[u])
{
if (d[u] + w < d[v])
{
d[v] = d[u] + w;
q.push({d[v], v});
}
}
}
}
int main()
{
cin >> n >> m;
for (int i = 1; i <= m; i++)
{
int u, v, w;
cin >> u >> v >> w;
g1[u].push_back({v, w}); // 正向边:u→v
g2[v].push_back({u, w}); // 反向边:v→u(原边反过来)
}
dj(g1, d1); // 正向图:1→各点
dj(g2, d2); // 反向图:各点→1
long long ans = 0;
for (int i = 2; i <= n; i++)
ans += d1[i] + d2[i];
cout << ans << endl;
return 0;
}
时间复杂度:\(O((n+m)\log n)\)——两次 Dijkstra,常数 2
空间复杂度:\(O(n+m)\)——正反两个邻接表
总结与拓展
- 反向建边是图论中的经典技巧——将"多点到一点"问题转化为"一点到多点"。
- 适用场景:求所有点到某个点的最短距离、反向 BFS 等。
- 拓展:P1073 最优贸易(反向建边求最大差价)。
例题 4:P3385 【模板】负环(SPFA 判环)
知识点: 负环与差分约束 — SPFA 判负环
题意
给定 \(n\) 个点 \(m\) 条边的有向图,判断是否存在负环(从某点出发经过一圈回到自身,总权值为负)。\(m\) 条边中,双向边权非负,单向边权可能为负。
样例解释
| 输入 | 输出 | 解释 |
|---|---|---|
n=4, m=2 |
NO |
双向边 1-2(2), 单向边 3→4(-3),无负环 |
| 多组测试数据 | YES 或 NO |
核心概念:SPFA 判负环
SPFA 算法是 Bellman-Ford 的队列优化版:
- 每次从队列中取出节点 \(u\),尝试松弛其出边
- 如果某节点入队次数 \(\ge n\)(总节点数),说明存在负环
为什么? 任意两点之间的最短路径最多经过 \(n-1\) 条边。如果松弛次数超过 \(n-1\) 还能继续松弛,说明一定有负环在反复更新距离。
负环示意图
红色边 ②→③(-4) 使环 ①→②→③→① 的总权值为 \(2 + (-4) + 1 = -1 < 0\),是负环!SPFA 会在该环上无限循环。
🎯 算法设计引导
Q1:Dijkstra 为什么不行?
→ Dijkstra 基于贪心(最小值不会再次更新),但负权边会使已确定的最小值被更小的值再次更新——贪心失效。
Q2:怎么处理负权边?
→ Bellman-Ford 的思路:对全部边做 \(n-1\) 轮松弛,每轮至少确定一条最短边。如果第 \(n\) 轮还能松弛,说明有负环。
Q3:怎么优化?
→ 大部分点的距离在前几轮就收敛了。SPFA 用队列维护"距离被更新过的点",只对这些点做松弛。
AC 代码
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
vector<pair<int, int>> g[N];
int n, m, s = 1;
int d[N], in[N], cnt[N]; // d=距离, in=在队列中, cnt=入队次数
int spfa()
{
memset(d, 0x3f, sizeof(d));
memset(in, 0, sizeof(in));
memset(cnt, 0, sizeof(cnt));
d[s] = 0;
queue<int> q;
q.push(s);
in[s] = 1; cnt[s] = 1;
while (q.size())
{
int u = q.front(); q.pop(); in[u] = 0;
for (auto [v, w] : g[u])
{
if (d[u] + w < d[v]) // 松弛成功
{
d[v] = d[u] + w;
if (in[v] == 0) // v 不在队列中则入队
{
q.push(v);
in[v] = 1;
cnt[v]++; // 入队次数 +1
if (cnt[v] >= n) return 1; // ⭐ 入队 ≥ n 次 → 有负环
}
}
}
}
return 0; // 没有负环
}
int main()
{
int T;
cin >> T;
while (T--)
{
cin >> n >> m;
for (int i = 1; i <= n; i++) g[i].clear();
for (int i = 1; i <= m; i++)
{
int x, y, w;
cin >> x >> y >> w;
if (w >= 0) g[x].push_back({y, w}), g[y].push_back({x, w}); // 双向边
else g[x].push_back({y, w}); // 单向边(负权边可能是单向)
}
if (spfa()) cout << "YES" << endl;
else cout << "NO" << endl;
}
return 0;
}
时间复杂度:\(O(km)\)——\(k\) 为平均入队次数,最坏 \(O(nm)\)
空间复杂度:\(O(n+m)\)——邻接表 + d/in/cnt 数组
总结与拓展
- 判负环 = SPFA 中某节点入队 \(\ge n\) 次。
- SPFA 在随机图上的效率很高(\(O(km)\)),但出题人可以卡成 \(O(nm)\)(网格图等)。
- 拓展:SPFA 也可以求最短路(有负权边时),但优先使用 Dijkstra(非负权时)。
例题 5:P5960 【模板】差分约束
知识点: 负环与差分约束 — 差分约束算法
题意
给定 \(n\) 个未知数 \(x_1..x_n\) 和 \(m\) 个形如 \(x_u - x_v \le w\) 的不等式,求一组可行解。若无解输出 NO,有解输出 \(x_1..x_n\)。
样例解释
| 输入 | 输出 | 解释 |
|---|---|---|
n=4, m=3 |
-3 -4 -8 -9 |
可验证所有不等式成立 |
| \(x_1 - x_0 \le -3\) | ||
| \(x_3 - x_0 \le -8\) | ||
| \(x_3 - x_1 \le -5\) |
核心概念:不等式转最短路
差分约束:将 \(x_u - x_v \le w\) 转化为图论边 \(v \to u\),权值为 \(w\)。
- 含义:\(d[v] + w \ge d[u]\)(最长路视角)或 \(d[v] + w \le d[u]\)(最短路视角)
- 更常用的转化:\(x_u - x_v \le w \;\Rightarrow\; x_u \le x_v + w \;\Rightarrow\; add\_edge(v, u, w)\)
然后从超级源点 \(0\) 向所有点连权值为 \(0\) 的边,跑最短路:
- 如果出现负环 → 不等式组无解
- 否则 \(dist[i]\) 就是一组可行解
🎯 算法设计引导
Q1:不等式怎么和图联系起来?
→ 回忆最短路中的"三角不等式":\(d[v] \le d[u] + w\)(松弛操作)。这和 \(x_v \le x_u + w\) 形式完全一样!
Q2:怎么建图?
对于 \(x_u - x_v \le w\):
- 变形为 \(x_u \le x_v + w\)
- 建边 \(v \to u\),权值 \(w\)(表示 \(x_u\) 最多比 \(x_v\) 大 \(w\))
Q3:无解怎么判断?
→ 如果图中存在负环,说明约束条件矛盾(例如 \(x_1 \le x_2 + 1\) 且 \(x_2 \le x_1 - 2\),无解)。用 SPFA 判负环。
Q4:为什么加超级源点?
→ 保证图连通,让所有约束都能被传递到。
AC 代码
#include<bits/stdc++.h>
using namespace std;
const int N = 1e4 + 5;
vector<pair<int, int>> g[N];
int n, m;
int vis[N], d[N], cnt[N];
int spfa()
{
queue<int> q;
memset(vis, 0, sizeof(vis));
memset(d, 0x3f, sizeof(d));
d[0] = 0; // 超级源点 0
q.push(0);
vis[0] = 1;
while (q.size())
{
int u = q.front(); q.pop();
vis[u] = 0;
for (auto [v, w] : g[u])
{
if (d[u] + w < d[v]) // 松弛操作 = 满足约束
{
d[v] = d[u] + w;
if (vis[v] == 0)
{
q.push(v);
cnt[v]++;
if (cnt[v] > n) return 0; // 负环 → 无解
vis[v] = 1;
}
}
}
}
return 1;
}
int main()
{
cin >> n >> m;
for (int i = 1; i <= m; i++)
{
int u, v, w;
cin >> v >> u >> w; // 注意输入顺序:x_v - x_u ≤ w
g[u].push_back({v, w}); // 建边 u→v,权值 w
}
// 超级源点 0 向所有点连权值为 0 的边
for (int i = 1; i <= n; i++) g[0].push_back({i, 0});
if (spfa() == 0) cout << "NO" << endl;
else
{
for (int i = 1; i <= n; i++) cout << d[i] << " ";
}
return 0;
}
时间复杂度:\(O(nm)\)——SPFA 判负环,最坏复杂度
空间复杂度:\(O(n+m)\)——邻接表 + 辅助数组
总结与拓展
- 差分约束的本质是将不等式组转化为最短路/最长路问题。
- 超级源点 \(0\) 确保图连通,\(x_i \le x_0 + 0\) 表示 \(x_i\) 有上界。
- 若需要 \(x_u - x_v \ge w\)(至少关系),两边乘以 \(-1\) 或跑最长路。
- 拓展:P1993 小 K 的农场、P3275 糖果——均为差分约束的实际应用。
例题 6:P2865 [USACO06NOV] Roadblocks G(次短路)
知识点: 次短路 — 严格次短路的求解方法
题意
给定 \(n\) 个点 \(m\) 条边的无向图,求从 \(1\) 到 \(n\) 的严格次短路径的长度(即比最短路长,但比其他所有路径短)。
样例解释
| 输入 | 输出 | 解释 |
|---|---|---|
n=4, m=4 |
4 |
最短路 1→2→4 = 3,次短路 1→3→4 = 4 |
| 1-2(1), 2-4(2), 1-3(2), 3-4(2) |
原图样例
最短路:\(1 \to 2 \to 4 = 1+2 = 3\);次短路:\(1 \to 3 \to 4 = 2+2 = 4\)。
核心概念:同时维护最短路和次短路
次短路 Dijkstra:在普通 Dijkstra 的基础上,对每个节点同时维护两个距离:
d1[u]= 到 \(u\) 的最短距离d2[u]= 到 \(u\) 的严格次短距离每次从堆中取出一个距离 \(dis\) 和节点 \(u\):
- 如果 \(dis > d2[u]\),跳过(对次短路也没贡献)
- 尝试用 \(dis + w\) 更新 \(v\) 的
d1和d2
🎯 算法设计引导
Q1:怎么求次短路?
→ 先求最短路,然后枚举删除最短路上的每条边再跑最短路?太慢!
Q2:更好的思路?
→ 在 Dijkstra 的过程中,除了记录最短距离,也记录次短距离。具体来说,对每个点维护两个值:最短距离和次短距离。当弹出的距离在最短和次短之间时,尝试用它对邻接点进行"次短更新"。
Q3:怎么保证严格次短?
→ 更新次短时,要求和 d1[v](最短)不相等,即严格大于最短路。
AC 代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<int, int> pa;
const int N = 1e5 + 5, inf = 0x3f3f3f3f;
vector<pa> G[N]; // 邻接表
priority_queue<pa, vector<pa>, greater<pa>> q; // 小根堆
int d[N], d2[N]; // d=最短路, d2=次短路
int n, m, s = 1;
void dijkstra()
{
fill(d, d + N, inf);
fill(d2, d2 + N, inf);
d[s] = 0;
q.push({0, s});
while (q.size())
{
pa t = q.top(); q.pop();
int u = t.second, dis = t.first;
if (d2[u] < dis) continue; // 如果比次短还大,跳过
for (int i = 0; i < G[u].size(); i++)
{
int v = G[u][i].first, w = G[u][i].second;
int nd = dis + w; // 经过 u 到 v 的新距离
if (nd < d[v]) // 比最短路短 → 更新最短路
{
d2[v] = d[v]; // 原来的最短路变成次短路
d[v] = nd; // 更新最短路
q.push({d[v], v});
}
else if (nd > d[v] && nd < d2[v]) // 在最短和次短之间 → 更新次短路
{
d2[v] = nd;
q.push({d2[v], v});
}
}
}
}
int main()
{
cin >> n >> m;
int x, y, w;
for (int i = 1; i <= m; i++)
{
cin >> x >> y >> w;
G[x].push_back({y, w});
G[y].push_back({x, w});
}
dijkstra();
cout << d2[n] << endl; // 输出到 n 的次短路
return 0;
}
时间复杂度:\(O((n+m)\log n)\)——每个节点可能入堆两次(最短和次短)
空间复杂度:\(O(n+m)\)——邻接表 + d/d2 数组
总结与拓展
- 次短路是 Dijkstra 的经典扩展——维护两个距离,在更新最短路时将旧值降级为次短路。
- 注意严格次短的条件:
nd > d[v](不能等于最短路)。 - 拓展:k 短路——用 A* 算法 + 可持久化堆,复杂度 \(O((n+m)\log n + k\log k)\)。
例题 7:P2910 [USACO08OPEN] Clear And Present Danger S(Floyd)
知识点: 多源最短路径 — Floyd 算法
题意
给定 \(n\) 个点 \(m\) 条边的无向图,给出一个长度为 \(m\) 的必经路径 \(a[1..m]\)(依次经过这些顶点),求从 \(a[1]\to a[2]\to ... \to a[m]\) 的最短总距离。
样例解释
| 输入 | 输出 | 解释 |
|---|---|---|
n=3, m=4 |
49 |
\(1\to2\to1\to3\) 按最短路径走 |
必经顺序:1 2 1 3 |
||
| 邻接矩阵给出所有点对距离 |
核心概念:Floyd 算法
Floyd 算法采用动态规划思想:\(dis[i][j]\) 从 \(i\) 到 \(j\) 的最短距离。
\[dis[i][j] = \min(dis[i][j],\; dis[i][k] + dis[k][j]) \]外层循环 \(k\) 表示"允许经过前 \(k\) 个点作为中转",内层 \(i,j\) 枚举所有点对。\(O(n^3)\) 次操作后得到全局最短路。
🎯 算法设计引导
Q1:暴力怎么做?
→ 对于必经路径中的每一段 \(a[t] \to a[t+1]\),分别跑一次 Dijkstra。如果路径较长,效率很低。
Q2:有什么更好的办法?
→ 如果预先算出所有点对之间的最短距离,那么对于必经路径只需要累加:\(\sum dis[a[t]][a[t+1]]\)。这就是 Floyd 的应用场景——多源最短路。
Q3:Floyd 的核心思想?
→ DP:\(dis[i][j]\) 表示从 \(i\) 到 \(j\)、只允许经过前 \(k\) 个中转点的最短距离。逐步增加中转点,最终得到全局最短路。
Floyd 算法过程图解
初始状态:
1 2 3
1 0 4 11
2 6 0 2
3 3 ∞ 0
k=1(允许经过 1 中转):
dis[2][3] = min(2, 6+11=17) = 2 ← 无变化
dis[3][2] = min(∞, 3+4=7) → 7
k=2(允许经过 2 中转):
dis[1][3] = min(11, 4+2=6) → 6
dis[3][1] = min(3, 7+6=13) = 3
k=3(允许经过 3 中转):
dis[1][2] = min(4, 6+7=13) = 4
dis[2][1] = min(6, 2+3=5) → 5
AC 代码
#include<bits/stdc++.h>
using namespace std;
int n, m, ans = 0;
int dis[101][101]; // dis[i][j] = i 到 j 的最短距离
int a[10001]; // 必经路径
int main()
{
cin >> n >> m;
for (int i = 1; i <= m; i++) cin >> a[i];
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
cin >> dis[i][j]; // 直接读入邻接矩阵
// Floyd:三层循环,k 为中转点
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dis[i][j] = min(dis[i][k] + dis[k][j], dis[i][j]);
// 累加必经路径的总距离
for (int i = 2; i <= m; i++) ans += dis[a[i-1]][a[i]];
cout << ans << endl;
return 0;
}
时间复杂度:\(O(n^3)\)——三层循环,适合 \(n \le 500\) 的稠密图
空间复杂度:\(O(n^2)\)——邻接矩阵
总结与拓展
- Floyd 是最简洁的多源最短路算法,核心只有三层循环,\(O(n^3)\) 复杂度制约了其使用范围(\(n \le 500\))。
- 除了求最短路,Floyd 的 DP 思想还被广泛应用在传递闭包(见例 8)、最小环等问题中。
- 拓展:P1119 灾后重建——Floyd 的在线版本,按时间逐渐开放中转点。
例题 8:B3611 【模板】传递闭包
知识点: 多源最短路径与传递闭包 — Floyd 求可达性
题意
给定 \(n\) 个点的有向图,求其传递闭包——即 \(f[i][j] = 1\) 表示 \(i\) 能到达 \(j\),否则为 \(0\)。
样例解释
| 输入 | 输出 | 解释 |
|---|---|---|
n=4 |
1 1 1 1 |
邻接矩阵显示的联通关系 |
| 邻接矩阵 | 0 1 0 0 |
|
0 0 1 0 |
||
0 0 0 1 |
核心概念:传递闭包
传递闭包 = 根据直接联通关系,推导出所有间接联通关系。
使用 Floyd 思想的改进版:
\[f[i][j] = f[i][j] \lor (f[i][k] \land f[k][j]) \]
- 与 Floyd 的区别:用逻辑运算(与、或)代替数值运算(最小值、加法)
- 含义:如果 \(i\) 能到 \(k\),且 \(k\) 能到 \(j\),则 \(i\) 能到 \(j\)
🎯 算法设计引导
Q1:为什么是 Floyd?
→ 传递闭包本质上也是一种"多源可达性"问题。\(i\) 能否到达 \(j\) 可以分解为"\(i\) 能否经过某些中转点 \(k\) 到达 \(j\)"——这和 Floyd 的 DP 结构完全一致。
Q2:与 Floyd 求最短路有什么不同?
→ 传递闭包不关心路径长度,只关心"是否可达"。所以用的是逻辑运算(与、或),而不是 min 和 +。
Q3:代码怎么写?
→ 和 Floyd 一模一样的三层循环,把 dis[i][k] + dis[k][j] 换成 f[i][k] && f[k][j]。
AC 代码
#include<bits/stdc++.h>
using namespace std;
const int N = 105;
int n, f[N][N]; // f[i][j] = 1 表示 i 能到达 j
int main()
{
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
cin >> f[i][j]; // 读入邻接矩阵
// Floyd 求传递闭包
for (int k = 1; k <= n; k++) // 中转点
for (int i = 1; i <= n; i++) // 起点
for (int j = 1; j <= n; j++) // 终点
f[i][j] = f[i][j] || (f[i][k] && f[k][j]);
// 本来就能到 || 经过 k 中转能到
for (int i = 1; i <= n; i++)
{
for (int j = 1; j <= n; j++)
cout << f[i][j] << " ";
cout << endl;
}
return 0;
}
时间复杂度:\(O(n^3)\)——三层循环
空间复杂度:\(O(n^2)\)——邻接矩阵
总结与拓展
- 传递闭包使用 Floyd 的三层循环框架 + 逻辑运算,代码极其简洁。
- bitset 优化:将二维数组改为
bitset<N> f[N],内层循环用位运算优化到 \(O(n^3 / \omega)\)。 - 拓展:P2419 Cow Contest——利用传递闭包确定牛的比赛排名(\(i\) 能否确定排名 \(\iff\) 能确定比它强和比它弱的牛总数 \(= n-1\))。
常见错误与坑点
错误 1:Dijkstra 堆优化中的重复节点导致复杂度退化
❌ 错误写法:不标记
vis[],导致同一个节点反复入堆
// ❌ 没有 vis 标记,同一个节点可能被入堆 n 次
while (q.size()) {
auto [dis, u] = q.top(); q.pop();
for (auto [v, w] : g[u]) {
if (d[u] + w < d[v]) {
d[v] = d[u] + w;
q.push({d[v], v}); // v 可能在堆中已有多个旧值
}
}
}
✅ 正确写法:用
vis[]标记已确定节点,跳过过期的堆元素
while (q.size()) {
auto [dis, u] = q.top(); q.pop();
if (vis[u]) continue; // 已确定,跳过
vis[u] = 1; // 标记
for (auto [v, w] : g[u]) { ... } // 松弛
}
错误 2:SPFA 判负环入队计数器初始化错误
❌ 错误写法:
cnt计数了入队次数而非出队/松弛次数
✅ 正确写法:每次入队时
cnt[v]++,如果cnt[v] >= n说明有负环
错误 3:差分约束建边方向搞反
\(x_u - x_v \le w\) 应建边 \(v \to u\)(权值 \(w\)),而不是 \(u \to v\)。
记法:"小于是从右到左"——\(x_{\color{red}u} \le x_{\color{blue}v} + w\) 建边 \({\color{blue}v} \to {\color{red}u}\)。
错误 4:次短路更新时忘记"严格"条件
次短路必须严格大于最短路。如果忘记
nd > d[v]的判断,次短路可能等于最短路。
错误 5:Floyd 中转点循环不在最外层
Floyd 的三层循环必须是
k-i-j的顺序,k在最外层。如果把i或j放在最外层,会导致结果错误——因为你没有"逐个开放中转点"。
题单完整题解(16 题)
单源最短路(6 题)
P4779 【模板】单源最短路径(标准版) ★★★ — Dijkstra 堆优化
题意:有向图,非负权,求从 s 到所有点的最短路。
核心技巧:堆优化 Dijkstra,vis[] 标记已确定节点,小根堆取最小距离。
时间 \(O((n+m)\log n)\)
空间 \(O(n+m)\)
AC 代码见例题 1。
P4568 [JLOI2011] 飞行路线 ★★★★ — 分层图最短路
题意:k 次免费飞行机会,求最小花费。
核心技巧:建 k+1 层图,层间连权值为 0 的边表示免费飞行,然后跑 Dijkstra。
时间 \(O(k(n+m)\log(kn))\)
空间 \(O(k(n+m))\)
AC 代码见例题 2。
P1629 邮递员送信 ★★★ — 反向建边
题意:有向图,从 1 出发到各点并返回,求总距离最小。
核心技巧:正向图 Dijkstra 算去程,反向图 Dijkstra 算回程。
时间 \(O((n+m)\log n)\)
空间 \(O(n+m)\)
AC 代码见例题 3。
P1144 最短路计数 ★★★★ — 最短路方案数
题意:求从 1 到每个点的最短路径条数(MOD 100003)。
核心技巧:在 BFS/Dijkstra 过程中同时统计方案数。若 d[v] > d[u] + 1 则 cnt[v] = cnt[u];若相等则 cnt[v] += cnt[u]。
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5,mod=100003;
vector<int>g[N];
int n,m,d[N],cnt[N];
queue<int>q;
int main()
{
cin>>n>>m;
for(int i=1;i<=m;i++)
{
int x,y;
cin>>x>>y;
g[x].push_back(y);
g[y].push_back(x);
}
memset(d,0x3f,sizeof(d));
d[1]=0;cnt[1]=1;q.push(1);
while(q.size())
{
int u=q.front();q.pop();
for(auto v:g[u])
{
if(d[v]>d[u]+1)
{
d[v]=d[u]+1;
cnt[v]=cnt[u];
q.push(v);
}
else if(d[v]==d[u]+1)
cnt[v]=(cnt[v]+cnt[u])%mod;
}
}
for(int i=1;i<=n;i++) cout<<cnt[i]<<endl;
return 0;
}
时间 \(O(n+m)\)
空间 \(O(n+m)\)
P1462 通往奥格瑞玛的道路 ★★★★★ — 二分 + 最短路
题意:经过城市需交过路费,血量有限。求在血量不耗尽的情况下,最大化单次交费的最小值。
核心技巧:二分答案(最大费用),每次二分时用 Dijkstra 判断在该费用限制下能否到达终点。
#include<bits/stdc++.h>
using namespace std;
const int N=1e4+5;
vector<pair<int,int>>g[N];
int n,m,b,f[N];
long long d[N],vis[N];
int dj(int limit)
{
memset(d,0x3f,sizeof(d));
memset(vis,0,sizeof(vis));
d[1]=0;
priority_queue<pair<long long,int>,vector<pair<long long,int>>,greater<>>q;
q.push({0,1});
while(q.size())
{
auto [dis,u]=q.top();q.pop();
if(vis[u]) continue;
vis[u]=1;
for(auto [v,w]:g[u])
{
if(f[v]<=limit&&d[u]+w<d[v])
{
d[v]=d[u]+w;
q.push({d[v],v});
}
}
}
return d[n]<=b;
}
int main()
{
cin>>n>>m>>b;
for(int i=1;i<=n;i++) cin>>f[i];
for(int i=1;i<=m;i++)
{
int x,y,w;cin>>x>>y>>w;
g[x].push_back({y,w});
g[y].push_back({x,w});
}
int l=0,r=1e9+5,ans=-1;
while(l<=r)
{
int mid=l+r>>1;
if(dj(mid)) ans=mid,r=mid-1;
else l=mid+1;
}
if(ans==-1) cout<<"AFK";
else cout<<ans;
return 0;
}
时间 \(O(\log C \cdot (n+m)\log n)\)
空间 \(O(n+m)\)
P1073 [NOIP2009 提高组] 最优贸易 ★★★★★ — 反向建图求最大差价
题意:在一个无向图(实际由多条双向边构成)中,从 1 走到 n,可以在某个点买入、在后面的某个点卖出水晶球,求最大差价。
核心技巧:正向 Dijkstra 求 \(minPrice[i]\)(从 1 到 i 路径上的最小价格),反向 Dijkstra 求 \(maxPrice[i]\)(从 i 到 n 路径上的最大价格)。答案 = \(\max(maxPrice[i] - minPrice[i])\)。
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5;
vector<int>g1[N],g2[N];
int n,m,a[N],mn[N],mx[N],vis[N];
void dj_min()
{
memset(mn,0x3f,sizeof(mn));
memset(vis,0,sizeof(vis));
queue<int>q;q.push(1);mn[1]=a[1];vis[1]=1;
while(q.size())
{
int u=q.front();q.pop();vis[u]=0;
for(auto v:g1[u])
{
int t=min(mn[u],a[v]);
if(t<mn[v]) mn[v]=t,q.push(v),vis[v]=1;
}
}
}
void dj_max()
{
memset(mx,-0x3f,sizeof(mx));
memset(vis,0,sizeof(vis));
queue<int>q;q.push(n);mx[n]=a[n];vis[n]=1;
while(q.size())
{
int u=q.front();q.pop();vis[u]=0;
for(auto v:g2[u])
{
int t=max(mx[u],a[v]);
if(t>mx[v]) mx[v]=t,q.push(v),vis[v]=1;
}
}
}
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>a[i];
for(int i=1;i<=m;i++)
{
int x,y,z;cin>>x>>y>>z;
g1[x].push_back(y);g2[y].push_back(x);
if(z==2) g1[y].push_back(x),g2[x].push_back(y);
}
dj_min();dj_max();
int ans=0;
for(int i=1;i<=n;i++) ans=max(ans,mx[i]-mn[i]);
cout<<ans;
return 0;
}
时间 \(O(n+m)\)
空间 \(O(n+m)\)
负环与差分约束(2 题)
P3385 【模板】负环 ★★★★ — SPFA 判负环
题意:判断有向图是否有负环。
核心技巧:SPFA 中某节点入队 ≥ n 次即存在负环。
时间 \(O(nm)\)
空间 \(O(n+m)\)
AC 代码见例题 4。
P5960 【模板】差分约束 ★★★★ — 差分约束
题意:解不等式组 \(x_u - x_v \le w\)。
核心技巧:建边 \(v \to u\) 权值 \(w\),超级源点连边,SPFA 判负环。
时间 \(O(nm)\)
空间 \(O(n+m)\)
AC 代码见例题 5。
次短路(1 题)
P2865 [USACO06NOV] Roadblocks G ★★★★★ — 严格次短路
题意:无向图,求从 1 到 n 的严格次短路径长度。
核心技巧:同时维护最短路 d1[] 和次短路 d2[],Dijkstra 过程中同时更新两个距离。
时间 \(O((n+m)\log n)\)
空间 \(O(n+m)\)
AC 代码见例题 6。
Floyd 多源最短路(3 题)
P2910 [USACO08OPEN] Clear And Present Danger S ★★★ — Floyd 求多源最短路
题意:给定必经路径,求最短总距离。
核心技巧:Floyd 求出所有点对最短路,累加必经路径相邻点的距离。
时间 \(O(n^3)\)
空间 \(O(n^2)\)
AC 代码见例题 7。
B3611 【模板】传递闭包 ★★★ — Floyd 求传递闭包
题意:求有向图的传递闭包(任意两点是否可达)。
核心技巧:Floyd 三层循环 + 逻辑运算。
时间 \(O(n^3)\)
空间 \(O(n^2)\)
AC 代码见例题 8。
P1119 灾后重建 ★★★★ — Floyd 在线更新
题意:村庄依次修通,求不同时间点两村庄间的最短距离。
核心技巧:Floyd 外层循环 k 按时间逐步开放,每次新增一个中转点就更新所有点对。
#include<bits/stdc++.h>
using namespace std;
const int N=205;
int n,m,q,t[N];
int dis[N][N];
int main()
{
cin>>n>>m;
for(int i=0;i<n;i++) cin>>t[i];
memset(dis,0x3f,sizeof(dis));
for(int i=0;i<n;i++) dis[i][i]=0;
for(int i=1;i<=m;i++)
{
int x,y,w;cin>>x>>y>>w;
dis[x][y]=dis[y][x]=w;
}
cin>>q;
int k=0;
while(q--)
{
int x,y,tm;cin>>x>>y>>tm;
while(k<n&&t[k]<=tm)
{
for(int i=0;i<n;i++)
for(int j=0;j<n;j++)
dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
k++;
}
if(t[x]>tm||t[y]>tm||dis[x][y]==0x3f3f3f3f) cout<<-1<<endl;
else cout<<dis[x][y]<<endl;
}
return 0;
}
时间 \(O(n^3 + q)\)
空间 \(O(n^2)\)
其他最短路应用(4 题)
P1144 最短路计数 ★★★★ — 见上文
P1462 通往奥格瑞玛的道路 ★★★★★ — 见上文
P1073 最优贸易 ★★★★★ — 见上文
P1119 灾后重建 ★★★★ — 见上文
总结与 OI Trick 清单
一、最短路算法选择框架
| 场景 | 算法 | 理由 |
|---|---|---|
| 单源、非负权 | Dijkstra 堆优化 | \(O((n+m)\log n)\),不能被卡 |
| 单源、有负权 | SPFA | 可处理负权,但慎用(可能被卡) |
| 多源、\(n \le 500\) | Floyd | 实现简单,\(O(n^3)\) 可接受 |
| 判负环 | SPFA 入队次数 | 边数多时可用 Bellman-Ford |
| 差分约束 | SPFA 判负环 | 不等式转化建图 |
| 次短路 / k 短路 | Dijkstra 双距离 / A* | 维护多个候选值 |
二、核心技巧记忆
| Trick | 适用问题 | 关键代码 |
|---|---|---|
| 堆优化 Dijkstra | 单源非负权 | q.push({d[v], v}) + vis[] |
| 分层图建图 | k 次免费操作 | g[u+i*n].push_back({v+(i+1)*n,0}) |
| 反向建边 | 多点到一点 | g2[v].push_back({u,w}) |
| SPFA 判负环 | 判断无解 | cnt[v] >= n → 有负环 |
| 差分约束 | 不等式组求解 | \(x_u - x_v \le w \to v \to u\) 权值 \(w\) |
| 双距离 Dijkstra | 次短路 | d2[v] 严格大于 d1[v] |
| Floyd 三元环 | 多源最短路 | k-i-j 顺序不能乱 |
| 传递闭包 | 可达性判断 | \(f[i][j] |= f[i][k] \&\& f[k][j]\) |
三、可复用的 OI Trick
Trick 1:堆优化 Dijkstra 用 greater 小根堆(P4779)
priority_queue<pair<int,int>, vector<...>, greater<...>> q;或存负数用大根堆模拟。
Trick 2:分层图编号公式(P4568)
第 \(i\) 层的节点 \(u\) 编号 = \(u + i \times n\),建图时 \(k\) 层共 \(n(k+1)\) 个节点。
Trick 3:正向 + 反向 Dijkstra(P1629, P1073)
一个图存正反两个邻接表,各跑一次 Dijkstra/SPFA,得到两个方向的答案。
Trick 4:SPFA 判负环用入队计数(P3385, P5960)
cnt[v] >= n即存在负环,起始节点也计数。
Trick 5:差分约束转化为最短路(P5960)
将不等式 \(x_u - x_v \le w\) 建边 \(v \to u\)(权值 \(w\)),加超级源点连所有点(权值 0)。
Trick 6:双距离 Dijkstra 求次短路(P2865)
同时维护
d1[](最短路)和d2[](次短路),更新最短路时将旧值降级为次短路。
Trick 7:Floyd 在线更新(P1119)
将 Floyd 外层循环 \(k\) 按时间顺序开放,查询时只处理 available 的中转点。
Trick 8:传递闭包的 bitset 优化(B3611)
用
bitset<N> f[N]代替二维数组,内层循环用f[i] |= f[k]位运算,复杂度 \(O(n^3/\omega)\)。
Trick 9:二分答案 + Dijkstra 检验(P1462)
最大值最小化问题 → 二分答案,每次在限制条件下跑 Dijkstra 判断可行性。
Trick 10:最短路计数(P1144)
若
d[v] == d[u] + 1,cnt[v] += cnt[u];若d[v] > d[u] + 1,cnt[v] = cnt[u]。
一句话总结:最短路问题 = 根据边权正负选算法 + 根据特殊条件(免费、时间、不等式)建图 + 在最短路框架上统计/判定。这 10 个 Trick 覆盖了提高组最短路问题的 90% 套路。
浙公网安备 33010602011771号