X_i < Y_i 让编号本身就是拓扑序:按编号扫一遍、只维护「能走到 v 的最低买入价」即可 O(N+M) 求出最大差价,含暴力枚举、记忆化 DFS 对照与答案为负的边界处理。
OJ: atcoder
题目 ID: abc188_e
难度:普及+/提高-
形式化题目
给定一张
要求选一条长度至少为
原题把点称为「城镇」、把边称为「道路」、把权值称为「金价」,下文遇到时按这些叫法理解。
暴力解法
适用范围
brute.cpp 里 MAXN = 205 的上界。它的用途有两个:把题意写成可执行的说明书,以及作为对拍的基准。
思路
对每个买入城镇
实现上有两个细节。第一,把本轮标记记成起点编号本身(判断 visited[v] == u),就不必每轮清空 visited。第二,答案初值取
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-29 20:24
* update_at: 2026-10-29 20:32
*/
// brute.cpp:小数据暴力解,枚举买入城镇,DFS 找出它能到达的所有城镇,再取最大差价。
// 只适合小数据:单次 DFS 是 O(N + M),对每个起点都做一遍就是 O(N(N + M))。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 205; // 暴力只跑小数据,这个上界够用
int n, m;
ll price[MAXN]; // price[v]:城镇 v 的金价
vector<int> g[MAXN]; // g[u]:城镇 u 能直接前往的城镇
int visited[MAXN]; // visited[v] == u 表示本轮(起点为 u)已经到达城镇 v
// 从 u 出发沿有向边标记所有能到达的城镇。
void dfs_walk(int u, int mark) {
for (auto v : g[u]) {
if (visited[v] == mark) {
continue;
}
visited[v] = mark;
dfs_walk(v, mark);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> price[i];
}
for (int i = 1; i <= m; i++) {
int x, y;
cin >> x >> y;
g[x].push_back(y);
}
ll ans = LLONG_MIN / 4; // 答案可能是负数
for (int u = 1; u <= n; u++) {
dfs_walk(u, u); // 用起点编号当本轮标记,省去每次清空 visited
for (int v = 1; v <= n; v++) {
if (visited[v] == u) { // 从 u 出发能走到 v,且至少走了一条边
ans = max(ans, price[v] - price[u]);
}
}
}
cout << ans << '\n';
return 0;
}复杂度与瓶颈
- 时间:每个起点做一次 DFS,单次
, 个起点合计 。 时是 量级,不可行。 - 空间:
。
瓶颈不在一次 DFS 本身,而在于它被重复执行了
正解:按编号扫描
关键观察
观察一:编号本身就是拓扑序。 题面保证
这条保证省掉的东西比看上去多。一般的有向图要按依赖顺序处理,得先做拓扑排序;如果图里存在环,还得先 SCC 缩点。本题用一句
观察二:卖出点固定时,利润只取决于最低买入价。 路径终点是
把这个数记作
右边第二项说的是:能走到某个入边对端
下图是题面样例 2 的图,每个点里同时写着金价和最终算出的
graph LR n1["1:A=13,low=13"] --> n2["2:A=8,low=8"] n1 --> n3["3:A=3,low=3"] n2 --> n3 n2 --> n4["4:A=15,low=8"] n4 --> n5["5:A=18,low=8"]
读图时注意三件事:所有箭头都从小编号指向大编号,所以从左往右读就天然是拓扑序;城镇 5 的
思路
把两点观察拼起来,就得到一个
- 只存入边,
from[v]记下所有满足的 。DP 只往反方向读,出边表用不上。 - 按
处理。先算出 ,也就是走到 之前能买到的最低价。 - 如果
有入边,用 更新答案(答案初值取 )。没有入边的城镇不能当卖出点,因为方案要求至少走一条边。 - 最后写回
,把「在 买」这条路留给后继。
第 3 步是本题最容易写错的地方。顺手写成
样例 2 的逐点过程如下。给定
| 指向 |
|||||
|---|---|---|---|---|---|
| 1 | 13 | 无 | — | — | 13 |
| 2 | 8 | 1 | 13 | -5 | 8 |
| 3 | 3 | 1、2 | 8 | -5 | 3 |
| 4 | 15 | 2 | 8 | 7 | 8 |
| 5 | 18 | 4 | 8 | 10 | 8 |
每一行是「在
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-29 20:24
* update_at: 2026-10-29 20:32
*/
// main.cpp:按编号从小到大扫一遍。题目保证 X_i < Y_i,编号顺序就是拓扑序,
// 于是只要维护「能走到 v 的城镇里的最低金价」,答案在 O(N + M) 内就能求出来。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 200005;
const ll INF = LLONG_MAX / 4;
int n, m;
ll price[MAXN]; // price[v]:城镇 v 的金价,买入与卖出同价
ll low[MAXN]; // low[v]:能走到 v 的城镇(含 v 自己)里的最低金价
vector<int> from[MAXN]; // from[v]:所有有一条边直接指向 v 的城镇,即边 u -> v 里的 u
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> price[i];
}
for (int i = 1; i <= m; i++) {
int x, y;
cin >> x >> y;
from[y].push_back(x);
}
ll ans = -INF; // 答案可能是负数:买入价高于卖出价时,要输出真实的亏损
for (int v = 1; v <= n; v++) {
// from[v] 里的城镇编号都比 v 小,它们的 low 在更早的轮次已经算完,可以直接用。
ll buy = INF; // buy:能走到 v 的城镇里的最低买入价
for (auto u : from[v]) {
buy = min(buy, low[u]);
}
// 在 v 卖出的前提是至少走过一条边,所以没有入边的城镇不产生答案。
if (!from[v].empty()) {
ans = max(ans, price[v] - buy);
}
// low[v] 允许「在 v 买入」,把这个值留给 v 的后继城镇使用。
low[v] = min(price[v], buy);
}
cout << ans << '\n';
return 0;
}复杂度
- 时间:每个城镇扫一次,每条边只在它终点的
from里被读一次,合计。 - 空间:
from邻接表存个起点编号,加上 price、low两个数组,合计。
-O2、cin 关闭同步、含读入,重复三次取代表值):随机图约 125 ms、11.2 MiB,链约 84 ms、14.5 MiB。题面限制为 2 s / 1024 MiB,余量充足。
解法二:反向记忆化 DFS
思路
同一个 get_low(v) 先把所有入边对端递归算完,再与 low[v],下次直接返回。这份实现只用「图是有向无环图」这一条性质,并不依赖
它和主解的关系可以用一句话概括:主解把递归展开成按编号的循环,代价是必须存在一个已知的拓扑序;解法二把「顺序」交给递归自己找,代价是栈。
代价有多大可以直接量。--stack 8 时崩溃(RE,signal 11),--stack 16 时通过,折合每帧约 56 B;不带 --stack 时峰值内存 26.8 MiB,比主解多出约 12 MiB。判题器的栈默认与内存上限同样大,所以这份代码在本地判题下能过;但栈上限一旦收紧,它就会先出问题,而 main.cpp 没有这层约束。
还有一个前提不能忘:记忆化递归只在 DAG 上成立。题面的
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-29 20:28
* update_at: 2026-10-29 20:32
*/
// main2.cpp:同一个 DP 的反向记忆化写法。它只要求图是有向无环图,
// 不依赖 X_i < Y_i 带来的「编号即拓扑序」;代价是递归深度等于最长链的长度。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 200005;
const ll INF = LLONG_MAX / 4;
int n, m;
ll price[MAXN]; // price[v]:城镇 v 的金价
ll low[MAXN]; // low[v]:能走到 v 的城镇(含 v)里的最低金价;等于 INF 表示还没算过
vector<int> from[MAXN]; // from[v]:所有有一条边直接指向 v 的城镇
// 求 low[v]:先算完所有入边的对端,再取它们和 price[v] 的最小值。
ll get_low(int v) {
if (low[v] != INF) {
return low[v]; // 记住结果,每个城镇只算一次
}
ll buy = INF;
for (auto u : from[v]) {
buy = min(buy, get_low(u));
}
low[v] = min(price[v], buy);
return low[v];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> price[i];
low[i] = INF;
}
for (int i = 1; i <= m; i++) {
int x, y;
cin >> x >> y;
from[y].push_back(x);
}
// 从编号大的城镇开始问,递归会沿入边一路往回走,深度等于最长链的长度。
for (int v = n; v >= 1; v--) {
get_low(v);
}
ll ans = -INF; // 答案可能是负数
for (int v = 1; v <= n; v++) {
if (from[v].empty()) {
continue; // 没有入边,无法「先走一条边再来这里卖」
}
ll buy = INF;
for (auto u : from[v]) {
buy = min(buy, low[u]);
}
ans = max(ans, price[v] - buy);
}
cout << ans << '\n';
return 0;
}复杂度
时间与主解同阶:每个城镇、每条边各访问一次,
总结
本题的骨架是「在有向无环图上做一次极值传播」,三件事各自解决一个具体障碍:
- 编号即拓扑序。
是整道题的关键条件。它让拓扑排序、SCC 缩点、记忆化递归这些处理顺序的手段都变成不必要,只留下一个从 到 的普通循环。 - 最大差价只需要一个数。卖出点固定时只关心最低买入价,于是暴力里那份被重复算了
次的可达信息,被压成每个城镇一次的最小值合并。 - 答案口径有两处坑。候选必须挂在入边上,否则
会让答案被强行抬到非负;初值必须取 ,因为真实答案可以是负数。
把本题与同类题对照:多源最短路也是把初始值沿边传播,但它每次比较的是「距离加边权」,本题比较的只是「买入价取最小」,所以每条边读一次就够。而一旦图允许有环,同一条传播规则就必须先 SCC 缩点,题面那句
图示解析
下面这张 ASCII 图串起两层解法,标出每层消除的瓶颈。
暴力(brute.cpp)
枚举买入城镇 u -> DFS 标记 u 能到达的所有城镇 -> 取 max(A[v] - A[u])
每个起点都要重新遍历一遍边,总计 O(N(N + M))
|
| 瓶颈:每个起点都把可达集合重标一遍,N 次遍历互相重叠
v
正解(main.cpp)
X[i] < Y[i] -> 编号就是拓扑序,一遍 for v = 1..N 即可
buy = min(low[u]) over (u -> v) // 走到 v 之前的最低价
ans = max(ans, A[v] - buy) // 只在有入边的 v 上产生候选
low[v] = min(A[v], buy) // 就地买入,留给后继
总计 O(N + M),无递归
|
| 同一套递推换一种实现:把「顺序」交给递归
v
解法二(main2.cpp)
反向记忆化 DFS,只要求图是 DAG,不依赖编号顺序
代价:递归深度 = 最长链(2e5 链上需要 8-16 MiB 栈)图中两处标注对应两次真正的跨越。第一次把