把状态设成 (点, 当前时刻 mod k),在状态图上跑 Dijkstra,转移时把时间补到不早于开放时刻且同余不变的最早值。
OJ: luogu
题目 ID: P9751
难度:普及+/提高
标签:图论最短路状态压缩
日期: 2026-06-19 19:45
题意
给出一个有向图,边有开放时间。游客只能在时间是 k 的倍数时进出景区,进入后不能停留,只能一直沿边走,每条边要在不早于其开放时间时通过。
要求最早离开景区的时刻。
思路
最直接的办法是按时间模拟或者枚举起步时刻。
先看一个可以直接验证想法的朴素解:
#include <bits/stdc++.h>
using namespace std;
struct Edge {
int to;
int open;
};
const long long INF = (1LL << 60);
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 = 0; i < m; ++i) {
int u, v, a;
cin >> u >> v >> a;
g[u].push_back({v, a});
}
vector<vector<long long>> dist(n + 1, vector<long long>(k, INF));
deque<pair<int, int>> q;
vector<vector<int>> inq(n + 1, vector<int>(k, 0));
dist[1][0] = 0;
q.push_back({1, 0});
inq[1][0] = 1;
while (!q.empty()) {
auto [u, mod] = q.front();
q.pop_front();
inq[u][mod] = 0;
for (const auto &e : g[u]) {
long long nd = dist[u][mod];
if (nd < e.open) {
long long delta = e.open - nd;
long long t = (delta + k - 1) / k;
nd += t * 1LL * k;
}
++nd;
int nmod = nd % k;
if (nd < dist[e.to][nmod]) {
dist[e.to][nmod] = nd;
if (!inq[e.to][nmod]) {
inq[e.to][nmod] = 1;
q.push_back({e.to, nmod});
}
}
}
}
if (dist[n][0] == INF) {
cout << -1 << '\n';
} else {
cout << dist[n][0] << '\n';
}
return 0;
}下面是另一种「状态搜索」风格的暴力写法。它从当前点和当前时刻出发,递归枚举下一条已经开放的边,只适合小数据观察状态变化:
另一种暴力写法:状态搜索
// brute_01_style.cpp:状态搜索风格暴力,枚举每一时刻走哪条边,只适合小数据。
#include <bits/stdc++.h>
using namespace std;
struct Edge {
int to;
int open;
};
const int MAXN = 105;
const int MAX_TIME = 500;
const long long INF = (1LL << 60);
int n, m, k;
vector<Edge> g[MAXN];
bool seen[MAXN][MAX_TIME + 5];
long long answer;
void dfs(int u, int time_now) {
if (time_now > MAX_TIME || time_now >= answer) {
return;
}
if (seen[u][time_now]) {
return;
}
seen[u][time_now] = true;
if (u == n && time_now % k == 0) {
answer = min(answer, (long long)time_now);
return;
}
// 当前层递归选择下一条要走的道路。不能等待,只能走已经开放的边。
for (int i = 0; i < (int)g[u].size(); i++) {
int v = g[u][i].to;
int open_time = g[u][i].open;
if (time_now < open_time) {
continue;
}
dfs(v, time_now + 1);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> k;
for (int i = 1; i <= m; i++) {
int u, v, a;
cin >> u >> v >> a;
g[u].push_back({v, a});
}
answer = INF;
// 枚举乘哪一班车到入口:出发时刻必须是 k 的倍数。
for (int start = 0; start <= MAX_TIME; start += k) {
memset(seen, 0, sizeof(seen));
dfs(1, start);
}
if (answer == INF) {
cout << -1 << '\n';
} else {
cout << answer << '\n';
}
return 0;
}brute.cpp 在小图上直接按状态图做朴素松弛,适合对拍,但正式数据下还是应该用 Dijkstra。
关键观察是:虽然不能在图里原地等待,但我们可以把“乘坐入口巴士的时刻”整体往后推迟若干个 k。这样整条前缀路径上经过每条边的时刻都会同时增加 k 的倍数,所有时刻对 k 的余数保持不变。
因此状态只需要记录:
- 当前在哪个点
- 当前时刻对
k的余数
这张图展示样例 1 的小图:
digraph G {
1 -> 2 [label="0"];
2 -> 5 [label="1"];
1 -> 3 [label="0"];
3 -> 4 [label="3"];
4 -> 5 [label="1"];
}
从图里可以看出,虽然 1 -> 2 -> 5 路径更短,但它的总长度不是 1 -> 3 -> 4 -> 5 长度正好是 3,只要把起步时刻推迟到 3,就能满足中间边 3 -> 4 的开放时间限制,最终在 6 时刻离开。
因此设 dist[u][r] 表示到达点 u 且当前时刻 (u, r) 上走一条开放时间为 a 的边时,如果当前时刻还没到 a,就把真实时刻向上补到不早于 a 且余数仍为 r 的最早值,然后再走这条边。
最后答案就是 dist[n][0],因为离开景区时刻也必须是 k 的倍数。
代码
#include <bits/stdc++.h>
using namespace std;
struct Edge {
int to;
int open;
};
struct Node {
long long dist;
int u;
int mod;
bool operator<(const Node &other) const {
return dist > other.dist;
}
};
const long long INF = (1LL << 60);
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 = 0; i < m; ++i) {
int u, v, a;
cin >> u >> v >> a;
g[u].push_back({v, a});
}
vector<vector<long long>> dist(n + 1, vector<long long>(k, INF));
priority_queue<Node> pq;
dist[1][0] = 0;
pq.push({0, 1, 0});
while (!pq.empty()) {
auto cur = pq.top();
pq.pop();
long long d = cur.dist;
int u = cur.u;
int mod = cur.mod;
if (d != dist[u][mod]) {
continue;
}
for (const auto &e : g[u]) {
long long nd = d;
if (nd < e.open) {
long long delta = e.open - nd;
long long t = (delta + k - 1) / k;
nd += t * 1LL * k;
}
++nd;
int nmod = nd % k;
if (nd < dist[e.to][nmod]) {
dist[e.to][nmod] = nd;
pq.push({nd, e.to, nmod});
}
}
}
if (dist[n][0] == INF) {
cout << -1 << '\n';
} else {
cout << dist[n][0] << '\n';
}
return 0;
}SPFA 风格
下面这份代码还是同一个状态定义和转移,只是把优先队列换成普通队列做松弛。它更接近“不断更新状态”的写法,适合和上面的 Dijkstra 版本对照理解;正式提交时,Dijkstra 版本的复杂度更稳定。
#include <bits/stdc++.h>
using namespace std;
struct Edge {
int to; // 终点
int open_time; // 这条边最早可以通过的时刻
};
struct State {
int vertex; // 当前所在的点
int remainder; // 当前时刻除以 k 的余数
};
const int maxn = 10005;
const long long INF = (1LL << 60);
long long dist[maxn][105];
bool in_queue[maxn][105];
int n, k;
vector<Edge> g[maxn];
// SPFA 在分层图(点 × 余数)上求最短路。
// 状态 (u, r) 表示:到达 u 且到达时刻 mod k = r。
// dist[u][r] 记录该状态的最早真实到达时刻。
void spfa() {
// 初始化所有状态为 INF
for (int i = 1; i <= n; ++i)
for (int j = 0; j < k; ++j)
dist[i][j] = INF;
queue<State> q;
// 入口:0 时刻乘车到达 1 号点,余数为 0
dist[1][0] = 0;
q.push({1, 0});
in_queue[1][0] = true;
while (!q.empty()) {
State cur = q.front(); q.pop();
int u = cur.vertex;
int r = cur.remainder;
in_queue[u][r] = false; // 出队标记
for (int i = 0; i < (int)g[u].size(); ++i) {
Edge &e = g[u][i];
long long t = dist[u][r];
// 如果当前时刻早于道路的开放时间,
// 不能原地等待,只能把整条路径后移若干个 k(入口巴士推迟)
if (t < e.open_time) {
long long diff = e.open_time - t;
long long p = (diff + k - 1) / k; // 需要推迟几个周期
t += p * k;
}
// 每条道路恰好走 1 单位时间
long long nt = t + 1;
int nr = nt % k; // 下一状态的余数
// 松弛:找到更早的到达时刻才更新
if (nt >= dist[e.to][nr]) continue;
dist[e.to][nr] = nt;
// 如果不在队列中则入队,避免重复
if (!in_queue[e.to][nr]) {
q.push({e.to, nr});
in_queue[e.to][nr] = true;
}
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m;
cin >> n >> m >> k;
for (int i = 0; i < m; ++i) {
int u, v, a;
cin >> u >> v >> a;
g[u].push_back({v, a});
}
spfa();
if (dist[n][0] == INF) cout << -1 << '\n';
else cout << dist[n][0] << '\n';
return 0;
}复杂度
状态数是
总结
这题的关键不在于直接模拟时间,而在于抓住“起步时间可以整体延后 k 的倍数”这一点。把状态写成 (点, 时间 mod k) 后,就是一个标准的状态最短路。
一图流解析
这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。
