题解:洛谷 P5905 全源最短路(Johnson)

【题目来源】

洛谷:P5905 【模板】全源最短路(Johnson)

【题目描述】

给定一个包含 \(n\) 个结点和 \(m\) 条带权边的有向图,求所有点对间的最短路径长度,一条路径的长度定义为这条路径上所有边的权值和。

注意:

  1. 边权可能为负,且图中可能存在重边和自环;

  2. 部分数据卡 \(n\) 轮 SPFA 算法。

【输入】

\(1\) 行:\(2\) 个整数 \(n,m\),表示给定有向图的结点数量和有向边数量。

接下来 \(m\) 行:每行 \(3\) 个整数 \(u,v,w\),表示有一条权值为 \(w\) 的有向边从编号为 \(u\) 的结点连向编号为 \(v\) 的结点。

【输出】

若图中存在负环,输出仅一行 \(-1\)

若图中不存在负环:

输出 \(n\) 行:令 \(dis_{i,j}\) 为从 \(i\)\(j\) 的最短路,在第 \(i\) 行输出 \(\sum\limits_{j=1}^n j\times dis_{i,j}\),注意这个结果可能超过 int 存储范围。

如果不存在从 \(i\)\(j\) 的路径,则 \(dis_{i,j}=10^9\);如果 \(i=j\),则 \(dis_{i,j}=0\)

【输入样例】

5 7
1 2 4
1 4 10
2 3 7
4 5 3
4 2 -2
3 4 -3
5 3 4

【输出样例】

128
1000000072
999999978
1000000026
1000000014

【核心思想】

  1. 问题分析:给定一个包含 \(n\) 个结点和 \(m\) 条带权边的有向图,边权可能为负,且可能存在重边和自环。要求计算所有点对之间的最短路径长度,若图中存在负环则输出 \(-1\),否则输出每行 \(\sum_{j=1}^n j \times dist_{i,j}\)(不可达时 \(dist_{i,j}=10^9\)\(dist_{i,i}=0\))。由于边权可负且需要全源最短路,直接使用 Floyd 复杂度 \(O(n^3)\) 可能无法承受(\(n\) 可达 \(3000\)),而多源 SPFA 也容易超时(题目特别说明卡 \(n\) 轮 SPFA)。因此需要一种既能处理负权边,又能高效求解全源最短路的算法。

  2. 算法选择

    • Johnson 算法:通过引入“势函数”(potential)对边权进行重新赋值,使得所有边权变为非负,然后利用 \(n\) 次 Dijkstra 求出全源最短路。该算法适合稀疏图,时间复杂度 \(O(n m \log n)\),并能处理负权边(前提是无负环)。
    • SPFA:用于计算势函数,同时检测负环。从超级源点 \(0\) 向所有点连权值为 \(0\) 的边,运行 SPFA 得到每个点的最短距离 \(h[v]\)(即势函数),若存在负环则输出 \(-1\)
    • Dijkstra(堆优化):重赋权后边权非负,对每个点作为源点运行一次 Dijkstra,得到重赋权后的最短路 \(d'[v]\),再还原真实距离 \(dist[u][v] = d'[v] - h[u] + h[v]\)
  3. 关键步骤

    • 预处理与建图:读入 \(n, m\) 及所有有向边 \((u, v, w)\),存入邻接表。添加超级源点 \(0\),向每个点 \(i\) 连一条权值为 \(0\) 的边,用于 SPFA 求势函数。
    • SPFA 求势函数并检测负环:从超级源点 \(0\) 出发运行 SPFA,记录每个点的最短距离 \(h[v]\) 和入队次数 \(cnt[v]\)。若某点入队次数超过 \(n\),则存在负环,输出 \(-1\) 并结束。
    • 重赋权(reweighting):对每条原边 \((u, v, w)\),将其权重修改为 \(w' = w + h[u] - h[v]\)。根据三角不等式,\(h[v] \le h[u] + w\),故 \(w' \ge 0\),保证非负。
    • \(n\) 次 Dijkstra:对每个节点 \(i\)\(1 \le i \le n\)),以 \(i\) 为源点运行堆优化 Dijkstra,得到重赋权后的最短距离 \(d'[j]\)\(j=1..n\))。
    • 还原真实距离并输出:原图最短距离 \(dist[i][j] = d'[j] - h[i] + h[j]\)。若 \(d'[j]\) 为无穷大(不可达),则 \(dist[i][j] = 10^9\)。计算每行 \(\sum_{j=1}^n j \times dist_{i,j}\) 并输出。
  4. 时间/空间复杂度

    • 时间复杂度:\(O(n m)\)(SPFA)+ \(O(n m \log n)\)\(n\) 次 Dijkstra),即 \(O(n m \log n)\),其中 \(n\) 为点数,\(m\) 为边数。
    • 空间复杂度:\(O(n + m)\),存储邻接表和势函数数组。
  5. Johnson 算法的核心思想

    • 势函数:利用 SPFA 从超级源点求得 \(h[v]\),满足 \(h[v] \le h[u] + w(u, v)\),从而保证重赋权后边权非负,使得 Dijkstra 可用于负权图。
    • 重赋权技巧:将边权修改为 \(w' = w + h[u] - h[v]\),这不会改变最短路径的拓扑结构,因为对于任意路径 \(p\),其重赋权长度与原长度之差为常数 \(h[起点] - h[终点]\),因此最短路径保持不变。
    • 负环检测:SPFA 入队次数超过 \(n\) 即表示存在负环,此时全源最短路无定义,直接输出 \(-1\)
    • 适用场景:适用于边权可负、但无负环的稀疏图全源最短路问题,是处理负权边且避免多次 SPFA 的高效方案。

【算法标签】

提高 #Johnson

【代码详解】

#include <bits/stdc++.h>
using namespace std;

#define int long long                     // 使用 long long 防止中间结果溢出

typedef pair<int, int> PII;               // 用于优先队列存储 {距离, 节点编号}

const int N = 3005;                       // 最大节点数
const int INF = 1e9;                      // 定义不可达距离(10^9)

int n, m;                                 // n: 节点数, m: 边数
int ans;                                  // 存储每次 Dijkstra 后计算的答案(点对最短路径加权和)
vector<PII> g[N];                         // 邻接表存储原图(有向边)
int dis[N];                               // 势函数(从超级源点到各点的最短距离)
int d[N];                                 // 当前源点 Dijkstra 求得的距离(重赋权后)
int cnt[N];                               // 记录 SPFA 入队次数,用于检测负环

// 使用 SPFA 从超级源点 0 出发,检测负环并计算势函数
void spfa()
{
    memset(dis, 0x3f, sizeof(dis));       // 初始化为极大值
    dis[0] = 0;                           // 超级源点到自身距离为 0
    queue<int> q;
    q.push(0);

    while (!q.empty())
    {
        int u = q.front();
        q.pop();

        for (auto [v, w] : g[u])          // 遍历所有出边
        {
            if (dis[v] > dis[u] + w)      // 可以松弛
            {
                dis[v] = dis[u] + w;
                q.push(v);
                cnt[v] = cnt[u] + 1;      // 记录入队次数(相对于 u 的路径长度)
                if (cnt[v] > n)           // 入队次数超过节点数,存在负环
                {
                    cout << -1 << endl;
                    exit(0);              // 直接终止程序
                }
            }
        }
    }
}

// 重赋权后的 Dijkstra 算法,计算从源点 s 到所有节点的最短路
void dij(int s)
{
    memset(d, 0x3f, sizeof(d));           // 初始化距离数组
    priority_queue<PII, vector<PII>, greater<PII>> q;
    d[s] = 0;
    q.push({0, s});

    while (!q.empty())
    {
        auto [D, u] = q.top();
        q.pop();

        if (D > d[u])                     // 跳过过时记录
            continue;

        for (auto [v, w] : g[u])          // 注意:此时图上的边权已经被重赋为 w'
        {
            if (d[v] > d[u] + w)
            {
                d[v] = d[u] + w;
                q.push({d[v], v});
            }
        }
    }
}

signed main()
{
    cin >> n >> m;                        // 输入节点数和边数

    // 读入所有有向边
    while (m--)
    {
        int u, v, w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});
    }

    // 添加超级源点 0,向每个节点连一条权值为 0 的边,用于求势函数
    for (int i = 1; i <= n; i++)
        g[0].push_back({i, 0});

    // 运行 SPFA,得到势函数 dis[],同时检测负环
    spfa();

    // 重赋权:对每条边 (u, v, w),新权重 w' = w + dis[u] - dis[v] (保证非负)
    for (int u = 1; u <= n; u++)
        for (auto &[v, w] : g[u])
            w += dis[u] - dis[v];

    // 对每个节点作为源点,运行 Dijkstra 求最短路径
    for (int i = 1; i <= n; i++)
    {
        dij(i);                           // 计算从 i 出发到所有节点的最短距离(重赋权后)

        ans = 0;
        for (int j = 1; j <= n; j++)
        {
            // 如果原图中 i 到 j 不可达(重赋权后仍为 INF),则原距离为 INF
            if (d[j] >= INF)
                ans += j * INF;
            else
                // 还原真实距离:原距离 = d[j] + dis[j] - dis[i]
                ans += j * (d[j] + dis[j] - dis[i]);
        }
        cout << ans << endl;              // 输出第 i 行的结果
    }

    return 0;
}

【运行结果】

5 7
1 2 4
1 4 10
2 3 7
4 5 3
4 2 -2
3 4 -3
5 3 4
128
1000000072
999999978
1000000026
1000000014
posted @ 2026-09-01 23:08  团爸讲算法  阅读(18)  评论(0)    收藏  举报