降维打击最短路:深入解析 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 层(下层):表示“免费机会已经用过”的平行宇宙。
连边规则(极其关键):
- 同层连边:如果在原图中节点
u到v有一条权值为w的边,那么在第 0 层和第 1 层各自内部,都连一条从u到v,权值为w的边。 - 跨层连边(状态跃迁):你可以消耗一次免费机会,从第 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])
*/
四、 避坑指南与细节拉满
- 无穷大的设定(INF):
在涉及求最短路的代码中,如果使用long long存储带权距离,切忌将INF设为LLONG_MAX。因为在松弛操作du + w时,极容易发生整型溢出变成了负数!推荐设定为(1LL << 62)。 - 多源最短路与虚拟超级源点:
如果题目有多个维修站、多个起点,上述模板支持直接将所有起点塞入sources[cite: 6]。这在数学上等价于建立了一个“虚拟超级源点”,向所有实际源点连了一条权值为 0 的边,避免了做多次 Dijkstra 取最小值的低效操作。 - 负权边绝对禁用:
只要图中存在负权边,Dijkstra 算法立刻失效(即使是无环图)。如果遇到分层图中出现必须使用的负边,请立即转向 SPFA 算法,或者通过重新标号(Johnson 算法)来消除负权。 - “至多”与“恰好”的区别:
- 至多使用 1 次免费机会:答案是
min(dist[end], dist[end + n])[cite: 6]。 - 恰好使用 1 次免费机会:答案只能是
dist[end + n][cite: 6]。在建图时需要根据题意严格限制边层的指向。
- 至多使用 1 次免费机会:答案是
五、 总结
分层图并不是一种全新的算法,而是一种建模思维。当问题的状态难以通过单一的节点属性描述时,利用扩展节点编号的方式“复制图层”,就能用纯粹的空间换取极低的代码复杂度。
在算法竞赛中,熟练掌握这种降维打击的思维,你的 Dijkstra 将不再局限于寻路,而是化身为解决复杂状态规划的一把尖刀。

浙公网安备 33010602011771号