[TJOI2015] 旅游
树剖拆路径为 O(log n) 段,线段树四元信息维护正反序最大买卖差并支持路径加。
OJ: luogu
题目 ID: P3976
难度:省选/NOI-
标签:重链剖分线段树懒标记区间合并
日期: 2026-07-16 23:59
形式化题目
给定一棵
- 沿
的路径序列 ( ),先选一个买入城市 ,再选一个卖出城市 ( ,买入必须先于卖出),利润为 ,输出最大利润,为负时输出 ; - 路径上所有城市的价格同时增加
。
思路
先看一个可以直接验证想法的朴素解:
/**
* 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-08-12 22:28
* update_at: 2026-08-12 22:29
*/
// brute.cpp:小数据暴力解,对每条路径直接枚举所有(买入城市,卖出城市)点对,辅助对拍。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 35;
int n, q;
long long price[MAXN]; // price[u] 城市 u 当前的宝石价格
vector<int> g[MAXN]; // 树的邻接表
int prev_node[MAXN]; // BFS 时记录的父链
int path[MAXN]; // 还原出的 a -> b 路径序列
int path_len; // 路径长度(城市个数)
// 用 BFS 在树上求 a -> b 的路径,结果按旅行顺序放在 path[1..path_len]。
void get_path(int a, int b) {
for (int i = 1; i <= n; i++) prev_node[i] = 0;
queue<int> qq;
qq.push(a);
prev_node[a] = -1;
while (!qq.empty()) {
int u = qq.front();
qq.pop();
if (u == b) break;
for (size_t i = 0; i < g[u].size(); i++) {
int v = g[u][i];
if (prev_node[v] != 0) continue;
prev_node[v] = u;
qq.push(v);
}
}
// 从 b 沿父链回溯到 a,再反转得到 a -> b 顺序。
int cnt = 0;
for (int u = b; u != -1; u = prev_node[u]) {
path[++cnt] = u;
}
for (int i = 1; i <= cnt / 2; i++) {
swap(path[i], path[cnt + 1 - i]);
}
path_len = cnt;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> price[i];
}
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
cin >> q;
while (q--) {
int a, b;
long long v;
cin >> a >> b >> v;
get_path(a, b);
// 枚举路径上的所有买卖点对:先经过的为买入城市,后经过的为卖出城市。
long long best = 0; // 最大利润,至少为 0(不交易)
for (int i = 1; i <= path_len; i++) {
for (int j = i + 1; j <= path_len; j++) {
long long profit = price[path[j]] - price[path[i]];
if (profit > best) best = profit;
}
}
cout << best << '\n';
// ZJY 路过路径上每个城市,价格整体上涨 v。
for (int i = 1; i <= path_len; i++) {
price[path[i]] += v;
}
}
return 0;
}brute.cpp 每次询问用 BFS 还原出
关键观察有三点:
- 路径是带方向的序列:买卖利润依赖"先到、后到"的顺序,只存一个最大值最小值不够——例如价格序列
[10, 1, 5],maxv - minv = 4,但先到 10 再买、后到 5 再卖只会亏本,正确利润是。 - 四个量足以合并区间:对一段区间维护
(minv, maxv, fwd, bwd),其中fwd是按段内 dfn 增序(先到左端、后到右端)的最大利润,bwd是反序最大利润。两段按先后顺序合并时,跨段的买卖点对只有两种可能:
即在左段买右段卖,或在右段买左段卖。所以四元信息在合并下封闭,区间整体也能被 minv、maxv 各加 hld 内嵌的 SegmentTree 的 pull / apply / push 结构为基底)。
于是正式解是 树链剖分 + 线段树:先把路径拆成 [top[a], a],真实旅行方向是从 fwd / bwd(reverse_info);从 max(0, fwd);之后把路径上所有段做区间加
下面这张表展示合并两段时四个字段的来源:
| 字段 | 左段候选 | 右段候选 | 跨段候选 |
|---|---|---|---|
minv |
minv_L |
minv_R |
— |
maxv |
maxv_L |
maxv_R |
— |
fwd(先左段后右段) |
fwd_L |
fwd_R |
maxv_R − minv_L |
bwd(先右段后左段) |
bwd_L |
bwd_R |
maxv_L − minv_R |
观察 fwd 的跨段候选:只有"在左段买、右段卖"才符合先到后到的顺序;反过来买在右段卖在左段属于 bwd。这正是"买卖有方向"在区间合并里的唯一落点——minv 与 maxv 必须分开携带,不能只存一个差值。
代码
/**
* 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-08-12 22:28
* update_at: 2026-08-12 22:29
*/
#include <bits/stdc++.h>
using namespace std;
// 区间四元信息:
// minv 区间最小价格,maxv 区间最大价格
// fwd 按 dfn 增序(左买右卖)的最大利润,即"先到左端后到右端"
// bwd 按 dfn 减序(右买左卖)的最大利润,即"先到右端后到左端"
struct Info {
long long minv, maxv, fwd, bwd;
};
// 按"先 first 段、后 second 段"的旅行顺序合并两段信息。
Info merge_info(const Info &a, const Info &b) {
Info c;
c.minv = min(a.minv, b.minv);
c.maxv = max(a.maxv, b.maxv);
// 正序利润三种可能:整段在 a 内、整段在 b 内、跨段(在 a 买、在 b 卖)。
c.fwd = max(max(a.fwd, b.fwd), b.maxv - a.minv);
// 反序利润三种可能:整段在 a 内、整段在 b 内、跨段(在 b 买、在 a 卖)。
c.bwd = max(max(a.bwd, b.bwd), a.maxv - b.minv);
return c;
}
// 翻转一段的旅行方向:最值不变,正序利润和反序利润交换。
Info reverse_info(const Info &a) {
Info c;
c.minv = a.minv;
c.maxv = a.maxv;
c.fwd = a.bwd;
c.bwd = a.fwd;
return c;
}
// 仿照 rbook 模板 hld 内嵌的 SegmentTree:pull/apply/push 结构,
// 把"区间求和"改为"四元信息",把"区间加"改为对最值整体平移(差值不变)。
struct SegmentTree {
int n = 0;
vector<long long> mn, mx, fwd, bwd, lazy;
SegmentTree(int n = 0) {
init(n);
}
void init(int size) {
n = size;
mn.assign(n * 4 + 5, 0);
mx.assign(n * 4 + 5, 0);
fwd.assign(n * 4 + 5, 0);
bwd.assign(n * 4 + 5, 0);
lazy.assign(n * 4 + 5, 0);
}
// 把两个儿子的四元信息合并回父节点。
void pull(int p) {
Info c = merge_info(Info{mn[p << 1], mx[p << 1], fwd[p << 1], bwd[p << 1]},
Info{mn[p << 1 | 1], mx[p << 1 | 1], fwd[p << 1 | 1], bwd[p << 1 | 1]});
mn[p] = c.minv;
mx[p] = c.maxv;
fwd[p] = c.fwd;
bwd[p] = c.bwd;
}
// 整段价格同时加 value:最值平移,买卖差值不变。
void apply(int p, long long value) {
mn[p] += value;
mx[p] += value;
lazy[p] += value;
}
// 下传懒标记到两个儿子。
void push(int p) {
if (lazy[p] == 0) return;
apply(p << 1, lazy[p]);
apply(p << 1 | 1, lazy[p]);
lazy[p] = 0;
}
void build(int p, int l, int r, const vector<long long> &base) {
if (l == r) {
mn[p] = mx[p] = base[l];
return;
}
int mid = (l + r) >> 1;
build(p << 1, l, mid, base);
build(p << 1 | 1, mid + 1, r, base);
pull(p);
}
// 区间 [ql, qr] 整体加 value。
void range_add(int ql, int qr, long long value, int p, int l, int r) {
if (ql <= l && r <= qr) {
apply(p, value);
return;
}
push(p);
int mid = (l + r) >> 1;
if (ql <= mid) range_add(ql, qr, value, p << 1, l, mid);
if (qr > mid) range_add(ql, qr, value, p << 1 | 1, mid + 1, r);
pull(p);
}
// 查询区间 [ql, qr] 的四元信息,返回段内按 dfn 增序的信息。
Info range_query(int ql, int qr, int p, int l, int r) {
if (ql <= l && r <= qr) {
return Info{mn[p], mx[p], fwd[p], bwd[p]};
}
push(p);
int mid = (l + r) >> 1;
bool has = false;
Info res;
if (ql <= mid) {
res = range_query(ql, qr, p << 1, l, mid);
has = true;
}
if (qr > mid) {
Info t = range_query(ql, qr, p << 1 | 1, mid + 1, r);
if (has)
res = merge_info(res, t);
else
res = t;
}
return res;
}
};
// 仿照 rbook 模板 hld:两次 DFS 求重儿子并分配 dfn,
// 路径操作把 u -> v 拆成 O(log n) 段连续区间交给线段树。
struct HeavyLightDecomposition {
int n;
int root;
int timer = 0;
vector<vector<int>> graph;
vector<int> parent, depth, subtree_size, heavy_son;
vector<int> top, dfn;
vector<long long> value, ordered_value;
SegmentTree seg;
HeavyLightDecomposition(int n, int root)
: n(n), root(root),
graph(n + 1),
parent(n + 1), depth(n + 1), subtree_size(n + 1),
heavy_son(n + 1, 0), top(n + 1), dfn(n + 1),
value(n + 1), ordered_value(n + 1),
seg(n) {}
void add_edge(int u, int v) {
graph[u].push_back(v);
graph[v].push_back(u);
}
// 第一次 DFS:求 parent、depth、subtree_size、heavy_son。
void dfs_size(int u, int father) {
parent[u] = father;
depth[u] = depth[father] + 1;
subtree_size[u] = 1;
heavy_son[u] = 0;
for (size_t i = 0; i < graph[u].size(); i++) {
int v = graph[u][i];
if (v == father) continue;
dfs_size(v, u);
subtree_size[u] += subtree_size[v];
if (heavy_son[u] == 0 || subtree_size[v] > subtree_size[heavy_son[u]]) {
heavy_son[u] = v;
}
}
}
// 第二次 DFS:先重儿子,让每条重链的 dfn 连续。
void dfs_decompose(int u, int chain_top) {
top[u] = chain_top;
dfn[u] = ++timer;
ordered_value[timer] = value[u];
if (heavy_son[u] != 0) {
dfs_decompose(heavy_son[u], chain_top);
}
for (size_t i = 0; i < graph[u].size(); i++) {
int v = graph[u][i];
if (v == parent[u] || v == heavy_son[u]) continue;
dfs_decompose(v, v);
}
}
void build() {
dfs_size(root, 0);
dfs_decompose(root, root);
seg.build(1, 1, n, ordered_value);
}
// 路径 u -> v 上的所有城市价格整体加 delta。
void path_add(int u, int v, long long delta) {
while (top[u] != top[v]) {
if (depth[top[u]] < depth[top[v]]) swap(u, v);
seg.range_add(dfn[top[u]], dfn[u], delta, 1, 1, n);
u = parent[top[u]];
}
if (depth[u] > depth[v]) swap(u, v);
seg.range_add(dfn[u], dfn[v], delta, 1, 1, n);
}
// 查询路径 u -> v 的四元信息(按旅行方向 u 先、v 后)。
Info path_query(int u, int v) {
vector<Info> left_parts; // u 侧向上收集的段,按旅行顺序排列
vector<Info> right_parts; // v 侧向上收集的段,按逆旅行顺序排列
while (top[u] != top[v]) {
if (depth[top[u]] >= depth[top[v]]) {
// u 侧这段的旅行方向是从 u 走到链头,和 dfn 增序相反,需要反序信息。
left_parts.push_back(reverse_info(seg.range_query(dfn[top[u]], dfn[u], 1, 1, n)));
u = parent[top[u]];
} else {
// v 侧这段的旅行方向是从链头走到 v,和 dfn 增序一致。
right_parts.push_back(seg.range_query(dfn[top[v]], dfn[v], 1, 1, n));
v = parent[top[v]];
}
}
// 最后 u、v 在同一条重链上。
if (depth[u] >= depth[v]) {
left_parts.push_back(reverse_info(seg.range_query(dfn[v], dfn[u], 1, 1, n)));
} else {
right_parts.push_back(seg.range_query(dfn[u], dfn[v], 1, 1, n));
}
// 按旅行顺序合并:left_parts 顺序收集,right_parts 要倒序。
Info res;
bool has = false;
for (size_t i = 0; i < left_parts.size(); i++) {
if (!has) {
res = left_parts[i];
has = true;
} else {
res = merge_info(res, left_parts[i]);
}
}
for (int i = (int)right_parts.size() - 1; i >= 0; i--) {
if (!has) {
res = right_parts[i];
has = true;
} else {
res = merge_info(res, right_parts[i]);
}
}
return res;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
HeavyLightDecomposition hld(n, 1);
for (int i = 1; i <= n; i++) {
cin >> hld.value[i];
}
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
hld.add_edge(u, v);
}
hld.build();
int q;
cin >> q;
while (q--) {
int a, b;
long long v;
cin >> a >> b >> v;
// 答案取路径正序最大利润,亏本输出 0。
Info res = hld.path_query(a, b);
cout << max(0LL, res.fwd) << '\n';
hld.path_add(a, b, v);
}
return 0;
}复杂度
- 时间:树剖预处理
,单次路径查询或路径加 ,总 。 - 空间:线段树与树剖数组均为
。
总结
本题是"路径方向性统计 + 区间加"的组合:线段树节点只存最值与两个方向的最大差值,就能在合并下保持封闭;树剖负责把有方向的树上路径规约成若干连续区间,再用方向修正(reverse_info)保证合并顺序与真实旅行顺序一致。与 P3870(区间翻转懒标记)、P1438(区间加)相比,本题的关键升级是统计量必须携带方向:一旦查询信息对合并封闭且区间更新是摘要上的自同态,懒标记线段树就可以放心使用。
图示解析
这张 ASCII 图展示整道题的解题路线,重点标出"路径方向"如何被处理:
朴素模拟(brute.cpp)
每次询问 BFS 求 a -> b 路径,枚举所有买卖点对 i < j
O(k^2) 个点对,k <= n,q 次询问 O(q*n^2) 太大
|
| 瓶颈:路径是树上散点,且买卖必须先到后到
v
关键观察
路径是"有方向的序列":
(minv, maxv, fwd, bwd) 四元信息合并封闭
跨段候选只有两个:b.maxv - a.minv(左买右卖)/ a.maxv - b.minv(右买左卖)
整体加价只平移 minv、maxv,差值不变 -> 懒标记可用
|
v
树链剖分 + 线段树(main.cpp)
路径 a -> b 拆成 O(log n) 段 dfn 连续区间
a 侧段:区间 [top[a], a] 但旅行方向是 a 先到,reverse_info 交换 fwd/bwd
b 侧段:区间 [top[b], b] 旅行方向与 dfn 增序一致,保持正序
合并顺序:a 侧段顺序拼 + b 侧段倒序拼
答案 = max(0, fwd),然后路径整体区间加 v
|
v
复杂度 O((n + q) log^2 n),空间 O(n)图中四条主线分别对应"暴力慢在哪"“四元信息为什么封闭”“树剖如何拆段并修正方向”“懒标记为什么能处理路径加”。最需要记住的是 reverse_info:路径查询的错误几乎都来自这里——把某一段的方向搞反,合并出来的 fwd 就不再是"先到后到"的利润。