图中经过恰好 k 条边的最短路径

345. 牛站

https://www.acwing.com/problem/content/347/

给定一张由 T 条边构成的无向图,点的编号为 1∼1000之间的整数。 求从起点 S 到终点 E 恰好经过 N
条边(可以重复经过)的最短路。 注意: 数据保证一定有解。

输入格式 第 1 行:包含四个整数 N,T,S,E。 第 2…T+1 行:每行包含三个整数,描述一条边的边长以及构成边的两个点的编号。
输出格式 输出一个整数,表示最短路的长度。 数据范围 2≤T≤100, 2≤N≤10^6
输入样例:
2 6 6 4
11 4 6
4 4 8
8 4 9
6 6 8
2 6 9
3 8 9
输出样例: 10

1. 问题定义

给定一个带权有向图 G=(V,E)G = (V, E)G=(V,E),求从任意点 iii 到任意点 jjj 恰好经过 k 条边的最短路径。

  • 输入:图的邻接矩阵 ggg,其中 g[i][j]g[i][j]g[i][j] 表示边 i→ji \to jij 的权值(若无边则为 +∞+\infty+)。
  • 输出:矩阵 resresres,其中 res[i][j]res[i][j]res[i][j] 表示从 iiijjj 经过恰好 k 条边的最短路径长度。

2. 动态规划解法(暴力法)

(1) 状态定义

dp[m][i][j]dp[m][i][j]dp[m][i][j] 表示从 iiijjj恰好经过 m 条边的最短路径长度。

(2) 状态转移

  • 初始条件
    dp[0][i][j]={0if i=j (经过 0 条边,原地不动)+∞otherwise dp[0][i][j] = \begin{cases} 0 & \text{if } i = j \ (\text{经过 0 条边,原地不动}) \\ +\infty & \text{otherwise} \end{cases} dp[0][i][j]={0+if i=j (经过 0 条边,原地不动)otherwise
    dp[1][i][j]=g[i][j](直接走一条边)。 dp[1][i][j] = g[i][j] (直接走一条边)。dp[1][i][j]=g[i][j](直接走一条边)。

  • 递推关系

    • 要计算 dp[m][i][j]dp[m][i][j]dp[m][i][j],可以枚举中转点ttt,并取最小值:
      dp[m][i][j]=min⁡1≤t≤n{dp[m−1][i][t]+g[t][j]} dp[m][i][j] = \min_{1 \leq t \leq n} \{ dp[m-1][i][t] + g[t][j] \} dp[m][i][j]=1tnmin{dp[m1][i][t]+g[t][j]}
    • 即,im-1 条边到 t,再走 1 条边到 j

#include <iostream>
#include <cstring>
#include <map>

using namespace std;

const int N = 210;
const int INF = 0x3f3f3f3f;

int dp[N][N], g[N][N]; // dp[m][i][j] 表示经过 m 条边的最短路径
int k, n, m, S, E;
map<int, int> id;

int main() {
    cin >> k >> m >> S >> E;
    
    // 初始化邻接矩阵
    memset(g, 0x3f, sizeof g);
    
    // 离散化
    if (!id.count(S)) id[S] = ++n;
    if (!id.count(E)) id[E] = ++n;
    S = id[S], E = id[E];
    
    while (m--) {
        int a, b, c;
        cin >> c >> a >> b;
        if (!id.count(a)) id[a] = ++n;
        if (!id.count(b)) id[b] = ++n;
        a = id[a], b = id[b];
        g[a][b] = g[b][a] = min(g[a][b], c); // g存储经过一条边的最短路径长度
    }
    
    // 初始化 DP 数组
    memset(dp, 0x3f, sizeof dp);
    for (int i = 1; i <= n; i++) dp[i][i] = 0; // 经过 0 条边
    
    // 暴力 DP 计算 dp[k][i][j]
    for (int step = 1; step <= k; step++) {
        int temp[N][N];
        memset(temp, 0x3f, sizeof temp);
        
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= n; j++) {
                for (int t = 1; t <= n; t++) {
                    temp[i][j] = min(temp[i][j], dp[i][t] + g[t][j]);
                }
            }
        }
        
        memcpy(dp, temp, sizeof temp);
    }
    
    cout << dp[S][E] << endl;
    return 0;
}

为什么“恰好经过 k 条边的最短路径”具有最优子结构?

这个问题的最优子结构性质来源于路径的分解性质。具体来说,如果我们要找从 iiijjj 经过 恰好 kkk 条边 的最短路径,那么这条路径可以拆分成:

  1. aaa 条边:从 iii 到某个中间点 ttt 的最短路径(经过 aaa 条边)。
  2. bbb 条边:从 tttjjj 的最短路径(经过 bbb 条边),其中 a+b=ka + b = ka+b=k

由于最短路径的最优性,整体最优解必然由子问题的最优解构成,这就是最优子结构的体现。
所以
dp[a+b]=dp[a]×dp[b]dp[a+b]=dp[a]×dp[b]dp[a+b]=dp[a]×dp[b]

  • dp[m]dp[m]dp[m] 表示经过 m 条边的最短路径矩阵

3. 优化思路:矩阵快速幂(倍增)

(1) 状态转移的矩阵表示

  • 定义矩阵乘法 C=A×BC = A \times BC=A×B
    C[i][j]=min⁡1≤t≤n{A[i][t]+B[t][j]} C[i][j] = \min_{1 \leq t \leq n} \{ A[i][t] + B[t][j] \} C[i][j]=1tnmin{A[i][t]+B[t][j]}

    • 这类似于 Floyd 算法的松弛操作,但用 min+ 代替了传统的 +*
  • 关键性质

    • dp[m]dp[m]dp[m] 表示经过 m 条边的最短路径矩阵,则:
      dp[a+b]=dp[a]×dp[b] dp[a + b] = dp[a] \times dp[b] dp[a+b]=dp[a]×dp[b]
    • 即,a + b 条边的最短路径 = 先走 a 条边,再走 b 条边的最短路径

(2) 快速幂优化

  • 由于矩阵乘法满足结合律,我们可以用快速幂计算 dp[k]dp[k]dp[k]
    • 初始化 dp[1]=gdp[1] = gdp[1]=g(原图的邻接矩阵)。
    • 计算 dp[k]=gkdp[k] = g^kdp[k]=gk(矩阵快速幂)。
    • 例如:
      • k=13k = 13k=13(二进制 1101),则:
        dp[13]=dp[8]×dp[4]×dp[1] dp[13] = dp[8] \times dp[4] \times dp[1] dp[13]=dp[8]×dp[4]×dp[1]
      • 通过倍增(平方)计算 dp[1],dp[2],dp[4],dp[8],…dp[1], dp[2], dp[4], dp[8], \dotsdp[1],dp[2],dp[4],dp[8],,再按需合并。

(3) 时间复杂度

  • 每次矩阵乘法(mul)需要 O(n3)O(n^3)O(n3) 时间。
  • 快速幂需要 O(log⁡k)O(\log k)O(logk) 次矩阵乘法。
  • 总时间复杂度O(n3log⁡k)O(n^3 \log k)O(n3logk),比暴力 O(n3k)O(n^3 k)O(n3k) 快很多。

(4) 代码

链接:https://www.acwing.com/solution/content/17209/
来源:AcWing

#include<iostream>
#include<cstring>
#include<map>

using namespace std;

const int N=210;

int res[N][N],g[N][N];
int k,n,m,S,E;
map<int,int> id;

void mul(int c[][N],int a[][N],int b[][N])
{
    static int temp[N][N];
    memset(temp,0x3f,sizeof temp);
    for(int k=1;k<=n;k++)
        for(int i=1;i<=n;i++)
            for(int j=1;j<=n;j++)
                temp[i][j]=min(temp[i][j],a[i][k]+b[k][j]);
    memcpy(c,temp,sizeof temp);
}

void qmi()
{
    memset(res,0x3f,sizeof res);
    for(int i=1;i<=n;i++) res[i][i]=0;//经过0条边
    while(k)//更新的过程
    {
        if(k&1) mul(res,res,g);//res=res*g;根据k决定是否用当前g的结果去更新res
        mul(g,g,g);//g=g*g;g的更新
        k>>=1;
    }
}

int main()
{
    cin>>k>>m>>S>>E;//虽然点数较多,但由于边数少,所以我们实际用到的点数也很少,可以使用map来离散化来赋予
    //他们唯一的编号
    memset(g,0x3f,sizeof g);
    //这里我们来解释一下为什么不去初始化g[i][i]=0呢?
    //我们都知道在类Floyd算法中有严格的边数限制,如果出现了i->j->i的情况其实在i->i中我们是有2条边的
    //要是我们初始化g[i][i]=0,那样就没边了,影响了类Floyd算法的边数限制!
    if(!id.count(S)) id[S]=++n;
    if(!id.count(E)) id[E]=++n;
    S=id[S],E=id[E];
    while(m--)
    {
        int a,b,c;
        scanf("%d%d%d",&c,&a,&b);
        if(!id.count(a)) id[a]=++n;
        if(!id.count(b)) id[b]=++n;
        a=id[a],b=id[b];
        g[a][b]=g[b][a]=min(g[a][b],c);
    }
    qmi();
    cout<< res[S][E] <<endl;
    return 0;
}

输出这条路径的节点序列

#include <iostream>
#include <cstring>
#include <vector>
#include <map>

using namespace std;

const int N = 210;
const int INF = 0x3f3f3f3f;

int dp[N][N], g[N][N];   // dp[k][i][j] 表示恰好 k 条边的最短路径
int pre[N][N][N];        // pre[k][i][j] 表示在 k 步时,i -> j 的前驱
int k, n, m, S, E;
map<int, int> id;        // 离散化节点
map<int, int> rid;       // 反向映射(编号 -> 原始节点)

void recover_path(int i, int j, int steps, vector<int>& path) {
    if (steps == 0) {
        path.push_back(rid[i]);
        return;
    }
    int t = pre[steps][i][j];
    recover_path(i, t, steps - 1, path);
    path.push_back(rid[j]);
}

int main() {
    cin >> k >> m >> S >> E;

    // 初始化邻接矩阵
    memset(g, 0x3f, sizeof g);
    memset(pre, -1, sizeof pre);

    // 离散化节点
    if (!id.count(S)) id[S] = ++n, rid[n] = S;
    if (!id.count(E)) id[E] = ++n, rid[n] = E;
    S = id[S], E = id[E];

    while (m--) {
        int a, b, c;
        cin >> c >> a >> b;
        if (!id.count(a)) id[a] = ++n, rid[n] = a;
        if (!id.count(b)) id[b] = ++n, rid[n] = b;
        a = id[a], b = id[b];
        if (c < g[a][b]) {
            g[a][b] = g[b][a] = c;
            pre[1][a][b] = a;  // 1 步时,a -> b 的前驱是 a
            pre[1][b][a] = b;  // 1 步时,b -> a 的前驱是 b
        }
    }

    // 初始化 DP 数组
    memset(dp, 0x3f, sizeof dp);
    for (int i = 1; i <= n; i++) dp[i][i] = 0;  // 0 条边

    // 动态规划计算 dp[k][i][j]
    for (int step = 1; step <= k; step++) {
        int temp[N][N];
        int temp_pre[N][N][N];
        memset(temp, 0x3f, sizeof temp);
        memset(temp_pre, -1, sizeof temp_pre);

        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= n; j++) {
                for (int t = 1; t <= n; t++) {
                    if (dp[i][t] + g[t][j] < temp[i][j]) {
                        temp[i][j] = dp[i][t] + g[t][j];
                        temp_pre[step][i][j] = t;  // 记录 step 步时的前驱
                    }
                }
            }
        }

        memcpy(dp, temp, sizeof temp);
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= n; j++) {
                pre[step][i][j] = temp_pre[step][i][j];
            }
        }
    }

    // 输出最短路径长度
    cout << "最短路径长度: " << dp[S][E] << endl;

    // 回溯路径
    if (dp[S][E] == INF) {
        cout << "无可行路径!" << endl;
    } else {
        vector<int> path;
        recover_path(S, E, k, path);
        cout << "路径: ";
        for (int node : path) cout << node << " ";
        cout << endl;
    }

    return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值