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)

原图样例

graph LR 1((1)) 2((2)) 3((3)) 4((4)) 1 -->|2| 2 1 -->|5| 3 1 -->|4| 4 2 -->|2| 3 2 -->|1| 4 3 -->|3| 4

核心概念:Dijkstra 算法

Dijkstra 算法(堆优化版)的核心是贪心 + 松弛:

  1. d[] 记录从起点到每个点的当前最短距离,初始 d[s] = 0,其余为 \(\infty\)
  2. 用小根堆维护当前"距离最小的未确定点" \(u\)
  3. \(u\) 的所有出边 \((u, v, w)\) 执行松弛:若 \(d[u] + w < d[v]\),则更新 \(d[v]\) 并入堆
  4. 重复直到堆为空

正确性保证:因为边权非负,堆顶取出的点 \(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]
graph LR subgraph Step1["Step 1: 从 ① 出发"] direction LR 1s1["① d=0*"] -->|"2"| 2s1["② d=2"] 1s1 -->|"5"| 3s1["③ d=5"] 1s1 -->|"4"| 4s1["④ d=4"] end
graph LR subgraph Step2["Step 2: 松弛 ②"] direction LR 1s2["① d=0*"] 2s2["② d=2*"] 3s2["③ d=4"] 4s2["④ d=3"] 2s2 -->|"2"| 3s2 2s2 -->|"1"| 4s2 end
graph LR subgraph Step3["Step 3: 取出 ④(d=3),松弛出边"] direction LR 1s3["① d=0*"] 2s3["② d=2*"] 3s3["③ d=4*"] 4s3["④ d=3*"] 4s3 -->|"3"| 3s3 end
graph LR subgraph Step4["Step 4: 取出 ③(d=4),已标记跳过"] direction LR 1s4["① d=0*"] 2s4["② d=2*"] 3s4["③ d=4*"] 4s4["④ d=3*"] end

* 表示该点的最短距离已确定

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)

原图样例

graph LR 0((0)) 1((1)) 2((2)) 3((3)) 4((4)) 0 ---|5| 1 1 ---|5| 2 2 ---|5| 3 3 ---|5| 4 2 ---|3| 3 0 ---|100| 2

核心概念:分层图

分层图思想:将原图复制 \(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)。

graph TB subgraph L0["第 0 层(原始图,编号 0~4)"] 0_0(0) ---|5| 0_1(1) 0_1 ---|5| 0_2(2) 0_2 ---|5| 0_3(3) 0_3 ---|5| 0_4(4) 0_2 ---|3| 0_3 0_0 ---|100| 0_2 end subgraph L1["第 1 层(编号 = 原编号 + 5)"] 1_0(5) ---|5| 1_1(6) 1_1 ---|5| 1_2(7) 1_2 ---|5| 1_3(8) 1_3 ---|5| 1_4(9) 1_2 ---|3| 1_3 1_0 ---|100| 1_2 end 0_0 -->|"0"| 1_1 0_0 -->|"0"| 1_2 0_1 -->|"0"| 1_0 0_1 -->|"0"| 1_2 0_2 -->|"0"| 1_0 0_2 -->|"0"| 1_1 0_2 -->|"0"| 1_3 0_3 -->|"0"| 1_2 0_3 -->|"0"| 1_4 0_4 -->|"0"| 1_3 0_4 -->|"0"| 1_4

第 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\) 的最短路
graph LR subgraph Forward["正向图:1→各点"] direction LR 1f["①"] --> 2f["②"] 1f --> 3f["③"] 2f --> 4f["④"] end subgraph Reverse["反向图:各点→1"] direction LR 2r["②"] --> 1r["①"] 3r["③"] --> 1r 4r["④"] --> 2r end

正向图跑一次 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),无负环
多组测试数据 YESNO

核心概念:SPFA 判负环

SPFA 算法是 Bellman-Ford 的队列优化版:

  • 每次从队列中取出节点 \(u\),尝试松弛其出边
  • 如果某节点入队次数 \(\ge n\)(总节点数),说明存在负环

为什么? 任意两点之间的最短路径最多经过 \(n-1\) 条边。如果松弛次数超过 \(n-1\) 还能继续松弛,说明一定有负环在反复更新距离。

负环示意图

graph LR 1((1)) -->|"2"| 2((2)) 2 -->|"-4"| 3((3)) 3 -->|"1"| 1 style 2 fill:#ff6b6b,stroke:#c0392b,stroke-width:2px,color:#fff

红色边 ②→③(-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)

原图样例

graph LR 1((1)) ---|"1"| 2((2)) 2 ---|"2"| 4((4)) 1 ---|"2"| 3((3)) 3 ---|"2"| 4

最短路:\(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\)

  1. 如果 \(dis > d2[u]\),跳过(对次短路也没贡献)
  2. 尝试用 \(dis + w\) 更新 \(v\)d1d2

🎯 算法设计引导

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 在最外层。如果把 ij 放在最外层,会导致结果错误——因为你没有"逐个开放中转点"。


题单完整题解(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] + 1cnt[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] + 1cnt[v] += cnt[u];若 d[v] > d[u] + 1cnt[v] = cnt[u]

一句话总结:最短路问题 = 根据边权正负选算法 + 根据特殊条件(免费、时间、不等式)建图 + 在最短路框架上统计/判定。这 10 个 Trick 覆盖了提高组最短路问题的 90% 套路。

posted @ 2026-07-04 14:02  surprise_ying  阅读(17)  评论(0)    收藏  举报