降维打击最短路:深入解析 Dijkstra 与分层图建模实战

降维打击最短路:深入解析 Dijkstra 与分层图建模实战

在算法竞赛(如 ICPC、PTA 天梯赛、洛谷等)与实际业务路线规划中,求解最短路是一项极其基础的能力。大多数同学都能熟练地默写出基于堆优化的 Dijkstra 算法,但在真实的竞赛舞台上,出题人绝不会简单地问你“从 A 到 B 的最短距离”。

当题目中出现诸如“你可以免费通过一条边(或 K 条边)”、“换乘需要额外的时间成本”或“拥有某种状态切换限制”时,传统的单层图 Dijkstra 就会瞬间束手无策。

要解决这类带状态限制的最短路问题,我们需要引入图论中极其惊艳的建模思维:分层图(Layered Graph)。本文将刨析分层图的本质,并结合 C++17 模板给出实战落地方案。


一、 Dijkstra 算法的局限性

我们知道,Dijkstra 算法的底层逻辑是贪心算法。它维护一个优先队列(小根堆),每次从队列中取出当前距离起点最近的节点,并用它去“松弛”其相邻节点。其适用的核心前提是:图中的边权必须非负。

然而,当问题附加了“状态”属性时,简单的 dist[u](起点到 u 的最短距离)就不够用了。
举个经典的例子:“你有 1 次机会,可以把某条边的权值变成 0”。
如果你在跑 Dijkstra 时,遇到一条长边直接贪心地把免费机会用了,那如果后面还有更长的边呢?因为普通 Dijkstra 无法“后悔”,也无法区分到达同一个节点时“用过机会”和“没用过机会”的区别,它就会给出错误答案。


二、 分层图建模:用空间扩展状态

分层图的本质,是将“状态信息”融入到节点的维度中,用空间的降维打击来化解状态转移的复杂性。

既然我们有“1次免费机会”,我们就可以把整个城市的地图复印一份,变成上下两层:

  • 第 0 层(上层):表示“免费机会还没用过”的平行宇宙。
  • 第 1 层(下层):表示“免费机会已经用过”的平行宇宙。

连边规则(极其关键):

  1. 同层连边:如果在原图中节点 u 到 v 有一条权值为 w 的边,那么在第 0 层和第 1 层各自内部,都连一条从 u 到 v,权值为 w 的边。
  2. 跨层连边(状态跃迁):你可以消耗一次免费机会,从第 0 层的 u 节点,走到第 1 层的 v 节点,代价为 0!这就是跨层边。

这样建图后,问题瞬间被降维成了最普通的单源最短路:我们只需要以“第 0 层的起点”为源点跑一遍朴素的 Dijkstra,最后的答案就是 min(第 0 层的终点距离, 第 1 层的终点距离)。


三、 C++17 竞赛级实战模板

在代码实现层面,我们不需要真的开一个二维数组去建图。最优雅的做法是扩展节点编号。
假设原图有 n 个节点(编号 1 到 n)。
我们可以规定:节点 u 在第 used 层的编号为 u + used * n。

以下是基于堆优化的 Dijkstra 核心模板,适用于普通图、多源图以及扩点后的分层图:

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll LINF = (1LL << 62); // 避免使用 LLONG_MAX 防止相加溢出 [cite: 6]

struct Edge { int to; ll w; };

// sources 中存放的是 {节点编号, 初始距离};如果是单源最短路,直接传 {{s, 0}} 即可 [cite: 6]
vector<ll> dijkstra(int n, const vector<vector<Edge>>& g,
                    const vector<pair<int,ll>>& sources) {
    // n 是扩展后的总节点数
    vector<ll> dist(n + 1, LINF);
    priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<pair<ll,int>>> pq;
    
    // 多源初始化 [cite: 6]
    for (auto [s, d] : sources) {
        if (d < dist[s]) {
            dist[s] = d;
            pq.push({d, s});
        }
    }
    
    while (!pq.empty()) {
        auto [du, u] = pq.top(); pq.pop();
        
        // 关键优化:丢弃堆中因多次更新而过期的旧节点 [cite: 6]
        if (du != dist[u]) continue; 
        
        for (auto [v, w] : g[u]) {
            if (du + w < dist[v]) {
                dist[v] = du + w;
                pq.push({dist[v], v});
            }
        }
    }
    return dist;
}

// === 分层图建图使用示例 ===
/*
int k = 1; // 免费机会次数
int total_nodes = n * (k + 1); // 扩展后的总节点数
vector<vector<Edge>> g(total_nodes + 1);

for(int i = 1; i <= m; i++){
    int u, v; ll w; cin >> u >> v >> w;
    // 第 0 层连到第 0 层,第 1 层连到第 1 层...
    for(int layer = 0; layer <= k; layer++){
        g[u + layer * n].push_back({v + layer * n, w});
        g[v + layer * n].push_back({u + layer * n, w}); // 无向图
        
        // 跨层连边:消耗一次机会,权值为 0
        if(layer < k){
            g[u + layer * n].push_back({v + (layer + 1) * n, 0});
            g[v + layer * n].push_back({u + (layer + 1) * n, 0});
        }
    }
}
// 跑一遍 Dijkstra 即可
auto dist = dijkstra(total_nodes, g, {{start, 0}});
// 答案为 min(dist[end + 0*n], dist[end + 1*n])
*/

四、 避坑指南与细节拉满

  1. 无穷大的设定(INF):
    在涉及求最短路的代码中,如果使用 long long 存储带权距离,切忌将 INF 设为 LLONG_MAX。因为在松弛操作 du + w 时,极容易发生整型溢出变成了负数!推荐设定为 (1LL << 62)。
  2. 多源最短路与虚拟超级源点:
    如果题目有多个维修站、多个起点,上述模板支持直接将所有起点塞入 sources [cite: 6]。这在数学上等价于建立了一个“虚拟超级源点”,向所有实际源点连了一条权值为 0 的边,避免了做多次 Dijkstra 取最小值的低效操作。
  3. 负权边绝对禁用:
    只要图中存在负权边,Dijkstra 算法立刻失效(即使是无环图)。如果遇到分层图中出现必须使用的负边,请立即转向 SPFA 算法,或者通过重新标号(Johnson 算法)来消除负权。
  4. “至多”与“恰好”的区别:
    • 至多使用 1 次免费机会:答案是 min(dist[end], dist[end + n]) [cite: 6]。
    • 恰好使用 1 次免费机会:答案只能是 dist[end + n] [cite: 6]。在建图时需要根据题意严格限制边层的指向。

五、 总结

分层图并不是一种全新的算法,而是一种建模思维。当问题的状态难以通过单一的节点属性描述时,利用扩展节点编号的方式“复制图层”,就能用纯粹的空间换取极低的代码复杂度。
在算法竞赛中,熟练掌握这种降维打击的思维,你的 Dijkstra 将不再局限于寻路,而是化身为解决复杂状态规划的一把尖刀。

posted @ 2026-10-07 16:45  阿尹想学会C++  阅读(19)  评论(0)    收藏  举报