普通最短路用 dis[u] 保存到达顶点
分层图最短路的关键是:把影响后续选择的信息并入状态,再在状态组成的新图上求最短路。
本文以“至多
1 位置与状态
下面的有向图允许至多一次免费通行:
从
| 到达 |
到这里的费用 | 已用免费次数 | 继续到 |
总费用 |
|---|---|---|---|---|
| 付费走第一条边 | 把第二条边免费 | |||
| 免费走第一条边 | 第二条边付费 |
第二条记录到达
普通最短路可以丢弃较贵的记录,是因为到达同一顶点后,后续选择和代价相同。本题的免费机会改变了后续代价,必须区分已用次数。
需要区分的是:
两个分量分别表示顶点和已用次数,各状态独立保存最小费用。
遇到操作次数、边数余数或当前模式等限制时,检查位置是否足以决定下一步的合法选择与代价。
若不足,就将必要信息加入状态,并估算状态总数是否可控。
2 状态定义
现在考虑一般模型:图有
定义状态
已用次数相同的状态属于同一层。每个顶点对应
dis[j][u] 表示从
这里的层号是资源使用量,不是最短距离,也不是 BFS 的搜索深度。
初始时只知道:
其余状态初始化为无穷大。起点只有第
若改用“剩余次数”,状态定义、初值、转移方向和答案范围也要相应修改。
3 状态转移
设原图有一条有向边
3.1 付费通行
对应松弛:
3.2 免费通行
只有
对应松弛:
免费通行同时改变顶点、消耗机会。
回到开头的有向图,把
两条到达
如果原图是无向边
两个方向的层号都加一。不能把跨层边反向连成
4 正确性证明
4.1 路线映射
给定原图中一条路线,并指定每次通行是否免费。从
- 付费时,走对应的同层边;
- 免费时,走对应的跨层边,已用次数加一。
至多免费
4.2 路线还原
新图中的每条边都来自原图的一条真实边。去掉层号,就得到原图上的路线;跨层边表示使用一次免费机会,同层边表示照常付费。
层号从
合法路线可以互相转换且费用不变,因此状态图中的最短路等价于原问题。
4.3 状态充分性
位置
若优惠不能连续使用,还需记录上次是否使用;若钥匙种类决定能走的边,仅保存钥匙数量也不够。附加状态必须由题目规则决定。
5 合法终点
状态中的
只有题目要求恰好使用 dis[k][t]。
例如一张有向图只有一条
| 题目要求 | 当前状态定义下的合法终点 |
|---|---|
| 至多免费 |
|
| 恰好免费 |
仅 |
| 已走边数必须为偶数 | 记录奇偶后,仅接受 |
| 到终点时两种模式都允许 | 两种终点模式都要考虑 |
合法终点由题意决定,不能仅凭距离数组的最后一层取答案。
6 算法选择
根据状态图的边权选择算法:
| 状态图的边权情况 | 可用算法 |
|---|---|
| 所有转移代价都是 |
广度优先搜索(BFS):从迷宫到最少步数 |
| 所有转移代价只能为 |
0-1 BFS:用双端队列求最小代价 |
| 所有转移代价非负 | dijkstra |
主例中免费边权为
分层不会消除负权。若转移有负权,需要另选适用算法。
6.1 层号与计算顺序
免费次数只增不减,但同层边仍可能有环,状态图未必是 DAG。因此不能只按层号扫描一次,Dijkstra 仍须按费用顺序处理状态。
开头的反例会按下面的顺序发生更新:
| 弹出的有效状态 | 新得到或改进的距离 |
|---|---|
| 已得到最小合法终点费用 |
费用为
7 隐式建图
隐式建图只保存原图。处理 g[u] 临时生成付费、免费两种转移,无须存储完整状态图的边表。
和普通 Dijkstra 相比,只需要对应地改动三处:
| 普通 Dijkstra | 分层状态 Dijkstra |
|---|---|
dis[u] |
dis[used][u] |
堆中保存 (费用, 顶点) |
堆中保存 (费用, 顶点, 已用次数) |
| 沿原边正常付费 | 尝试付费通行和免费通行 |
过期判断也必须针对完整状态:
if (du != dis[used][u]) {
continue;
}
不能用 vis[u] 一并标记所有层,各层状态必须独立处理。
7.1 代码实现
下面采用 luogu P4568: [JLOI2011] 飞行路线 的输入形式:顶点编号为
函数返回每个状态的距离,主程序按“至多”取终点各层的最小值;本实现约定不可达时输出 -1。
#include <algorithm>
#include <functional>
#include <iostream>
#include <queue>
#include <tuple>
#include <vector>
using namespace std;
using ll = long long;
using State = tuple<ll, int, int>; // (当前费用, 顶点, 已用免费次数)
const ll INF = 1LL << 62;
struct Edge {
int to;
ll w;
};
// dis[used][u]:从 s 到 u,恰好使用 used 次免费机会的最小费用。
vector<vector<ll>> layered_dijkstra(const vector<vector<Edge>>& g, int s, int k) {
int n = static_cast<int>(g.size());
vector<vector<ll>> dis(k + 1, vector<ll>(n, INF));
priority_queue<State, vector<State>, greater<State>> q;
dis[0][s] = 0;
q.push({0, s, 0});
while (!q.empty()) {
auto [du, u, used] = q.top();
q.pop();
if (du != dis[used][u]) {
continue; // 只跳过这个完整状态的过期条目。
}
for (const auto& e : g[u]) {
// 付费通行:仍在第 used 层。
ll nd = du + e.w;
if (nd < dis[used][e.to]) {
dis[used][e.to] = nd;
q.push({nd, e.to, used});
}
// 免费通行:真正走过这条边,并消耗一次机会。
if (used < k && du < dis[used + 1][e.to]) {
dis[used + 1][e.to] = du;
q.push({du, e.to, used + 1});
}
}
}
return dis;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, k, s, t;
if (!(cin >> n >> m >> k)) {
return 0;
}
cin >> s >> t;
// 本例沿用 P4568 的输入:顶点编号为 0..n-1,航线为无向边。
vector<vector<Edge>> g(n);
for (int i = 0; i < m; ++i) {
int u, v;
ll w;
cin >> u >> v >> w;
g[u].push_back({v, w});
g[v].push_back({u, w});
}
auto dis = layered_dijkstra(g, s, k);
ll ans = INF;
for (int used = 0; used <= k; ++used) {
ans = min(ans, dis[used][t]);
}
cout << (ans == INF ? -1 : ans) << '\n';
return 0;
}
已有的 luogu P4568: [JLOI2011] 飞行路线 还提供了具体题目的讨论与演示,可以在理解本文的状态定义后对照阅读。
7.2 样例分析
输入:
5 6 1
0 4
0 1 5
1 2 5
2 3 5
3 4 5
2 3 3
0 2 100
输出:
8
一条最优走法是先免费走
7.3 边界处理
时只有一层,不产生免费转移,算法回到普通 Dijkstra。 - 只初始化
dis[0][s];其他层要通过真实转移到达。 - 已用次数到
以后仍能正常付费通行,只是不能再跨层免费。 - 起点等于终点时,零次通行、零费用已经是合法答案。
- 零权边与重边都按正常松弛处理;严格变小才入堆,不会因零权环反复加入相同距离。
- 费用使用
long long;输入编号从还是从 开始,要与数组和编号映射一致。
若需提前结束,可在首次弹出有效的 u == t 状态时返回。堆按所有层的累计费用排序,该状态即为最小合法终点;不能在终点刚入堆时返回。
8 显式建图
隐式与显式表示的是同一张状态图。顶点从
状态编号范围为
// new_graph 是分层后的邻接表;n 为原图顶点数。
auto id = [n](int u, int used) {
return used * n + u;
};
for (int used = 0; used <= k; ++used) {
new_graph[id(u, used)].push_back({id(v, used), w});
if (used < k) {
new_graph[id(u, used)].push_back({id(v, used + 1), 0});
}
}
无向原图还要对 new_graph 上直接使用普通 Dijkstra,源点为 id(s,0),终点仍要在 id(t,0) 到 id(t,k) 中取最小距离。
显式写法便于核对连边,隐式写法节省边表空间;二者都在完整状态上求最短路。
9 复杂度
令
每个原图方向在所有
代码使用允许过期条目的二叉堆。距离数组初始化需要
空间要分开看:
| 存储内容 | 显式建图 | 本文的隐式建图 |
|---|---|---|
| 邻接表 | 原图的 |
|
| 距离表 | ||
| 惰性堆的最坏条目量 |
隐式建图节省的是边表空间;它不会让需要尝试的转移消失,堆的空间也不能在估算总内存时忽略。
P4568 上限对应
10 模型扩展
10.1 折扣次数
若最多使用
若题目保证价格为偶数,折后价格就是
状态个数与主例相同,合法终点仍由“至多”或“恰好”的要求决定。若一次通行只能用一张卡,就只有加一层的转移,不能在同一条边上连续打折多次。
10.2 边数奇偶
若只接受经过偶数条边的走法,可以把状态定义为
初始状态为
这里层号会从
保存余数足够,是因为后续只关心最终的余数条件;如果要求恰好走
10.3 方向模式
考虑一张有向图:每走一条边费用为
对于原图边
翻转操作则对应:
翻转不移动位置,所以跨层边连接同一顶点的两种模式。跨层方式取决于操作含义,不能照搬免费通行模型。
从
11 推荐练习
建议先练次数,再练层号循环变化的模型。每道题都先写状态、转移和合法终点,再选择最短路算法。
| 顺序 | 题目 | 训练目标 |
|---|---|---|
| 1 | [[[p: luogu-P4568 | P4568:飞行路线]]] |
| 2 | [[[p: luogu-P4822 | P4822:冻结]]] |
| 3 | [[[p: atcoder-abc132_e | ABC132 E:Hopscotch Addict]]] |
| 4 | [[[p: atcoder-abc395_e | ABC395 E:Flip Edge]]] |
冻结保证原始边权均为偶数,且不必用完所有卡,因此可以直接用整数半价,最后在合法层中取最小值。P4822 题面
Hopscotch Addict每次完整操作恰好连续走三条边,中途经过终点不算完成。应查询余数为
还可扩展到以下状态:
- 钥匙集合:roj 1495: 「一本通 3.2 例 2」拯救大兵瑞恩。同一格子、持有不同钥匙集合时,可通过的门不同;可以结合 状态压缩 了解集合的位掩码表示。状态量要按格子数乘
估算,而不是只乘钥匙种类数 。 - 剩余油量:roj 1502: 「一本通 3.2 练习 6」汽车加油行驶。位置与油量一起决定能否继续行驶,再按规则处理行驶、加油等转移。本文不展开这些额外的操作规则。
12 相关题解
暂无相关题解