分层图最短路
在标准的最短路问题中,状态通常只由节点编号决定。但在实际建模时,会遇到一些带有“限制条件”或“附加状态”的题目,例如:
- 资源限制:移动需要消耗资源,或者在节点处可以补充资源。
- 次数限制:可以免费通过 \(k\) 条边,或者有 \(k\) 次反转边权的机会。
- 开关状态:某些边的开启取决于当前是否持有了某种钥匙。
针对这样的问题,可以考虑将原图“分层”,即将附加状态并入空间维度,把一个点拆成一堆点。
例题:P4568 [JLOI2011] 飞行路线
由于购买机票需要花费金钱,所以肯定不会重复乘坐多次同样的航线或者多次访问同一个城市。如果 \(k=0\),本题就是最基础的最短路问题。但题目中提供了一些特殊情况(对有限条边设置为免费),可以使用分层图的方式,将图多复制 \(k\) 次,原编号为 \(i\) 的结点复制为编号 \(i+jn(1 \le j \le k)\) 的结点,然后对于原图存在的边,第 \(j\) 层和第 \(j+1\) 层的对应结点也需要连上,看起来就是相同结构的图上下堆叠起来,因此被称为分层图

从上面一层跳到下面一层就是乘坐免票的飞机,花费的代价是 \(0\),这个过程是不可逆的,每乘坐一次免费航班就跳到下一层图中,因此上一层到下一层的边是单向的。从编号为 \(s\) 的结点开始,计算到其他点的单源最短路径。因为并不一定要坐满 \(k\) 次免费航班,所以查找所有编号 \(t+jn(0 \le j \le n)\) 的结点作为终点的最短路的值,找到的最小的值就是答案。
参考代码
#include <cstdio>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
const int N = 110005;
const int INF = 1e9;
struct Edge {
int to, w;
};
vector<Edge> g[N];
int dis[N];
bool vis[N];
struct Node {
int i, d;
};
struct NodeCmp {
bool operator()(const Node& a, const Node& b) {
return a.d > b.d;
}
};
priority_queue<Node, vector<Node>, NodeCmp> q;
int main()
{
int n, m, k, s, t;
scanf("%d%d%d%d%d", &n, &m, &k, &s, &t);
while (m--) {
int a, b, c; scanf("%d%d%d", &a, &b, &c);
g[a].push_back({b, c}); g[b].push_back({a, c});
for (int i = 1; i <= k; i++) {
int u = a + i * n, v = b + i * n;
g[u].push_back({v, c}); g[v].push_back({u, c});
g[u - n].push_back({v, 0}); g[v - n].push_back({u, 0});
}
}
for (int i = 0; i < k * n + n; i++) dis[i] = INF;
dis[s] = 0;
q.push({s, 0});
while (!q.empty()) {
int u = q.top().i; q.pop();
if (vis[u]) continue;
vis[u] = true;
for (Edge e : g[u]) {
int to = e.to, w = e.w;
if (dis[u] + w < dis[to]) {
dis[to] = dis[u] + w; q.push({to, dis[to]});
}
}
}
int ans = INF;
for (int i = 0; i <= k; i++) ans = min(ans, dis[t + i * n]);
printf("%d\n", ans);
return 0;
}
例题:UVA11367 Full Tank?
由于状态不仅取决于当前所在的城市,还取决于油箱中剩余的油量,可以将状态定义为 \((u, f)\),表示当前在城市 \(u\) 且剩余油量为 \(f\)。
如果选择在当前城市加油,相当于从 \((u, f)\) 转移到 \((u, f+1)\),花费为 \(p_u\),前提是 \(f \lt c\)。
如果选择前往相邻城市,假设去的是相邻城市 \(v\) 且道路距离为 \(d\),当 \(f \ge d\) 时,可以从 \((u, f)\) 转移到 \((v, f-d)\),花费为 \(0\)。
由于“边权”(这里的边权指的是花费)非负,使用 Dijkstra 算法来寻找从 \((s, 0)\) 到 \((e, *)\) 的最小花费。
参考代码
#include <cstdio>
#include <vector>
#include <queue>
using namespace std;
const int N = 1005;
const int INF = 1e9;
const int C = 105;
struct State {
int cost, u, f;
bool operator>(const State& other) const {
return cost > other.cost;
}
};
struct Edge {
int to, w;
};
vector<Edge> g[N];
int n, p[N], d[N][C];
void solve() {
int c, s, e;
scanf("%d%d%d", &c, &s, &e);
for (int i = 0; i < n; i++) {
for (int j = 0; j <= c; j++) {
d[i][j] = INF;
}
}
priority_queue<State, vector<State>, greater<State>> pq;
pq.push({0, s, 0});
d[s][0] = 0;
while (!pq.empty()) {
State cur = pq.top();
pq.pop();
if (cur.cost > d[cur.u][cur.f]) continue;
if (cur.u == e) {
printf("%d\n", cur.cost);
return;
}
// 策略 1: 在当前城市加 1 单位油
if (cur.f < c && d[cur.u][cur.f + 1] > cur.cost + p[cur.u]) {
d[cur.u][cur.f + 1] = cur.cost + p[cur.u];
pq.push({d[cur.u][cur.f + 1], cur.u, cur.f + 1});
}
// 策略 2: 前往相邻城市
for (Edge& e : g[cur.u]) {
if (cur.f >= e.w) {
int fuel = cur.f - e.w;
if (d[e.to][fuel] > cur.cost) {
d[e.to][fuel] = cur.cost;
pq.push({d[e.to][fuel], e.to, fuel});
}
}
}
}
printf("impossible\n");
}
int main()
{
int m;
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) {
scanf("%d", &p[i]);
}
for (int i = 0; i < m; i++) {
int u, v, d;
scanf("%d%d%d", &u, &v, &d);
g[u].push_back({v, d});
g[v].push_back({u, d});
}
int q; scanf("%d", &q);
while (q--) {
solve();
}
return 0;
}
习题:P13271 [NOI2025] 机器人
解题思路
这是一个典型的带有附加状态的最短路问题,可以将问题抽象为一个在“状态图”上寻找最短路径的问题。
一个状态可以由一个二元组 \((当前路口, 当前参数 p)\) 唯一确定,将其表示为 \((x,p)\),状态图中的每一个顶点都对应一个 \((x,p)\) 状态。
机器人的三种行动对应了状态图中的三类有向边:
- 移动边:对于原图中从路口 \(x\) 出发的第 \(p\) 条路,其终点为 \(y\),长度为 \(z\)。这对应状态图中一条从 \((x,p)\) 到 \((y,p)\) 的边,权重为 \(z\)。
- 增参数边:对于每个路口 \(x\) 和参数 \(p \lt k\),存在一条从 \((x,p)\) 到 \((x,p+1)\) 的边,权重为 \(v_p\)。
- 减参数边:对于每个路口 \(x\) 和参数 \(p \gt 1\),存在一条从 \((x,p)\) 到 \((x,p-1)\) 的边,权重为 \(w_p\)。
问题转化为:在这个新建的状态图上,求从源点 \((1,1)\) 到所有其他点的单源最短路。
直接构建上述状态图是不可行的,顶点总数将达到 \(O(N \times K)\),在最大的数据范围下,时空复杂度都无法承受。
需要进行优化,观察发现,参数 \(p\) 的主要作用是决定机器人能走哪条路。一个状态 \((x,p)\) 如果不存在从 \(x\) 出发的第 \(p\) 条路,那么它本身无法触发“移动”操作,只能作为修改参数时的“跳板”。
因此可以只为输入中所有提到的 \((路口, 出边编号)\) 创建节点,例如,如果路口 \(i\) 的第 \(j\) 条出边通向 \(y\),就在状态图中为 \((i,j)\) 和 \((y,j)\) 创建节点(如果它们尚不存在)。这样,总节点数最多为 \(2m\) 级别,大大减少了节点数量。
对于原图中从 \(x\) 的第 \(j\) 条路到 \(y\),长度为 \(z\),连接状态图中的节点 \((x,j)\) 和 \((y,j)\),边权为 \(z\)。
对于同一个路口 \(x\),它可能有关联的多个端点节点,如 \((x,p_1), (x,p_2), \dots\)。将这些节点按参数 \(p_i\) 的大小进行排序,然后,只需要在排序后相邻的两个节点 \((x,p_i)\) 和 \((x,p_{i+1})\) 之间建立双向的“参数修改边”。
- 从 \((x,p_i)\) 到 \((x,p_{i+1})\) 的边权为 \(\sum\limits_{t=p_i}^{p_{i+1}-1} v_t\)(累积增加参数的费用)。
- 从 \((x,p_{i+1})\) 到 \((x,p_i)\) 的边权为 \(\sum\limits_{t=p_i+1}^{p_{i+1}} w_t\)(累积减少参数的费用)。
这样,任意两个 \((x,p_a)\) 和 \((x,p_c)\) 之间的参数转换,都可以通过这些相邻边构成的路径实现,且路径长度正好是总的修改费用。
参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <map>
#include <queue>
#include <algorithm>
using namespace std;
using ll = long long;
using pl = pair<ll, ll>;
const int N = 6e5 + 5; // 状态图的节点数,大致为 2*m
const int K = 2.5e5 + 5; // 参数 p 的上限
const ll INF = 1e18;
int v[K], w[K]; // v: 增加参数的费用, w: 减少参数的费用
vector<pl> g[N]; // 状态图的邻接表
ll dis[N], ans[N]; // dis: Dijkstra 距离数组, ans: 每个路口的最终答案
bool vis[N]; // Dijkstra 访问数组
// --- 状态图节点管理 ---
pl node[N]; // node[id] = {路口, 参数p},存储每个ID对应的状态
vector<int> idv[N]; // idv[i] 存储属于路口 i 的所有状态节点的ID
map<pl, int> ids; // 将状态 (路口, 参数p) 映射到唯一的整数ID
int id_cnt; // 状态节点的计数器
// 获取或创建 (x, y) -> (路口, 参数p) 状态对应的节点ID
int getnodeid(int x, int y) {
if (ids.count({x, y})) { // 如果已存在
return ids[{x, y}];
} else { // 如果不存在,则创建
node[++id_cnt] = {x, y};
idv[x].push_back(id_cnt); // 记录该节点属于路口 x
return ids[{x, y}] = id_cnt;
}
}
int main()
{
int c; scanf("%d", &c);
int n, m, k; scanf("%d%d%d", &n, &m, &k);
for (int i = 1; i < k; i++) scanf("%d", &v[i]);
for (int i = 2; i <= k; i++) scanf("%d", &w[i]);
// --- 1. 构建“移动边” ---
for (int i = 1; i <= n; i++) {
int d; scanf("%d", &d);
for (int j = 1; j <= d; j++) {
int y, z; scanf("%d%d", &y, &z);
int from = getnodeid(i, j); // 起点状态 (i, j)
int to = getnodeid(y, j); // 终点状态 (y, j),注意参数p不变
g[from].push_back({(ll)to, (ll)z});
}
}
// --- 2. 构建“参数修改边” ---
for (int i = 1; i <= n; i++) { // 对每个路口 i
// 将路口 i 的所有相关状态节点按参数 p 排序
sort(idv[i].begin(), idv[i].end(), [](int lhs, int rhs) {
return node[lhs].second < node[rhs].second;
});
// 在排序后相邻的状态节点之间添加边
int sz = (int)(idv[i].size());
for (int j = 1; j < sz; j++) {
int from = idv[i][j - 1], to = idv[i][j];
int start_p = node[from].second;
int end_p = node[to].second;
// 计算 p 从 start_p 增加到 end_p 的费用
ll sum_v = 0;
for (int t = start_p; t < end_p; t++) sum_v += v[t];
g[from].push_back({(ll)to, sum_v});
// 计算 p 从 end_p 减少到 start_p 的费用
ll sum_w = 0;
for (int t = start_p + 1; t <= end_p; t++) sum_w += w[t];
g[to].push_back({(ll)from, sum_w});
}
}
// --- 3. 运行 Dijkstra 算法 ---
priority_queue<pl, vector<pl>, greater<pl>> q;
for (int i = 1; i <= id_cnt; i++) dis[i] = INF;
int start_node_id = getnodeid(1, 1);
q.push({0, (ll)start_node_id});
dis[start_node_id] = 0;
while (!q.empty()) {
pl cur = q.top(); q.pop();
int u = cur.second;
ll d = cur.first;
if (vis[u]) continue;
vis[u] = true;
for (pl e : g[u]) {
ll v_node = e.first, w_edge = e.second;
if (d + w_edge < dis[v_node]) {
dis[v_node] = d + w_edge;
q.push({dis[v_node], v_node});
}
}
}
// --- 4. 统计并输出最终答案 ---
for (int i = 1; i <= n; i++) ans[i] = INF;
// 到达路口 i 的最小费用,是到达所有状态 (i, p) 的最小费用
for (int i = 1; i <= id_cnt; i++) {
int road_id = node[i].first;
ans[road_id] = min(ans[road_id], dis[i]);
}
for (int i = 1; i <= n; i++) {
printf("%lld ", ans[i] == INF ? -1 : ans[i]);
}
printf("\n");
return 0;
}

浙公网安备 33010602011771号