题单 · 习题解析

洛谷 P4315 月下“毛景树”

★★★ 这一章最重的一道:**边权 + 两个懒标记**(区间赋值 `Cover` 和区间加 `Add`)。边权那一半是[第 12 步](/ch/53-hld/)那句「一条边的权存到深的那一端」+「收尾那一段 `+1` 跳过 LCA」;⚠ 而它和[同一张题单里的 P2590](/sol/p2590/) **正好反过来**:那道题 `+1` 写上去就死,这道题不写就死 —— **每道题读一次题面**;★★★ 而这一页最值钱的是 **①「Cover 没清 add」那一整列 0**:小数据六档只抓到 0 / 0 / 0 / 1 / 76 / 2,⚠ 而档 0、档 3、档 5 那三个 0 **一个都证不出来** —— 只把树放大(其余一个字不改)就是 `n = 20` 抓 7、`n = 1000` 抓 **172 / 200** ⇒ ★★★ **这是「对拍是聋的」的一种新原因:这个 bug 要的不是一种输入,是一种「内部状态」**(一个还没下放的 `add` 标记正好被一次整段 `Cover` 撞上),而线段树浅的时候那个状态根本攒不出来 ⇒ **救法既不是加轮数也不是换数据内容,是把树放大**;★★★ 第二值钱的是那张 2×2 的表:「`applyAdd` 折进 `cov`」和「下推先 cover 后 add」**各自都是 0 次**(前者的 0 能两行证完:有 cover 时 add 恒为 0 ⇒ push 里两个 `if` 最多执行一个 ⇒ 顺序无关),而**两处一起改就当场现形** ⇒ ★★ **两处写法各自都无害,凑在一起才要命** —— 和[第 34 章 P1547](/sol/p1547/)「两处凑在一起才对」是同一件事的两面,而那个自检正是「报『一次都不会错』之前先证明代码不是空壳」;★★ 顶格那张表是这一章**第三次**撞见「顶格随机在骗人」(暴力 0.04 秒、比正解快 3.75 倍;链上顶死两端 **39.17 秒**,差 **979 倍**);⚠⚠ 而官方样例**五个错法只挡住一个**;★ 外带一条「预判归预判」:我以为链那一档会把「Change 的 k 当成点号」打成精确的 0,实测 59 / 300

⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

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 101—33—4 各 +10(变成 15); Change 1 16第 1 条树枝(也就是 1—2)改成 16 ⇒ 第二问答 16。

⚠ 注意题面没有规定 u = v 时(路径上一条边都没有)该输出什么 —— 本页所有版本一律打 0,生成器也回避那一格。

1★★★ 这道题比前两道多的东西,只有两件

★ 一、边权 —— 本章第 12 步那一小节的正式出场

本章第 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第一版:一条边一条边地改、一条边一条边地查

p4315Brute.cpp✗ 第一版(也是对拍的标准答案):沿路径爬,每条边挨个动
p4315GenBig.cpp★ 顶格生成器:随机树 / 链 / 菊花 / 二叉树 / ★ 链且顶死两端
★★★ 顶格 N = 10⁵、操作 10⁵(时限 1 秒)—— 第三次撞见同一件事
顶格形状 ✗ 暴力 ★ 正解
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正解

p4315.cpp★ 正解:树链剖分(边权版)+ 带两个懒标记的线段树
// ★ 正解:树链剖分(**边权版**)+ 一棵带**两个懒标记**的线段树
//
// ============ 这道题比前两道多的东西,只有两件 ============
//
// ① **边权**。本章第 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

4★ 五个错法 × 六个档位 —— 而最值钱的是那一列 0

p4315NoClear.cpp✗ 错法①:Cover 的时候没把攒着的 add 标记清掉
p4315NoSkip.cpp✗ 错法③:收尾那一段忘了 +1 —— 没跳过 LCA
p4315EdgeSide.cpp✗ 错法④:边权存到输入给的第二个端点,没判哪一端是儿子
p4315ChangeIdx.cpp✗ 错法⑤:Change k w 把 k 当成了点的编号
p4315Zero.cpp★ 试金石:什么都不算,每个 Max 一律输出 0
p4315Gen.cpp★ 生成器:六个档位
p4315Count.cpp★ 数触发条件:Cover 前面有没有 Add / 边的两端有没有反 / 边号点号对不对得上
// 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;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★★★ 300 轮 × 六档(本机实测)
档位 ①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 是能证的 (没有 CoverapplyCov 只会被单点的 Change 调到,而叶子从来不下推; 没有 Addadd 标记恒为 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 kk 不是「第 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★★★ 「单看都无害,凑在一起才要命」—— 两个懒标记的另外两条规矩

p4315PushOrder.cpp★ 对照②:下推时先放 add、再放 cover —— 六档 1800 轮 0 次
p4315NoFold.cpp★ 对照⑥:applyAdd 不把 add 折进 cov 里 —— 也是 0 次
p4315NoFoldSwap.cpp✗ 错法⑥:两处一起改 —— 当场现形
★★★ 一张 2 × 2 的表
版本 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 kk 是边号;④ ★★★ Cover 要把 add 清掉

★ 一句话带走

这道题真正教的是:两个懒标记之间有先后,而「先后」这件事在代码里有三个落点 —— 而它们不是三条独立的规矩,是一条规矩的三种写法。

Coveradd 是非写不可的那一条; 「applyAdd 折进 cov」和「下推先 cover 后 add」互为备份,保住一条就行。 ⇒ ★★ 而这也是为什么这一页要写三个「一次都不会错」的对照版: 说清楚一段代码为什么对,比说它错了有用得多 —— 而「一个反例都没有」和「这段代码没在跑」,输出上一模一样。