0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P4315,日期见页头。两边不一致时信原站。
题目背景
毛毛虫经过及时的变形,最终逃过的一劫,离开了菜妈的菜园。毛毛虫经过千山万水,历尽千辛万苦, 最后来到了小小的绍兴一中的校园里。
题目描述
爬啊爬~爬啊爬~~毛毛虫爬到了一颗小小的“毛景树”下面,发现树上长着他最爱吃的毛毛果~~~
“毛景树”上有 N 个节点和 N−1 条树枝,但节点上是没有毛毛果的,毛毛果都是长在树枝上的。
但是这棵“毛景树”有着神奇的魔力,他能改变树枝上毛毛果的个数:
Change k w:将第k条树枝上毛毛果的个数改变为w个。Cover u v w:将节点u与节点v之间的树枝上毛毛果的个数都改变为w个。Add u v w:将节点u与节点v之间的树枝上毛毛果的个数都增加w个。
由于毛毛虫很贪,于是他会有如下询问:
Max u v:询问节点u与节点v之间树枝上毛毛果个数最多有多少个。
输入格式
第一行一个正整数 N。
接下来 N−1 行,每行三个正整数 Uᵢ, Vᵢ 和 Wᵢ,第 i+1 行描述第 i 条树枝。
表示第 i 条树枝连接节点 Uᵢ 和节点 Vᵢ,树枝上有 Wᵢ 个毛毛果。
接下来是操作和询问,以 Stop 结束。
输出格式
对于毛毛虫的每个询问操作,输出一个答案。
数据范围
对于全部数据,1 ≤ N ≤ 10⁵,操作和询问数目不超过 10⁵。
保证在任意时刻,所有树枝上毛毛果的个数都不会超过 10⁹ 个。
时限 1 秒,内存 131072 KB(128 MiB)。
输入输出样例
输入
4 1 2 8 1 3 7 3 4 9 Max 2 4 Cover 2 4 5 Add 1 4 10 Change 1 16 Max 2 4 Stop
输出
9 16
★ 树是 1—2(8 个果)、1—3(7 个)、3—4(9 个)。
第一问 Max 2 4 走的是 2—1—3—4 三条边,最大 9;
Cover 2 4 5 把这三条全改成 5;Add 1 4 10 把 1—3、3—4 各 +10(变成 15);
Change 1 16 把第 1 条树枝(也就是 1—2)改成 16 ⇒ 第二问答 16。
⚠ 注意题面没有规定 u = v 时(路径上一条边都没有)该输出什么 ——
本页所有版本一律打 0,生成器也回避那一格。
1★★★ 这道题比前两道多的东西,只有两件
本章第 12 步那句话:一条边的权,存到它「深的那一端」那个点上。
每个点恰好对应它头顶那一条边(根没有,所以根那一格永远空着)。 于是「路径上的边」就变成了「路径上的点」—— 除了一个例外:
⚠ LCA 那一格必须去掉。 路径上的点比边多一个,多的那个正是 LCA, 而 LCA 头顶那条边不在这条路径上。 ⇒ 收尾那一段从
[dfn[x], dfn[y]]改成[dfn[x] + 1, dfn[y]]。
★ 而这道题还多要一张表:Change k w 给的是边号,
所以得记住「第 k 条边深的那一端是哪个点」。
⚠ 全题只有这一处用边号,剩下三种操作全用点号 —— 顺手就串过去了(错法⑤)。
Cover(区间赋值)和 Add(区间加)不是对称的,谁压谁有明确的顺序:
来一个 Cover ⇒ 这一段被彻底重写 ⇒ 之前攒的 add 全部作废(add = 0)
来一个 Add ⇒ 这一段已经有 cover 的话,就把 add 并进 cover 里(cov += v);
否则老老实实攒进 add
下推时 ⇒ 先放 cover,再放 add⇒ 一句话记:Cover 是「重写」,Add 是「修补」;重写会把之前的修补一笔勾销。
★★★ 而这三条里,只有第一条是真的必须 —— 第 ⑥ 步会把另外两条称一遍重量, 结论是「单看任何一处都无害,两处凑在一起才要命」。
2第一版:一条边一条边地改、一条边一条边地查
| 顶格形状 | ✗ 暴力 | ★ 正解 |
|---|---|---|
star(菊花) |
★ 0.02 秒 | 0.07 秒 |
rand(随机树) |
★ 0.04 秒 | 0.15 秒 |
binary(完全二叉树) |
★ 0.04 秒 | 0.20 秒 |
chain(链,端点随机) |
10.14 秒 | 0.07 秒 |
chain + 每次都问两端 |
⚠ 39.17 秒 | 0.04 秒 |
(本机 · A 机 WSL2 · nproc 12 · 2026-09-13 · 独占;前三行每格 3 次取中位数。)
⇒ ★★★ 这一章题单连着三道题,「顶格随机」都在骗人 ——
P3384 暴力比正解快 3.25 倍、P2590 快 3.7 倍、这道题快 3.75 倍。
换成链就是 22.39 秒 / 6.01 秒 / 39.17 秒。
⇒ 主语从头到尾只有一个:随机树上两点之间只有 O(log n) 条边。
★ 而 39.17 / 0.04 = 979 倍 —— 同样 N = 10⁵、同样 10⁵ 个操作,
只换树的形状和端点的挑法,暴力差 979 倍。
⚠⚠ 链上那两格(10.14 秒 / 39.17 秒)没写成断言 —— 跑一趟要 50 秒,
而闸门本来就要跑半小时(和 P3384 那 22.39 秒同一个处理办法)。
闸门里跑的是 n = m = 3×10⁴ 的同一组形状:
随机 0.01 秒(比正解的 0.03 还快)/ 链 0.90 秒 / 链顶死两端 3.07 秒,
钉的是「暴力在随机上比正解快」+「只换端点的挑法就再慢 2.2~4.5 倍」两条比值。
3正解
// ★ 正解:树链剖分(**边权版**)+ 一棵带**两个懒标记**的线段树//// ============ 这道题比前两道多的东西,只有两件 ============//// ① **边权**。本章第 12 步那一句:**一条边的权,存到它「深的那一端」那个点上**。// 于是「路径上的边」变成「路径上的点」,⚠ 但要**去掉 LCA 那一格**// (LCA 头顶那条边不在这条路径上)⇒ 收尾那一段是 `[dfn[x] + 1, dfn[y]]`。// ★ 而 `Change k w` 给的是**边号**,所以还要记一张 `k → 那条边深的那一端` 的表。//// ② ★★★ **两个懒标记**:`Cover`(区间赋值)和 `Add`(区间加)。// 它们**不是对称的**,谁压谁有明确的顺序://// 来一个 Cover ⇒ 这一段被彻底重写 ⇒ **之前攒的 add 全部作废**(`add = 0`)// 来一个 Add ⇒ 如果这一段已经有 cover,就把 add 并进 cover 里(`cov += v`);// 否则老老实实攒进 add// 下推时 ⇒ **先放 cover,再放 add**(顺序反了就等于把作废的账又翻出来)//// ⇒ 一句话记:**Cover 是「重写」,Add 是「修补」;重写会把之前的修补一笔勾销。**// ⚠ 漏掉那句 `add = 0` 就是本页第一个错法,而它**官方样例照过不误**。//// ★ 要不要 long long:题面保证「任意时刻毛毛果个数不超过 10⁹」< 2³¹ ⇒ `int` 就够。// 这里仍写 `long long`(代价是零)。#include <bits/stdc++.h>using namespace std;
static int n;static vector<pair<int, int>> g[100005]; // (邻居, 边号)static int fa[100005], dep[100005], siz[100005], son[100005], upEdge[100005];static int top_[100005], dfn[100005], rnk[100005], bfsOrder[100005], cnt;static int epoint[100005]; // 第 k 条边 → 它「深的那一端」那个点static long long w[100005]; // 点上挂的边权(根那一格恒为 0,永远不会被碰到)
/* ---------- 线段树:区间赋值 + 区间加 + 区间最大值 ---------- */static long long mx_[400005], add_[400005], cov_[400005];static bool hasCov[400005];static void pull(int p) { mx_[p] = max(mx_[p << 1], mx_[p << 1 | 1]); }static void applyCov(int p, long long v) { mx_[p] = v; cov_[p] = v; hasCov[p] = true; add_[p] = 0; // ★★★ 这一行就是这道题的全部难点:重写 ⇒ 修补作废}static void applyAdd(int p, long long v) { mx_[p] += v; if (hasCov[p]) cov_[p] += v; else add_[p] += v;}static void push(int p) { if (hasCov[p]) { applyCov(p << 1, cov_[p]); applyCov(p << 1 | 1, cov_[p]); hasCov[p] = false; } if (add_[p]) { applyAdd(p << 1, add_[p]); applyAdd(p << 1 | 1, add_[p]); add_[p] = 0; }}static void build(int p, int l, int r) { add_[p] = 0; hasCov[p] = false; cov_[p] = 0; if (l == r) { mx_[p] = w[rnk[l]]; return; } int mid = (l + r) >> 1; build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r); pull(p);}static void rangeCov(int p, int l, int r, int L, int R, long long v) { if (L <= l && r <= R) { applyCov(p, v); return; } push(p); int mid = (l + r) >> 1; if (L <= mid) rangeCov(p << 1, l, mid, L, R, v); if (R > mid) rangeCov(p << 1 | 1, mid + 1, r, L, R, v); pull(p);}static void rangeAdd(int p, int l, int r, int L, int R, long long v) { if (L <= l && r <= R) { applyAdd(p, v); return; } push(p); int mid = (l + r) >> 1; if (L <= mid) rangeAdd(p << 1, l, mid, L, R, v); if (R > mid) rangeAdd(p << 1 | 1, mid + 1, r, L, R, v); pull(p);}static long long rangeMax(int p, int l, int r, int L, int R) { if (L <= l && r <= R) return mx_[p]; push(p); int mid = (l + r) >> 1; long long s = LLONG_MIN; if (L <= mid) s = max(s, rangeMax(p << 1, l, mid, L, R)); if (R > mid) s = max(s, rangeMax(p << 1 | 1, mid + 1, r, L, R)); return s;}
/* ---------- 两趟遍历(都不递归:顶格就是一条 10⁵ 个点的链) ---------- */static void pass1() { int head = 0, tail = 0; bfsOrder[tail++] = 1; fa[1] = 0; dep[1] = 1; upEdge[1] = 0; while (head < tail) { int u = bfsOrder[head++]; for (auto [v, id] : g[u]) if (v != fa[u]) { fa[v] = u; dep[v] = dep[u] + 1; upEdge[v] = id; epoint[id] = v; bfsOrder[tail++] = v; } } for (int i = 1; i <= n; i++) { siz[i] = 1; son[i] = 0; } for (int i = n - 1; i >= 0; i--) { int u = bfsOrder[i], p = fa[u]; if (!p) continue; siz[p] += siz[u]; if (!son[p] || siz[u] > siz[son[p]]) son[p] = u; }}static void pass2() { static int st[100005]; int sp = 0; cnt = 0; top_[1] = 1; st[sp++] = 1; while (sp) { int u = st[--sp]; dfn[u] = ++cnt; rnk[cnt] = u; for (auto [v, id] : g[u]) { (void)id; if (v != fa[u] && v != son[u]) { top_[v] = v; st[sp++] = v; } } if (son[u]) { top_[son[u]] = top_[u]; st[sp++] = son[u]; } }}
/* ---------- 路径:跳链,收尾那一段左端 +1(★ 跳过 LCA 那一格) ---------- */enum Kind { COVER, ADD, MAXQ };static long long onPath(int x, int y, long long v, Kind kind) { long long res = LLONG_MIN; auto seg = [&](int L, int R) { if (L > R) return; if (kind == COVER) rangeCov(1, 1, n, L, R, v); else if (kind == ADD) rangeAdd(1, 1, n, L, R, v); else res = max(res, rangeMax(1, 1, n, L, R)); }; while (top_[x] != top_[y]) { if (dep[top_[x]] < dep[top_[y]]) swap(x, y); seg(dfn[top_[x]], dfn[x]); x = fa[top_[x]]; } if (dep[x] > dep[y]) swap(x, y); seg(dfn[x] + 1, dfn[y]); // ★★ +1:LCA 头顶那条边不在这条路径上 return res == LLONG_MIN ? 0 : res; // ⚠ u = v ⇒ 一条边都没有,本页一律打 0}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); if (!(cin >> n)) return 0; static long long ew[100005]; for (int i = 1; i < n; i++) { int u, v; long long c; cin >> u >> v >> c; g[u].push_back({v, i}); g[v].push_back({u, i}); ew[i] = c; } pass1(); pass2(); for (int i = 1; i <= n; i++) w[i] = 0; for (int i = 1; i < n; i++) w[epoint[i]] = ew[i]; // ★ 边权下放到深的那一端 build(1, 1, n);
string op; while (cin >> op && op != "Stop") { if (op == "Change") { int k; long long v; cin >> k >> v; rangeCov(1, 1, n, dfn[epoint[k]], dfn[epoint[k]], v); } else { int x, y; cin >> x >> y; if (op == "Max") cout << onPath(x, y, 0, MAXQ) << '\n'; else { long long v; cin >> v; onPath(x, y, v, op == "Cover" ? COVER : ADD); } } } return 0;}点「运行 ▶」看结果
4★ 五个错法 × 六个档位 —— 而最值钱的是那一列 0
// P4315 的「触发条件计数器」:读一组数据,打一行 CSV//// covafteradd = 1 ⟺ 存在一次 `Cover`,而它**前面**出现过 `Add`// (①NoClear / ②PushOrder 的第一层:两个标记得真的压到一起)// flipped = 1 ⟺ 存在一条输入边,它的**第二个端点是父亲**(④EdgeSide 的触发条件)// changemis = 1 ⟺ 存在一次 `Change k`,而 `k` 和「第 k 条边深的那一端」不是同一个编号// (⑤ChangeIdx 的触发条件)// lcadiff = 1 ⟺ 存在一次 `Max`,把「LCA 头顶那条边」也算进去会让答案变// (③NoSkip 的触发条件;⚠ 只是**第一层** —— 那一版连 Cover/Add 也写歪了)//// ★ 全部拿**暴力那条路**算,和被测的剖分代码一行都不共享。#include <bits/stdc++.h>using namespace std;static int n;static vector<pair<int, int>> g[100005];static int fa[100005], dep[100005], upEdge[100005], bfsOrder[100005];static long long val[100005];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); if (!(cin >> n)) return 0; vector<int> ea(n + 1, 0), eb(n + 1, 0); for (int i = 1; i < n; i++) { int u, v; long long w; cin >> u >> v >> w; g[u].push_back({v, i}); g[v].push_back({u, i}); val[i] = w; ea[i] = u; eb[i] = v; } int head = 0, tail = 0; bfsOrder[tail++] = 1; fa[1] = 0; dep[1] = 1; upEdge[1] = 0; while (head < tail) { int u = bfsOrder[head++]; for (auto [v, id] : g[u]) if (v != fa[u]) { fa[v] = u; dep[v] = dep[u] + 1; upEdge[v] = id; bfsOrder[tail++] = v; } } vector<int> epoint(n + 1, 0); for (int i = 1; i < n; i++) epoint[i] = (fa[eb[i]] == ea[i]) ? eb[i] : ea[i];
int covafteradd = 0, flipped = 0, changemis = 0, lcadiff = 0, sawAdd = 0; for (int i = 1; i < n; i++) if (epoint[i] != eb[i]) flipped = 1;
string op; while (cin >> op && op != "Stop") { if (op == "Change") { int k; long long w; cin >> k >> w; if (epoint[k] != k) changemis = 1; val[k] = w; continue; } int x, y; cin >> x >> y; long long w = 0; if (op != "Max") cin >> w; if (op == "Add") sawAdd = 1; if (op == "Cover" && sawAdd) covafteradd = 1; vector<int> es; while (dep[x] > dep[y]) { es.push_back(upEdge[x]); x = fa[x]; } while (dep[y] > dep[x]) { es.push_back(upEdge[y]); y = fa[y]; } while (x != y) { es.push_back(upEdge[x]); es.push_back(upEdge[y]); x = fa[x]; y = fa[y]; } int lca = x; if (op == "Cover") { for (int e : es) val[e] = w; } else if (op == "Add") { for (int e : es) val[e] += w; } else { long long mx = 0, mx2; for (int e : es) mx = max(mx, val[e]); mx2 = (lca == 1) ? max(mx, 0LL) : max(mx, val[upEdge[lca]]); // 把 LCA 头顶那条也算进去 if (mx != mx2) lcadiff = 1; } } printf("covafteradd,%d\nflipped,%d\nchangemis,%d\nlcadiff,%d\n", covafteradd, flipped, changemis, lcadiff); return 0;}点「运行 ▶」看结果
| 档位 | ①NoClear | ③NoSkip | ④EdgeSide | ⑤ChangeIdx | 试金石 |
|---|---|---|---|---|---|
0 顺手写法(n ≤ 12) |
⚠ 0 | 67 | ★ 0 | 68 | 227 |
1 一次 Cover 都不出 |
★ 0 | 68 | 0 | 79 | 227 |
2 一次 Add 都不出 |
★ 0 | 58 | 0 | 53 | 227 |
| 3 每条边两端随机交换 | ⚠ 1 | 64 | 130 | 70 | 227 |
4 最终档(n ∈ [150,300]) |
76 | 265 | 300 | 167 | 300 |
| 5 从 1 号点生根的链 | ⚠ 2 | 89 | ★ 0 | 59 | 215 |
★★★ ①NoClear 那一列,是这一页最值钱的东西 ——
它在所有小数据档上都是 0(0 / 0 / 0 / 1 / 2),而档 1、档 2 那两个 0 是能证的
(没有 Cover ⇒ applyCov 只会被单点的 Change 调到,而叶子从来不下推;
没有 Add ⇒ add 标记恒为 0),
可 档 0、档 3、档 5 那三个 0 一个都证不出来。
⇒ 换成「只把树放大」(其余一个字不改):
n |
200 轮里 ①NoClear 被抓 |
|---|---|
| 20 | 7 |
| 50 | 34 |
| 200 | 62 |
| 1000 | ★ 172 |
⇒ ★★★ 这是「对拍是聋的」的一种新原因:
前面见过的三种是「答案永远对」「档位到不了那条线」「这一档在验零」——
而这一次,这个 bug 要的不是一种输入,是一种「内部状态」:
一个还没下放的 add 标记,正好被一次整段覆盖的 Cover 撞上。
而线段树浅的时候,标记几乎立刻就被推到叶子上了,那个状态根本攒不出来。
⇒ ★★ 救法既不是加轮数、也不是换数据的「内容」,是把树放大。
★ 另外两条各有各的 0:④EdgeSide 在「边一律按父子顺序打」的四档上是结构性的 0 (触发条件「存在一条边的第二个端点是父亲」在那四档上恰好也是 0:0 / 0 / 0 / 268 / 300 / 0 —— ⚠ 而档 3 是 268 触发只抓 130,触发仍然只是必要条件); ⑤ChangeIdx 在六档上都活着(⚠ 我起草时以为链那一档会把它打成 0,实测 59)。
| 第一层(触发条件) | 抓获 | |
|---|---|---|
| ①NoClear | 存在一次 Cover,前面出现过 Add:140 / 0 / 0 / 123 / 300 / 128 |
0 / 0 / 0 / 1 / 76 / 2 |
| ③NoSkip | 存在一次 Max,把 LCA 头顶那条边算进去会让答案变:34 / 38 / 30 / 29 / 182 / 78 |
67 / 68 / 58 / 64 / 265 / 89 |
| ⑤ChangeIdx | 存在一次 Change k 且 k 不是「第 k 条边深的那一端」:221 / 221 / 221 / 227 / 300 / 210 |
68 / 79 / 53 / 70 / 167 / 59 |
⚠ ③NoSkip 那一行的抓获反而比触发多 —— 那不是矛盾,是我的第一层只写了 Max 那一半:
那一版连 Cover / Add 也多改了一条边,后面某次 Max 读到就露馅。
⇒ ★★ 第一层写窄了会低估,写宽了会高估 —— 两种都见过了,
能不能写成 ≡ 取决于你把它写到第几格。
| 样例上的输出 | |
|---|---|
| ★ 正解 | 9 16 |
| ①NoClear / ②PushOrder / ③NoSkip / ④EdgeSide | 9 16(四个全放过) |
| ⑤ChangeIdx | 9 15 ✓ 挡住了 |
⇒ 那组样例一共四个操作、四个点。①NoClear 被放过的原因最具体:
Cover 之后根本没有第二次下推去把那笔作废的旧账翻出来。
⇒ ★★ 这是「样例是一测就死的过滤器」的另一个极端 ——
这道题会咬人的四处,样例哪一处都问不出来。
5★★★ 「单看都无害,凑在一起才要命」—— 两个懒标记的另外两条规矩
| 版本 | applyAdd 折不折 |
下推顺序 | 300 轮 × 六档被抓 |
|---|---|---|---|
| ★ 正解 | 折(cov += v) |
cover → add | — |
| ②PushOrder | 折 | ⚠ add → cover | ★ 0 / 0 / 0 / 0 / 0 / 0 |
| ⑥NoFold | ⚠ 不折 | cover → add | ★ 0 / 0 / 0 / 0 / 0 / 0 |
| ⑥NoFoldSwap | ⚠ 不折 | ⚠ add → cover | 1 / 0 / 0 / 1 / 58 / 1 |
★★★ ②PushOrder 那一整行 0 是能证的,而证明只有两行:
正解的
applyAdd里写着if (hasCov[p]) cov_[p] += v; else add_[p] += v;⇒ 一个节点上只要有 cover,add 就恒为 0 ⇒ 两个标记永远不会同时非平凡 ⇒push里那两个if最多只有一个会执行 ⇒ 谁先谁后毫无区别。
⚠⚠ 而「一个反例都没有」和「这段代码根本没在跑」输出上一模一样 —— 所以这个 0 配了自检:把那句折叠去掉(⑥NoFold,它自己仍然是对的), 再把顺序反过来 ⇒ 当场现形。
⇒ ★★★ 两处写法各自都无害,凑在一起才要命。
这和第 34 章 P1547(「max 写在 continue 前面」靠隔壁那句 break 兜底,
去掉 break 当场 268 / 300)是同一件事的两面:
一个是「两处凑在一起才对」,一个是「两处凑在一起才错」。
⇒ ★★ 所以 p4315.cpp 里那三条规矩,只有「Cover 清 add」是非写不可的;
另外两条是互为备份的一对,你只要保住其中一条就行 —— 但你得知道自己保的是哪一条。
6★ 哪一版就已经能过了
| 版本 | 能过吗 | 数字(顶格最坏形状) |
|---|---|---|
| ✗ 一条边一条边地爬 | ✗ | 39.17 秒 / 时限 1 秒(⚠ 但顶格随机只要 0.04 秒) |
✗ Cover 不清 add |
✗ | ⚠ 小数据对拍抓不到,树要到 n = 1000 才 172/200 |
✗ 收尾忘了 +1 |
✗ | — |
| ✗ 边权存错一端 | ✗ | ⚠ 边按固定顺序打的数据上是精确的 0 |
✗ Change k 当成点号 |
✗ | — |
| ★ 下推顺序反了 | ✓ | 一次都不会错(前提是 applyAdd 折叠着写) |
| ★ 正解 | ✓ | 0.20 秒(最慢那一档)、峰值 21.4 MiB / 128 MiB |
⇒ 这道题的四件功课:
① 边权下放到深的那一端;② 收尾那一段 +1(跳过 LCA);
③ Change k 的 k 是边号;④ ★★★ Cover 要把 add 清掉。
这道题真正教的是:两个懒标记之间有先后,而「先后」这件事在代码里有三个落点 —— 而它们不是三条独立的规矩,是一条规矩的三种写法。
⇒ Cover 清 add 是非写不可的那一条;
「applyAdd 折进 cov」和「下推先 cover 后 add」互为备份,保住一条就行。
⇒ ★★ 而这也是为什么这一页要写三个「一次都不会错」的对照版:
说清楚一段代码为什么对,比说它错了有用得多 ——
而「一个反例都没有」和「这段代码没在跑」,输出上一模一样。