分层图最短路

在标准的最短路问题中,状态通常只由节点编号决定。但在实际建模时,会遇到一些带有“限制条件”或“附加状态”的题目,例如:

  • 资源限制:移动需要消耗资源,或者在节点处可以补充资源。
  • 次数限制:可以免费通过 \(k\) 条边,或者有 \(k\) 次反转边权的机会。
  • 开关状态:某些边的开启取决于当前是否持有了某种钥匙。

针对这样的问题,可以考虑将原图“分层”,即将附加状态并入空间维度,把一个点拆成一堆点

例题:P4568 [JLOI2011] 飞行路线

由于购买机票需要花费金钱,所以肯定不会重复乘坐多次同样的航线或者多次访问同一个城市。如果 \(k=0\),本题就是最基础的最短路问题。但题目中提供了一些特殊情况(对有限条边设置为免费),可以使用分层图的方式,将图多复制 \(k\) 次,原编号为 \(i\) 的结点复制为编号 \(i+jn(1 \le j \le k)\) 的结点,然后对于原图存在的边,第 \(j\) 层和第 \(j+1\) 层的对应结点也需要连上,看起来就是相同结构的图上下堆叠起来,因此被称为分层图

image

从上面一层跳到下面一层就是乘坐免票的飞机,花费的代价是 \(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)\) 状态。

机器人的三种行动对应了状态图中的三类有向边:

  1. 移动边:对于原图中从路口 \(x\) 出发的第 \(p\) 条路,其终点为 \(y\),长度为 \(z\)。这对应状态图中一条从 \((x,p)\)\((y,p)\) 的边,权重为 \(z\)
  2. 增参数边:对于每个路口 \(x\) 和参数 \(p \lt k\),存在一条从 \((x,p)\)\((x,p+1)\) 的边,权重为 \(v_p\)
  3. 减参数边:对于每个路口 \(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;
}
posted @ 2026-02-09 16:33  RonChen  阅读(9)  评论(0)    收藏  举报