最短路

给定一张图,边上有边权(无权图可以用 BFS 求最短路),定义一条路径的长度为这条路径上经过的边的边权之和,两点间的最短路即为经过的边的边权之和最小的路径。

  • 多源(全源):要对每一个点作为起点的情况,都做最短路,要求出任意两个点之间的最短路。
  • 单源:固定起点,只求这个起点到其他点的最短路。

最短路是图论中最经典的模型之一,在生活中也有很多应用。例如,城市之间有许多高速公路相连接,想从一个地方去另一个地方,有很多种路径,如何选择一条最短路的路径就是最基本的最短路问题。有时候问题会更加复杂,加上别的限制条件,例如某些城市拥有服务区,连续开车达到一定的时间就必须要进服务区休息,或者是某些路段在特定的时间内会堵车,需要绕行等等,这需要更加灵活地使用最短路算法来解决这些问题。

除了解决给定图的最短路问题,最短路模型还可以解决许多看起来不是图的问题。如果解决一个问题的最佳方案的过程中涉及很多状态,这些状态是明确的且数量合适,而且状态之间可以转移,可以考虑建立图论模型,使用最短路算法求解。

Floyd

基于 DP 的思想,设 \(dp_{k,i,j}\) 表示中间经过的点的编号不超过 \(k\) 的情况下,\(i\)\(j\) 的最短路。

image

其中初始化为 \(dp_{0,i,j} = w(i,j)\)(重边要取最小值,无向图的话再补 \(j \rightarrow i\) 这一条),\(dp_{0,i,i} = 0\),其他位置为 \(\infty\)(理论上最短路的最大值是 \((n-1) \times maxw\),在实现时建议取一个足够大并且两倍之后不会溢出的值)。

for (int k = 1; k <= n; k++)
	for (int i = 1; i <= n; i++)
		for (int j = 1; j <= n; j++)
			dp[k][i][j] = min(dp[k - 1][i][j], dis[k - 1][i][k] + dis[k - 1][k][j]);

一般写的时候第一维会优化掉,可以直接在邻接矩阵上做转移。代码非常简洁,三层循环,但要注意,最外层一定是中间点 \(k\)

g 是邻接矩阵
for (int k = 1; k <= n; k++)
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            g[i][j] = min(g[i][j], g[i][k] + g[k][j]);

三层循环一定是 \(k,i,j\)(中间点,起点,终点),每层内部是递增/递减/乱序是随意的。\(k\) 这层循环的本质就是每回把一个点加进来考虑。

时间复杂度为 \(O(n^3)\),所以一般处理 \(n=500\) 以内的问题。当存在负权边时 Floyd 算法正确性依然可以保证。

例题:B3647 【模板】Floyd

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 105;
const int INF = 1e9; // 定义一个极大的数作为无穷大

// dis[i][j] 存储从点 i 到点 j 的最短路径长度
int dis[N][N];

int main()
{
    int n, m; scanf("%d%d", &n, &m);

    // --- 1. 初始化邻接矩阵 ---
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            // 自己到自己的距离为0,到其他点的距离初始化为无穷大
            dis[i][j] = (i == j) ? 0 : INF;
        }
    }

    // --- 2. 读入边信息 ---
    while (m--) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        // 因为是无向图,所以两个方向都要更新
        // 考虑到可能存在重边,所以取最小值
        dis[u][v] = min(dis[u][v], w);
        dis[v][u] = min(dis[v][u], w);
    }

    // --- 3. Floyd-Warshall 核心算法 ---
    // k 是中转点,必须在最外层循环
    for (int k = 1; k <= n; k++) {
        // i 是起点
        for (int i = 1; i <= n; i++) {
            // j 是终点
            for (int j = 1; j <= n; j++) {
                // 状态转移方程:尝试通过中转点 k 来松弛 i 到 j 的路径
                dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j]);
            }
        }
    }

    // --- 4. 输出结果 ---
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            printf("%d%c", dis[i][j], j == n ? '\n' : ' ');
        }
    }

    return 0;
}

例题:B3611 【模板】传递闭包

“传递闭包”的本质是判断图中任意两个节点之间是否存在路径(可达性)。这是一个“所有点对可达性”问题,其结构与“所有点对最短路径”问题高度相似。

因此,可以借鉴用于求解所有点对最短路径的 Floyd-Warshall 算法思想来解决此问题。

Floyd-Warshall 算法的核心思想是通过一个中转点 \(k\) 来更新 \(i\)\(j\) 的最短路径,其状态转移方程为 \(\text{dis}_{i,j} = \min (\text{dis}_{i,j}, \ \text{dis}_{i,k} + \text{dis}_{k,j})\)

可以将这个思想应用到布尔逻辑(可达性)上:

  • \(\text{dis}_{i,j}\) 对应于 \(\text{reachable}_{i,j}\)(一个布尔值,表示 \(i\) 能否到达 \(j\))。
  • \(\min\) 运算对应于逻辑 或(OR),因为只要有一条路径存在,两点就是可达的。
  • \(+\) 运算对应于逻辑 与(AND),因为要通过 \(k\)\(i\) 到达 \(j\),必须同时满足 \(i\) 能到达 \(k\) 并且 \(k\) 能到达 \(j\)
参考代码
#include <cstdio>
const int N = 105;

// a[i][j] = 1 表示 i 可以到达 j,0 表示不能
// 初始时是邻接矩阵,算法结束后会变成传递闭包矩阵
int a[N][N];

int main()
{
    int n; scanf("%d", &n);

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            scanf("%d", &a[i][j]);
        }
    }

    // k 是中转点,必须在最外层循环
    for (int k = 1; k <= n; k++) {
        // i 是起点
        for (int i = 1; i <= n; i++) {
            // j 是终点
            for (int j = 1; j <= n; j++) {
                // 状态转移方程: a[i][j] = a[i][j] OR (a[i][k] AND a[k][j])
                // 如果 i 能到 k,并且 k 能到 j,那么 i 就能到 j。
                // 使用按位或 `|` 和按位与 `&` 操作实现逻辑运算。
                a[i][j] |= (a[i][k] & a[k][j]);
            }
        }
    }

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            printf("%d ", a[i][j]);
        }
        printf("\n");
    }

    return 0;
}

例题:P2910 [USACO08OPEN] Clear And Present Danger S

题意:给定一张 \(n\) 个点的图和图中每两个结点的边权,以及一个 \(m\) 个点的序列,求该序列中相邻的点的最短路径之和

数据范围\(0 \le n \le 100, 0 \le m \le 10^4\),保证边权非负且不超过 \(10^4\)

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 105;
const int M = 10005;
int a[M], dis[N][N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) scanf("%d", &a[i]);
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            scanf("%d", &dis[i][j]);
    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][j], dis[i][k] + dis[k][j]);
    int ans = 0;
    for (int i = 1; i < m; i++) ans += dis[a[i]][a[i + 1]];
    printf("%d\n", ans);
    return 0;
}

习题:P8312 [COCI 2021/2022 #4] Autobus

解题思路

这是一个在标准最短路问题上增加了“经过的边数不超过 \(k\)”这一限制的变种问题。由于 \(n\) 的范围非常小(\(n \le 70\)),这提示我们可以使用一个与 \(n\) 的高次幂相关的算法。

题目中 \(k\) 的值可以非常大(高达 \(10^9\)),但图中的点数 \(n\) 很小。由于所有边的权重(时间)都是正数,任何一条最短路径都不会包含环路。在一个有 \(n\) 个点的图中,一条不含环路的简单路径最多经过 \(n-1\) 条边。

这意味着,如果一条路径的边数超过 \(n-1\),它必然不是最短路径。因此,允许使用超过 \(n-1\) 趟公交车并不会得到比使用 \(n-1\) 趟更优的解。可以得出结论:最多乘坐 \(k\) 趟的最短路,等同于最多乘坐 \(\min(k, n-1)\) 趟的最短路。

这个观察将 \(k\) 的有效上限从 \(10^9\) 降低到了 \(n-1\)(约 69),使得问题可以在 \(k\) 的维度上进行迭代。

这个问题非常适合使用动态规划解决,可以类比 Floyd-Warshall 算法的结构,增加一个维度来表示经过的边数。

\(\text{dp}_{t,i,j}\) 表示从城市 \(i\) 到城市 \(j\)最多使用 \(t\) 条边的最短路径长度。

\(g\) 是邻接矩阵,\(g_{i,j}\) 存储 \(i\)\(j\) 的直接线路所需时间,如果不存在直接线路,则为无穷大;\(g_{i,i}=0\)\(\text{dp}_{1,i,j}=g_{i,j}\),最多使用 1 条边的最短路就是直达的路径。当 \(t \gt 1\) 时,\(\text{dp}_{t,i,j}=\infty\)

要计算 \(\text{dp}_{t,i,j}\),即从 \(i\)\(j\) 最多走 \(t\) 步的最短路,有两种情况:

  1. 这条最短路最多走了 \(t-1\) 步,那么它的长度就是 \(\text{dp}_{t-1,i,j}\)
  2. 这条最短路恰好走了 \(t\) 步,那么它一定是从 \(i\) 走了 \(t-1\) 步到达某个中间城市 \(p\),然后再从 \(p\) 走一步直达 \(j\),其长度为 \(\text{dp}_{t-1,i,p}+g_{p,j}\)

综合起来,状态转移方程为 \(\text{dp}_{t,i,j}=\min(\text{dp}_{t-1,i,j}, \min\limits_{1 \le p \le n} \{ \text{dp}_{t-1,i,p}+g_{p,j} \})\)

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 75;
const int INF = 1e9;

// dp[i][j][t] 表示从 i 到 j 最多走 t 步的最短路
int dis[N][N][N]; 
// g[i][j] 存储 i 到 j 的直达路径长度(邻接矩阵)
int g[N][N];

int main()
{
    int n, m; scanf("%d%d", &n, &m);

    // --- 1. 初始化 ---
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++) {
            g[i][j] = (i == j) ? 0 : INF;
            // 初始化 dp 数组,最多1步的最短路就是直达
            dis[i][j][1] = g[i][j];
            // 其他步数暂时设为无穷大
            for (int k = 2; k <= n; k++) dis[i][j][k] = INF;
        }

    // 读入所有边,构建邻接矩阵 g
    while (m--) {
        int a, b, t; scanf("%d%d%d", &a, &b, &t);
        // 存在重边,取最短的
        g[a][b] = min(g[a][b], t);
        dis[a][b][1] = g[a][b];
    }

    // --- 2. 动态规划 ---
    // t: 乘坐的公交车趟数(路径边数)
    for (int t = 2; t < n; t++) { // 步数超过 n-1 没有意义
        // k: 最后一步的前一个中转城市
        for (int k = 1; k <= n; k++) {
            // i: 起点
            for (int i = 1; i <= n; i++) {
                // j: 终点
                for (int j = 1; j <= n; j++) {
                    // 状态转移:从 i 到 j 走 t 步,可以是从 i 到 k 走 t-1 步,再从 k 到 j 走 1 步
                    // dis[i][k][t-1] 已经是“最多” t-1 步,所以这里计算的 dis[i][j][t] 也是“最多” t 步
                    // 当 k == j 时,此式子自动处理了从 t-1 步继承最优解的情况
                    if (dis[i][k][t - 1] != INF) { // 如果 i 无法在 t-1 步内到达 k,则跳过
                        dis[i][j][t] = min(dis[i][j][t], dis[i][k][t - 1] + g[k][j]); 
                    }
                }
            }
        }
    }
    
    int k, q; scanf("%d%d", &k, &q);
    // 关键:路径长度超过 n-1 没有意义,因为会产生非最优的环
    k = min(k, n - 1);

    // --- 3. 回答询问 ---
    while (q--) {
        int c, d; scanf("%d%d", &c, &d);
        // 直接从预处理好的DP数组中查询答案
        printf("%d\n", dis[c][d][k] == INF ? -1 : dis[c][d][k]);
    }

    return 0;
}

例题:P1119 灾后重建

询问 \(x\)\(y\),只能经过在第 \(t\) 天以前重建好的村庄的情况下的最短路,\(n \le 200\)\(q \le 50000\)

解题思路

Floyd 算法本身就是加点过程,最外层循环就是在加点,至于加点的顺序,不一定非要按编号的顺序,像这道题就希望按照重建完成的时间加点。

可以将询问离线,按询问的 \(t\) 的顺序,每次把重建完成的时间小于等于这个询问的 \(t\) 的点都加进来,然后看此时 \(x\)\(y\) 的最短路。

加点的时候要注意 \(i\)\(j\) 的循环跑满 \(1\)\(n\),这样才能把所有路径都跑出来。

最后要注意,Floyd 加点是往路径中间加,所以在回答询问时还要判断起点和终点是否已经重建完成,如果没完成,输出 \(-1\)

参考代码
#include <cstdio>
#include <algorithm>
const int N = 205;
const int INF = 1e9;
int tm[N], g[N][N];
bool ok[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 0; i < n; i++) scanf("%d", &tm[i]);
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++)
            g[i][j] = i == j ? 0 : INF; 
    for (int i = 1; i <= m; i++) {
        int x, y, w; scanf("%d%d%d", &x, &y, &w);
        g[x][y] = g[y][x] = w;
    }
    int q; scanf("%d", &q);
    int idx = 0;
    for (int id = 1; id <= q; id++) {
        int x, y, t; scanf("%d%d%d", &x, &y, &t);
        while (idx < n && tm[idx] <= t) {
            for (int i = 0; i < n; i++)
                for (int j = 0; j < n; j++)
                    g[i][j] = std::min(g[i][j], g[i][idx] + g[idx][j]);
            ok[idx] = true; idx++;
        }
        printf("%d\n", !ok[x] || !ok[y] || g[x][y] == INF ? -1 : g[x][y]);
    }
    return 0;   
}

拓展:加边 Floyd

刚开始给一个 \(n \ (n \le 100)\) 个点 \(0\) 条边的图,加 \(m \ (m \le n^2)\) 条边,每次加一条边 \((u, v, w)\) 进来,问所有最短路的情况?

解题思路

针对每一次加的边,枚举 \(i, j\)\(dis_{i, j} = \min (dis_{i, j}, dis_{i, u} + w + dis_{v, j})\)

时间复杂度为 \(O(mn^2)\)

例题:P1613 跑路

解题思路

很明显,直接走最短路不对,如果 \(1\)\(n\) 之间有一条长度为 \(3\) 的路,还有一条长度为 \(4\) 的路,应该选 \(4\)

应该建一个图:如果两个点之间 \(1s\) 能到,它俩之间连边权为 \(1\) 的边。

问题变成:怎么知道两个点之间能不能 \(1s\) 到,也就是两个点之间有没有长度为 \(2^k\) 的路径?

原图的边权都是 \(1\),首先对于所有原图的边 \((u,v)\),说明 \((u,v)\) 之间有长度为 \(2^0=1\) 的边。因为原图边权均为 \(1\),所以如果 \(i\)\(j\) 之间有长度为 \(2^x\) 的路径,一定存在一个中间点 \(k\) 满足 \(i\)\(k\) 之间有长度为 \(2^{x-1}\) 的路径,\(k\)\(j\) 之间有长度为 \(2^{x-1}\) 的路径。

用一个 bool 数组 \(f_{i,j,x}\) 表示 \(i\)\(j\) 之间有没有长度为 \(2^x\) 的路径(本题中因为路径长度不超过 long long 上界 \(2^{63}-1\),所以 \(x\) 最大可以到 \(62\))。结合 Floyd 算法推出 \(f\) 数组,利用 \(f\) 数组重新建图:

if (f[i][k][x - 1] && f[k][j][x - 1]) {
    f[i][j][x] = g[i][j] = true;
}

最后在新的图上计算 \(1\)\(n\) 的最短路。

参考代码
#include <cstdio>
#include <queue>
const int N = 55;
bool f[N][N][63], g[N][N];
int ans[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int x, y; scanf("%d%d", &x, &y);
        g[x][y] = true; f[x][y][0] = true;
    }
    for (int t = 1; t < 63; t++) {
        for (int k = 1; k <= n; k++) {
            for (int i = 1; i <= n; i++)
                for (int j = 1; j <= n; j++) {
                    if (f[i][k][t - 1] && f[k][j][t - 1]) {
                        f[i][j][t] = g[i][j] = true;
                    }
                }
        }
    }
    std::queue<int> q;
    for (int i = 1; i <= n; i++) ans[i] = -1;
    q.push(1); ans[1] = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        if (u == n) break;
        for (int v = 1; v <= n; v++)
            if (g[u][v] && ans[v] == -1) {
                ans[v] = ans[u] + 1; q.push(v);
            }
    }
    printf("%d\n", ans[n]);
    return 0;
}

习题:P2047 [NOI2007] 社交网络

解题思路

首先,需要理解如何计算 \(C_{s,t}(v)\)。根据最短路径的性质,如果一条从 \(s\)\(t\) 的最短路径经过了节点 \(v\),那么这条路径一定是由一条从 \(s\)\(v\) 的最短路径和一条从 \(v\)\(t\) 的最短路径拼接而成的。这个论断成立的充要条件是:dist(s, t) = dist(s, v) + dist(v, t),其中 dist(a, b) 表示 \(a\)\(b\) 的最短路径长度。

当这个条件满足时,根据乘法原理,经过 \(v\)\(s-t\) 最短路径数量就是 \(s-v\) 最短路径数量乘以 \(v-t\) 最短路径数量:\(C_{s,t}(v) = C_{s,v} \times C_{v,t}\)

将此代入原公式,得到

\[I(v)=\sum\limits_{s\ne v, t \ne v} \dfrac{C_{s,v} \times C_{v,t}}{C_{s,t}} \]

为了计算这个公式,需要预处理出任意两点 \((i,j)\) 之间的:

  1. 最短路径长度 \(\text{dist}_{i,j}\)
  2. 最短路径数量 \(\text{cnt}_{i,j}\)

由于 \(N \le 100\),需要一个能在 \(O(N^3)\) 时间内计算出所有点对最短路信息的算法,Floyd-Warshall 算法是自然的选择。可以在经典 Floyd-Warshall 算法的基础上进行扩展,使其在更新最短路径的同时,也维护最短路径的计数。

\(O(N^3)\) 的 Floyd-Warshall 过程结束后,得到了所有点对之间的最短路长度和数量。接下来,再用一个 \(O(N^3)\) 的循环来计算每个节点的重要度。

参考代码
#include <cstdio>
typedef long long LL;
const int N = 105;
const int INF = 1e9;

// dis[i][j] 存储 i 到 j 的最短路径长度
int dis[N][N];
// cnt[i][j] 存储 i 到 j 的最短路径数量
LL cnt[N][N];

int main()
{
    int n, m; scanf("%d%d", &n, &m);

    // --- 1. 初始化 ---
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            dis[i][j] = (i == j ? 0 : INF);
        }
    }
    while (m--) {
        int a, b, c; scanf("%d%d%d", &a, &b, &c);
        // 因为是无向图,所以两个方向都设置
        dis[a][b] = dis[b][a] = c;
        // 初始时,直连边只有1条最短路
        cnt[a][b] = cnt[b][a] = 1;
    }

    // --- 2. 带计数的 Floyd-Warshall 算法 ---
    for (int k = 1; k <= n; k++) { // 中转点
        for (int i = 1; i <= n; i++) { // 起点
            for (int j = 1; j <= n; j++) { // 终点
                if (dis[i][k] == INF || dis[k][j] == INF) continue; // 无法通过k中转
                int tmp = dis[i][k] + dis[k][j];
                
                // 情况一:通过 k 中转发现了一条更短的路径
                if (tmp < dis[i][j]) {
                    dis[i][j] = tmp; // 更新最短路长度
                    // 路径数等于 i->k 的路径数乘以 k->j 的路径数
                    cnt[i][j] = cnt[i][k] * cnt[k][j];
                } 
                // 情况二:通过 k 中转发现了同样长度的另一条最短路
                else if (tmp == dis[i][j]) {
                    // 累加路径数
                    cnt[i][j] += cnt[i][k] * cnt[k][j];
                }
            }
        }
    }

    // --- 3. 计算每个节点的重要程度 ---
    for (int k = 1; k <= n; k++) { // 枚举要计算重要程度的节点 k (v)
        double ans = 0;
        for (int i = 1; i <= n; i++) { // 枚举源点 i (s)
            for (int j = 1; j <= n; j++) { // 枚举汇点 j (t)
                // 题目要求 s, t, v 互不相同
                if (k == i || k == j || i == j) continue;
                
                // 检查 k 是否在 i 到 j 的最短路径上
                if (dis[i][k] + dis[k][j] == dis[i][j]) {
                    // 累加贡献值: (经过k的s-t最短路数) / (s-t最短路总数)
                    ans += 1.0 * (cnt[i][k] * cnt[k][j]) / cnt[i][j];
                }
            }
        }
        printf("%.3f\n", ans);
    }

    return 0;
}

习题:CF25C Roads in Berland

解题思路

本题的核心是在一个已知所有点对最短路径的图中,动态地加入新边,并快速地更新最短路径信息。这是一个“在线”或“增量”的全源最短路径问题。

每次加边后,如果都重新运行一次完整的 Floyd-Warshall 算法 (\(O(N^3)\)),总复杂度将是 \(O(K \cdot N^3)\),对于 \(N=300, K=300\) 来说会超时。

需要一个更高效的更新策略。

Floyd-Warshall 算法的本质是通过不断尝试引入中转点 \(k\) 来松弛(更新)任意两点 \(i\)\(j\) 之间的最短路。其核心是 \(d_{i,j}=\min(d_{i,j},d_{i,k}+d_{k,j})\)

当新增一条权值为 \(w\) 的边 \((u,v)\) 时,图中发生了什么变化?

  1. \(u\)\(v\) 之间的直接距离可能变短了,新的距离是 \(\min (d_{u,v}, w)\)
  2. 对于图中任意一点对 \((i,j)\),它们的最短路径如果发生了改变,那么新的最短路径必然经过了新加入的边 \((u,v)\)。这意味着,新的路径形态必然是 \(i \to \cdots \to u \to v \to \cdots \to j\) 或者 \(i \to \cdots \to v \to u \to \cdots \to j\)

这带来了一个启示:所有可能被优化的路径,都是因为 \(u\)\(v\) 成为了一个更好的“中转点”。因此,不需要重新对所有 \(k\) 进行松弛操作,只需要以 \(u\)\(v\) 作为中转点,对整个图进行两轮松弛操作即可

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long LL;
const int N = 305;

// d[i][j] 存储 i 到 j 的最短路径长度
int d[N][N];

int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            scanf("%d", &d[i][j]);

    for (int k = 1; k <= n; k++)
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= n; j++)
                d[i][j] = min(d[i][j], d[i][k] + d[k][j]);

    // 计算初始的所有点对最短距离之和
    LL ans = 0;
    for (int i = 1; i <= n; i++)
        for (int j = i + 1; j <= n; j++) // i < j 保证每对只统计一次
            ans += d[i][j];

    int q; scanf("%d", &q);
    while (q--) {
        int a, b, c; scanf("%d%d%d", &a, &b, &c);

        // 如果新修的路比已知的 a-b 最短路还短
        if (c < d[a][b]) {
            // 更新总和,减去缩短的长度
            // 注意这里没有判断 i<j,因为 (a,b) 和 (b,a) 只会更新一次 ans
            ans -= d[a][b] - c;
            // 更新 a-b 的最短路
            d[a][b] = d[b][a] = c;
        }

        // 增量更新:以 a 作为中转点,松弛所有其他点对
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= n; j++) {
                if (d[i][a] + d[a][j] < d[i][j]) {
                    // 如果路径被缩短,则更新总和
                    if (i < j) ans -= d[i][j] - (d[i][a] + d[a][j]);
                    // 更新最短路矩阵
                    d[i][j] = d[j][i] = d[i][a] + d[a][j];
                }
            }
        }

        // 增量更新:以 b 作为中转点,松弛所有其他点对
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= n; j++) {
                if (d[i][b] + d[b][j] < d[i][j]) {
                    // 如果路径被缩短,则更新总和
                    if (i < j) ans -= d[i][j] - (d[i][b] + d[b][j]);
                    // 更新最短路矩阵
                    d[i][j] = d[j][i] = d[i][b] + d[b][j];
                }
            }
        }

        // 输出当前的总和
        printf("%lld ", ans);
    }

    return 0;
}

例题:P1730 最小密度路径

解题思路

经过的边数最多会是多少?因为是无环图,所以任何一条路径的长度都不超过 \(n-1\)

所以可以枚举路径长度,用 \(dis_{u,v,x}\) 表示 \(u\)\(v\) 经过 \(x\) 条边的最短路。需要注意本题有重边。

一条路径可以拆成两段,枚举中间点 \(p\),给两边分配长度,则有 \(dis_{u,v,x} = \min (dis_{u,v,x}, dis_{u,p,y} + dis_{p,v,x-y})\),但是这样做的时间复杂度是 \(O(n^5)\),会超时。

假设计算经过 \(4\) 条边的最短路,那么一定有一个点,\(u\) 到它的距离为 \(3\) 的最短路加上它到 \(v\) 距离为 \(1\) 的最短路,就是 \(u\)\(v\) 距离为 \(4\) 的最短路。

所以实际上不用考虑分配长度,只需要用 \(dis_{u,v,x} = \min (dis_{u,v,x}, dis_{u,p,x-1} + dis_{p,v,1})\) 就能求出符合条件的最短路了,计算的时间复杂度也就降到了 \(O(n^4)\)

对于每个询问,遍历不同边数下的最短路计算最小密度。

参考代码
#include <cstdio>
#include <algorithm>
const int N = 55;
const int INF = 1e9;
int g[N][N][N]; // g[x][y][z]表示x到y经过z条边的情况下最短路
bool f[N][N][N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++) {
            for (int k = 1; k <= n; k++)
                g[i][j][k] = i == j ? 0 : INF;
        }
    for (int i = 1; i <= m; i++) {
        int a, b, w; scanf("%d%d%d", &a, &b, &w);
        g[a][b][1] = std::min(g[a][b][1], w); // 注意重边
    }
    for (int cnt = 2; cnt <= n; cnt++) {
        for (int k = 1; k <= n; k++)
            for (int i = 1; i <= n; i++) {
                if (i == k) continue;
                for (int j = 1; j <= n; j++) {
                    if (j == k) continue;     
                    g[i][j][cnt] = std::min(g[i][j][cnt], g[i][k][cnt - 1] + g[k][j][1]);
                }
            }
    }
    int q; scanf("%d", &q);
    for (int i = 1; i <= q; i++) {
        int x, y; scanf("%d%d", &x, &y);
        double ans = INF;
        for (int cnt = 1; cnt <= n; cnt++) 
            if (g[x][y][cnt] != INF) // 有总共cnt条边的路径
                ans = std::min(ans, 1.0 * g[x][y][cnt] / cnt);
        if (ans == INF) printf("OMG!\n"); 
        else printf("%.3f\n", ans);
    }
    return 0;
}

例题:P1841 [JSOI2007] 重要的城市

判定一个点是否是某两个点之间最短路的必经点(删了这个点,存在其他点 \(u, v\) 间的最短路变长了或者变成不连通)。

解题思路

Floyd 算法可以算出任意两点距离 \(dis_{i,j}\),如果 \(dis_{u,x}+dis_{x,v}=dis_{u,v}\),则 \(x\)\(u, v\) 间至少一条最短路上(存在经过 \(x\) 的最短路)。

更进一步,如果经过 \(x\)\(u \rightarrow v\) 间的最短路的数量和 \(u \rightarrow v\) 之间全部最短路的数量相等,则 \(x\) 就是重要的城市。

\(f_{i,j}\) 表示 \(i\)\(j\) 的最短路方案数,\(dis_{u,x} + dis_{x,v} = dis_{u,v} \wedge f_{u,x} \times f_{x,v} = f_{u,v}\) 说明 \(x\)\(u\)\(v\) 最短路的必经点。

if (dis[i][k] + dis[k][j] < dis[i][j]) 
    f[i][j] = f[i][k] * f[k][j] // 在i到j的最短路上只经过编号<=k的点的方案数
else if (dis[i][k] + dis[k][j] == dis[i][j])
    f[i][j] += f[i][k] * f[k][j]

但是 \(f\) 可能很大,可能溢出,所以这个做法不一定靠谱,并不是保证正确的做法。

参考代码
#include <cstdio>
using ll = long long;
const int N = 205;
const int INF = 1e9;
int dis[N][N];
ll f[N][N];
bool key[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            dis[i][j] = i == j ? 0 : INF;
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        dis[u][v] = dis[v][u] = w;
        f[u][v] = f[v][u] = 1;
    }
    for (int k = 1; k <= n; k++)
        for (int i = 1; i <= n; i++) {
            if (i == k) continue;
            for (int j = 1; j <= n; j++) {
                if (j == k) continue;
                if (dis[i][k] + dis[k][j] < dis[i][j]) {
                    dis[i][j] = dis[i][k] + dis[k][j];
                    f[i][j] = f[i][k] * f[k][j];
                } else if (dis[i][j] == dis[i][k] + dis[k][j]) {
                    f[i][j] += f[i][k] * f[k][j];
                }
            }
        }
    for (int k = 1; k <= n; k++) {
        for (int i = 1; i <= n; i++) {
            if (i == k) continue;
            for (int j = 1; j <= n; j++) {
                if (j == k) continue;
                if (dis[i][k] + dis[k][j] == dis[i][j] && f[i][k] * f[k][j] == f[i][j])
                    key[k] = true;
            }
        }
    }     
    int cnt = 0;
    for (int i = 1; i <= n; i++) 
        if (key[i]) {
            printf("%d ", i); cnt++;
        }
    if (cnt == 0) printf("No important cities.\n");
    return 0;
}

拓展:求 \(u \rightarrow v\) 之间经过 \(x \rightarrow y\) 这条边的最短路的数量。

解题思路

\(f_{u,x} \times f_{y,v}\)

考虑 Floyd 算法在添加一个点 \(k\) 的时候是在干什么?对于一条 \(i \rightarrow j\) 的路,中间经过的点的编号 \(\le k\)

\(key_{i,j}\) 表示 \(i\)\(j\) 的最短路上中间经过的编号最大的点可能是几。

if (dis[i][k] + dis[k][j] < dis[i][j]) {
    // i->j目前的最短路经过的点的最大编号一定是k
    key[i][j].clear();
    key[i][j].push_back(k);
} else if (dis[i][k] + dis[k][j] == dis[i][j]) {
    // i->j目前的最短路经过的点的最大编号可能是以前存下来的,也可能是k
    key[i][j].push_back(k);
}

对于一对点 \(i, j\) 来讲,\(key_{i,j}\) 说明什么?

  • 如果 \(key_{i,j}\) 只记了一个值,那这个记下来的就是重要城市
  • 如果记了不止一个,可以都不考虑

为什么可以不考虑?如果删除记下来的最大的点,这个点肯定没用;如果删除的是记下来的点里边编号小的点,并且这个点确实是重要城市,它也一定会在 \(i \rightarrow j\) 最短路的中间某一段中记录着。

image

因此 \(key_{i,j}\) 实际上不用全记(不需要 vector)。

\(key_{i,j}\) 表示 \(i\)\(j\) 的最短路上中间经过的编号最大的点可能是几,如果有不止一个可能值,记为 \(0\)

\(key_{i,j} = 0\),则 \(i\)\(j\) 的最短路上中间经过的编号最大的点要么不是重要城市,就算是,也会被更小的段记录下来,不用在 \(i \rightarrow j\) 的路径上考虑。

在 Floyd 过程中,如果 \(dis_{i,k} + dis_{k,j} \lt dis_{i,j}\),那么更新 \(key_{i,j} \leftarrow k\),如果 \(dis_{i,k} + dis_{k,j} = dis_{i,j}\),那么更新 \(key_{i,j} \leftarrow 0\)

注意 \(k = i\)\(k = j\) 的时候不做相应更新。

最后对于 \(key_{i,j} \ne 0\) 的情况,标记 \(key_{i,j}\) 为重要的城市。

参考代码
#include <cstdio>
const int N = 205;
const int INF = 1e9;
int dis[N][N], key[N][N];
bool f[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++) 
            dis[i][j] = i == j ? 0 : INF;
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        dis[u][v] = dis[v][u] = w;
    }
    for (int k = 1; k <= n; k++)
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= n; j++) {
                if (dis[i][k] + dis[k][j] < dis[i][j]) {
                    dis[i][j] = dis[i][k] + dis[k][j];
                    key[i][j] = k;
                } else if (i != k && k != j && dis[i][k] + dis[k][j] == dis[i][j]) {
                    key[i][j] = 0;
                }
            }
    int cnt = 0;
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            if (key[i][j] > 0) {
                cnt++; f[key[i][j]] = true;
            }
    if (cnt == 0) printf("No important cities.\n");
    else for (int i = 1; i <= n; i++) if (f[i]) printf("%d ", i);
    return 0;
}

另一种方法:

对于每个起点 \(i\),看 \(i\) 到每个点的最短路中,有哪些边是有用的,有哪些边是没用的。

枚举边 \((u,v)\),看 \((u,v)\) 在不在以 \(i\) 为起点,某个点为终点的最短路上。如果 \(dis_{i,u} + w(u,v) = dis_{i,v}\),就说明 \(u \rightarrow v\)\(i\)\(v\) 的至少一条最短路上;\(dis_{i,v} + w(v,u) = dis_{i,u}\) 同理,说明 \(v \rightarrow u\)\(i\)\(v\) 的至少一条最短路上。只有这些边有用,其他边就算全没有,也不影响 \(i\) 到任何点的最短路。

只考虑这些有用的边,会构成一个怎样的图?由于最短路上一定没环(无环),\(dis_{i,u}\)\(dis_{i,v}\) 一定是有大小关系的,不可能从大的往小的走(有向),因此构成的必然是有向无环图。这被称为最短路 DAG。

image

找到入度为 \(1\) 的点,这个点之前的那个点(不能是起点,因为考虑的是枚举的起点到其它点的最短路)就是重要城市,因为删掉前面那个点,会影响起点到这个入度为 \(1\) 的点的最短路。

参考代码
#include <cstdio>
#include <algorithm>
const int N = 205;
const int INF = 1e9;
int g[N][N], dis[N][N], ind[N], pre[N];
bool key[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++) {
            dis[i][j] = i == j ? 0 : INF;
            g[i][j] = dis[i][j];
        }
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        g[u][v] = g[v][u] = w;
        dis[u][v] = dis[v][u] = w;
    }
    // Floyd
    for (int k = 1; k <= n; k++)
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= n; j++)
                dis[i][j] = std::min(dis[i][j], dis[i][k] + dis[k][j]);
    for (int i = 1; i <= n; i++) { // 枚举起点
        for (int u = 1; u <= n; u++) ind[u] = pre[u] = 0;
        for (int u = 1; u <= n; u++) {
            for (int v = 1; v <= n; v++) {
                if (u == v) continue;
                if (dis[i][u] + g[u][v] == dis[i][v]) {
                    ind[v]++; pre[v] = u;
                }
            }
        }
        for (int u = 1; u <= n; u++) {
            if (ind[u] == 1 && pre[u] != i) key[pre[u]] = true;
        }
    }
    int cnt = 0;
    for (int i = 1; i <= n; i++)
        if (key[i]) {
            printf("%d ", i); cnt++;
        }
    if (cnt == 0) printf("No important cities.\n");
    return 0;
}

Dijkstra

Dijkstra 算法用于处理单源最短路问题,并且边权不能有负的。

例题:P3371 【模板】单源最短路径(弱化版)

题意:给定一个 \(n\) 个点 \(m\) 条边的有向图,以及一个起点 \(s\),每条边长度(边权)给定,输出 \(s\) 到所有终点的最短路径长度(如无法到达则输出 \(2^{32}-1\)),所有边的长度均为非负整数,且保证最短路径长度不会超过 \(2^{32}-1\)

数据范围:\(1 \le n \le 10^4, 1 \le m \le 5 \times 10^5\)

分析:最简单的想法是,使用搜索来寻找所有可能的路径并比较它们的长度,选取最短的路径作为最短路。然而,由于可能的路径太多,时间复杂度是指数级别的,无法在规定时间内运行完毕。因此需要使用最短路算法,从起点开始尝试往外走,不断用已知点的最短路长度来更新其他点的最短路长度。

用一个 \(dis\) 数组来记录到目前为止起点到各点的最短路径长度。

image

  1. 在初始时刻,先将整个 \(dis\) 数组设为 \(\infty\)
  2. 对于一条从 \(u\)\(v\),长度为 \(w\) 的边,如果 \(dis[u]+w \lt dis[v]\),那么就可以将 \(dis[v]\) 的值变成 \(dis[u]+w\),因为这代表着从 \(s\) 走到 \(u\) 经过这条边再走到 \(v\) 是一条未发现过的,且比原先的最优路径还要短的路径。这个操作称为松弛。假设 \(s=1\),那么 \(dis[1]=0\) 且不会再变化。于是就可以用 \(1\) 号点的所有出边来进行松弛并将 \(1\) 标记,代表这个点的 \(dis\) 值将不再变化
  3. 可以发现 \(2\) 号点的 \(dis\) 值是当前未标记的点中最小的。由于从 \(1\) 走来的边已经全部完成松弛,且 \(dis[3]\)\(dis[4]\) 都大于 \(dis[2]\)(这意味着从 \(3、4\) 号点走到 \(2\) 号点的边都无法成功松弛),所以此时的 \(dis[2]\) 就是从 \(1\)\(2\) 的最短路。接着就可以用与 \(2\) 号点相连的所有边进行松弛并标记
  4. 同样地,发现 \(4\) 号点的 \(dis\) 是当前未标记的点中最小的,它的 \(dis\) 值不会再改变,接着用 \(4\) 号点进行松弛并标记
  5. 最后,用 \(3\) 号点进行松弛并标记得到最终结果

这种求最短路的方法是 Dijkstra 算法,其过程可以概括为:

  1. 将起始点的 \(dis\) 置为 \(0\)
  2. 选择当前未标记的点中 \(dis\) 值最小的一个
  3. 对该点的所有连边依次进行松弛操作
  4. 对该点进行标记
  5. 重复第二步至第四步,直到不存在一条从已标记点通往未标记点的连边

这个算法的核心在于每次取出来的最小的 \(dis\) 的点 \(x\),它的 \(dis_x\) 一定是对的,之后不会再更新了。

可以用反证法证明:假设有一个点 \(x'\) 会再更新 \(dis_x\),因为边权非负,要想更新的话,\(dis_{x'}\) 一定小于 \(dis_x\),此时与假设 \(dis_x\) 是最小的矛盾。这也解释了 Dijkstra 算法必须在没有负权边时才能保证正确性。

参考代码
#include <cstdio>
#include <vector>
using namespace std;
const int N = 10005;
const int INF = 2147483647;
struct Edge {
    int to, w;
};
vector<Edge> g[N];
int dis[N];
bool vis[N];
int main()
{
    int n, m, s;
    scanf("%d%d%d", &n, &m, &s);
    while (m--) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        g[u].push_back({v, w});
    }   
    for (int i = 0; i <= n; i++) dis[i] = INF;
    dis[s] = 0;
    while (true) {
        int u = 0;
        for (int i = 1; i <= n; i++) 
            if (!vis[i] && dis[i] < dis[u]) u = i;
        if (u == 0) break;
        vis[u] = true;
        for (Edge e : g[u]) {
            int to = e.to, w = e.w;
            if (dis[u] + w < dis[to]) dis[to] = dis[u] + w;
        }
    }
    for (int i = 1; i <= n; i++) printf("%d%c", dis[i], i == n ? '\n' : ' ');
    return 0;
}

上述算法的时间复杂度是 \(O(n^2+m)\),尽管这一复杂度能够通过本题,但并不够优秀。


不定项选择题:下列关于最短路算法的说法正确的有?

  • A. 当图中不存在负权回路但是存在负权边时,Dijkstra 算法不一定能求出源点到所有点的最短路
  • B. 当图中不存在负权边时,调用多次 Dijkstra 算法能求出每对顶点间最短路径
  • C. 图中存在负权回路时,调用一次 Dijkstra 算法也一定能求出源点到所有点的最短路
  • D. 当图中不存在负权边时,调用一次 Dijkstra 算法不能用于每对顶点间最短路计算
答案

ABD

Dijkstra 算法的贪心选择是基于“一旦一个点的最短路径被确定,它就不可能再被更新得更短”。这个前提在有负权边时会被打破。反例:从点 S 出发,有两条路 S->A(权重 5)和 S->B(权重 4),同时存在一条边 A->B(权重 -6)。Dijkstra 算法会先确定 S->B 的路径长度为 4,然后它会错误地认为到 B 的最短路就是 4。但实际上,真正的最短路可能是通过其他更长的路径,再经过一条负权边到达。在这个例子中,到 B 的最短路是 S->A->B,长度为 5 + (-6) = -1,而不是算法在早期计算出的 4。因此,只要存在负权边,Dijkstra 算法就可能给出错误答案。

而 Dijkstra 算法连负权边都无法正确处理,更不用说负权回路了。

当图中没有负权边时,Dijkstra 算法可以正确地计算出单源最短路径。因此,可以通过以图中每个顶点 v 为源点,分别调用一次 Dijkstra 算法,来计算出从 v 到所有其他点的最短路径。将这个过程对所有 n 个顶点都执行一遍,就能得到每对顶点之间的最短路径。


例题:P4779 【模板】单源最短路径(标准版)

本题的 \(n \le 10^5, m \le 2 \times 10^5\),因此用前面的算法无法通过本题了

需要对 Dijkstra 算法加一点优化:使用一个小根堆来维护 \(dis\) 最小的点。用一个结构体记录结点的编号 \(i\)\(dis[i]\),并以 \(dis[i]\) 为关键字排序。每次松弛成功时,将被松弛的边的终点和对应的 \(dis\) 值打包放入堆中,在需要寻找 \(dis\) 最小的点时将堆顶端的点取出,验证其是否已被标记即可。

为什么在堆顶取出时,要验证其是否已被标记?如果在更新的时候,更新了一个已经被放进优先队列的点(也就是优先队列里有它更新前的 \(dis\) 值),那么较早的那次入堆已经成没用的信息了。而标记数组 \(vis\) 被标记过意味着这个点一定在之前被取出来过,所以当发现 vis 被标记时,这一次取出的信息不用来更新其它点的最短路,直接 continue。这是一种惰性删除的思想,因为优先队列没法删除非堆顶的元素。

每个点会扫一次边,所以扫边的循环是 \(O(m)\) 的,由于每一条边最多被入堆、出堆各一次,且堆内元素最多为 \(m\) 个,其时间复杂度为 \(O(m \log m)\)

参考代码
#include <cstdio>
#include <vector>
#include <queue>
using namespace std;
const int N = 100005;
const int INF = 2147483647;
struct Edge {
    int to, w;
};
vector<Edge> g[N];
int dis[N];
bool vis[N];
struct Node {
    int i, d;
};
struct NodeCmp {
    bool operator()(const Node& a, const Node& b) const {
        return a.d > b.d;
    }
};
priority_queue<Node, vector<Node>, NodeCmp> q;
int main()
{
    int n, m, s; scanf("%d%d%d", &n, &m, &s);
    while (m--) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        g[u].push_back({v, w});
    }
    for (int i = 1; i <= n; i++) dis[i] = INF;
    dis[s] = 0;
    q.push({s, 0});
    while (!q.empty()) {
        int u = q.top().i; q.pop();
        if (vis[u]) continue;
        vis[u] = true;
        for (Edge e : g[u]) {
            int to = e.to, w = e.w;
            if (dis[u] + w < dis[to]) {
                dis[to] = dis[u] + w; q.push({to, dis[to]});
            }
        }
    }
    for (int i = 1; i <= n; i++) printf("%d%c", dis[i], i == n ? '\n' : ' ');
    return 0;
}

拓展vis 数组是在弹出时标记还是放入时标记?

分析

image

容易发现,当 \(m\)\(n\) 同级时,采用堆优化会使得程序运行效率获得极大的提升;但如果 \(m\)\(n^2\) 同级(比如完全图),那么使用堆优化之后复杂度变为 \(O(n^2 \log n^2)\),反而劣于不加优化的 Dijkstra,在实际应用时,应当根据实际数据的范围来选择使用哪种算法。


选择题:对一个 n 个顶点、m 条边的带权有向简单图用 Dijkstra 算法计算单源最短路时,如果不使用堆或其他优先队列进行优化,则其时间复杂度为?

  • A. \(O((m+n^2)\log n)\)
  • B. \(O(mn+n^3)\)
  • C. \(O((m+n)\log n)\)
  • D. \(O(n^2)\)
答案

D


选择题:以下哪个算法不属于贪心算法?

  • A. Dijkstra 算法
  • B. Floyd 算法
  • C. Prim 算法
  • D. Kruskal 算法
答案

B


例题:P1629 邮递员送信

题意:给定一张 \(n\) 个点 \(m\) 条边的有向图,邮递员从结点 \(1\) 出发,有 \(n-1\) 个快递,分别要送到结点 \(2\)\(n\),每次快递员只能携带一个快递出发并在送完后返回邮局。求邮递员走过的最少的路程。

数据范围\(1 \le n \le 10^3, 1 \le m \le 10^5\),保证任意两点间可相互到达

解题思路

由于快递员必须每次都要从 \(1\) 结点出发,到达结点 \(i\),然后返回 \(1\),因此就是求 \(1\)\(i\) 的最短路,加上 \(i\)\(1\) 的最短路。考虑到是有向图,从 \(1\)\(i\) 的距离不一定等于从 \(i\)\(1\) 的距离。计算从 \(1\)\(i\) 的距离很简单,但如何求从其他结点到 \(1\) 的距离呢?而从某个点到 \(1\) 的路径,等价于从 \(1\) 开始,倒着沿路走。于是可以建立另外一个图,将读入的边起点和终点对调后存入这个新图中,从 \(1\) 开始计算到其他点的单源最短路径,得到的距离就是原图从其他点到起点的最短路径。一来一回加起来就是送货的最短路径

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 2005;
const int INF = 1e9;
int dis[N];
bool vis[N];
struct Edge {
    int to, w;
};
vector<Edge> g[N];
void update(int u) {
    vis[u] = true;
    for (Edge e : g[u]) {
        int to = e.to, w = e.w;
        dis[to] = min(dis[to], dis[u] + w);
    }
}
int main()
{
    int n, m;
    scanf("%d%d", &n, &m);
    while (m--) {
        int u, v, w;
        scanf("%d%d%d", &u, &v, &w);
        g[u].push_back({v, w}); 
        g[v + n].push_back({u + n, w});
    }
    for (int i = 0; i <= n * 2; i++) dis[i] = INF;
    dis[1] = 0;
    while (true) {
        int u = 0;
        for (int i = 1; i <= n; i++) 
            if (!vis[i] && dis[i] < dis[u]) u = i;
        if (u == 0) break;
        update(u);
    }
    dis[n + 1] = 0;
    while (true) {
        int u = 0;
        for (int i = n + 1; i <= n * 2; i++) 
            if (!vis[i] && dis[i] < dis[u]) u = i;
        if (u == 0) break;
        update(u);
    }
    int ans = 0;
    for (int i = 2; i <= n; i++) ans += dis[i] + dis[i + n];
    printf("%d\n", ans);
    return 0;
}

例题:P2176 [USACO11DEC] RoadBlock S / [USACO14FEB]Roadblock G/S

给定 \(n \ (n \le 100)\) 个点 \(m \ (m \le 5000)\) 条边的无向图,允许把某一条边的长度变成 \(2\) 倍,问 \(1\)\(n\) 的最短路最多能增加多少?

解题思路

枚举每一条边,将其修改后再跑最短路。取这些情况中最短路的最大值减去一开始的最短路,即为答案,时间复杂度为 \(O(mn^2)\)\(O(m^2 \log m)\)

实际上,只有 \(1 \sim n\) 的最短路上的边需要考虑翻倍,最多只有 \(n-1\) 条边。因为如果翻倍的边不在最短路径上,则跑最短路的结果不会变。

如果有多条最短路,只考虑一条也够了。因为这一条路径上可以分成必经边和非必经边,必经边都考虑到了,而非必经边多考虑一下也不会造成影响。

怎么记录一条最短路?只需要在 Dijkstra 算法更新距离的时候,记录一下是谁造成的更新。跑出一条路径后,从 \(n\) 顺着记下来的路径往回找就行了。

这样一来可以将时间复杂度优化到 \(O(n^3)\)

参考代码
#include <cstdio>
#include <algorithm>
const int N = 105;
const int INF = 1e9;
int g[N][N], dis[N], pre[N], n;
bool vis[N];
void dijkstra(bool rec) {
    for (int i = 1; i <= n; i++) {
        dis[i] = INF;
        vis[i] = false;
    }
    dis[1] = 0;
    while (true) {
        int u = -1;
        for (int i = 1; i <= n; i++)
            if (!vis[i] && (u == -1 || dis[i] < dis[u])) u = i;
        if (u == -1) break;
        vis[u] = true;
        for (int i = 1; i <= n; i++)
            if (dis[u] + g[u][i] < dis[i]) {
                dis[i] = dis[u] + g[u][i];
                if (rec) pre[i] = u; 
            }
    }
}
int main()
{
    int m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) g[i][j] = INF;
        g[i][i] = 0;
    }
    for (int i = 1; i <= m; i++) {
        int a, b, l; scanf("%d%d%d", &a, &b, &l);
        g[a][b] = std::min(g[a][b], l);
        g[b][a] = std::min(g[b][a], l);
    }
    dijkstra(true);
    int tmp = dis[n];
    int u = n, ans = 0;
    while (u != 1) {
        // 把边权翻倍
        g[pre[u]][u] *= 2; g[u][pre[u]] *= 2;
        dijkstra(false);
        ans = std::max(ans, dis[n] - tmp);
        // 把图恢复原样
        g[pre[u]][u] /= 2; g[u][pre[u]] /= 2;
        u = pre[u];
    }
    printf("%d\n", ans);
    return 0;
}

拓展:最短路树、最短路 DAG

记下来的这个路径形成的这个图是一种特殊图吗?如果连通的话是一棵树,起点是根,每个点的父亲是更新它最短距离的那个点。如果不连通的话就是森林(多棵树)。这被称为最短路树。

区分最短路树、最短路 DAG:

  1. 最短路树:从起点到每个点只记了一条最短路(有多条的时候根据实现的最短路算法相应记了一条)。
  2. 最短路 DAG:是在跑完最短路以后,留下所有 \(dis_{s,u} + w(u,v) = dis_{s,v}\) 的边,这个时候如果有多条最短路,都会保留下来。

Floyd 算法其实也能记路径,就是记 \(k\),拆成 \(u \rightarrow k\)\(k \rightarrow v\),递归处理。

例题:P7100 [W1] 团

解题思路

直接建图边太多了,并且有一些点之间是全都有边的。因此要把边的数量降下来。

考虑在每个集合 \(S_i\) 中添加一个虚点 \(x\)\(x\)\(T_i\) 连接一条边权为 \(W_i\) 的无向边。这样一来 \(i \rightarrow x \rightarrow j\) 的边权等价于原来的边权 \(W_i + W_j\),但是边数和点数同阶,然后对这个图跑最短路即可。

image

参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <queue>
using ll = long long;
using node = std::pair<ll, ll>;
const int N = 600005;
const ll INF = 4557430888798830399ll;
std::vector<node> g[N];
bool vis[N];
ll dis[N];
int main()
{
    int n, k; scanf("%d%d", &n, &k);
    for (int i = 1; i <= k; i++) {
        int s; scanf("%d", &s);
        for (int j = 1; j <= s; j++) {
            int t, w; scanf("%d%d", &t, &w);
            g[t].push_back({n + i, w});
            g[n + i].push_back({t, w});
        }
    }
    for (int i = 1; i <= n + k; i++) dis[i] = INF;
    std::priority_queue<node, std::vector<node>, std::greater<node>> q;
    dis[1] = 0; q.push({0, 1});
    while (!q.empty()) {
        node tmp = q.top(); q.pop();
        int u = tmp.second;
        if (vis[u]) continue;
        vis[u] = true;
        for (node nd : g[u]) {
            int v = nd.first, w = nd.second;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                q.push({dis[v], v});
            }
        }
    }
    for (int i = 1; i <= n; i++) printf("%lld ", dis[i]);
    return 0;
}

习题:P1462 通往奥格瑞玛的道路

解题思路

题目目的:求出到达路线中最大收费的最小值

看到“最小化……最大值”问题,可以先考虑二分答案可不可做,需要分析题目是否符合某种单调性。

本题中若最多一次交的费越大,能用的结点就越多,可以走到终点的可能性也就越大,反之则可能性越小,因此可以使用二分答案实现

而判断某个最大交费限制下能否到达终点则可以将损失血量看作边权转化为最短路问题,即此时“不完整的图”(由于最大交费限制)上的最短路是否小于等于初始血量

参考代码
#include <cstdio>
#include <algorithm>
#include <vector>
#include <queue>
using namespace std;
typedef long long LL;
const int N = 10005;
const LL INF = 1e14;
int f[N], n, m, b;
LL dis[N];
bool vis[N];
struct Edge {
    int to, c;
};
vector<Edge> g[N], sub[N];
struct Node {
    int id; 
    LL d;
};
struct NodeCmp {
    bool operator()(const Node& a, const Node& b) const {
        return a.d > b.d;
    }
};
bool check(int x) {
    if (x < f[1]) return false;
    for (int i = 1; i <= n; i++) vis[i] = false;
    for (int i = 1; i <= n; i++) dis[i] = INF;
    priority_queue<Node, vector<Node>, NodeCmp> q; 
    q.push({1, 0}); dis[1] = 0;
    while (!q.empty()) {
        int u = q.top().id; q.pop();
        if (vis[u]) continue;
        vis[u] = true;
        for (Edge e : g[u]) {
            if (f[e.to] <= x && dis[u] + e.c < dis[e.to]) {
                dis[e.to] = dis[u] + e.c;
                q.push({e.to, dis[e.to]});
            }
        }
    }
    return dis[n] <= b;
}
int main()
{
    scanf("%d%d%d", &n, &m, &b);
    int ans = -1, l = 0, r = 0;
    for (int i = 1; i <= n; i++) {
        scanf("%d", &f[i]); r = max(r, f[i]);
    }
    while (m--) {
        int x, y, z; scanf("%d%d%d", &x, &y, &z);
        g[x].push_back({y, z}); g[y].push_back({x, z});
    }
    while (l <= r) {
        int mid = (l + r) / 2;
        if (check(mid)) {
            ans = mid; r = mid - 1;
        } else l = mid + 1;
    }
    if (ans == -1) printf("AFK\n");
    else printf("%d\n", ans);
    return 0;
}

分层图最短路

例题:P4568 [JLOI2011] 飞行路线

由于购买机票需要花费金钱,所以肯定不会重复乘坐多次同样的航线或者多次访问同一个城市。如果 \(k=0\),本题就是最基础的最短路问题。但题目中提供了一些特殊情况(对有限条边设置为免费),可以使用分层图的方式,将图多复制 \(k\) 次,原编号为 \(i\) 的结点复制为编号 \(i+jn(1 \le j \le k)\) 的结点,然后对于原图存在的边,第 \(j\) 层和第 \(j+1\) 层的对应结点也需要连上,看起来就是相同结构的图上下堆叠起来,因此被称为分层图

image

从上面一层跳到下面一层就是乘坐免票的飞机,花费的代价是 \(0\),这个过程是不可逆的,每乘坐一次免费航班就跳到下一层图中,因此上一层到下一层的边是单向的。从编号为 \(s\) 的结点开始,计算到其他点的单源最短路径。因为并不一定要坐满 \(k\) 次免费航班,所以查找所有编号 \(t+jn(0 \le j \le n)\) 的结点作为终点的最短路的值,找到的最小的值就是答案。

#include <cstdio>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
const int N = 110005;
const int INF = 1e9;
struct Edge {
    int to, w;
};
vector<Edge> g[N]; 
int dis[N];
bool vis[N];
struct Node {
    int i, d;
};
struct NodeCmp {
    bool operator()(const Node& a, const Node& b) {
        return a.d > b.d;
    }
};
priority_queue<Node, vector<Node>, NodeCmp> q;
int main()
{
    int n, m, k, s, t; 
    scanf("%d%d%d%d%d", &n, &m, &k, &s, &t);
    while (m--) {
        int a, b, c; scanf("%d%d%d", &a, &b, &c);
        g[a].push_back({b, c}); g[b].push_back({a, c});
        for (int i = 1; i <= k; i++) {
            int u = a + i * n, v = b + i * n;
            g[u].push_back({v, c}); g[v].push_back({u, c});
            g[u - n].push_back({v, 0}); g[v - n].push_back({u, 0});
        }
    }
    for (int i = 0; i < k * n + n; i++) dis[i] = INF;
    dis[s] = 0;
    q.push({s, 0});
    while (!q.empty()) {
        int u = q.top().i; q.pop();
        if (vis[u]) continue;
        vis[u] = true;
        for (Edge e : g[u]) {
            int to = e.to, w = e.w;
            if (dis[u] + w < dis[to]) {
                dis[to] = dis[u] + w; q.push({to, dis[to]});
            }
        }
    }
    int ans = INF;
    for (int i = 0; i <= k; i++) ans = min(ans, dis[t + i * n]);
    printf("%d\n", ans);
    return 0;
}

习题:P13271 [NOI2025] 机器人

解题思路

这是一个典型的带有附加状态的最短路问题,可以将问题抽象为一个在“状态图”上寻找最短路径的问题。

一个状态可以由一个二元组 \((当前路口, 当前参数 p)\) 唯一确定,将其表示为 \((x,p)\),状态图中的每一个顶点都对应一个 \((x,p)\) 状态。

机器人的三种行动对应了状态图中的三类有向边:

  1. 移动边:对于原图中从路口 \(x\) 出发的第 \(p\) 条路,其终点为 \(y\),长度为 \(z\)。这对应状态图中一条从 \((x,p)\)\((y,p)\) 的边,权重为 \(z\)
  2. 增参数边:对于每个路口 \(x\) 和参数 \(p \lt k\),存在一条从 \((x,p)\)\((x,p+1)\) 的边,权重为 \(v_p\)
  3. 减参数边:对于每个路口 \(x\) 和参数 \(p \gt 1\),存在一条从 \((x,p)\)\((x,p-1)\) 的边,权重为 \(w_p\)

问题转化为:在这个新建的状态图上,求从源点 \((1,1)\) 到所有其他点的单源最短路。

直接构建上述状态图是不可行的,顶点总数将达到 \(O(N \times K)\),在最大的数据范围下,时空复杂度都无法承受。

需要进行优化,观察发现,参数 \(p\) 的主要作用是决定机器人能走哪条路。一个状态 \((x,p)\) 如果不存在从 \(x\) 出发的第 \(p\) 条路,那么它本身无法触发“移动”操作,只能作为修改参数时的“跳板”。

因此可以只为输入中所有提到的 \((路口, 出边编号)\) 创建节点,例如,如果路口 \(i\) 的第 \(j\) 条出边通向 \(y\),就在状态图中为 \((i,j)\)\((y,j)\) 创建节点(如果它们尚不存在)。这样,总节点数最多为 \(2m\) 级别,大大减少了节点数量。

对于原图中从 \(x\) 的第 \(j\) 条路到 \(y\),长度为 \(z\),连接状态图中的节点 \((x,j)\)\((y,j)\),边权为 \(z\)

对于同一个路口 \(x\),它可能有关联的多个端点节点,如 \((x,p_1), (x,p_2), \dots\)。将这些节点按参数 \(p_i\) 的大小进行排序,然后,只需要在排序后相邻的两个节点 \((x,p_i)\)\((x,p_{i+1})\) 之间建立双向的“参数修改边”。

  • \((x,p_i)\)\((x,p_{i+1})\) 的边权为 \(\sum\limits_{t=p_i}^{p_{i+1}-1} v_t\)(累积增加参数的费用)。
  • \((x,p_{i+1})\)\((x,p_i)\) 的边权为 \(\sum\limits_{t=p_i+1}^{p_{i+1}} w_t\)(累积减少参数的费用)。

这样,任意两个 \((x,p_a)\)\((x,p_c)\) 之间的参数转换,都可以通过这些相邻边构成的路径实现,且路径长度正好是总的修改费用。

参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <map>
#include <queue>
#include <algorithm>
using namespace std;
using ll = long long;
using pl = pair<ll, ll>;
const int N = 6e5 + 5;      // 状态图的节点数,大致为 2*m
const int K = 2.5e5 + 5;    // 参数 p 的上限
const ll INF = 1e18;

int v[K], w[K]; // v: 增加参数的费用, w: 减少参数的费用
vector<pl> g[N]; // 状态图的邻接表
ll dis[N], ans[N]; // dis: Dijkstra 距离数组, ans: 每个路口的最终答案
bool vis[N]; // Dijkstra 访问数组

// --- 状态图节点管理 ---
pl node[N]; // node[id] = {路口, 参数p},存储每个ID对应的状态
vector<int> idv[N]; // idv[i] 存储属于路口 i 的所有状态节点的ID
map<pl, int> ids; // 将状态 (路口, 参数p) 映射到唯一的整数ID
int id_cnt; // 状态节点的计数器

// 获取或创建 (x, y) -> (路口, 参数p) 状态对应的节点ID
int getnodeid(int x, int y) {
    if (ids.count({x, y})) { // 如果已存在
        return ids[{x, y}];
    } else { // 如果不存在,则创建
        node[++id_cnt] = {x, y};
        idv[x].push_back(id_cnt); // 记录该节点属于路口 x
        return ids[{x, y}] = id_cnt;
    }
}

int main()
{
    int c; scanf("%d", &c);
    int n, m, k; scanf("%d%d%d", &n, &m, &k);
    for (int i = 1; i < k; i++) scanf("%d", &v[i]);
    for (int i = 2; i <= k; i++) scanf("%d", &w[i]);

    // --- 1. 构建“移动边” ---
    for (int i = 1; i <= n; i++) {
        int d; scanf("%d", &d);
        for (int j = 1; j <= d; j++) {
            int y, z; scanf("%d%d", &y, &z);
            int from = getnodeid(i, j); // 起点状态 (i, j)
            int to = getnodeid(y, j);   // 终点状态 (y, j),注意参数p不变
            g[from].push_back({(ll)to, (ll)z});
        }
    }

    // --- 2. 构建“参数修改边” ---
    for (int i = 1; i <= n; i++) { // 对每个路口 i
        // 将路口 i 的所有相关状态节点按参数 p 排序
        sort(idv[i].begin(), idv[i].end(), [](int lhs, int rhs) {
            return node[lhs].second < node[rhs].second;
        });
        // 在排序后相邻的状态节点之间添加边
        int sz = (int)(idv[i].size());
        for (int j = 1; j < sz; j++) {
            int from = idv[i][j - 1], to = idv[i][j];
            int start_p = node[from].second;
            int end_p = node[to].second;
            
            // 计算 p 从 start_p 增加到 end_p 的费用
            ll sum_v = 0;
            for (int t = start_p; t < end_p; t++) sum_v += v[t];
            g[from].push_back({(ll)to, sum_v});

            // 计算 p 从 end_p 减少到 start_p 的费用
            ll sum_w = 0;
            for (int t = start_p + 1; t <= end_p; t++) sum_w += w[t];
            g[to].push_back({(ll)from, sum_w});
        }
    }

    // --- 3. 运行 Dijkstra 算法 ---
    priority_queue<pl, vector<pl>, greater<pl>> q;
    for (int i = 1; i <= id_cnt; i++) dis[i] = INF;
    
    int start_node_id = getnodeid(1, 1);
    q.push({0, (ll)start_node_id});
    dis[start_node_id] = 0; 

    while (!q.empty()) {
        pl cur = q.top(); q.pop();
        int u = cur.second;
        ll d = cur.first;
        if (vis[u]) continue;
        vis[u] = true;
        for (pl e : g[u]) {
            ll v_node = e.first, w_edge = e.second;
            if (d + w_edge < dis[v_node]) {
                dis[v_node] = d + w_edge;
                q.push({dis[v_node], v_node});
            }
        }
    }

    // --- 4. 统计并输出最终答案 ---
    for (int i = 1; i <= n; i++) ans[i] = INF;
    
    // 到达路口 i 的最小费用,是到达所有状态 (i, p) 的最小费用
    for (int i = 1; i <= id_cnt; i++) {
        int road_id = node[i].first;
        ans[road_id] = min(ans[road_id], dis[i]);
    }

    for (int i = 1; i <= n; i++) {
        printf("%lld ", ans[i] == INF ? -1 : ans[i]);
    }
    printf("\n");

    return 0;
}

0-1 图最短路

一般求解最短路径,高效的方法是 Dijkstra 算法,如果用优先队列实现则时间复杂度为 \(O(m \log m)\)\(m\) 为边数。但是在边权为 \(0\)\(1\) 的特殊图中,利用双端队列可以在 \(O(n+m)\) 时间内求得最短路径。Dijkstra 算法中优先队列的作用是保证单调性,实际上 \(0-1\) 图可以不使用优先队列的原因就在于只需要在队列的两端插入就可以保证单调性。

例题:P2937 [USACO09JAN] Laserphones S

给一个网格图,里边有两个 C,求一条拐弯最少的连接两个 C 的路径。(注意输入是先列数再行数)

解题思路

状态肯定不能只设计成现在在哪儿,因为转移跟方向也有关系。

假设现在在 \((x,y)\),方向为 \(d\),相当于用分层图形式,把原来的一个点拆成四个,初始时把起点 \(4\) 种方向的状态都当成起始状态。

接下来就是看要走的方向和当前方向是不是一致,决定边权是 \(0\) 还是 \(1\)。如果接下来走的方向和现在方向一样,边权是 \(0\);如果接下来走的方向和现在方向不一样,边权是 \(1\)

可以想象一下最短路计算过程中优先队列的变化过程:0......1 -> 1......2 -> 2......3 -> ......。由于边权只有 \(0\)\(1\) 两种,所以可以不用优先队列,改用双端队列:如果走的是边权为 \(0\) 的,更新后的点放到队首;如果走的是边权为 \(1\) 的,更新后的点放到队尾。

每个点最多入队两次,时间复杂度为 \(O(点数+边数)\)

这个问题叫做 01 最短路,这个做法可以看作是 Dijkstra 算法在特殊情况下的一种优化。

参考代码
#include <cstdio>
#include <deque>
#include <algorithm>
const int N = 105;
const int INF = 1e9;
char s[N][N];
int dx[4] = {-1, 0, 0, 1};
int dy[4] = {0, -1, 1, 0};
struct Node {
    int x, y, d;
};
int dis[N][N][4];
bool vis[N][N][4];
int main()
{
    int n, m; scanf("%d%d", &m, &n);
    for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
    int x1, y1, x2, y2; x1 = y1 = x2 = y2 = 0;
    for (int i = 1; i <= n; i++) 
        for (int j = 1; j <= m; j++) {
            for (int dir = 0; dir < 4; dir++) dis[i][j][dir] = INF;
            if (s[i][j] == 'C') {
                if (x1 == 0) {
                    x1 = i; y1 = j;
                } else {
                    x2 = i; y2 = j;
                }
            }
        }
    std::deque<Node> q;
    for (int dir = 0; dir < 4; dir++) {
        q.push_back({x1, y1, dir});
        dis[x1][y1][dir] = 0;
    }
    while (!q.empty()) {
        Node tmp = q.front(); q.pop_front();
        int x = tmp.x, y = tmp.y, d = tmp.d;
        if (vis[x][y][d]) continue;
        vis[x][y][d] = true;
        for (int dir = 0; dir < 4; dir++) {
            int xx = x + dx[dir], yy = y + dy[dir];
            if (xx < 1 || xx > n || yy < 1 || yy > m || s[xx][yy] == '*') continue;
            int w = 1 - (dir == d);
            if (dis[x][y][d] + w < dis[xx][yy][dir]) {
                dis[xx][yy][dir] = dis[x][y][d] + w;
                if (w == 0) q.push_front({xx, yy, dir});
                else q.push_back({xx, yy, dir});
            }
        }
    }
    int ans = INF;
    for (int dir = 0; dir < 4; dir++) ans = std::min(ans, dis[x2][y2][dir]);
    printf("%d\n", ans);
    return 0;
}

例题:P4667 [BalticOI 2011 Day1] Switch the Lamp On

时间限制为 150ms;内存限制为 125MB
问题描述:Casper 正在设计电路。有一种正方形的电路元件,在它的两组相对顶点中,有一组会用导线连接起来,另一组则不会。有 \(N \times M\) 个这样的元件(\(1 \le N,M \le 500\)),排列成 \(N\) 行,每行 \(M\) 个。电源连接到电路板的左上角,灯连接到电路板的右下角。只有在电源和灯之间有一条电线连接的情况下,灯才会亮。为了亮灯,任何数量的电路元件都可以转动 90°(两个方向)。
编写一个程序,求出最少需要旋转多少电路元件。

image

本题可以建模为最短路径问题。把起点 \(s\) 到终点 \(t\) 的路径长度记录为需要旋转的元件数量。从一个点到邻居点,如果元件不旋转就能到达,则距离为 \(0\);如果需要旋转元件才行,距离为 \(1\)。题目要求找出 \(s\)\(t\) 的最短路径。

如果用 Dijkstra 算法,复杂度为 \(O(NM \log NM)\),因为这个图的边的数量级是 \(NM\),而题目给的时间限制为 150ms,可能会超时。

在优先队列优化的 Dijkstra 算法中,优先队列的作用是在队列中找到距离起点最短的那个结点,并弹出它。使用优先队列的原因是,每个结点到起点的距离不同,需要用优先队列保证单调性。

本题是一种特殊情况,边权为 0 或 1。简单地说,就是“边权为 0,插到队头;边权为 1,插入队尾”,这样就省去了优先队列维护有序性的代价,从而减少了计算,优化了时间复杂度。这个操作用双端队列实现,这样保证了距离更近的点总是在队列的前面,队列中元素是单调的。每个结点只入队和出队一次,总的时间复杂度是线性的。

#include <cstdio>
#include <deque>
#include <utility>
#include <string>
using namespace std;
typedef pair<int, int> PII;
const int N = 505;
const int INF = 1e9;
int dis[N][N]; // dis记录从起点出发的最短路径
char s[N][N];
// 4个点
int d1[4][2] = {{-1, -1}, {-1, 1}, {1, -1}, {1, 1}};
// 4个电子元件
int d2[4][2] = {{-1, -1}, {-1, 0}, {0, -1}, {0, 0}};
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
    for (int i = 1; i <= n + 1; i++)
        for (int j = 1; j <= m + 1; j++)
            dis[i][j] = INF;
    deque<PII> q; q.push_back({1, 1}); dis[1][1] = 0;
    string match = "\\//\\"; // 注意反斜杠需要加转义字符
    while (!q.empty()) {
        PII cur = q.front(); q.pop_front(); // 弹出队头
        int x = cur.first, y = cur.second;
        for (int i = 0; i < 4; i++) { // 4个方向
            char ch = match[i];
            int px = x + d1[i][0], py = y + d1[i][1];
            int cx = x + d2[i][0], cy = y + d2[i][1];
            if (px >= 1 && px <= n + 1 && py >= 1 && py <= m + 1) {
                if (cx >= 1 && cx <= n && cy >= 1 && cy <= m) {
                    if (s[cx][cy] == ch && dis[x][y] < dis[px][py]) {
                        dis[px][py] = dis[x][y];
                        q.push_front({px, py});
                    } 
                    if (s[cx][cy] != ch && dis[x][y] + 1 < dis[px][py]) {
                        dis[px][py] = dis[x][y] + 1;
                        q.push_back({px, py});
                    }
                }
            }
        }
    }
    if (dis[n + 1][m + 1] == INF) printf("NO SOLUTION\n");
    else printf("%d\n", dis[n + 1][m + 1]);
    return 0;
}

习题:SP22393 KATHTHI

参考代码
#include <cstdio>
#include <utility>
#include <deque>
using namespace std;
typedef pair<int, int> PII;
const int N = 1005;

char s[N][N];    
int dis[N][N];   

// 四个方向的位移数组:上、左、右、下
int dx[4] = {-1, 0, 0, 1};
int dy[4] = {0, -1, 1, 0};

int main()
{
    int t; scanf("%d", &t);
    while (t--) {
        int n, m; scanf("%d%d", &n, &m);
        for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);

        // 初始化距离数组为一个极大值
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= m; j++)
                dis[i][j] = n * m;

        // --- 0-1 BFS 核心逻辑 ---
        deque<PII> q; // 双端队列,用于0-1 BFS

        // 将起点 (1,1) 入队
        q.push_back({1, 1}); 
        dis[1][1] = 0; // 起点距离为0

        while (!q.empty()) {
            PII cur = q.front(); q.pop_front(); // 从队首取出当前节点
            int x = cur.first, y = cur.second;

            // 遍历四个方向的邻居
            for (int i = 0; i < 4; i++) {
                int xx = x + dx[i], yy = y + dy[i];
                // 检查邻居是否在网格范困内
                if (xx >= 1 && xx <= n && yy >= 1 && yy <= m) {
                    // 情况1:边权为 0 (字符相同)
                    if (s[xx][yy] == s[x][y] && dis[x][y] < dis[xx][yy]) {
                        dis[xx][yy] = dis[x][y];
                        q.push_front({xx, yy}); // 0权边,推入队首
                    }
                    // 情况2:边权为 1 (字符不同)
                    if (s[xx][yy] != s[x][y] && dis[x][y] + 1 < dis[xx][yy]) {
                        dis[xx][yy] = dis[x][y] + 1;
                        q.push_back({xx, yy}); // 1权边,推入队尾
                    }
                } 
            }
        }

        // 输出到终点 (n,m) 的最短距离
        printf("%d\n", dis[n][m]);
    }
    return 0;
}

习题:P13823 「Diligent-OI R2 C」所谓伊人

解题思路

首先分析点权交换的规则,规则是:如果 \(u\) 能到达 \(v\),则可以交换 \(p_u\)\(p_v\)。这个操作的本质是,点权可以在 \(u\)\(v\) 之间双向流动。

  • 如果有边 \(u \to v\),则 \(u\) 能到达 \(v\),可以交换 \(p_u, p_v\)
  • 如果有路径 \(u \to v \to w\),则 \(u\) 能到达 \(v\)\(v\) 能到达 \(w\)\(u\) 也能到达 \(w\),这意味着 \(p_u, p_v, p_w\) 之间可以互相交换。例如,想交换 \(p_u, p_w\),可以先交换 \(p_u, p_v\),再交换 \(p_v, p_w\)

由此可以推断,只要两个点在图的无向版本中是连通的,它们之间就可以通过一系列交换来互换点权。因此,对于任何一个点 \(i\),它能获得的最大权,就是它所在的无向连通分量中的最大点权。

目标是把连通分量中的最大值 \(\text{maxp}\),从其所在的源点 \(s\) 移动到目标点 \(t\),求最少交换次数。

一次交换 \(\text{swap}(u,v)\) 的前提是存在 \(u \to \cdots \to v\) 的路径,这次交换的代价是 1。

考虑将 \(\text{maxp}\)\(s\) 移动到 \(t\),如果存在路径 \(s \to \cdots \to t\),可以直接执行 \(\text{swap}(s,t)\),代价为 1。如果存在路径 \(t \to \cdots \to s\),也可以执行 \(\text{swap}(t, s)\),代价同样为 1。

如果路径不是直接的,比如 \(s \to \cdots \to u\)\(t \to \cdots \to u\)。可以先 \(\text{swap}(s,u)\)(代价 1),此时 \(\text{maxp}\) 到了 \(u\) 点。然后,由于存在 \(t \to \cdots \to u\) 的路径,可以 \(\text{swap}(t,u)\)(代价为 1),将 \(\text{maxp}\)\(u\) 移动到 \(t\),总代价为 2。

这启发我们把问题建模成一个最短路问题,需要找到一种代价模型,使得从所有源点 \(s\) 出发到所有其他点的最短路是最少交换次数。

这个问题的代价不是简单的 1,关键在于:沿着一条已经建立的“交换路径”继续传递值是无代价的

例如,存在路径 \(s \to u \to v\),想把 \(\text{maxp}\)\(s\) 移动到 \(v\)

  • 可以直接 \(\text{swap}(s,v)\),因为 \(s\) 可以到达 \(v\),代价为 1。
  • 也可以看作 \(\text{swap}(s,u)\)(代价 1),然后 \(\text{maxp}\) 到了 \(u\)。此时,由于 \(s\) 已经和路径上的点建立了联系,可以认为将 \(\text{maxp}\)\(u\) 移动到 \(v\) 是这次交换的延续,不产生新代价。

这表明,一次交换的代价为 1,而沿着同方向路径的传递代价为 0,这正是 0-1 BFS 的典型应用场景。

  • 状态:需要定义 BFS 的状态,仅仅一个节点 \(u\) 是不够的,因为到达 \(u\) 的方式(顺着边来,还是逆着边来)会影响后续的代价。因此,状态定义为 \((u,d)\),表示 \(\text{maxp}\) 到达了节点 \(u\)\(d=0\) 表示是顺着有向边方向到达的,\(d=1\) 表示是逆着有向边方向到达的。
  • 代价
    • 代价为 1(开启一次新的交换)
      1. 从一个源点 \(s\) 开始移动到邻居。
      2. 改变移动方向,例如,顺着 \(u \to v\) 到达 \(v\) 后,又逆着 \(w \to v\) 到达 \(w\)
    • 代价为 0(延续一次交换):从非源点 \(u\) 继续沿着同方向移动,例如,顺着 \(w \to u\) 到达 \(u\) 后,继续顺着 \(u \to v\) 到达 \(v\)
参考代码
#include <cstdio>
#include <vector>
#include <deque>
#include <utility>
#include <algorithm>
using namespace std;
using pi = pair<int, int>;
const int N = 5e5 + 5;
const int INF = 1e9;

int p[N], maxp[N], dis[N][2], start[N];
vector<int> g[N], fwd[N], bwd[N], sc[N];
int sc_cnt;
bool vis[N];

// DFS 用于找出图中的所有(无向)连通分量
void dfs(int u, int id) {
    sc[id].push_back(u); // 将节点 u 加入到编号为 id 的连通分量中
    maxp[id] = max(maxp[id], p[u]); // 更新该连通分量的最大点权
    vis[u] = true;
    for (int v : g[u]) { // g 是无向图邻接表
        if (!vis[v]) {
            dfs(v, id);
        }
    }
}

int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &p[i]);
    }
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        // g 用于建无向图,找连通分量
        g[u].push_back(v); g[v].push_back(u);
        // fwd 和 bwd 用于建有向图,跑 0-1 BFS
        fwd[u].push_back(v); // 正向边
        bwd[v].push_back(u); // 反向边
    }

    // 1. 找出所有连通分量,并记录每个分量中的最大点权
    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            dfs(i, sc_cnt);
            sc_cnt++;
        }
    }

    // 初始化所有距离为无穷大
    for (int i = 1; i <= n; i++) {
        dis[i][0] = dis[i][1] = INF;
    }

    // 2. 对每个连通分量分别执行 0-1 BFS
    for (int i = 0; i < sc_cnt; i++) {
        deque<pi> q; // 双端队列,用于 0-1 BFS
        // 找到分量中所有拥有最大权值的点,作为 BFS 的源点
        for (int x : sc[i]) {
            if (p[x] == maxp[i]) {
                // 源点距离为 0,可以作为顺向或逆向路径的起点
                q.push_back({x, 0});
                q.push_back({x, 1});
                dis[x][0] = dis[x][1] = 0;
                start[x] = 1; // 标记为最大权值的初始源点
            }
        }

        // Lambda 表达式,用于更新距离
        auto update = [&](int u, int d1, int v, int d2, int val) {
            if (dis[u][d1] + val < dis[v][d2]) {
                dis[v][d2] = dis[u][d1] + val;
                if (val) q.push_back({v, d2}); // 代价为 1,入队尾
                else q.push_front({v, d2});   // 代价为 0,入队首
            }
        };

        // 0-1 BFS 过程
        while (!q.empty()) {
            int u = q.front().first;
            int dir = q.front().second; // 0: 顺向到达 u, 1: 逆向到达 u
            q.pop_front();

            // 遍历 u 的所有正向出边 u -> v
            for (int v : fwd[u]) {
                // 尝试更新 v,新状态是顺向到达 v (dir=0)
                // 代价计算:如果是在一条顺向路径上延续(dir=0),且u不是源点,则代价为0。
                // 否则(u是源点或之前是逆向路径),开启一条新路径,代价为1。
                update(u, dir, v, 0, dir == 0 ? start[u] : 1);
            }
            // 遍历 u 的所有反向入边 v -> u
            for (int v : bwd[u]) {
                // 尝试更新 v,新状态是逆向到达 v (dir=1)
                // 代价计算:逻辑同上,对称
                update(u, dir, v, 1, dir == 0 ? 1 : start[u]);
            }
        }
    }

    // 3. 输出结果
    for (int i = 1; i <= n; i++) {
        printf("%d ", min(dis[i][0], dis[i][1]));
    }
    return 0;
}

习题:CF1340C Nastya and Unexpected Guest

解题思路

这是一个在特定约束下的最短路问题,可以通过图论建模来解决。

问题的关键在于,位置不仅和所在的安全岛有关,还和当前绿灯过去了多久有关,因为绿灯剩余时间决定了还能走多远。

因此,可以定义一个状态 \((i,j)\),表示:

  • 当前位于第 \(i\) 个安全岛(安全岛按坐标排序后的编号)
  • 当前这个绿灯周期已经过去了 \(j\)

从一个状态 \((i,j)\) 出发,可以尝试移动到相邻的安全岛 \(i'\)(即 \(i-1\)\(i+1\))。

  • 移动耗时:从第 \(i\) 个安全岛到第 \(i'\) 个安全岛的距离为 \(\text{dist} = |d_{i'} - d_i|\),这会消耗 \(\text{dist}\) 秒的绿灯时间。
  • 新状态:到达 \(i'\) 后,当前绿灯周期已用时间会变为 \(j' = j + \text{dist}\)

接下来,分析 \(j'\) 的值:

  1. \(j' \lt g\):在当前红绿灯周期内成功到达了 \(i'\),此时状态变为 \((i',j')\),这个转移没有跨越一个完整的红绿灯周期
  2. \(j' = g\):恰好在绿灯结束的瞬间到达了 \(i'\),此时,需要在 \(i'\) 等待一个完整的红灯周期(\(r\) 秒)。等待结束后,新的绿灯周期开始,状态变为 \((i',0)\),这个转移跨越了一个完整的红绿灯周期\(g+r\) 秒)。
  3. \(j' \gt g\):这意味着在当前红绿灯周期内,无法从 \(i\) 移动到 \(i'\),这是一个非法的移动。

可以发现,状态转移的“代价”有两种:

  • 代价为 0:在同一个绿灯周期内移动,总时间增加 \(\text{dist}\)
  • 代价为 1:跨越一个红绿灯周期,总时间增加 \(\text{dist} + r\)

目标是最小化总时间,注意到总时间由两部分组成:完整的红绿灯周期数最后一个不满周期的绿灯时间

总时间 = 周期数 * (g + r) + 最后一个绿灯耗时

为了最小化总时间,应该优先最小化周期数,这启发我们使用 0-1 BFS

  • 图的节点\((i,j)\) 状态。
  • 图的边权
    • \((i,j)\)\((i',j')\)(当 \(j' \lt g\)),边权为 0。
    • \((i,j)\)\((i',0)\)(当 \(j' = g\)),边权为 1。
  • \(\text{dis}_{i,j}\) 存储到达状态 \((i,j)\) 所需的最少完整周期数
  • BFS 队列:使用一个双端队列 deque
    • 当遇到 0 权边时,将新状态加入队首。
    • 当遇到 1 权边时,将新状态加入队尾。

这样可以保证总是优先扩展周期数最少的状态,符合最短路的要求。

参考代码
#include <cstdio>
#include <utility>
#include <deque>
#include <algorithm>
using namespace std;
// PII 用于存储状态 {安全岛编号, 绿灯已用时间}
typedef pair<int, int> PII;
const int M = 10005; // 安全岛数量上限
const int G = 1005;  // 绿灯时间上限

int d[M];        // 存储排序后的安全岛坐标
// dis[i][j]: 到达状态(安全岛i, 绿灯已用时j)所需的最少完整周期数
// 值为-1表示不可达
int dis[M][G];   
deque<PII> q;    // 0-1 BFS 使用的双端队列

// 松弛操作的辅助函数,用于更新状态和队列
// curm, curg: 当前状态 (current island, current green time)
// nxtm, nxtg: 下一个状态 (next island, next green time)
// g: 绿灯总时间
void relax(int curm, int curg, int nxtm, int nxtg, int g) {
    // 情况1:在同一个绿灯周期内到达下一个安全岛 (0权边)
    // 移动后,绿灯时间没有用完
    if (nxtg < g) {
        // 如果 nxtm 状态是第一次到达,或者找到了更少的周期数
        if (dis[nxtm][nxtg] == -1 || dis[curm][curg] < dis[nxtm][nxtg]) {
            dis[nxtm][nxtg] = dis[curm][curg]; // 周期数不变
            q.push_front({nxtm, nxtg}); // 0权边,优先处理,入队首
        }
    } 
    // 情况2:恰好用完一个绿灯周期 (1权边)
    // 移动后,绿灯时间恰好用完
    if (nxtg == g) {
        // 新状态是下一个周期的开始,即 (nxtm, 0)
        // 如果 (nxtm, 0) 状态是第一次到达,或者找到了更少的周期数
        if (dis[nxtm][0] == -1 || dis[curm][curg] + 1 < dis[nxtm][0]) {
            dis[nxtm][0] = dis[curm][curg] + 1; // 周期数+1
            q.push_back({nxtm, 0}); // 1权边,延后处理,入队尾
        }
    }
}

int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) scanf("%d", &d[i]);
    sort(d + 1, d + m + 1); // 对安全岛坐标排序
    int g, r; scanf("%d%d", &g, &r);

    // 初始化dis数组为-1,表示所有状态初始时都不可达
    for (int i = 1; i <= m; i++)
        for (int j = 0; j < g; j++)
            dis[i][j] = -1; 

    // --- 0-1 BFS ---
    // 起点状态:第1个安全岛,绿灯已用时0,周期数为0
    q.push_back({1, 0}); 
    dis[1][0] = 0;

    while (!q.empty()) {
        PII cur = q.front(); q.pop_front();
        int curm = cur.first; // 当前所在安全岛编号
        int curg = cur.second; // 当前绿灯已用时间

        // 尝试向左移动 (到前一个安全岛)
        if (curm > 1) {
            int nxtm = curm - 1;
            int dist = d[curm] - d[nxtm];
            int nxtg = curg + dist; // 计算新的绿灯已用时间
            if (nxtg <= g) relax(curm, curg, nxtm, nxtg, g);
        } 
        // 尝试向右移动 (到后一个安全岛)
        if (curm < m) {
            int nxtm = curm + 1;
            int dist = d[nxtm] - d[curm];
            int nxtg = curg + dist;
            if (nxtg <= g) relax(curm, curg, nxtm, nxtg, g);
        }
    }

    // --- 计算最终答案 ---
    int ans = -1;
    // 遍历终点(第m个安全岛)的所有可能状态
    for (int i = 0; i < g; i++) {
        // 如果该状态可达
        if (dis[m][i] != -1) {
            // 总时间 = 完整周期数 * (绿灯+红灯) + 最后一段绿灯时间
            int res = dis[m][i] * (g + r) + i;
            // 特殊情况: 如果最后一段绿灯时间为0 (i=0),
            // 说明恰好在一个周期的末尾到达终点,不需要再等红灯。
            // 但前提是至少经过了一个周期,dis[m][0]>0
            if (i == 0 && dis[m][i] > 0) res -= r;
            if (ans == -1 || res < ans) ans = res; 
        }
    }

    printf("%d\n", ans);
    return 0;
}

次短路

例题:P1491 集合位置

路径上不允许经过重复的点(如果最短路有多条,次短路长度就是最短路长度)。

解题思路

先求出一条最短路的路径,次短路一定不会把求出来的那条最短路全都经过一遍,至少有一条边不属于求出来的那条最短路径上的边。

枚举删除最短路径上的一条边,重新跑最短路。

时间复杂度为 \(O(n^3)\)\(O(nm \log m)\)

参考代码
#include <cstdio>
#include <algorithm>
#include <cmath>
const int N = 205;
const double INF = 1e9;
int n, x[N], y[N], pre[N];
double g[N][N], dis[N];
bool vis[N];
double distance(int i, int j) {
    double dx = x[i] - x[j];
    double dy = y[i] - y[j];
    return sqrt(dx * dx + dy * dy);
}
void dijkstra(bool rec) {
    for (int i = 1; i <= n; i++) {
        dis[i] = INF; vis[i] = false;
    }
    dis[1] = 0;
    while (true) {
        int u = -1;
        for (int i = 1; i <= n; i++)
            if (!vis[i] && (u == -1 || dis[i] < dis[u])) u = i;
        if (u == -1) break;
        vis[u] = true;
        for (int i = 1; i <= n; i++) {
            double w = g[u][i];
            if (dis[u] + w < dis[i]) {
                dis[i] = dis[u] + w;
                if (rec) pre[i] = u;
            }
        }
    }
}
int main()
{
    int m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%d%d", &x[i], &y[i]);
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) g[i][j] = INF;
        g[i][i] = 0;
    }
    for (int i = 1; i <= m; i++) {
        int p, q; scanf("%d%d", &p, &q);
        g[p][q] = g[q][p] = std::min(g[p][q], distance(p, q));
    } 
    dijkstra(true);
    if (dis[n] == INF) {
        printf("-1\n"); return 0;
    }
    int u = n;
    double ans = INF;
    while (u != 1) {
        double tmp = g[pre[u]][u];
        g[pre[u]][u] = g[u][pre[u]] = INF;
        dijkstra(false);
        ans = std::min(ans, dis[n]);
        g[pre[u]][u] = g[u][pre[u]] = tmp;
        u = pre[u];
    }
    if (ans == INF) printf("-1\n");
    else printf("%.2f\n", ans);
    return 0;
}

例题:P2865 [USACO06NOV] Roadblocks G

允许经过重复点和重复边的最短路。

解题思路

类似分层图,用 \(dis_{u,0}\) 记从起点到 \(u\) 的最短路,\(dis_{u,1}\) 记从起点到 \(u\) 的次短路。

初始化 \(dis_{1,0} = 0\)\(dis_{1,1} = \infty\),往优先队列里放 (dis, 点),刚开始放 (0, 1)

vis 标记也要给每个点准备两份(有两次取出机会),一个点 \(u\) 第一次取出来的时候,一定是最短路,第二次取出来的时候,就是次短路了,所以每个点只会有两次有效取出,有效取出才会往后扫边更新。

对于边 \(u \rightarrow v\)

  • 如果当前这一次能更新最短路,就把原最短路放到次短路上,更新最短路,将点 \(v\) 和这次更新后的 \(dis\) 放入优先队列。
  • 如果当前这一次只能更新次短路,就把次短路更新,将点 \(v\) 和这次更新后的 \(dis\) 放入优先队列。

\(u\) 在第一次取出时标记 \(vis_{u,0}\),第二次取出时标记 \(vis_{u,1}\),之后如果再取出来的时候 \(vis_{u,0}\)\(vis_{u,1}\) 均已被标记,说明这个堆顶已经无效了,直接 continue

参考代码
#include <cstdio>
#include <vector>
#include <queue>
using pr = std::pair<int, int>;
const int N = 5005;
const int INF = 1e9;
std::vector<pr> g[N];
int dis[N][2];
bool vis[N][2];
int main()
{
    int n, r; scanf("%d%d", &n, &r);
    for (int i = 1; i <= r; i++) {
        int a, b, d; scanf("%d%d%d", &a, &b, &d);
        g[a].push_back({b, d});
        g[b].push_back({a, d});
    }
    std::priority_queue<pr, std::vector<pr>, std::greater<pr>> q;
    for (int i = 1; i <= n; i++) dis[i][0] = dis[i][1] = INF;
    dis[1][0] = 0; q.push({0, 1});
    while (!q.empty()) {
        pr p = q.top(); q.pop();
        int u = p.second, x = -1;
        if (!vis[u][0]) {
            vis[u][0] = true; x = 0;
        } else if (!vis[u][1]) {
            vis[u][1] = true; x = 1;
        } else continue;
        for (pr e : g[u]) {
            int v = e.first, w = e.second;
            int tmp = dis[u][x] + w;
            if (tmp < dis[v][0]) {
                dis[v][1] = dis[v][0];
                dis[v][0] = tmp;
                q.push({dis[v][0], v});
            } else if (tmp > dis[v][0] && tmp < dis[v][1]) {
                dis[v][1] = tmp;
                q.push({dis[v][1], v});
            }
        }
    }
    printf("%d\n", dis[n][1]);
    return 0;
}

习题:P10947 Sightseeing

解题思路

维护最短路、严格次短路的长度和数量,最后看次短路的长度是不是刚好是最短路长度 +1,从而决定是否要将次短路数量加到最终答案中。

参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <queue>
using namespace std;
using pi = pair<int, int>;
const int N = 1005;
const int INF = 1e9;

vector<pi> g[N]; // 邻接表存储图
int dis[N][2];   // dis[i][0]存最短路, dis[i][1]存次短路
int cnt[N][2];   // cnt[i][0]存最短路数量, cnt[i][1]存次短路数量
bool vis[N][2];  // vis[i][0/1]标记i的最短/次短路是否已确定

void solve() {
    int n, m; scanf("%d%d", &n, &m);
    // --- 初始化每个测试用例的数据 ---
    for (int i = 1; i <= n; i++) {
        g[i].clear();
        dis[i][0] = dis[i][1] = INF;
        cnt[i][0] = cnt[i][1] = 0;
        vis[i][0] = vis[i][1] = false;
    }
    for (int i = 1; i <= m; i++) {
        int a, b, l; scanf("%d%d%d", &a, &b, &l);
        g[a].push_back({b, l});
    }
    int s, f; scanf("%d%d", &s, &f);

    // --- 扩展 Dijkstra 算法 ---
    priority_queue<pi, vector<pi>, greater<pi>> q; // 小顶堆优先队列
    q.push({0, s}); // {距离, 节点}
    dis[s][0] = 0;  // 起点的最短路是0
    cnt[s][0] = 1;  // 路径数量是1

    while (!q.empty()) {
        int d = q.top().first, u = q.top().second;
        q.pop();

        // 如果u的最短和次短路都已确定,则跳过
        if (vis[u][0] && vis[u][1]) continue;

        // 判断当前取出的(d, u)是u的最短路还是次短路
        int x = 0; // 0代表最短路, 1代表次短路
        if (vis[u][0]) x = 1; // 如果最短路已确定,则当前处理的是次短路
        vis[u][x] = true; // 标记对应类型的路径已确定

        // --- 松弛操作 ---
        for (pi e : g[u]) {
            int v = e.first, w = e.second;
            int new_dist = d + w;

            // Case 1: 发现更短的最短路
            if (new_dist < dis[v][0]) {
                // 原最短路降级为次短路
                dis[v][1] = dis[v][0];
                cnt[v][1] = cnt[v][0];
                // 更新最短路
                dis[v][0] = new_dist;
                cnt[v][0] = cnt[u][x];
                q.push({dis[v][0], v});
            } 
            // Case 2: 发现等长的最短路
            else if (new_dist == dis[v][0]) {
                cnt[v][0] += cnt[u][x];
            } 
            // Case 3: 发现更短的次短路
            else if (new_dist < dis[v][1]) {
                dis[v][1] = new_dist;
                cnt[v][1] = cnt[u][x];
                q.push({dis[v][1], v});
            } 
            // Case 4: 发现等长的次短路
            else if (new_dist == dis[v][1]) {
                cnt[v][1] += cnt[u][x];
            }
        }
    }

    // --- 计算最终答案 ---
    int ans = cnt[f][0]; // 先加上最短路的数量
    // 如果次短路长度恰好是“最短路+1”
    if (dis[f][1] == dis[f][0] + 1) {
        ans += cnt[f][1]; // 再加上次短路的数量
    }
    printf("%d\n", ans);
}

int main()
{
    int t; scanf("%d", &t);
    for (int i = 1; i <= t; i++) {
        solve();
    }
    return 0;
}

同余最短路

同余最短路就是把余数相同的情况归为一类,找形成这种情况的最短路径的问题,通常与周期性问题有关。

例题:P3403 跳楼机

给定 \(x,y,z,h\),对于 \(k \in [1,h]\),有多少个 \(k\) 能够满足 \(ax+by+cz=k\)
\(a,b,c \ge 0; 1 \le x,y,z \le 10^5; h \le 2^{63}-1\)

\(dis_i\) 表示只通过 操作 1操作 2,满足 \(p \ {\rm mod } \ z = i\) 能够达到的最低楼层 \(p\),即 操作 1操作 2 后能得到的模 \(z\) 意义下与 \(i\) 同余的最小数,用来计算该同余类满足条件的数的个数。

可以得到两种转移:

  • \(i \stackrel{x}{\longrightarrow} (i+x) \ {\rm mod } \ z\)
  • \(i \stackrel{y}{\longrightarrow} (i+y) \ {\rm mod } \ z\)

这相当于对 \(i\)\((i+x) \ {\rm mod } \ z\) 建了一条边权为 \(x\) 的边,对 \(i\)\((i+y) \ {\rm mod } \ z\) 建了一条边权为 \(y\) 的边。

接下里只需要求出 \(dis_0, dis_1, dis_2, \dots, dis_{z-1}\),只需要跑一次最短路就可求出相应的 \(dis_i\)

答案即为:\(\sum \limits_{i=0}^{z-1} (\frac{h-dis_i}{z}+1)\),加 \(1\) 是因为 \(dis_i\) 所在楼层也算一次。

#include <cstdio>
#include <queue>
#include <utility>
using namespace std;
typedef long long LL;
typedef pair<LL, int> PLI;
const int N = 100005;
const LL INF = 1e15;
LL dis[N];
bool vis[N];
int main()
{
	LL h; int x, y, z;
	scanf("%lld%d%d%d", &h, &x, &y, &z);
	for (int i = 0; i < z; i++) dis[i] = INF;
	dis[1 % z] = 1; 
    priority_queue<PLI, vector<PLI>, greater<PLI>> q; // <dis, id>
	q.push({1, 1 % z});
	while (!q.empty()) {
		int u = q.top().second; q.pop();
		if (vis[u]) continue;
		vis[u] = true;
		// +x
		int nxt = (u + x) % z;
		if (dis[u] + x < dis[nxt]) {
			dis[nxt] = dis[u] + x;
			q.push({dis[nxt], nxt});
		}
		// +y
		nxt = (u + y) % z;
		if (dis[u] + y < dis[nxt]) {
			dis[nxt] = dis[u] + y;
			q.push({dis[nxt], nxt});
		}
	}
	LL ans = 0;
	for (int i = 0; i < z; i++)
		if (dis[i] <= h && dis[i] != INF) ans += (h - dis[i]) / z + 1;
	printf("%lld\n", ans);
	return 0;
}

习题:[ABC077D] Small Multiple

给定 \(k\),求 \(k\) 的倍数中,数位和最小的那一个的数位和。\((2 \le k \le 10^5)\)

解题思路

任意一个正整数都可以从 \(1\) 开始,按照某种顺序执行 \(\times 10\)\(+1\) 两种操作得到,而其中 \(+1\) 操作的次数就是这个数的数位和。考虑最短路。

对于所有的 \(0 \le x \le k-1\),从 \(x\)\(10x\) 连边权为 \(0\) 的边,从 \(x\)\(x+1\) 连边权为 \(1\) 的边。(模 \(k\) 意义下)

每个 \(k\) 的倍数在这个图中都对应从 \(1\) 号点到 \(0\) 号点的一条路径,求出最短路即可。某些路径不合法(如连续走 \(10\)\(+1\)),但这些路径产生的答案不优,不影响最终结果。

时间复杂度为 \(O(k)\)

参考代码
#include <cstdio>
#include <deque>
using namespace std;
const int N = 100005;
const int INF = 1e9;
int dis[N];
int main()
{
    int k; scanf("%d", &k);
    for (int i = 0; i < k; i++) {
        dis[i] = INF;
    }
    dis[1] = 1;
    deque<int> dq; dq.push_back(1);
    while (!dq.empty()) {
        int u = dq.front(); dq.pop_front();
        // *10
        int v = u * 10 % k;
        if (dis[u] < dis[v]) {
            dq.push_front(v); dis[v] = dis[u];
        }
        // +1
        v = (u + 1) % k;
        if (dis[u] + 1 < dis[v]) {
            dq.push_back(v); dis[v] = dis[u] + 1;
        }
    }
    printf("%d\n", dis[0]);
    return 0;
}

习题:P2371 [国家集训队] 墨墨的等式

解题思路

首先,最棘手的数据范围是 \(r\) 可以达到 \(10^{12}\),这彻底排除了任何与值域 \(r\) 相关的动态规划或直接枚举的方法。

这个问题的核心是“哪些数可以被凑出来”。一个关键的性质是:如果能凑出数字 \(B\),并且 \(a_1\) 是其中一个系数,那么我们必然也能凑出 \(B+a_1, B+2a_1, B+3a_1, \ldots\)

这启发我们从同余的角度思考。选择一个基准数作为模数,不妨选择所有 \(a_i\) 中最小的那个非零数,记为 \(\text{mina}\)

现在,任何一个能被凑出的数 \(B\),都可以根据它对 \(\text{mina}\) 的余数进行分类。假设 \(B \equiv k \pmod{\text{mina}}\)

如果能找到对于每个余数 \(k \in [0, \text{mina} - 1]\),能够凑出的最小的那个数,记为 \(d_k\),那么所有其他能凑出的、且余数也为 \(k\) 的数,都可以通过 \(d_k\) 加上若干个 \(\text{mina}\) 得到。即,能凑出的、余数为 \(k\) 的数的集合是:\(\{ d_k, d_k+\text{mina}, d_k+2\text{mina}, \dots \}\)

这样就把一个在巨大值域上的问题,转化为了 \(\text{mina}\) 个等差数列的计数问题。

问题的核心变为:如何求出对于每个余数 \(k\),最小的能凑出的数 \(d_k\) 是多少?

这可以被建模成一个最短路问题

  • 建图:建立一个包含 \(\text{mina}\) 个节点的图,节点编号从 \(0\)\(\text{mina}-1\),分别代表对 \(\text{mina}\) 的余数。
  • 边的定义:对于图中的任意一个节点 \(u\)(代表余数 \(u\)),可以尝试加上任意一个 \(a_i\)。这会得到一个新的数,其值为“某个凑出余数为 \(u\) 的数”加上 \(a_i\)。这个新数对 \(\text{mina}\) 的余数是 \((u+a_i) \bmod \text{mina}\)。因此,可以从节点 \(u\) 向节点 \((u+a_i) \bmod \text{mina}\) 连一条有向边,边权为 \(a_i\)。这里的边权代表了为了实现这次状态(余数)转移,给总和增加了多少。
  • 最短路求解:想求的是凑出每个余数的最小总和,这恰好对应了图论中的单源最短路问题。
    • 源点:源点是节点 \(0\),因为数字 \(0\) 可以被凑出(所有 \(x_i=0\)),它的余数是 \(0\),总和也是 \(0\),所以 \(d_0=0\)
    • 算法:图中所有边权 \(a_i\) 都是非负的,因此可以使用 Dijkstra 算法来求解从源点 \(0\) 到所有其他节点的最短路。
    • 结果:Dijkstra 算法结束后,\(d_k\) 就存储了从源点 \(0\) 出发,到达节点 \(k\) 的最短路径长度,也就是能凑出的、余数为 \(k\) 的最小数值。

现在有了 \(d_k\) 数组,对于每个余数 \(k \in [0, \text{mina}-1]\),可知能凑出的数是 \(d_k + i \cdot \text{mina}\)(其中 \(i \ge 0\))。

需要统计在区间 \([l,r]\) 内有多少这样的数,这个问题可以利用前缀和/差分思想解决:\(\text{count}(l, r) = \text{count}(0, r) - \text{count}(0, l-1)\)

只需要实现一个函数 \(\text{count}(K)\) 来计算在 \([0,K]\) 中有多少个数能被凑出。

对于每个余数 \(k\)

  1. 如果 \(d_k \gt K\),那么这个余数对应的等差数列中没有任何数在 \([0,K]\) 内。
  2. 如果 \(d_k \le K\),那么需要计算有多少个非负整数 \(i\) 满足 \(d_k + i \cdot \text{mina} \le K\)
    • \(i \cdot \text{mina} \le K - d_k\)
    • \(i \le (K-d_k) / \text{mina}\)
    • 由于 \(i\)\(0\) 开始,所以满足条件的 \(i\) 的个数是 \(\lfloor (K-d_k)/ \text{mina} \rfloor +1\)

将所有余数 \(k\) 的结果累加起来,就得到了 \(\text{count}(K)\),最终答案即为 \(\text{count}(r) - \text{count}(l-1)\)

参考代码
#include <cstdio>
#include <vector>
#include <queue>
#include <algorithm> 

using namespace std;
using ll = long long;

// 图的边结构
struct Edge {
    int to; // 边的终点
    int w;  // 边的权值
};

// Dijkstra 算法中优先队列的元素结构
struct Distance {
    int u;      // 当前节点编号
    ll dis;     // 从源点到该节点的距离

    // 重载小于号,用于构建最小堆
    bool operator<(const Distance &other) const {
        return dis > other.dis;
    }
};

int main()
{
    int n; 
    ll l, r;
    scanf("%d%lld%lld", &n, &l, &r);

    vector<int> a;
    int mod = 500001; // a[i] 的上限 + 1
    for (int i = 0; i < n; i++) {
        int val;
        scanf("%d", &val);
        if (val != 0) {
            a.push_back(val);
            mod = min(mod, val); // 找到 a[i] 中最小的非零数作为模数
        }
    }

    // --- 同余最短路建图 ---
    // 节点 0, 1, ..., mod-1 代表对 mod 的余数
    vector<vector<Edge>> g(mod);
    for (int i = 0; i < mod; i++) { // 对于每个余数 i
        for (int x : a) { // 尝试加上每个 a[j]
            if (x == mod) continue; // 加模数本身不改变余数,但会增加不必要的边
            // 从余数 i 出发,加上 x 后,到达余数 (i+x)%mod
            // 边权为 x,代表总和增加了 x
            g[i].push_back({(i + x) % mod, x});
        }
    }

    // --- Dijkstra 算法求最短路 ---
    priority_queue<Distance> pq;
    vector<ll> dist(mod, -1); // dist[i] 存储凑出余数为 i 的最小数值,-1表示无穷大

    // 源点是0,因为总和为0可以凑出,余数也为0
    pq.push({0, 0});
    dist[0] = 0;

    while (!pq.empty()) {
        Distance t = pq.top(); pq.pop();

        // 如果是已经处理过的旧状态,则跳过
        if (t.dis > dist[t.u] && dist[t.u] != -1) continue;
        // 如果当前凑出的最小数已经大于r,则无需继续扩展
        if (t.dis > r) continue;

        // 遍历当前节点的所有出边
        for (Edge e : g[t.u]) {
            // 松弛操作
            if (dist[e.to] == -1 || t.dis + e.w < dist[e.to]) {
                dist[e.to] = t.dis + e.w;
                pq.push({e.to, dist[e.to]});
            }
        }
    }

    // --- 统计答案 ---
    ll ans = 0;
    // 遍历所有余数
    for (int i = 0; i < mod; i++) {
        // 如果这个余数可以凑出 (dist[i] != -1)
        if (dist[i] != -1) {
            // 计算 [0, r] 中有多少个数能凑出
            ll count_r = r < dist[i] ? 0 : (r - dist[i]) / mod + 1;
            // 计算 [0, l-1] 中有多少个数能凑出
            ll count_l_minus_1 = (l - 1 < dist[i]) ? 0 : ((l - 1 - dist[i]) / mod + 1);
            // 差分得到 [l, r] 中的个数
            ans += count_r - count_l_minus_1;
        }
    }

    printf("%lld\n", ans);
    return 0;
}

Bellman-Ford

\(dis_{i,u}\) 表示从起点经过 \(i\) 条边到达点 \(u\) 的最短路,则有 \(dis_{i,v} = \min \{ dis_{i-1,u} + w(u,v) \}\)

在没有负环的图上,最短路最多经过 \(n-1\) 条边,因此第一维也只需要做到 \(n-1\)

如果求至多经过 \(i\) 条边时的最短路,可以在每一轮开始前让 $dis_{i,u} \leftarrow dis_{i-1,u} $。

时间复杂度为 \(O(nm)\),空间可以压缩到一维。

类似于分层图,但因为没有 \(dis_{i,u}\) 更新 \(dis_{i,v}\) 的情况,所以不用优先队列,直接按层推就可以。

压维前:

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= n; j++) dis[i][j] = dis[i - 1][j];
    for (int j = 1; j <= n; j++) 
        for (Edge e : g[j]) 
            dis[i][e.v] = min(dis[i][e.v], dis[i - 1][j] + e.w);
}

压维后:

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= n; j++) 
        for (Edge e : g[j]) 
            dis[e.v] = min(dis[e.v], dis[j] + e.w);
}

例题:B3601 [图论与代数结构 201] 最短路问题_1

参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <algorithm>
using ll = long long;
const int N = 2005;
const ll INF = 1e18;
std::vector<std::pair<int, int>> g[N];
ll dis[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        g[u].push_back({v, w});
    }
    for (int i = 1; i <= n; i++) dis[i] = INF;
    dis[1] = 0;
    for (int i = 1; i < n; i++) {
        for (int j = 1; j <= n; j++)
            for (auto e : g[j]) {
                int v = e.first, w = e.second;
                dis[v] = std::min(dis[v], dis[j] + w);
            }
    }
    for (int i = 1; i <= n; i++) printf("%lld ", dis[i] == INF ? -1 : dis[i]);
    return 0;
}

队列优化的 Bellman-Ford

回顾 Bellman-Ford 算法的计算过程,如果 \(3\) 这个点在第 \(2\) 轮最短路没发生更新,还用 \(3\) 去参与下一轮吗?因为 \(dis_{2,3} 和 dis_{1,3}\) 一样,用 \(dis_{2,3}\) 往后更新和 \(dis_{1,3}\) 往后更新效果是一样的,而这个过程在第 \(2\) 轮已经做过了,所以不用让其参加下一轮。但要注意,这个不用是指“暂时不用”,如果某一轮它的 \(dis\) 又被更新到了那么它还要继续参加之后的下一轮。

于是可以用一个队列维护哪些点已经被更新(处于活跃状态)。

每次将最短路 \(dis\) 更新到的点放入队列,一般会用一个数组表示这个点现在在不在队列中,避免重复入队,每次入队时标记这个点在队列里了,出队时标记它不在队列里了。

常见的其它常数优化:

  • SLF:将普通队列换成双端队列,每次将入队结点距离和队首比较,如果更大则插入队尾,否则插入队首。
  • LLL:将普通队列换成双端队列,每次将入队结点和队内距离平均值比较,如果更大则插入至队尾,否则插入队首。

注意最差时间复杂度仍然是 \(O(nm)\),如果图中没有负权边还是应该考虑 Dijkstra 算法。

在一般随机图上跑不满,制作 hack 数据的方法详见知乎:如何看待 SPFA 算法已死这种说法?

例题:B3601 [图论与代数结构 201] 最短路问题_1

参考代码
#include <cstdio>
#include <algorithm>
#include <vector>
#include <queue>
#include <utility>
using ll = long long;
const int N = 2005;
const ll INF = 1e18;
std::vector<std::pair<int, int>> g[N];
ll dis[N];
bool inq[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        g[u].push_back({v, w});
    }
    for (int i = 1; i <= n; i++) dis[i] = INF;
    std::queue<int> q;
    q.push(1); dis[1] = 0; inq[1] = true;
    while (!q.empty()) {
        int u = q.front(); q.pop(); inq[u] = false;
        for (auto e : g[u]) {
            int v = e.first, w = e.second;
            if (dis[v] > dis[u] + w) {
                dis[v] = dis[u] + w;
                if (!inq[v]) {
                    q.push(v); inq[v] = true;
                }
            }
        }
    }
    for (int i = 1; i <= n; i++) printf("%lld ", dis[i] == INF ? -1 : dis[i]);
    return 0;
}

习题:P9126 [USACO23FEB] Moo Route II S

解题思路

这是一个在图上求最短路径的变体问题,可以将 \(N\) 个机场看作图的 \(N\) 个节点,将 \(M\) 条航班看作图的 \(M\) 条有向边。

问题的核心是,边的“权重”不是固定的。能否通过一条边(航班),取决于到达该点的时刻。具体来说,从机场 \(u\)\(v\) 的航班(起飞时间 \(r\),到达时间 \(s\)),只有当满足 \(到达u的时间 + 在u的停留时间 \le r\) 时,才能乘坐。如果能乘坐,到达 \(v\) 的时间就被更新为 \(s\)

目标是最小化每个节点的“到达时间”,这是一个典型的单源最短路问题模型。由于到达时间是更新为特定值而不是累加,传统的 Dijkstra 算法不直接适用(相当于有些时候边权可以理解为负数)。但是,其核心思想——松弛操作,仍然是解决问题的关键。当一个节点的到达时间被更新(变早)时,需要重新检查从它出发的所有路径,看是否能引发连锁的更新。

这自然地引向了 SPFA 算法,SPFA 算法使用一个队列来维护那些“到达时间”被缩短的节点,并不断地从队列中取出节点来更新其邻居,直到队列为空。

然而,朴素的 SPFA 在本题中可能会超时。每次一个节点 \(u\) 的到达时间被更新,都需要检查所有从 \(u\) 出发的航班。如果一个节点被多次更新,其出边也会被多次遍历,在最坏情况下复杂度会达到 \(O(NM)\)

为了优化 SPFA,必须减少冗余的边检查,一个关键的观察是:如果把每个机场出发的航班,按起飞时间 \(r\) 从大到小(降序)排序

当处理机场 \(u\) 时,按排序后的顺序检查其出港航班。假设当前检查到航班 \(j\),其起飞时间为 \(r_j\)

  • 如果 \(到达u的时间 + a_u \le r_j\),说明这个航班可以赶上。用它的到达时间 \(s_j\) 去更新目的地的最早到达时间,并继续检查下一个航班(起飞时间更早)。
  • 如果 \(到达u的时间 + a_u \gt r_j\),说明这个航班已经来不及了。由于航班是按起飞时间降序排列的,后续所有航班的起飞时间都比 \(r_j\) 更早,因此也必然都来不及了。这时可以直接 break,停止对 \(u\) 的后续航班的检查。

这个剪枝大大提高了效率,更进一步地,因为如果某个航班被拿来更新,那么之后这个航班就不需要再考虑了(因为飞某个航班后更新到达时间是个定值)。引入一个指针 \(\text{idx}_u\),记录对于机场 \(u\),已经检查并确认到哪一个航班了。下次再处理到 \(u\) 时,只需要从 \(\text{idx}_u\) 指向的航班开始检查即可,因为之前的航班都已经被处理过了。

参考代码
#include <cstdio>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;

const int N = 200005; // 点和边的最大数量
const int INF = 1e9 + 7; // 代表无穷大,用于初始化距离

// 定义边的结构体
struct Edge {
    int to; // 目的地机场
    int r;  // 起飞时间
    int s;  // 到达时间
};

vector<Edge> graph[N]; // 邻接表存图,graph[i] 存储从机场 i 出发的所有航班
int a[N];      // a[i] 表示在机场 i 的停留时间
int dis[N];    // dis[i] 记录到达机场 i 的最早时间
int idx[N];    // 优化指针,idx[u] 表示下次从 u 出发时,应该从第几条边开始检查
bool inq[N];   // 标记节点是否在队列中,SPFA算法需要

// 比较函数,用于对航班按起飞时间 r 降序排序
bool cmp(const Edge& a, const Edge& b) {
    return a.r > b.r;
}

int main()
{
    int n, m;
    scanf("%d%d", &n, &m);

    // 读入 M 条航班信息
    for (int i = 1; i <= m; i++) {
        int c, r, d, s;
        scanf("%d%d%d%d", &c, &r, &d, &s);
        graph[c].push_back({d, r, s});
    }

    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]); // 读入每个机场的停留时间
        dis[i] = INF;       // 初始化所有机场的到达时间为无穷大
        inq[i] = false;     // 初始化都不在队列中
        // 对每个机场出发的航班按起飞时间 r 降序排序
        // 这是关键优化:方便后续剪枝
        sort(graph[i].begin(), graph[i].end(), cmp);
    }

    // SPFA 算法变体
    queue<int> q;
    q.push(1); // 从 1 号机场开始
    // 初始条件技巧:Bessie 在时间 0 到达机场 1,可以乘坐起飞时间 r >= 0 + a[1] 的航班。
    // 为了统一判断条件 dis[u] + a[u] <= r,将 dis[1] 设为 -a[1],
    // 这样 dis[1] + a[1] <= r 就变成了 0 <= r,符合题意。
    dis[1] = -a[1];
    inq[1] = true;

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        inq[u] = false;

        // 从上次检查的位置 idx[u] 开始遍历从 u 出发的航班
        for (int i = idx[u]; i < graph[u].size(); i++) {
            int v = graph[u][i].to;
            int r = graph[u][i].r;
            int s = graph[u][i].s;

            // 检查是否能赶上这趟航班
            // 到达 u 的时间 dis[u] + 停留时间 a[u] <= 航班起飞时间 r
            // 注意:这里的 dis[u] 是一个抽象值,dis[u]+a[u] 才代表实际可出发时间
            if (dis[u] + a[u] <= r) {
                // 如果乘坐这趟航班可以更早到达 v
                if (s < dis[v]) {
                    dis[v] = s; // 更新到达 v 的最早时间
                    // 如果 v 不在队列中,则将其入队
                    if (!inq[v]) {
                        q.push(v);
                        inq[v] = true;
                    }
                }
                // 优化:这条边已经检查过,下次从下一条开始,避免重复计算
                idx[u]++;
            } else {
                // 剪枝:因为航班已按起飞时间 r 降序排序,
                // 如果当前航班都赶不上,那么后续的航班(起飞时间更早)也一定赶不上。
                break;
            }
        }
    }

    // 最终结果修正:到达机场 1 的时间是 0,而不是初始化的 -a[1]
    dis[1] = 0;

    // 输出结果
    for (int i = 1; i <= n; i++) {
        printf("%d\n", dis[i] == INF ? -1 : dis[i]);
    }

    return 0;
}

判负环

一种判负环的简单做法是用一个数组 \(cnt\) 记录从起点出发的每个点的当前最短路经过了多少条边,如果超过了 \(n\) 就有负环。

每次更新一个点最短路时,该点的 \(cnt\) 等于更新它的点的 \(cnt\)\(1\)

需要注意,如果只从 \(1\) 号点出发判断负环,只能判断出 \(1\) 号点能不能走到负环,不能判整张图是不是有负环,如果想判整张图,可以新建一个虚点 \(0\),向每个点连一条边权为 \(0\) 的边,从 \(0\) 号点出发开始判。

例题:P3385 【模板】负环

参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <queue>
const int N = 2005;
const int INF = 1e9;
std::vector<std::pair<int, int>> g[N];
int dis[N], cnt[N];
bool inq[N];
void solve() {
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        g[i].clear(); dis[i] = INF; inq[i] = false; cnt[i] = 0;
    }
    for (int i = 1; i <= m; i++) {
        int u, v, w; scanf("%d%d%d", &u, &v, &w);
        g[u].push_back({v, w});
        if (w >= 0) g[v].push_back({u, w});
    }
    std::queue<int> q;
    q.push(1); dis[1] = 0; inq[1] = true;
    while (!q.empty()) {
        int u = q.front(); q.pop(); inq[u] = false;
        for (auto e : g[u]) {
            int v = e.first, w = e.second;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                cnt[v] = cnt[u] + 1;
                if (cnt[v] >= n) {
                    printf("YES\n"); return;
                }
                if (!inq[v]) {
                    q.push(v); inq[v] = true;
                }
            }
        }
    }
    printf("NO\n");
}
int main()
{
    int t; scanf("%d", &t);
    for (int i = 1; i <= t; i++) solve();
    return 0;
}

差分约束系统

用于求解 \(n\) 个未知数 \(x_1 \sim x_n\)\(m\) 个形如 \(x_u - x_v \le w\) 也就是 \(x_u \le x_v + w\) 的不等式的解。

两种建图方式:

  1. \(v\)\(u\) 连边权为 \(w\) 的有向边,建立虚点 \(0\) 向每个点连边权为 \(0\) 的有向边,从点 \(0\) 开始跑最短路,得到的是每个 \(x_u \le 0\)\(x_u\) 的最大值,如果图中有负环则无解。
  2. \(u\)\(v\) 连边权为 \(-w\) 的有向边,建立虚点 \(0\) 向每个点连边权为 \(0\) 的有向边,从点 \(0\) 开始跑最长路,得到的是每个 \(x_u \ge 0\)\(x_u\) 的最大值,如果图中有负环则无解。

例题:P5960 【模板】差分约束

参考代码
#include <cstdio>
#include <vector>
#include <queue>
#include <utility>
const int N = 5005;
const int INF = 1e9;
std::vector<std::pair<int, int>> g[N];
int cnt[N], dis[N];
bool inq[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int c1, c2, y;
        scanf("%d%d%d", &c1, &c2, &y);
        g[c2].push_back({c1, y});
    }
    for (int i = 1; i <= n; i++) {
        dis[i] = INF; 
        g[0].push_back({i, 0});
    }
    std::queue<int> q;
    q.push(0); dis[0] = 0; inq[0] = true;
    while (!q.empty()) {
        int u = q.front(); q.pop(); inq[u] = false;
        for (auto e : g[u]) {
            int v = e.first, w = e.second;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                cnt[v] = cnt[u] + 1;
                if (cnt[v] >= n + 1) { // 注意多了一个虚点0
                    printf("NO\n"); return 0;
                }
                if (!inq[v]) {
                    q.push(v); inq[v] = true;
                }
            }
        }
    }
    for (int i = 1; i <= n; i++) printf("%d ", dis[i]);
    return 0;
}

例题:P1993 小 K 的农场

解题思路
  1. \(x_a - x_b \ge c\) 实际上就是 \(x_b - x_a \le -c\),可以让 \(a\)\(b\) 连一条边权为 \(-c\) 的边
  2. \(x_a - x_b \le c\) 是差分约束系统的标准形式,让 \(b\)\(a\) 连一条边权为 \(c\) 的边
  3. \(x_a = x_b\) 也可以转化成不等式关系,即 \(x_b - x_a \le 0\)\(x_a - x_b \le 0\),让 \(a\)\(b\) 之间互相连边权为 \(0\) 的边
参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <queue>
const int N = 5005;
const int INF = 1e9;
std::vector<std::pair<int, int>> g[N];
int cnt[N], dis[N];
bool inq[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int t, a, b, c; scanf("%d%d%d", &t, &a, &b);
        if (t != 3) scanf("%d", &c);
        if (t == 1) g[a].push_back({b, -c});
        else if (t == 2) g[b].push_back({a, c});
        else {
            g[a].push_back({b, 0}); g[b].push_back({a, 0});
        }
    }
    for (int i = 1; i <= n; i++) {
        g[0].push_back({i, 0}); dis[i] = INF;
    }
    std::queue<int> q;
    q.push(0); inq[0] = true; dis[0] = 0;
    while (!q.empty()) { 
        int u = q.front(); q.pop(); inq[u] = false;
        for (auto e : g[u]) {
            int v = e.first, w = e.second;
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                cnt[v] = cnt[u] + 1;
                if (cnt[v] >= n + 1) {
                    printf("No\n"); return 0;
                }
                if (!inq[v]) {
                    q.push(v); inq[v] = true;
                }
            }
        }
    }
    printf("Yes\n");
    return 0;
}
posted @ 2023-12-23 07:14  RonChen  阅读(425)  评论(0)    收藏  举报