分层图最短路

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

  • 资源限制:移动需要消耗资源,或者在节点处可以补充资源。
  • 次数限制:可以免费通过 \(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;
}
2025 CSP-S1 程序填空 T1

(特殊最短路)给定一个含 \(N\) 个点、\(M\) 条边的带权无向图,边权非负。起点为 \(S\),终点为 \(T\)。对于一条 \(S\)\(T\) 的路径,可以在整条路径中,至多选择一条边作为“免费边”:当第一次经过这条被选中的边时,费用视为 \(0\);如果之后再次经过该边,则仍按其原始权重计费。点和边均允许重复经过。求从 \(S\)\(T\) 的最小总费用。以下代码求解了上述问题。试补全程序。

#include <algorithm>
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

const long long INF = 1e18;

struct Edge {
    int to;
    int weight;
};

struct State {
    long long dist;
    int u;
    int used_freebie; // 0 for not used, 1 for used
    bool operator>(const State &other) const {
        return dist > other.dist;
    }
};

int main() {
    int n, m, s, t;
    cin >> n >> m >> s >> t;
    
    vector<vector<Edge>> adj(n + 1);
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    }

    vector<vector<long long>> d(n + 1, vector<long long>(2, INF));
    priority_queue<State, vector<State>, greater<State>> pq;

    d[s][0] = 0;
    pq.push({0, s, ①});

    while (!pq.empty()) {
        State current = pq.top();
        pq.pop();

        long long dist = current.dist;
        int u = current.u;
        int used = current.used_freebie;

        if (dist > ②) {
            continue;
        }

        for (const auto &edge : adj[u]) {
            int v = edge.to;
            int w = edge.weight;

            if (d[u][used] + w < ③) {
                ③ = d[u][used] + w;
                pq.push({③, v, used});
            }

            if (used == 0) {
                if (④ < d[v][1]) {
                    d[v][1] = d[u][used];
                    pq.push({d[v][1], v, 1});
                }
            }
        }
    }

    cout << ⑤ << endl;
    return 0;
}
答案

这是一道经典的分层图最短路问题,题目允许在图的遍历中免费经过一次边,因此需要在求最短路的同时记录“是否已经使用了免费机会”这一状态。

根据 Dijkstra 算法的松弛过程与优先队列的优化原理,代码填空如下:

  • 0
    初始状态下,从起点 \(S\) 出发,距离为 0,并且还未使用免费边的机会。因此 used_freebie 的初始状态应该是 0
  • d[u][used]
    这里是 Dijkstra 算法中常用的“懒惰删除”优化,如果当前从优先队列取出的路径长度 dist 已经大于目前记录的最短路径距离 d[u][used],说明这是一个过期的冗余状态,应该直接 continue 跳过。
  • d[v][used]
    这一段表示不使用免费边的常规状态转移(或者之前已经用过了),从 uv 经过权重为 w 的边,花费为 d[u][used] + w。需要用它去更新目标节点 v 在同等状态下的距离,即比较并更新 d[v][used]
  • d[u][used] 或填 d[u][0]
    这里在 used == 0 的前提下,表示决定把当前这条连接 uv 的边作为免费边。作为免费边走过去的话,这一步的权重视为 0,也就是到达 v 点的总花费依旧等于到达 u 点的花费 d[u][used],需要用它去尝试更新 d[v][1](表示已使用 1 次免费边的状态)。
  • min(d[t][0], d[t][1])
    算法结束时,求起点到终点 \(T\) 的最小总费用。可能在途中用了免费边,对应的距离是 d[t][1],也有可能根本没用,比如全程权重都为 0,或者根本没有边,对应的距离是 d[t][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  阅读(58)  评论(0)    收藏  举报