P9751 [CSP-J 2023] 旅游巴士

一节课讲清:不能停留时,如何把“等边开放”变成“改乘更晚的入口巴士”。

分层图 Dijkstra 时间取模 CSP-J

一、先回答问题

结论

答案是到达状态 `(n, 0)` 的最早时间

从入口出发和从出口离开都必须是 `k` 的倍数。走一条边用时 `1`,所以只记录点号不够,还要记录当前时间对 `k` 的余数。

最终只有余数为 `0` 的出口状态,才是真正能坐上离开巴士的方案。

教学安排

先拆掉一个误区

题目说不能在点上或路上停留,所以“等到边开放”不是合法动作。

但可以选择更晚一班巴士进景区。这样整条已经走过的前缀都会整体后移 `k` 的倍数,仍然没有在景区内等待。

二、核心观察

状态设计

`dist[u][r]` 代表什么

`dist[u][r]` 表示:到达 `u` 号点,且当前时刻模 `k` 等于 `r` 的最早绝对时刻。

每走一条边,时间加 `1`,所以余数从 `r` 变成 `(r + 1) % k`。

点号 `u` 当前人在图上的哪个位置。
余数 `r` 当前时间与巴士周期的关系。
开放时间 `a` 边最早允许通过的时刻。
答案状态 必须是 `(n, 0)`。
错误想法

`t < a` 时在原地等

这会违反“不想在任何地点或者道路上停留”。代码里不能写成普通等待。

正确转换

整体后移若干个 `k`

如果当前记录的到达时刻 `t` 还不能走这条边,就把入口出发时间改成更晚一班。这样当前点的到达时刻也增加若干个 `k`。

depart = dist[u][r]
if depart < open:
  depart += ceil((open - depart) / k) * k
arrive = depart + 1

三、互动演示

样例路线:1 → 3 → 4 → 5,k = 3

重点看边 `3 → 4`:它在 `3` 时刻才开放。

当前记录时刻 0
当前状态 (1, 0)
检查边 1 → 3
动作 可直接走
1 开 0 3 开 3 4 开 1 5
从 `0` 时刻从入口出发,先看边 `1 → 3`。它的开放时间是 `0`,可以直接通过,到达 `3` 号点的时刻是 `1`。

四、自己选答案

1. 为什么状态里要记录 `time % k`?

2. 当前时刻 `t = 1`,`k = 3`,边开放时间 `a = 3`。正确的离开当前点时刻是多少?

3. 为什么不能用普通 BFS?

五、手推结果

弹出状态 尝试的边 开放判断 更新结果
`(1, 0)`,时间 `0` `1 → 2`,开 `0` 可以直接走 `dist[2][1] = 1`
`(1, 0)`,时间 `0` `1 → 3`,开 `0` 可以直接走 `dist[3][1] = 1`
`(2, 1)`,时间 `1` `2 → 5`,开 `1` 可以直接走 `dist[5][2] = 2`,但余数不是 `0`,不能作为答案
`(3, 1)`,时间 `1` `3 → 4`,开 `3` `1` 不够,整体后移到 `4` 再走 `dist[4][2] = 5`
`(4, 2)`,时间 `5` `4 → 5`,开 `1` 可以直接走 `dist[5][0] = 6`,所以最早离开时刻是 `6`

六、C++14 参考代码

Dijkstra + 时间余数分层
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll INF = (1LL << 60);

struct Edge {
    int to;
    int open;
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m, k;
    cin >> n >> m >> k;

    vector<vector<Edge>> g(n + 1);
    for (int i = 1; i <= m; i++) {
        int u, v, a;
        cin >> u >> v >> a;
        g[u].push_back({v, a});
    }

    vector<vector<ll>> dist(n + 1, vector<ll>(k, INF));
    priority_queue<
        tuple<ll, int, int>,
        vector<tuple<ll, int, int>>,
        greater<tuple<ll, int, int>>
    > pq;

    dist[1][0] = 0;
    pq.push({0, 1, 0});

    while (!pq.empty()) {
        ll tim;
        int u, r;
        tie(tim, u, r) = pq.top();
        pq.pop();

        if (tim != dist[u][r]) continue;

        for (const Edge &e : g[u]) {
            ll depart = tim;

            // 不能原地等待;这里表示改乘更晚一班入口巴士,整体后移若干个 k。
            if (depart < e.open) {
                ll need = e.open - depart;
                depart += (need + k - 1) / k * k;
            }

            ll arrive = depart + 1;
            int nr = (r + 1) % k;

            if (arrive < dist[e.to][nr]) {
                dist[e.to][nr] = arrive;
                pq.push({arrive, e.to, nr});
            }
        }
    }

    if (dist[n][0] == INF) cout << -1 << '\n';
    else cout << dist[n][0] << '\n';

    return 0;
}
读 `dist` 第一维是地点,第二维是时间余数。不要把余数省掉。
读 `depart` `depart` 被补到同一个余数下不早于开放时间的最小时刻。
读答案 只看 `dist[n][0]`,因为出口巴士也按 `k` 的倍数发车。

七、迁移训练

余数建图

P3403 跳楼机

同样把“数字对某个值的余数”当状态,适合迁移本题的建模方式。

时间限制

P1119 灾后重建

练习图论题里“某些点或边要到特定时间后才可用”的思维。