阶段 7 · 数据结构 · 第 39 章提高组 S

线段树入门

★ 关键一步是懒标记 —— 它不是「一种优化」,是一句「欠着的修改」:我这一段的和已经算清了,可我的儿子们还不知道这件事。⚠ 而这一章真正难的问题是另一句:「什么时候必须把账还了?」

需要先学:第 38 章 树状数组例题:区间加 + 区间求和建议用时:140 分钟
上一章的题只改了一个字,这一章就全变了

第 38 章的题是「单点修改 + 区间求和」。这一章只把「单点」换成「区间」:

1 l r x:把 a[l] … a[r] 每一个数都加上 x。

一个字之差,可上一章那两个各有一端便宜的做法(原数组改 O(1)、前缀和查 O(1))同时失效了:

做法 第 38 章「改一个数」 这一章「改一整段」
原数组 O(1) O(区间长)
前缀和 O(n) O(n)
树状数组 O(log n) ⚠ 它管的是「前缀」,区间加没法一步记下来

★ 所以这一章要的不是「更快的树状数组」,是一个能把一整段的修改先欠着的结构。 那句话就是这一章的全部:

懒标记 = 「我这一段的和已经算清了,可我的儿子们还不知道这件事。」

⚠ 而真正难的问题不是「怎么欠」,是「什么时候必须还」—— 第 6 步会把它收成一句话, 第 8 步会拿一份一次都不下推的正解来划它的边界。

1一句话问题

给定长度 n 的数组 a[1..n](n ≤ 10⁵,|a_i| ≤ 10⁹)。接下来 m 次操作(m ≤ 10⁵):

  • 1 l r x:把 a[l] … a[r] 每个数加上 x(|x| ≤ 10⁴);
  • 2 l r:输出 a[l] + a[l+1] + … + a[r]。

★ 全部操作做完之后,再输出一行:那时候的整个数组 a[1] … a[n]。

★ 最后那一行是故意加的,第 12 步会拿数字说话

这道题的原型是洛谷 P3372,那道题只有前两条。这里多要了一行「最终的整个数组」。

★ 它和这一章的主题是同一件事:懒标记是欠账,那么题面最后就要求你把账全部还清。 代码里那三行 flush(一路下推到叶子)不是附加题,它是很多题目真正要的东西 (「模拟完之后每个位置是多少」)。

⚠ 这是第 35~38 章那条「题面多问一句,对拍就多一条腿」的第五次现场。 第 37 章给它划过边界(弱数据上格外值钱,数据一狠边际价值就掉), 第 38 章说清了它兜的是「随机数据碰不到的那个边界」。 这一章的账又复刻了一次,而且这次能说清它兜住的是哪一半:

顺手写的生成器(档位 0) 调狠之后(最终档 9)
只比前面那几行 160 / 277 / 197 / 164 / 51 / 151 / 59 299 / 300 / 300 / 299 / 292 / 298 / 295
★ 加上最后那一行 160 / 277 / 197 / 164 / 51 / 238 / 150 299 / 300 / 300 / 299 / 292 / 300 / 299

★★ 弱数据上它把两个 bug 抬了 1.6 倍和 2.5 倍(151 → 238、59 → 150), 数据够狠之后只值 298 → 300 和 295 → 299。 ★ 而另外五列一个数字都没动 —— 因为末行走的是「一路下推到叶子」那条路, 它只检查账对不对,不检查和算得对不对。

2手算一遍:默认那 8 格 + 10 步

★ 这 10 步是特意排出来的:七个错误版本里有七个会在它上面现形
8 10
3 1 4 1 5 9 2 6

2 1 8        → 31       全区间:根节点一个人就答完了
1 3 4 10                ★ [3,4] 正好是一个节点,账记在它头上就走
1 3 4 5                 ★ 同一个节点又来一笔 —— 两笔账要叠起来(15)
2 1 4        → 39       ★ 只读到 [1,4] 那个节点,还没人下推过
1 2 3 100               ★ 这次要从那个欠着账的节点身上**穿过去**
2 4 5        → 21       跨过根的 mid,左右各拼一块
1 5 8 7                 ★ [5,8] 也正好是一个节点
2 6 7        → 25       ★ 钻进 [5,8] 里面 —— 被迫连下推三次
2 1 8        → 289
1 1 8 1                 ★ 整个数组加 1:只碰根节点一格

答案:31 39 21 25 289,末行 4 102 120 17 13 17 10 14。

  • 第 2、3 条:[3,4] 恰好是一个节点管的段,两次修改都只碰它一格,账叠成 15;
  • 第 5 条是这一章的关键现场:修改要穿过那个欠着 15 的节点, 于是它被迫先把账还给两个儿子;
  • 第 8 条 2 6 7 一步连下推三次([5,8] → [5,6] → [7,8]);
  • 最后一条 1 1 8 1 是「懒」的极致:改整个数组只碰一个节点;
  • 而那 8 个数的末行,是把还欠着的每一笔账都推到叶子之后才拿到的。

3两个暴力,而且这次它们没有一个是「有一端便宜」的

第一个暴力最老实:数组原样放着,改就一格格加,查就一格格累。

brute.cpp标准答案:改 O(区间长)、查 O(区间长) —— 两边都是线性
// 标准答案 —— 什么结构都不用:区间加就一格一格地加,区间查就一格一格地累。
//
// 为什么它存在:
// ① 它是对拍里的那份**标准答案**。思路和线段树完全不同(第 9、15 章那条规矩:
// 标准答案最好用另一种思路写,同一个思路写两遍只能验出打字错误)。
// ② ★ 它同时是这一章的「反面教材」:**改 O(区间长)、查 O(区间长)**,两边都是线性。
// 第 38 章那两个暴力(原数组 / 前缀和)好歹各有一端是 O(1),
// 这一章的题(区间加 + 区间和)把那两端**同时**堵死了 ——
// · 原数组:改一段要一格格加,O(len);
// · 前缀和:改一段之后整条 s[] 全废,O(n)。
// **这就是为什么这道题非要线段树不可,而第 38 章那道题不用。**
//
// 复杂度 O(nm)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<long long> a(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> a[i];
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
for (int i = l; i <= r; i++) a[i] += x;
} else {
long long s = 0;
for (int i = l; i <= r; i++) s += a[i];
out += to_string(s);
out += '\n';
}
}
// ★ 末行:全部操作做完之后的整个数组(题面多问的那一句 —— 见 gen.cpp 开头那笔账)
for (int i = 1; i <= n; i++) {
out += to_string(a[i]);
out += (i == n ? '\n' : ' ');
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

第二个暴力已经是一棵线段树了,只是不打标记:区间加老老实实一路递归到每个叶子。

noLazy.cpp⚠ 不是 bug:答案和正解逐字节相同,只是每次修改要碰 O(区间长) 个节点
// ⚠ 这不是错误版本 —— 它的答案和正解**逐字节相同**,只是不用懒标记。
//
// 为什么它存在:
// 区间加不打标记的话,就只能老老实实一路递归到叶子,把每一个数真的改掉,
// 再一层层 pushup 回来。**答案完全正确,只是每次修改要碰 O(len) 个节点。**
//
// ★ 于是它成了第 36 章立的那个「随机对拍的第四个盲区」在这一章的现场:
// **只影响复杂度、不影响答案的写法,对拍原理上一个字都看不见。**
// (第 36 章八种并查集写法、第 37 章 linear/sorted/堆、第 38 章 buildSlow —— 这是第四次。)
// ⇒ 尺子只能换成「数节点」:count.cpp 数的是「访问了多少个树节点」。
//
// ⚠ 而且它是这一章**唯一**能说清「懒标记到底省了什么」的东西:
// 正解和它的区别不在算法框架(同一棵树、同样的递归),
// 只在那一句 **「全覆盖就记一笔账走人」**。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4];
long long a[MAXN];
int n, m;
inline void pushup(int o) { sm[o] = sm[o * 2] + sm[o * 2 + 1]; }
void build(int o, int l, int r) {
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
pushup(o);
}
/** ⚠ 没有标记可打,全覆盖也得继续往下走,一路走到叶子 */
void update(int o, int l, int r, int ql, int qr, long long x) {
if (l == r) { sm[o] += x; return; }
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x);
pushup(o);
}
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o];
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
void flush(int o, int l, int r, string& out) {
if (l == r) {
out += to_string(sm[o]);
out += (l == n ? '\n' : ' ');
return;
}
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out);
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 先说清楚:noLazy 不是错误版本

它 300 轮和正解逐字节相同。放它进来是因为这一章要回答的问题很具体:

懒标记到底省了什么?

而 noLazy 和正解的差别只有一句话:全覆盖的时候要不要记一笔账就走。 框架、递归、pushup 全都一样。所以拿它当对照,量出来的差距就只能归给懒标记。

⚠ 代价是:这个差距对拍一个字都看不见(第 36 章立的第四个盲区)。第 5 步要换尺子。

4实测慢:而且旋钮不是 n,是「修改区间有多长」

genBig.cpp 的旋钮是修改区间的长度:

genBig.cpp(四个长度档位)档位 0 长度 1 / 1 长度 n/100 / 2 长度 n/10 / 3 长度 n

本机实测(n = m = 10⁵,每个数字跑三次取稳定值):

g++ -O2 -o genBig genBig.cpp && g++ -O2 -o brute brute.cpp && g++ -O2 -o noLazy noLazy.cpp && g++ -O2 -o fast fast.cpp
./genBig 100000 100000 3 > big3.txt      # 0 长度 1 / 1 长度 n/100 / 2 长度 n/10 / 3 长度 n
time ./fast < big3.txt > /dev/null
修改区间长度 brute 原数组 noLazy 不打标记 ✓ fast 懒标记 mark 标记永久化 只读入
1(=单点改) 0.32 秒 ✓ 0.03 秒 0.03 秒 0.03 秒 0.01 秒
n/100 = 1 000 0.33 秒 0.39 秒 0.04 秒 0.03 秒 0.01 秒
n/10 = 10 000 0.41 秒 3.62 秒 0.04 秒 0.04 秒 0.01 秒
n(整段) 1.26 秒 36.0 秒 0.03 秒 0.03 秒 0.01 秒
⚠ 这张表有三件事要单独说,一件都不能省

① 第一行里 noLazy 和 fast 一模一样(0.03 秒)。 区间长度是 1 的时候,「全覆盖的节点」就是叶子本身 —— 懒标记一点忙都帮不上。 ★ 所以只报一个档位(无论报哪个)都是骗人的:第 35 章那条 「数据的形状不对,暴力会假装自己不慢」在这一章的样子,只是旋钮换成了区间长度。

② 最后一列还是老规矩。 「只读入」是 ./count io,全程 0.01 秒; 而 fast 那一列在 0.03~0.05 之间晃 —— 秒表在正解身上又一次几乎失灵 (第 38 章同款)。所以下一步还得换尺子。

③ ⚠ 而 brute 那一列,恰恰是不能只看秒表的理由。 它在最后一行只用 1.26 秒 —— 可下一步会量出来,它碰的格子数比 noLazy 只少了 1.6 倍。 同样叫「碰一格」,代价能差将近二十倍,这笔账放在第 5 步末尾算。

5慢在哪:换尺子,数「碰了多少个格子」

★ 口径:读或写一个格子一次,算碰一格

秒表失灵,那就数次数(第 21 章 stairsCount.cpp 以来的老规矩:次数可复现,秒数不可复现)。

  • brute:把数组看成一棵「只有叶子、没有内部节点」的退化结构,碰 a[i] 一次算一格;
  • 三棵树:每递归进入一个节点算一格;
  • ⚠ fast 还要额外算上 pushdown 对两个儿子的读写(各一格)—— 那两下是实打实动了数据,不算进去这张表就是在给懒标记放水。
count.cpp四种做法并排跑,数碰的格子(附「只读入」开关)
// ★ 这一章的尺子:**数一数每种做法碰了多少个格子**
//
// 为什么它存在:
// brute(直接改数组)/ noLazy(线段树但不打标记)/ fast(懒标记)/ mark(标记永久化)
// —— 四份代码的输出**逐字节相同**,对拍在它们身上一个字都看不见
// (第 36 章立的「第四个盲区」,第 37、38 章各一次现场,这是第四次)。
// ★ 次数可复现,秒数不可复现 —— 而且这一章秒表会直接失灵(见正文那张耗时表)。
//
// ============ 口径(正文里要写清楚,否则这张表没法读)============
//
// 「碰一格」= **读或写一个格子一次**。四种做法各自碰的是:
//
// · brute 改:a[l..r] 整段;查:a[l..r] 整段;末行:a[1..n] 整段
// (把数组看成一棵「只有叶子、没有内部节点」的退化结构)
// · noLazy 每递归进入一个树节点算 1 ——「改」要一路走到每个叶子
// · fast 同上,★ 外加 pushdown 时对两个儿子各算 1(那两下是实打实的读写)
// · mark 同上,它没有 pushdown,但每次 update 沿途都要改 sm[o]
//
// ⚠ 建树的开销三家一样(都是 2n − 1 个节点),单独列出来,别偷偷藏起来
// (第 29、34、36 章那条:量之前先确认你量的就是它)。
//
// 用法:./count < 数据 打印那张表
// ./count csv < 数据 同一批数字,一行一种做法,给脚本和 check:viz 读
// ./count io < 数据 ★ 只把输入读完就退出(量耗时时要减掉它)
#include <bits/stdc++.h>
using namespace std;
// 按显示宽度补空格:ASCII 算 1 格,汉字算 2 格(setw 数的是字节,对不齐)
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
int n, m;
struct Op { int op, l, r; long long x; };
vector<Op> ops;
vector<long long> a0;
long long cnt; // 当前正在数的那一列
/* ---------------- ① brute:直接在数组上做 ---------------- */
static string runBrute(long long& upd, long long& qry, long long& tail) {
vector<long long> a = a0;
string out;
for (auto& o : ops) {
if (o.op == 1) { for (int i = o.l; i <= o.r; i++) a[i] += o.x; upd += o.r - o.l + 1; }
else {
long long s = 0;
for (int i = o.l; i <= o.r; i++) s += a[i];
qry += o.r - o.l + 1;
out += to_string(s); out += '\n';
}
}
tail += n;
for (int i = 1; i <= n; i++) { out += to_string(a[i]); out += (i == n ? '\n' : ' '); }
return out;
}
/* ---------------- ② noLazy:线段树,但区间加要一路走到叶子 ---------------- */
namespace NL {
vector<long long> sm;
void build(int o, int l, int r) {
if (l == r) { sm[o] = a0[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid); build(o * 2 + 1, mid + 1, r);
sm[o] = sm[o * 2] + sm[o * 2 + 1];
}
void update(int o, int l, int r, int ql, int qr, long long x) {
cnt++;
if (l == r) { sm[o] += x; return; }
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x);
sm[o] = sm[o * 2] + sm[o * 2 + 1];
}
long long query(int o, int l, int r, int ql, int qr) {
cnt++;
if (ql <= l && r <= qr) return sm[o];
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
void flush(int o, int l, int r, string& out) {
cnt++;
if (l == r) { out += to_string(sm[o]); out += (l == n ? '\n' : ' '); return; }
int mid = (l + r) / 2;
flush(o * 2, l, mid, out); flush(o * 2 + 1, mid + 1, r, out);
}
}
/* ---------------- ③ fast:懒标记 ---------------- */
namespace LZ {
vector<long long> sm, lz;
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a0[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid); build(o * 2 + 1, mid + 1, r);
sm[o] = sm[o * 2] + sm[o * 2 + 1];
}
void apply1(int o, int len, long long x) { sm[o] += x * len; lz[o] += x; cnt++; }
void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
apply1(o * 2, mid - l + 1, lz[o]);
apply1(o * 2 + 1, r - mid, lz[o]);
lz[o] = 0;
}
void update(int o, int l, int r, int ql, int qr, long long x) {
cnt++;
if (ql <= l && r <= qr) { sm[o] += x * (r - l + 1); lz[o] += x; return; }
pushdown(o, l, r);
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x);
sm[o] = sm[o * 2] + sm[o * 2 + 1];
}
long long query(int o, int l, int r, int ql, int qr) {
cnt++;
if (ql <= l && r <= qr) return sm[o];
pushdown(o, l, r);
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
void flush(int o, int l, int r, string& out) {
cnt++;
if (l == r) { out += to_string(sm[o]); out += (l == n ? '\n' : ' '); return; }
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out); flush(o * 2 + 1, mid + 1, r, out);
}
}
/* ---------------- ④ mark:标记永久化 ---------------- */
namespace MK {
vector<long long> sm, tg;
void build(int o, int l, int r) {
tg[o] = 0;
if (l == r) { sm[o] = a0[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid); build(o * 2 + 1, mid + 1, r);
sm[o] = sm[o * 2] + sm[o * 2 + 1];
}
void update(int o, int l, int r, int ql, int qr, long long x) {
cnt++;
sm[o] += x * (min(r, qr) - max(l, ql) + 1);
if (ql <= l && r <= qr) { tg[o] += x; return; }
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x);
}
long long query(int o, int l, int r, int ql, int qr, long long add) {
cnt++;
if (ql <= l && r <= qr) return sm[o] + add * (r - l + 1);
int mid = (l + r) / 2;
long long s = 0, down = add + tg[o];
if (ql <= mid) s += query(o * 2, l, mid, ql, qr, down);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr, down);
return s;
}
void flush(int o, int l, int r, long long add, string& out) {
cnt++;
if (l == r) { out += to_string(sm[o] + add); out += (l == n ? '\n' : ' '); return; }
int mid = (l + r) / 2;
long long down = add + tg[o];
flush(o * 2, l, mid, down, out); flush(o * 2 + 1, mid + 1, r, down, out);
}
}
int main(int argc, char** argv) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
const string mode = (argc > 1) ? argv[1] : "";
if (!(cin >> n >> m)) return 0;
a0.assign(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> a0[i];
ops.resize(m);
for (int q = 0; q < m; q++) {
cin >> ops[q].op >> ops[q].l >> ops[q].r;
if (ops[q].op == 1) cin >> ops[q].x; else ops[q].x = 0;
}
if (mode == "io") { printf("读入完毕:n = %d,m = %d\n", n, m); return 0; }
struct Row { const char* name; const char* note; long long upd = 0, qry = 0, tail = 0; string out; };
Row rows[4] = {
{"brute", "直接改数组:两边都 O(区间长)"},
{"noLazy", "线段树,不打标记:改 O(区间长)"},
{"fast", "★ 懒标记:两边都 O(log n)"},
{"mark", "标记永久化:不下推,也是 O(log n)"},
};
rows[0].out = runBrute(rows[0].upd, rows[0].qry, rows[0].tail);
{ // noLazy
NL::sm.assign(4 * (n + 1), 0);
NL::build(1, 1, n);
string out;
for (auto& o : ops) {
cnt = 0;
if (o.op == 1) { NL::update(1, 1, n, o.l, o.r, o.x); rows[1].upd += cnt; }
else { out += to_string(NL::query(1, 1, n, o.l, o.r)); out += '\n'; rows[1].qry += cnt; }
}
cnt = 0; NL::flush(1, 1, n, out); rows[1].tail = cnt;
rows[1].out = out;
}
{ // fast
LZ::sm.assign(4 * (n + 1), 0); LZ::lz.assign(4 * (n + 1), 0);
LZ::build(1, 1, n);
string out;
for (auto& o : ops) {
cnt = 0;
if (o.op == 1) { LZ::update(1, 1, n, o.l, o.r, o.x); rows[2].upd += cnt; }
else { out += to_string(LZ::query(1, 1, n, o.l, o.r)); out += '\n'; rows[2].qry += cnt; }
}
cnt = 0; LZ::flush(1, 1, n, out); rows[2].tail = cnt;
rows[2].out = out;
}
{ // mark
MK::sm.assign(4 * (n + 1), 0); MK::tg.assign(4 * (n + 1), 0);
MK::build(1, 1, n);
string out;
for (auto& o : ops) {
cnt = 0;
if (o.op == 1) { MK::update(1, 1, n, o.l, o.r, o.x); rows[3].upd += cnt; }
else { out += to_string(MK::query(1, 1, n, o.l, o.r, 0)); out += '\n'; rows[3].qry += cnt; }
}
cnt = 0; MK::flush(1, 1, n, 0, out); rows[3].tail = cnt;
rows[3].out = out;
}
const long long build3 = 2LL * n - 1; // 一棵管 [1,n] 的线段树有 2n−1 个节点
if (mode == "csv") {
for (auto& r : rows)
printf("%s,%lld,%lld,%lld,%lld,%s\n", r.name, r.upd, r.qry, r.tail,
r.upd + r.qry + r.tail, r.out == rows[0].out ? "同正解" : "不同");
printf("build,%lld,%lld,%lld,%lld,-\n", (long long)n, build3, build3, build3);
return 0;
}
int nUpd = 0, nQry = 0;
for (auto& o : ops) (o.op == 1 ? nUpd : nQry)++;
long long totLen = 0;
for (auto& o : ops) if (o.op == 1) totLen += o.r - o.l + 1;
printf("n = %d,m = %d(修改 %d 次,查询 %d 次;修改区间平均长 %.1f)\n\n",
n, m, nUpd, nQry, nUpd ? (double)totLen / nUpd : 0.0);
printf("%s %s %s %s %s %s %s\n",
padDisp("做法", 9).c_str(), padDisp("说明", 34).c_str(),
padDisp("改碰的格子", 12).c_str(), padDisp("查碰的格子", 12).c_str(),
padDisp("末行", 8).c_str(), padDisp("合计", 12).c_str(), padDisp("答案", 8).c_str());
for (auto& r : rows)
printf("%s %s %s %s %s %s %s\n",
padDisp(r.name, 9).c_str(), padDisp(r.note, 34).c_str(),
padDisp(to_string(r.upd), 12).c_str(), padDisp(to_string(r.qry), 12).c_str(),
padDisp(to_string(r.tail), 8).c_str(),
padDisp(to_string(r.upd + r.qry + r.tail), 12).c_str(),
padDisp(r.out == rows[0].out ? "同正解" : "✗ 不同", 8).c_str());
bool same = true;
for (auto& r : rows) same &= (r.out == rows[0].out);
printf("\n★ 四种做法的答案%s —— 所以这张表里的差别,对拍**一个字都看不见**。\n",
same ? "**完全一样**" : "有不一样的");
printf("\n建树:三棵树都是 %lld 个节点(2n − 1),brute 只要扫 %d 格。\n", build3, n);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

实测(./genBig 20000 20000 档位,n = m = 2×10⁴,全部写进了 check:viz):

修改区间长度 brute noLazy ✓ fast mark
1(=单点改) 49 909 536 550 973 550 973 550 973
n/100 = 200 52 218 556 4 535 016 1 014 442 ✓ 683 020
n/10 = 2 000 70 081 043 40 808 956 1 321 294 ✓ 746 090
n(整段) 251 066 841 402 744 320 876 724 ✓ 404 438

★★ 第一行那三个数字一字不差 —— 这就是「懒标记省了什么」最干净的回答:

区间长度是 1 的时候,它一格都省不了;区间拉到整段,它省 459 倍。 (402 744 320 ÷ 876 724 = 459。)

⚠ 而最后一列(mark,标记永久化)在每一档都比懒标记更便宜 —— 那是第 8 步的事。

★★ 两把尺子打架的时候:碰格数只差 1.6 倍,秒表差 30 倍

把最后那一行的两个暴力放在一起看(n = m = 2×10⁴、档位 3,命令就是上面那两条):

碰的格子 本机耗时 每秒能碰多少格
brute 原数组 251 066 841 0.05 秒 50.2 亿 / 秒
noLazy 线段树不打标记 402 744 320 1.50 秒 2.68 亿 / 秒

★★ 格子数只差 1.60 倍,秒表差 30 倍 —— 因为「碰一格」根本不是等价的: brute 碰的是连续的一段(缓存全命中,编译器还能向量化), 线段树碰的是在树上跳(每一步都是一次跳转和一次可能的缓存缺失)。

⚠ 所以这一章的两张表必须一起给,而且要说清各自能回答什么:

  • 碰格表回答「算法做了多少活」—— 可复现、能写成断言,但它不是耗时;
  • 耗时表回答「这台机器上要跑多久」—— 更贴近真实,但不可复现,而且在正解身上失灵。

★ 这是第 29 章(「链式前向星常数最小」在稠密图上被缓存打脸)那条的同源现场, 也是第 22、29、34、36 章那条「量之前先确认你量的就是它」的第六次: 你量的是「格子数」还是「秒数」,得先说清楚 —— 它们不成比例。

于是问题变得很具体了:

一次区间加要碰 O(区间长) 个节点,是因为每一个数都被真的改了一遍。 那么 —— 能不能先不改,只记一笔账?

6★ 关键一步:懒标记 = 一句「欠着的修改」

★★ lz[o] 的含义要背得一字不差

区间加 [l,r] += x 的时候,如果某个节点管的那一段整个落在 [l,r] 里面, 那么这一整段每个数都要加 x —— 可我们并不需要真的去改那一段里的每一个数:

  • 这个节点自己的和,一句话就算得出来:sm[o] += x * 段长;
  • 至于它底下那些节点,先欠着 —— 在 lz[o] 上记一笔「我欠你们每人一个 x」。

★ lz[o] 的含义:「o 这个节点自己的 sm 已经算进去了,但 o 的两个儿子还不知道这件事。」

⚠ 不是「这一段要加 lz」,也不是「这一段还没加」—— 差一个字,代码就写不对。 (sm[o] 到底算没算 lz[o]?这个问题只有一个答案,而第 11 步那七个 bug 里有三个就死在它上面。)

于是一次区间加只在边界上留下 O(log n) 个「全覆盖」的节点,每个记一笔账就走人。

★★ 那么,什么时候「必须」把账还了?

只有一种时候:当你要往下走、要读儿子的值的时候。 儿子的 sm 是「还没加过 lz[o]」的旧值,直接读就是错的。

把递归到一个节点时的三种情况摆开,答案就是一句话:

节点区间和操作区间的关系 要做什么 要不要下推
全覆盖(节点整段都在里面) 记一笔账 / 直接把 sm[o] 拿走 不用(sm[o] 本来就是对的)
不相交 立刻返回 不用(根本不往下走)
★ 半覆盖 递归进两个儿子 ★ 必须

⇒ 「懒」到不能再懒为止 —— 一直欠着,直到有人要看儿子。

⚠ 三条推论,每一条都对应第 11 步的一个真 bug:

  • 修改和查询两边都要下推(少写一处就是一个 bug —— 而且这两处坏的东西还不一样);
  • 下推完 lz[o] 必须清零(账还了要销账,否则下次路过再还一遍);
  • lz[o] += x,不是 = x(欠了两笔账要叠起来)。

★★ 但请记住「必须下推」的理由,而不是这句话本身:理由是「你要去读儿子的值」。 第 8 步会给出一份一次都不下推的正解 —— 理由消失了,要求也就消失了。

7正解

fast.cpp正解:线段树 + 懒标记(含末行那三行 flush)
// 正解 —— 线段树 + 懒标记(区间加 / 区间和)
//
// ============ ★ 关键一步:懒标记 = 一句「欠着的修改」============
//
// 区间加 [l,r] += x 的时候,如果某个节点管的那一段**整个**落在 [l,r] 里面,
// 那么这一整段每个数都要加 x —— 可我们并不需要真的去改那一段里的每一个数:
//
// · 这个节点自己的和,一句话就能算出来:sum[o] += x * (段长);
// · 至于它**底下**那些节点,先欠着 —— 在 lz[o] 上记一笔「我欠你们每人一个 x」。
//
// ★ 所以 lz[o] 的含义要背得一字不差:
// **「o 这个节点自己的 sum 已经算进去了,但 o 的两个儿子还不知道这件事。」**
// (不是「这个区间要加 lz」,也不是「还没加」—— 差一个字,代码就写不对。)
//
// 于是区间加只在**边界上**留下 O(log n) 个「全覆盖」的节点,每个记一笔账就走人。
// ⚠ 对照第 38 章:那道题只有单点改,一条 add 路径就完事,用不着欠账;
// 这一章一次要改一整段,**不欠账就只能一个个改,那就退回暴力了。**
//
// ============ ★★ 什么时候「必须」把账还了(pushdown)============
//
// 只有一种时候:**当你要往下走、要读儿子的值的时候。**
// 儿子的 sum 是「还没加过 lz[o]」的旧值,直接读就是错的。
//
// · 全覆盖 → 直接用 sum[o],**不用**下推(sum[o] 是对的);
// · 不相交 → 根本不往下走,**不用**下推;
// · ★ 半覆盖 → 要递归进儿子,**必须先下推**。
//
// ⇒ 一句话:**「懒」到不能再懒为止 —— 一直欠着,直到有人要看儿子。**
// ⚠ 修改和查询**两边都要下推**(少写一处就是一个 bug,见 wrongPushQ / wrongPushU)。
// ⚠ 下推之后 lz[o] 必须清零(账还完了要销账,见 wrongKeep)。
//
// ★★ 但「必须下推」这句话是有条件的:条件是「**你要读儿子的值**」。
// 标记永久化(mark.cpp)换了个办法 —— 不下推,而是在**查询的路上**把沿途欠的账补回来,
// 答案 300 轮逐字节相同。所以它不是定律,是一个可以绕开的具体理由。
//
// ============ 结构(递归版)============
//
// 节点 o 管区间 [l,r],左儿子 2o 管 [l,mid],右儿子 2o+1 管 [mid+1,r]。
// ⚠ 数组要开 **4n**:递归线段树的节点编号最坏能用到 4n(n 不是 2 的幂时树不满,
// 最下面一层会「错位」,2n 是不够的 —— 这是新手最常见的 RE)。
//
// 复杂度:建树 O(n),每次操作 O(log n)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4]; // sm[o]:o 这一段的和(★ 已经把 lz[o] 算进去了)
long long lz[MAXN * 4]; // lz[o]:欠儿子们的那笔账(每人一个 lz[o])
long long a[MAXN];
int n, m;
inline void pushup(int o) { sm[o] = sm[o * 2] + sm[o * 2 + 1]; }
/** 给节点 o(它管的段长是 len)整段加 x:自己的和当场算清,儿子的先欠着 */
inline void apply(int o, int len, long long x) {
sm[o] += x * len; // ⚠ 忘了乘 len 是最常见的错(wrongLen.cpp)
lz[o] += x; // ★ 是 +=,不是 =:欠了两笔账要叠起来
}
/** 把欠儿子的账还了,然后销账 */
inline void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
apply(o * 2, mid - l + 1, lz[o]);
apply(o * 2 + 1, r - mid, lz[o]);
lz[o] = 0; // ⚠ 不清零就会重复还账(wrongKeep.cpp)
}
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
pushup(o);
}
/** 区间加:[ql,qr] 每个数 += x */
void update(int o, int l, int r, int ql, int qr, long long x) {
if (ql <= l && r <= qr) { apply(o, r - l + 1, x); return; } // 全覆盖:记一笔账就走
pushdown(o, l, r); // ★ 要往下走了,先把账还了
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x); // ⚠ 两个 if,不是 if/else
pushup(o);
}
/** 区间和:[ql,qr] */
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o]; // 全覆盖:sm[o] 本来就是对的
pushdown(o, l, r); // ★ 查询这一侧同样要下推
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
/** ★ 末行要的是每一个 a[i]:把所有欠账一路推到叶子,账就全清了 */
void flush(int o, int l, int r, string& out) {
if (l == r) {
out += to_string(sm[o]);
out += (l == n ? '\n' : ' ');
return;
}
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out); // ★ 末行:把欠着的账全部还清
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 两个几乎人人都要栽一次的细节

① 数组开 4n,不是 2n。 递归线段树按 o / 2o / 2o+1 编号,n 不是 2 的幂时树不满,最下面一层会「错位」, 编号最坏能用到 4n。开小了就是 RE,而且样例多半还过得去。

② apply 里那个 len 不能忘。 一整段每个数都加 x,这一段的和要加的是 x × 段长。 sm[o] += x 是这一章最常见的错,而且它在叶子上一点毛病都没有(叶子的段长是 1)—— 所以只看末行是抓不到它的,第 11 步那张表会印证这一点。

8★★ 「必须下推」这句话的边界:一份一次都不下推的正解

mark.cpp(选讲)★ 标记永久化:账永远挂在原地,谁路过谁把它补进答案 —— 300 轮和正解逐字节相同
// 选讲 —— 标记永久化:**一次都不下推**,答案照样对。
//
// 为什么它存在:这一章从头到尾在讲「什么时候**必须**下推」,
// 而这份代码是那句话的**边界**:必须下推的真正理由只有一条 ——
//
// > **你要去读儿子的 sum,而儿子的 sum 还不知道你欠它的那笔账。**
//
// 那么换个办法:**账一直挂在打标记的那个节点上(永久化),
// 谁要从上面走过去,谁就顺手把路过的账补进答案里。**
//
// ============ 两处约定(记清楚就不会写错)============
//
// · sm[o] = o 这一段的**真实和**,但**不含它所有祖先的 tag**(自己的 tag 已经算进去了);
// · tg[o] = 「o 这一整段每个数都还要再加 tg[o]」,而且**永远不下发**。
//
// 区间加:沿途每个节点 sm[o] += x × (o 和 [ql,qr] 的交集长度);
// 走到全覆盖的节点就 tg[o] += x,停。
// 区间查:一路往下带着 add =「沿途所有祖先 tg 之和」,
// 到全覆盖的节点返回 sm[o] + add × 段长。
//
// ============ ★ 它值钱在哪 ============
//
// ① 它和正解 300 轮**逐字节相同** —— 又一个「对拍看不见」的现场,
// 而这次两份代码的**思路是不同的**(不是「同一个算法的两种写法」)。
// ② ★ 它把「必须下推」从一条**定律**降回一个**具体的理由**:
// 理由消失(不再去读儿子的旧值),要求也就消失了。
// ⚠ 但它有代价,正文里要写明:**只对「可交换、可叠加」的标记成立**
// (区间加就是)。碰到区间赋值那种「后来的把先前的盖掉」的标记,
// 顺序一乱就错了 —— 那种情况老老实实下推。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4]; // 不含祖先 tg 的真实和
long long tg[MAXN * 4]; // ★ 永久挂在这里,从不下发
long long a[MAXN];
int n, m;
void build(int o, int l, int r) {
tg[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
sm[o] = sm[o * 2] + sm[o * 2 + 1];
}
void update(int o, int l, int r, int ql, int qr, long long x) {
sm[o] += x * (min(r, qr) - max(l, ql) + 1); // 沿途每个节点都把真实贡献补上
if (ql <= l && r <= qr) { tg[o] += x; return; } // 全覆盖:账永久挂在这儿
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x);
}
/** add = 从根到 o 的**所有祖先**的 tg 之和 */
long long query(int o, int l, int r, int ql, int qr, long long add) {
if (ql <= l && r <= qr) return sm[o] + add * (r - l + 1);
int mid = (l + r) / 2;
long long s = 0, down = add + tg[o];
if (ql <= mid) s += query(o * 2, l, mid, ql, qr, down);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr, down);
return s;
}
void flush(int o, int l, int r, long long add, string& out) {
if (l == r) {
out += to_string(sm[o] + add);
out += (l == n ? '\n' : ' ');
return;
}
int mid = (l + r) / 2;
long long down = add + tg[o];
flush(o * 2, l, mid, down, out);
flush(o * 2 + 1, mid + 1, r, down, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r, 0));
out += '\n';
}
}
flush(1, 1, n, 0, out);
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 理由消失,要求就消失

必须下推的理由只有一条:你要读儿子的 sm,而它还不知道你欠它的那笔账。 那么换个办法:账一直挂在打标记的那个节点上(永久化),谁从上面走过去,谁顺手把路过的账补进答案。

  • sm[o] = 这一段的真实和,但不含它所有祖先的 tg(自己的 tg 已经算进去了);
  • 区间加:沿途每个节点 sm[o] += x × (交集长度),走到全覆盖的节点就 tg[o] += x,停;
  • 区间查:一路往下带着 add = 沿途所有祖先 tg 之和,到全覆盖的节点返回 sm[o] + add × 段长。

⚠ 它有代价,必须写明:只对「可交换、可叠加」的标记成立(区间加正是)。 碰到区间赋值那种「后来的把先前的盖掉」的标记,顺序一乱就错了 —— 那种情况老老实实下推。

★ 而它在第 5 步那张碰格表里每一档都比懒标记便宜(683 020 / 746 090 / 404 438 对 1 014 442 / 1 321 294 / 876 724)—— 因为它根本没有 pushdown 那两下, 末行也不用一路推账。

★★ 所以这一章要立的规矩是:「必须下推」不是定律,是一个具体的理由。 这和第 32 章那条(「不成立的是那句『取出来就定死』」,而不是「那份代码会错」)是同一种要求: 把结论钉在理由上,别钉在句子上。

9动画一:账挂在哪儿,什么时候被迫还

一个节点 = 一根横条,横条盖住的就是它管的那一段
全程碰格 69(改 17 / 查 23 / 末行 29)
第 1 / 12 步
这一步碰了 0 格★ 累计碰格 0操作区间 —n = 8,树高 4 层
1
2
3
4
5
6
7
8
31
9
22
4
5
14
8
3
1
4
1
5
9
2
6
实心 = 整段都在里面,停住(记账 / 取和)描边 = 半覆盖,只能继续往下橙色 = ★ 被迫下推 红色徽章 = 还欠着的 lz
★ 结论:一棵管 [1,n] 的线段树有 2n−1 个节点
建树完毕:每个节点存的是它管的那一段的和。所有的 lz 都是 0 —— 一笔账都没欠。
怎么看这个动画
  • 一个节点画成一根横条,横条盖住的就是它管的那一段 —— 沿用第 38 章那张阶梯图的语言。 越往下的行条越短,这就是线段树。
  • 实心 = 这一步停住的节点(全覆盖:记一笔账 / 直接取和); 描边 = 半覆盖,只能继续往下;橙色 = ★ 被迫下推。
  • 红色徽章就是 lz —— 盯住它什么时候出现、什么时候消失。
  • 切到「★ 只改不查」那一档:账一路挂着,一次都不用下推; 再切到「★ 改一段、马上查它里面的一小段」:每一次都被迫下推。 这两档就是第 6 步那张表的画面版。

⚠ trace.cpp 就是这个动画的文字版,check:viz 拿它和动画逐步比 (访问序列 / 下推了谁 / 停在哪 / 碰格 / 累计 / 整个 sm / 整个 lz / 结论),不只比最终答案。

trace.cpp动画照着它画:每一步的访问序列、下推、sm 和 lz
// 动画的文字版 —— 每一步走过哪些节点、在哪儿停住、什么时候被迫下推
//
// 为什么它存在:动画是用 TypeScript 重写一遍算法画出来的,
// 万一两边不一致,学生看到的画面就是在骗人(第 27 章那次就是这么被抓住的)。
// 所以这份代码把每一步的**全部状态**打出来,`check:viz` 拿它和动画**逐步**比 ——
// 不只比最终答案,连「碰了几个节点」这个画面上的计数器也要对得上
// (★ 第 38 章的教训:计数的口径必须**和 count.cpp 一字不差**,
// 否则会出现「逻辑对了、画面画的是另一回事」)。
//
// 口径(和 count.cpp 完全一致):
// · 每**递归进入**一个节点算碰 1 格;
// · pushdown 时对两个儿子各算 1(那两下是实打实的读写)。
//
// 输出:每一步给出「访问序列 / 下推的节点 / 停住的节点 / 碰格 / 累计 / sm / lz / 结论」。
// 节点按 DFS 前序编号列出("编号:值"),和动画那边的遍历顺序一致。
#include <bits/stdc++.h>
using namespace std;
int n, m;
vector<long long> sm, lz, a;
long long cnt, total;
vector<int> visited, pushed, stopped;
static void collect(int o, int l, int r, vector<int>& ids) {
ids.push_back(o);
if (l == r) return;
int mid = (l + r) / 2;
collect(o * 2, l, mid, ids);
collect(o * 2 + 1, mid + 1, r, ids);
}
static string dump(const vector<long long>& v, const vector<int>& ids) {
string s;
for (size_t i = 0; i < ids.size(); i++) {
if (i) s += ' ';
s += to_string(ids[i]);
s += ':';
s += to_string(v[ids[i]]);
}
return s;
}
static string join(const vector<int>& v) {
string s;
for (size_t i = 0; i < v.size(); i++) { if (i) s += ' '; s += to_string(v[i]); }
return s;
}
static void apply1(int o, int len, long long x) { sm[o] += x * len; lz[o] += x; cnt++; }
static void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
pushed.push_back(o);
apply1(o * 2, mid - l + 1, lz[o]);
apply1(o * 2 + 1, r - mid, lz[o]);
lz[o] = 0;
}
static void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
sm[o] = sm[o * 2] + sm[o * 2 + 1];
}
static void update(int o, int l, int r, int ql, int qr, long long x) {
cnt++; visited.push_back(o);
if (ql <= l && r <= qr) { stopped.push_back(o); sm[o] += x * (r - l + 1); lz[o] += x; return; }
pushdown(o, l, r);
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x);
sm[o] = sm[o * 2] + sm[o * 2 + 1];
}
static long long query(int o, int l, int r, int ql, int qr) {
cnt++; visited.push_back(o);
if (ql <= l && r <= qr) { stopped.push_back(o); return sm[o]; }
pushdown(o, l, r);
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
static void flush(int o, int l, int r, vector<long long>& leaf) {
cnt++; visited.push_back(o);
if (l == r) { leaf[l] = sm[o]; return; }
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, leaf);
flush(o * 2 + 1, mid + 1, r, leaf);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
a.assign(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> a[i];
sm.assign(4 * (n + 1), 0);
lz.assign(4 * (n + 1), 0);
build(1, 1, n);
vector<int> ids;
collect(1, 1, n, ids);
printf("建树 sm [%s]\n", dump(sm, ids).c_str());
for (int q = 1; q <= m; q++) {
int op, l, r; long long x = 0;
cin >> op >> l >> r;
if (op == 1) cin >> x;
cnt = 0; visited.clear(); pushed.clear(); stopped.clear();
long long ans = 0;
if (op == 1) update(1, 1, n, l, r, x);
else ans = query(1, 1, n, l, r);
total += cnt;
string note;
if (op == 1) {
note = "区间加 [" + to_string(l) + "," + to_string(r) + "] += " + to_string(x)
+ ":在 " + to_string(stopped.size()) + " 个节点上记账";
if (!pushed.empty()) note += ",路上被迫下推 " + to_string(pushed.size()) + " 次";
else note += ",一次都不用下推";
} else {
note = "区间和 [" + to_string(l) + "," + to_string(r) + "] = " + to_string(ans)
+ ":拼了 " + to_string(stopped.size()) + " 个节点";
if (!pushed.empty()) note += ",路上被迫下推 " + to_string(pushed.size()) + " 次";
else note += ",一次都不用下推";
}
printf("步 %d op=%d\n", q, op);
printf(" 访问 [%s] 下推 [%s] 停住 [%s]\n",
join(visited).c_str(), join(pushed).c_str(), join(stopped).c_str());
printf(" 碰格 %lld 累计碰格 %lld\n", cnt, total);
printf(" sm [%s]\n", dump(sm, ids).c_str());
printf(" lz [%s]\n", dump(lz, ids).c_str());
printf(" 结论 %s\n", note.c_str());
}
cnt = 0; visited.clear(); pushed.clear(); stopped.clear();
vector<long long> leaf(n + 1, 0);
flush(1, 1, n, leaf);
total += cnt;
string arr;
for (int i = 1; i <= n; i++) { if (i > 1) arr += ' '; arr += to_string(leaf[i]); }
printf("收尾 下推 [%s] 碰格 %lld 累计碰格 %lld 数组 [%s]\n",
join(pushed).c_str(), cnt, total, arr.c_str());
printf("★ 末行把每一笔还欠着的账都推到了叶子 —— 一共下推 %d 次。\n", (int)pushed.size());
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

10动画二:打不打标记 —— 而它们的答案逐字节相同

同一串操作,两棵树并排跑 —— 而它们的答案逐字节相同
第 1 / 12 步
线段树,不打标记:改 O(区间长)
这一步 0 ★ 累计 0
✓ 懒标记:两边都 O(log n)
这一步 0 ★ 累计 0
高亮 = 这一步递归进入过的节点。
⚠ 右边那个计数器还算上了 pushdown 对两个儿子的读写(口径和 count.cpp 一字不差), 所以它偶尔会比点亮的格子多。
★ 切到「全是单点修改」那一档:两个计数器一步不差 —— 区间长度是 1 的时候,懒标记一点忙都帮不上。
⚠ 默认这组数据的区间都很短,跑到最后是 71 对 69 —— 几乎打平。 切到「★ 全是长区间修改」才看得出它是干什么用的。
同一串操作,两棵一模一样的树并排跑。盯住右边那两个「累计碰过的节点」。
★★ 对拍看不见「慢」—— 这一章是第四次现场,而且这次是四份

brute / noLazy / fast / mark 是四个不同的做法(其中三个连算法框架都不同), 可它们的输出逐字节相同。第 5 步那张表里几百倍的差距,对拍一个字都看不见。

★ 第 36 章立的「随机对拍第四个盲区」,第 37 章证明了它比想象的宽 (linear / sorted / 堆三个不同算法答案相同),第 38 章第三次(brute / prefix / fenwick), 这一章第四次 —— 只要它们解的是同一道题,对拍就一个字都看不见。 唯一的出路还是那一条:换尺子,数次数。

⚠ 把动画切到「⚠ 全是单点修改」那一档:两个计数器一步不差。 再切到「⚠ 全是查询」:还是一步不差 —— ★ 懒标记省的是「改」,不是「查」。

⚠⚠ 还有一件必须老实写的事:默认这组数据上,懒标记几乎没赢 —— 跑到最后是 noLazy 71 格、fast 69 格,只差 2 格; 中间甚至有一步(1 2 3 100)懒标记反而更贵(6 对 8,因为它多了 pushdown 那两下)。 ★ 原因就是第 4、5 步那个旋钮:这 10 步里的区间都很短。 切到「★ 全是长区间修改」那一档才看得出它是干什么用的 ——

一个结构好不好,永远要连着「数据长什么样」一起说。

11★ 对拍:七个错误版本,各自靠什么现形

★ 动笔调生成器之前,先把「每个 bug 靠什么现形」一条条写下来

这是第 37 章立的规矩(写在 gen.cpp 开头),第 38 章验过一次。这一章的清单天然分成两半:

错误版本 靠什么现形
wrongLen 标记落 sum 时忘了乘长度 一次「长度 > 1 的全覆盖修改」+ 有人读到它 —— ★ 基线
wrongMid 查询递归写成 if / else ★ 查询区间要跨过某个 mid(半覆盖那一支要够多)
wrongUp 改完忘了 pushup 一次半覆盖的修改 + 之后读那个祖先
wrongPushQ 查询里忘了下推 ★★ 改在某节点停住,之后查询钻进它的子树
wrongPushU 修改里忘了下推 ★★ 同上,第二个操作换成修改 —— 全章最难抓的一个
wrongKeep 下推后没销账 ★★ 同一个节点被穿过两次(三件事按顺序发生)
wrongApply 标记写成 = ★★ 同一节点连着两次全覆盖,而且中间没人穿过去

★★ 上面三个只要「单个操作长什么样」对;下面四个要的是「这个操作和上一个操作之间是什么关系」。 ⚠ 我动笔前据此写下的预判是「这一章要拧一类新旋钮:操作之间的相关性」—— 第 12 步会告诉你这个预判是错的,而且错得能说清。

wrongLen.cpp✗ sm[o] += x,忘了乘区间长度(基线)
// ✗ 错误版本:**标记落到 sum 上时忘了乘区间长度**(sm[o] += x 而不是 x * len)
//
// 靠什么现形:只要有一次修改在**长度大于 1** 的节点上全覆盖就行 —— 什么数据都容易碰到。
// ★ 所以它是这一章的**基线**:抓得住它,一点都不能说明生成器够狠
// (第 38 章 wrongDir 的同款)。
// ⚠ 末行是**对的**:叶子的长度正好是 1,乘不乘一个样。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4]; // sm[o]:o 这一段的和(★ 已经把 lz[o] 算进去了)
long long lz[MAXN * 4]; // lz[o]:欠儿子们的那笔账(每人一个 lz[o])
long long a[MAXN];
int n, m;
inline void pushup(int o) { sm[o] = sm[o * 2] + sm[o * 2 + 1]; }
/** 给节点 o(它管的段长是 len)整段加 x:自己的和当场算清,儿子的先欠着 */
inline void apply(int o, int len, long long x) {
sm[o] += x; // ✗ 忘了乘 len:这一段有 len 个数,每人加 x
lz[o] += x; // ★ 是 +=,不是 =:欠了两笔账要叠起来
}
/** 把欠儿子的账还了,然后销账 */
inline void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
apply(o * 2, mid - l + 1, lz[o]);
apply(o * 2 + 1, r - mid, lz[o]);
lz[o] = 0; // ⚠ 不清零就会重复还账(wrongKeep.cpp)
}
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
pushup(o);
}
/** 区间加:[ql,qr] 每个数 += x */
void update(int o, int l, int r, int ql, int qr, long long x) {
if (ql <= l && r <= qr) { apply(o, r - l + 1, x); return; } // 全覆盖:记一笔账就走
pushdown(o, l, r); // ★ 要往下走了,先把账还了
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x); // ⚠ 两个 if,不是 if/else
pushup(o);
}
/** 区间和:[ql,qr] */
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o]; // 全覆盖:sm[o] 本来就是对的
pushdown(o, l, r); // ★ 查询这一侧同样要下推
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
/** ★ 末行要的是每一个 a[i]:把所有欠账一路推到叶子,账就全清了 */
void flush(int o, int l, int r, string& out) {
if (l == r) {
out += to_string(sm[o]);
out += (l == n ? '\n' : ' ');
return;
}
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out); // ★ 末行:把欠着的账全部还清
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongMid.cpp✗ 查询递归写成 if / else —— 跨过 mid 的区间丢了一半
// ✗ 错误版本:**查询的递归写成了 if / else**(跨过 mid 的区间只查了一半)
//
// if (qr <= mid) 走左边; else 走右边; ← 漏掉了「两边都要走」的那种情况
//
// 靠什么现形:★ **查询区间和节点区间的相对位置** —— 必须有查询**跨过某个 mid**。
// ⇒ 这是「半覆盖那一支要够多」的那个旋钮,和值域、规模都没关系。
// ⚠ 末行是**对的**:flush 不走 query。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4]; // sm[o]:o 这一段的和(★ 已经把 lz[o] 算进去了)
long long lz[MAXN * 4]; // lz[o]:欠儿子们的那笔账(每人一个 lz[o])
long long a[MAXN];
int n, m;
inline void pushup(int o) { sm[o] = sm[o * 2] + sm[o * 2 + 1]; }
/** 给节点 o(它管的段长是 len)整段加 x:自己的和当场算清,儿子的先欠着 */
inline void apply(int o, int len, long long x) {
sm[o] += x * len; // ⚠ 忘了乘 len 是最常见的错(wrongLen.cpp)
lz[o] += x; // ★ 是 +=,不是 =:欠了两笔账要叠起来
}
/** 把欠儿子的账还了,然后销账 */
inline void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
apply(o * 2, mid - l + 1, lz[o]);
apply(o * 2 + 1, r - mid, lz[o]);
lz[o] = 0; // ⚠ 不清零就会重复还账(wrongKeep.cpp)
}
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
pushup(o);
}
/** 区间加:[ql,qr] 每个数 += x */
void update(int o, int l, int r, int ql, int qr, long long x) {
if (ql <= l && r <= qr) { apply(o, r - l + 1, x); return; } // 全覆盖:记一笔账就走
pushdown(o, l, r); // ★ 要往下走了,先把账还了
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x); // ⚠ 两个 if,不是 if/else
pushup(o);
}
/** 区间和:[ql,qr] */
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o]; // 全覆盖:sm[o] 本来就是对的
pushdown(o, l, r); // ★ 查询这一侧同样要下推
int mid = (l + r) / 2;
// ✗ 写成了 if / else:查询区间跨过 mid 时,右半边被整个吞掉了
if (qr <= mid) return query(o * 2, l, mid, ql, qr);
else return query(o * 2 + 1, mid + 1, r, ql, qr);
}
/** ★ 末行要的是每一个 a[i]:把所有欠账一路推到叶子,账就全清了 */
void flush(int o, int l, int r, string& out) {
if (l == r) {
out += to_string(sm[o]);
out += (l == n ? '\n' : ' ');
return;
}
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out); // ★ 末行:把欠着的账全部还清
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongUp.cpp✗ 改完忘了 pushup
// ✗ 错误版本:**修改完忘了 pushup**(儿子改了,父亲的 sm 还是旧的)
//
// 靠什么现形:一次**半覆盖**的修改(全覆盖那一支根本走不到 pushup),
// 之后有人来读那个父亲的 sm。
// ⚠ 末行是**对的**:叶子的值一点没坏,坏的只有内部节点的和。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4]; // sm[o]:o 这一段的和(★ 已经把 lz[o] 算进去了)
long long lz[MAXN * 4]; // lz[o]:欠儿子们的那笔账(每人一个 lz[o])
long long a[MAXN];
int n, m;
inline void pushup(int o) { sm[o] = sm[o * 2] + sm[o * 2 + 1]; }
/** 给节点 o(它管的段长是 len)整段加 x:自己的和当场算清,儿子的先欠着 */
inline void apply(int o, int len, long long x) {
sm[o] += x * len; // ⚠ 忘了乘 len 是最常见的错(wrongLen.cpp)
lz[o] += x; // ★ 是 +=,不是 =:欠了两笔账要叠起来
}
/** 把欠儿子的账还了,然后销账 */
inline void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
apply(o * 2, mid - l + 1, lz[o]);
apply(o * 2 + 1, r - mid, lz[o]);
lz[o] = 0; // ⚠ 不清零就会重复还账(wrongKeep.cpp)
}
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
pushup(o);
}
/** 区间加:[ql,qr] 每个数 += x */
void update(int o, int l, int r, int ql, int qr, long long x) {
if (ql <= l && r <= qr) { apply(o, r - l + 1, x); return; } // 全覆盖:记一笔账就走
pushdown(o, l, r); // ★ 要往下走了,先把账还了
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x);
// ✗ 这里少了一句 pushup(o); —— 儿子改了,父亲的 sm 还停在改之前
}
/** 区间和:[ql,qr] */
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o]; // 全覆盖:sm[o] 本来就是对的
pushdown(o, l, r); // ★ 查询这一侧同样要下推
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
/** ★ 末行要的是每一个 a[i]:把所有欠账一路推到叶子,账就全清了 */
void flush(int o, int l, int r, string& out) {
if (l == r) {
out += to_string(sm[o]);
out += (l == n ? '\n' : ' ');
return;
}
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out); // ★ 末行:把欠着的账全部还清
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongPushQ.cpp✗ 查询里忘了下推(改那边还留着)
// ✗ 错误版本:**查询里忘了下推**(update 里那句还在,只漏了 query 里那一句)
//
// 靠什么现形:★ 一次区间加要在某个内部节点**停住**(半覆盖打了标记),
// 之后要有一次查询**钻进那个节点的子树里**(也是半覆盖)——
// ⇒ 这是「两个操作之间的关系」,不是单个操作的形状。
//
// ⚠ 末行(整个数组)它是**对的**:flush 走的是另一条路,那里的 pushdown 没被删。
// 所以这个 bug 只在**查询那几行**上现形。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4]; // sm[o]:o 这一段的和(★ 已经把 lz[o] 算进去了)
long long lz[MAXN * 4]; // lz[o]:欠儿子们的那笔账(每人一个 lz[o])
long long a[MAXN];
int n, m;
inline void pushup(int o) { sm[o] = sm[o * 2] + sm[o * 2 + 1]; }
/** 给节点 o(它管的段长是 len)整段加 x:自己的和当场算清,儿子的先欠着 */
inline void apply(int o, int len, long long x) {
sm[o] += x * len; // ⚠ 忘了乘 len 是最常见的错(wrongLen.cpp)
lz[o] += x; // ★ 是 +=,不是 =:欠了两笔账要叠起来
}
/** 把欠儿子的账还了,然后销账 */
inline void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
apply(o * 2, mid - l + 1, lz[o]);
apply(o * 2 + 1, r - mid, lz[o]);
lz[o] = 0; // ⚠ 不清零就会重复还账(wrongKeep.cpp)
}
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
pushup(o);
}
/** 区间加:[ql,qr] 每个数 += x */
void update(int o, int l, int r, int ql, int qr, long long x) {
if (ql <= l && r <= qr) { apply(o, r - l + 1, x); return; } // 全覆盖:记一笔账就走
pushdown(o, l, r); // ★ 要往下走了,先把账还了
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x); // ⚠ 两个 if,不是 if/else
pushup(o);
}
/** 区间和:[ql,qr] */
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o];
// ✗ 这里少了一句 pushdown(o, l, r); —— 儿子的 sm 还是欠账前的旧值
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
/** ★ 末行要的是每一个 a[i]:把所有欠账一路推到叶子,账就全清了 */
void flush(int o, int l, int r, string& out) {
if (l == r) {
out += to_string(sm[o]);
out += (l == n ? '\n' : ' ');
return;
}
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out); // ★ 末行:把欠着的账全部还清
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongPushU.cpp✗ 修改里忘了下推 —— ★ 它错在哪很反直觉,看文件开头
// ✗ 错误版本:**修改里忘了下推**(query 里那句还在,只漏了 update 里那一句)
//
// 它错在哪,要说清楚(这里最容易想当然):
// 区间加的标记是**可叠加**的,所以「儿子那边先欠着、我这边又欠一笔」本身并不矛盾。
// 真正错的是最后那句 **pushup**:sm[o] = sm[2o] + sm[2o+1] ——
// ★ 两个儿子都还不知道 o 欠它们的那笔 lz[o],于是 sm[o] 被这一句**改小了**。
// ⇒ 「必须下推」的理由从来只有一条:**你要去读儿子的值。**
// pushup 就是在读儿子的值。
//
// 靠什么现形:一次半覆盖的修改在某节点打上标记,之后**又一次修改**从它身上穿过去。
// ⚠ 末行(整个数组)它同样是**对的**:lz 本身没被改坏,flush 一路推到底还是对的。
// 坏掉的只有内部节点的 sm。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4]; // sm[o]:o 这一段的和(★ 已经把 lz[o] 算进去了)
long long lz[MAXN * 4]; // lz[o]:欠儿子们的那笔账(每人一个 lz[o])
long long a[MAXN];
int n, m;
inline void pushup(int o) { sm[o] = sm[o * 2] + sm[o * 2 + 1]; }
/** 给节点 o(它管的段长是 len)整段加 x:自己的和当场算清,儿子的先欠着 */
inline void apply(int o, int len, long long x) {
sm[o] += x * len; // ⚠ 忘了乘 len 是最常见的错(wrongLen.cpp)
lz[o] += x; // ★ 是 +=,不是 =:欠了两笔账要叠起来
}
/** 把欠儿子的账还了,然后销账 */
inline void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
apply(o * 2, mid - l + 1, lz[o]);
apply(o * 2 + 1, r - mid, lz[o]);
lz[o] = 0; // ⚠ 不清零就会重复还账(wrongKeep.cpp)
}
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
pushup(o);
}
/** 区间加:[ql,qr] 每个数 += x */
void update(int o, int l, int r, int ql, int qr, long long x) {
if (ql <= l && r <= qr) { apply(o, r - l + 1, x); return; } // 全覆盖:记一笔账就走
// ✗ 这里少了一句 pushdown(o, l, r); —— 下面那句 pushup 会读到儿子的旧值
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x); // ⚠ 两个 if,不是 if/else
pushup(o);
}
/** 区间和:[ql,qr] */
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o]; // 全覆盖:sm[o] 本来就是对的
pushdown(o, l, r); // ★ 查询这一侧同样要下推
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
/** ★ 末行要的是每一个 a[i]:把所有欠账一路推到叶子,账就全清了 */
void flush(int o, int l, int r, string& out) {
if (l == r) {
out += to_string(sm[o]);
out += (l == n ? '\n' : ' ');
return;
}
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out); // ★ 末行:把欠着的账全部还清
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongKeep.cpp✗ 下推之后忘了 lz[o] = 0
// ✗ 错误版本:**下推之后忘了销账**(lz[o] 没清零)
//
// 于是同一笔账会被**反复下发**:每有一个人从 o 身上穿过去,儿子就再被加一次。
//
// 靠什么现形:★ 同一个节点要被**下推两次**(也就是被穿过两次)——
// ⇒ 又是「两个操作之间的关系」,而且它比 wrongPushQ 更贪心:要三件事按顺序发生
// (打标记 → 穿过去一次 → 再穿过去一次)。
// ★ 末行会跟着一起错:flush 是最后一次穿过,重复的账全落到叶子上。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4]; // sm[o]:o 这一段的和(★ 已经把 lz[o] 算进去了)
long long lz[MAXN * 4]; // lz[o]:欠儿子们的那笔账(每人一个 lz[o])
long long a[MAXN];
int n, m;
inline void pushup(int o) { sm[o] = sm[o * 2] + sm[o * 2 + 1]; }
/** 给节点 o(它管的段长是 len)整段加 x:自己的和当场算清,儿子的先欠着 */
inline void apply(int o, int len, long long x) {
sm[o] += x * len; // ⚠ 忘了乘 len 是最常见的错(wrongLen.cpp)
lz[o] += x; // ★ 是 +=,不是 =:欠了两笔账要叠起来
}
/** 把欠儿子的账还了,然后销账 */
inline void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
apply(o * 2, mid - l + 1, lz[o]);
apply(o * 2 + 1, r - mid, lz[o]);
// ✗ 这里少了一句 lz[o] = 0; —— 账还了却不销,下次路过再还一遍
}
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
pushup(o);
}
/** 区间加:[ql,qr] 每个数 += x */
void update(int o, int l, int r, int ql, int qr, long long x) {
if (ql <= l && r <= qr) { apply(o, r - l + 1, x); return; } // 全覆盖:记一笔账就走
pushdown(o, l, r); // ★ 要往下走了,先把账还了
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x); // ⚠ 两个 if,不是 if/else
pushup(o);
}
/** 区间和:[ql,qr] */
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o]; // 全覆盖:sm[o] 本来就是对的
pushdown(o, l, r); // ★ 查询这一侧同样要下推
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
/** ★ 末行要的是每一个 a[i]:把所有欠账一路推到叶子,账就全清了 */
void flush(int o, int l, int r, string& out) {
if (l == r) {
out += to_string(sm[o]);
out += (l == n ? '\n' : ' ');
return;
}
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out); // ★ 末行:把欠着的账全部还清
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
wrongApply.cpp✗ lz[o] = x 而不是 +=(两笔账没叠起来)
// ✗ 错误版本:**打标记时写成 lz[o] = x,而不是 lz[o] += x**(两笔账没叠起来)
//
// 靠什么现形:★ 同一个节点要被**连着两次全覆盖**,而且中间**没有人来下推**。
// ⇒ 「两个操作之间的关系」里最苛刻的一个:不但要两次操作打在同一个节点上,
// 还要求这两次之间没有第三个操作从那儿穿过去(穿过去就把第一笔账下发了,也就叠不上了)。
// ★ 末行会跟着错:被吞掉的那笔账永远补不回来。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4]; // sm[o]:o 这一段的和(★ 已经把 lz[o] 算进去了)
long long lz[MAXN * 4]; // lz[o]:欠儿子们的那笔账(每人一个 lz[o])
long long a[MAXN];
int n, m;
inline void pushup(int o) { sm[o] = sm[o * 2] + sm[o * 2 + 1]; }
/** 给节点 o(它管的段长是 len)整段加 x:自己的和当场算清,儿子的先欠着 */
inline void apply(int o, int len, long long x) {
sm[o] += x * len; // ⚠ 忘了乘 len 是最常见的错(wrongLen.cpp)
lz[o] = x; // ✗ 写成了 =:先前欠的那笔账被直接抹掉了
}
/** 把欠儿子的账还了,然后销账 */
inline void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
apply(o * 2, mid - l + 1, lz[o]);
apply(o * 2 + 1, r - mid, lz[o]);
lz[o] = 0; // ⚠ 不清零就会重复还账(wrongKeep.cpp)
}
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
pushup(o);
}
/** 区间加:[ql,qr] 每个数 += x */
void update(int o, int l, int r, int ql, int qr, long long x) {
if (ql <= l && r <= qr) { apply(o, r - l + 1, x); return; } // 全覆盖:记一笔账就走
pushdown(o, l, r); // ★ 要往下走了,先把账还了
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x); // ⚠ 两个 if,不是 if/else
pushup(o);
}
/** 区间和:[ql,qr] */
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o]; // 全覆盖:sm[o] 本来就是对的
pushdown(o, l, r); // ★ 查询这一侧同样要下推
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
/** ★ 末行要的是每一个 a[i]:把所有欠账一路推到叶子,账就全清了 */
void flush(int o, int l, int r, string& out) {
if (l == r) {
out += to_string(sm[o]);
out += (l == n ? '\n' : ' ');
return;
}
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out); // ★ 末行:把欠着的账全部还清
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 默认那组数据上,七个 bug 的「指纹」互不相同
五行答案 末行(整个数组)
正解 31 39 21 25 289 4 102 120 17 13 17 10 14
wrongLen 31 24 21 25 268 ✓ 一个字不错
wrongMid 31 39 5 9 289 ✓ 一个字不错
wrongUp 31 9 21 25 31 ✓ 一个字不错
wrongPushQ 31 39 21 11 289 ✓ 一个字不错
wrongPushU 31 39 21 25 259 ✓ 一个字不错
wrongKeep 31 39 36 25 289 ✗ 4 102 150 47 27 31 24 28
wrongApply 31 39 11 25 269 ✗ 4 102 110 7 13 17 10 14

★★ 这张表里最该记住的是最后那一列:

七个 bug 里只有两个的末行是错的,另外五个末行一个字都不错。 而错的那两个(不销账 / 账被覆盖),毛病正好都出在账本身上; 另外五个坏的是和算得对不对,叶子上的值一点没歪。

⚠ wrongLen 尤其要单独说:它的末行永远正确 —— 叶子的段长是 1,乘不乘一个样。 所以「最后打印一遍数组看看对不对」这种自查方式,对它完全无效。

对拍器
★ 这个生成器有八个旋钮,其中一个是我动笔前认定的「这一章的主角」—— 实测它是负分,最后撤回了。下面三张表把每一处的账都摆出来。
// 正解 —— 线段树 + 懒标记(区间加 / 区间和)
//
// ============ ★ 关键一步:懒标记 = 一句「欠着的修改」============
//
// 区间加 [l,r] += x 的时候,如果某个节点管的那一段**整个**落在 [l,r] 里面,
// 那么这一整段每个数都要加 x —— 可我们并不需要真的去改那一段里的每一个数:
//
// · 这个节点自己的和,一句话就能算出来:sum[o] += x * (段长);
// · 至于它**底下**那些节点,先欠着 —— 在 lz[o] 上记一笔「我欠你们每人一个 x」。
//
// ★ 所以 lz[o] 的含义要背得一字不差:
// **「o 这个节点自己的 sum 已经算进去了,但 o 的两个儿子还不知道这件事。」**
// (不是「这个区间要加 lz」,也不是「还没加」—— 差一个字,代码就写不对。)
//
// 于是区间加只在**边界上**留下 O(log n) 个「全覆盖」的节点,每个记一笔账就走人。
// ⚠ 对照第 38 章:那道题只有单点改,一条 add 路径就完事,用不着欠账;
// 这一章一次要改一整段,**不欠账就只能一个个改,那就退回暴力了。**
//
// ============ ★★ 什么时候「必须」把账还了(pushdown)============
//
// 只有一种时候:**当你要往下走、要读儿子的值的时候。**
// 儿子的 sum 是「还没加过 lz[o]」的旧值,直接读就是错的。
//
// · 全覆盖 → 直接用 sum[o],**不用**下推(sum[o] 是对的);
// · 不相交 → 根本不往下走,**不用**下推;
// · ★ 半覆盖 → 要递归进儿子,**必须先下推**。
//
// ⇒ 一句话:**「懒」到不能再懒为止 —— 一直欠着,直到有人要看儿子。**
// ⚠ 修改和查询**两边都要下推**(少写一处就是一个 bug,见 wrongPushQ / wrongPushU)。
// ⚠ 下推之后 lz[o] 必须清零(账还完了要销账,见 wrongKeep)。
//
// ★★ 但「必须下推」这句话是有条件的:条件是「**你要读儿子的值**」。
// 标记永久化(mark.cpp)换了个办法 —— 不下推,而是在**查询的路上**把沿途欠的账补回来,
// 答案 300 轮逐字节相同。所以它不是定律,是一个可以绕开的具体理由。
//
// ============ 结构(递归版)============
//
// 节点 o 管区间 [l,r],左儿子 2o 管 [l,mid],右儿子 2o+1 管 [mid+1,r]。
// ⚠ 数组要开 **4n**:递归线段树的节点编号最坏能用到 4n(n 不是 2 的幂时树不满,
// 最下面一层会「错位」,2n 是不够的 —— 这是新手最常见的 RE)。
//
// 复杂度:建树 O(n),每次操作 O(log n)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long sm[MAXN * 4]; // sm[o]:o 这一段的和(★ 已经把 lz[o] 算进去了)
long long lz[MAXN * 4]; // lz[o]:欠儿子们的那笔账(每人一个 lz[o])
long long a[MAXN];
int n, m;
inline void pushup(int o) { sm[o] = sm[o * 2] + sm[o * 2 + 1]; }
/** 给节点 o(它管的段长是 len)整段加 x:自己的和当场算清,儿子的先欠着 */
inline void apply(int o, int len, long long x) {
sm[o] += x * len; // ⚠ 忘了乘 len 是最常见的错(wrongLen.cpp)
lz[o] += x; // ★ 是 +=,不是 =:欠了两笔账要叠起来
}
/** 把欠儿子的账还了,然后销账 */
inline void pushdown(int o, int l, int r) {
if (!lz[o]) return;
int mid = (l + r) / 2;
apply(o * 2, mid - l + 1, lz[o]);
apply(o * 2 + 1, r - mid, lz[o]);
lz[o] = 0; // ⚠ 不清零就会重复还账(wrongKeep.cpp)
}
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
pushup(o);
}
/** 区间加:[ql,qr] 每个数 += x */
void update(int o, int l, int r, int ql, int qr, long long x) {
if (ql <= l && r <= qr) { apply(o, r - l + 1, x); return; } // 全覆盖:记一笔账就走
pushdown(o, l, r); // ★ 要往下走了,先把账还了
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x); // ⚠ 两个 if,不是 if/else
pushup(o);
}
/** 区间和:[ql,qr] */
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o]; // 全覆盖:sm[o] 本来就是对的
pushdown(o, l, r); // ★ 查询这一侧同样要下推
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
/** ★ 末行要的是每一个 a[i]:把所有欠账一路推到叶子,账就全清了 */
void flush(int o, int l, int r, string& out) {
if (l == r) {
out += to_string(sm[o]);
out += (l == n ? '\n' : ' ');
return;
}
pushdown(o, l, r);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out); // ★ 末行:把欠着的账全部还清
cout << out;
return 0;
}
点一下即可编辑

300 轮实测(种子 1..300,最终档 9):

故意写错的地方 被抓 第几轮
wrongMid(if / else) 300 / 300 第 1 轮
wrongUp(忘了 pushup) 300 / 300 第 1 轮
wrongKeep(没销账) 300 / 300 第 1 轮
wrongLen(忘了乘长度) 299 / 300 第 1 轮
wrongApply(= 写成 +=) 299 / 300 第 1 轮
wrongPushQ(查询忘下推) 299 / 300 第 1 轮
wrongPushU(修改忘下推) 292 / 300 第 1 轮
noLazy / mark(只是快慢不同) ★ 0 / 300 —

12★★ 生成器:十二个档位,而这一章的主角被实测撤回了

★★ 第一张表:八个旋钮,全部从「顺手写法」出发

gen.cpp 带了十二个档位(./gen 种子 档位),种子固定 1..300。 顺序照第 11 步那张清单:Len / Mid / Up / PushQ / PushU / Keep / Apply。

档位 相对档位 0 改了什么 Len Mid Up PushQ PushU Keep Apply
0(顺手写法) n, m ∈ [6,12]、区间独立随机、值域 [1,100]、改查各半 160 277 197 164 51 238 150
1 ★ 操作序列拉长 m ∈ [20,40] 283 299 298 292 237 299 274
2 ★ 区间相关(35% 复用锚点 + 35% 取锚点的子区间) 200 233 151 178 54 245 200
3 区间偏短(长度 ≤ 3) 62 245 128 108 ⚠ 12 176 61
4 n 只取 2 的幂(4 / 8) 144 237 205 172 57 236 106
5 n 一个 2 的幂都不取(5/7/9/11) 147 261 200 178 59 242 134
6 ★ n 放大到 [40,80] 257 298 243 237 88 279 248
7 值域拉到 [−10⁴, 10⁴](含 0 和负数) 160 277 197 164 51 238 150
8 改查配比拧到「修改占七成」 137 214 168 148 56 272 205

① 真正的功臣只有两个,而且都不新鲜。 序列拉长把最弱的那一支从 51 抬到 237,n 放大抬到 88,合起来就是最终档的 292。

★ 第 36 章的结论(打假「写错的实现」靠操作序列够长)在这一章又一次原样成立 —— 因为这一章四个最难的 bug 要的都是「先埋下、后读出」。

② ⚠ 值域那一档,七列一个数字都没变。 和档位 0 逐位相同。原因也说得清:这道题的 bug 没有一个和数值有关 (这一章连溢出版本都没有 —— 那是第 38 章的主题)。

★ 第 33 章那条「加了个好东西不等于数据变好了」,这一章占第一次。撤回。

③ ⚠⚠ n 是不是 2 的幂,在这一章几乎没有影响(57 vs 59)。 而它在第 38 章是决定性的旋钮(wrongEnd 300 vs 177、wrongVar 170 vs 279)。 差别在于bug 吃的是什么:

树状数组的 bug 直接吃 lowbit(下标),所以下标的二进制形状就是生死; 线段树的 bug 吃的是「查询区间和节点区间的相对位置」—— 而树切得整不整齐,并不改变「一个随机区间会不会跨过某个 mid」这件事的概率。 ★ 第 38 章那条「第五类旋钮」是真的,但它属于树状数组,不属于「所有数据结构」。

④ ⚠ 「修改占七成」也是负分(最弱支 51 → 56 看着涨了,可 Len / Mid / Up / PushQ 四列一起掉)。 道理很直白:改多了就没人来读了 —— 这些 bug 全都要「有人来读」才现形。 第 31 章那条「某一支占得太多也是坑」的又一次。

★★ 第二张表:我认定的「这一章的主角」,实测是负分
档位 内容 Len Mid Up PushQ PushU Keep Apply 最弱支
9(最终档) 1 + 6(序列拉长 + n 放大) 299 300 300 299 292 300 299 ✓ 292
10(对照) 9 + 2(区间相关) 299 300 295 298 271 299 299 271
11(诊断) 10,但锚点跟着子区间一起缩 298 298 274 296 211 298 295 211

① 先说我原来的想法。 四个最难的 bug 要的都是「后一个操作的区间落在前一个的里面」, 所以我以为得专门造这种关系,而且以为独立随机碰巧撞上的概率会随 n 迅速下降。 于是有了档位 2 那个旋钮。

② 第一版量出来是 211,比对照档差得离谱。 按第 38 章那条规矩(负分时先问它是不是夹带了第二个旋钮)一查就查着了: 我让锚点跟着子区间一起缩,于是区间越缩越短 —— 而「区间偏短」是全场最差的一档(12)。 把锚点改成只跟「独立随机」那一支走,同一处改动立刻从 211 变成 271。

③ ⚠ 可它还是负分 —— 271 < 292,所以最后撤回了。

★★ 第 38 章那条规矩救了它一次,但救不活它。 「夹带了第二个旋钮」是一种解释,不是免罪符:拆干净之后该撤还是得撤。

④ 而这一次,「为什么白干」是能算出来的。

nest.cpp★ 量一量:随机两个区间,后一个落在前一个里面的概率
// ★ 这份代码是用来**打自己的脸**的:量一量「随机两个区间,后一个落在前一个里面」的概率。
//
// 动笔前我写下的预判是:懒标记那几个 bug 要的是「**操作之间的关系**」
// (后一个区间落在前一个里面),而**独立随机的两个区间碰巧构成这种关系的概率,
// 会随 n 变大迅速下降** —— 所以要专门造一个「区间相关」的旋钮。
//
// ⚠ 实测把这个预判打掉了,而且原因能说清:
//
// 生成器里那句最顺手的写法 —— `l = rnd(1, n); r = rnd(l, n);` —— 是**尺度不变**的。
// 把它看成「在 (0,1] 上取两个点」,n 只是刻度的粗细;n 一大,
// 离散的取法就收敛到同一个连续分布,**概率不随 n 变**。
//
// ★★ 而且那个极限值是**能算出来的,正好是 1/8**(实测 n = 10000 时 12.50%):
// 这样画出来的 (l, r) 的联合密度是 1/(1−l)(先均匀取 l,再在 [l,1] 上均匀取 r)。
// ① 先对 r₂ 积分(r₂ 从 l₂ 到 r₁):∫ dr₂/(1−l₂) = (r₁−l₂)/(1−l₂)
// ② 再对 l₂ 积分(l₂ 从 l₁ 到 r₁),记 A = 1−l₁、B = 1−r₁,换元 t = 1−l₂:
// ∫_B^A (t−B)/t dt = (A−B) − B·ln(A/B)
// ③ 再对 r₁ 积分(也就是 B 从 A 到 0),用 ∫₀¹ s·ln(1/s) ds = 1/4:
// (1/A)·[A²/2 − A²/4] = A/4
// ④ 最后对 l₁ 积分:∫₀¹ (1−l₁)/4 dl₁ = 1/8。
// ★ 「证出来的常数」和「实测出来的常数」是同一个数(第 37 章那条规矩,这是第二次)。
// ⇒ 「后一个区间套在前一个里面」这种关系,是随机数据**免费送**的,
// 专门去造它只会把操作挤到同一小片区域里,反而把多样性丢了。
//
// ★ 所以这一章的结论是第 33 章那条的第二次现场:**「加了个好东西」不等于「数据变好了」。**
// (账写在 gen.cpp 的档位 9 / 10 上:最弱的那一支 292 → 271,撤回。)
//
// 用法:./nest [每个 n 抽多少对] 默认 1000000
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
const long long T = (argc > 1) ? atoll(argv[1]) : 1000000LL;
const int NS[] = {8, 20, 80, 400, 2000, 10000};
mt19937 rng(20260814u);
auto rnd = [&](int lo, int hi) { return (int)(rng() % (unsigned)(hi - lo + 1)) + lo; };
printf("每个 n 抽 %lld 对区间,两个区间各自独立地按 l = rnd(1,n)、r = rnd(l,n) 画出来\n\n", T);
printf(" n 后一个套在前一个里面 两个完全相同\n");
for (int n : NS) {
long long nest = 0, same = 0;
for (long long t = 0; t < T; t++) {
int l1 = rnd(1, n), r1 = rnd(l1, n);
int l2 = rnd(1, n), r2 = rnd(l2, n);
if (l1 <= l2 && r2 <= r1) {
nest++;
if (l1 == l2 && r1 == r2) same++;
}
}
printf("%7d %6.2f%% %6.3f%%\n",
n, 100.0 * (double)nest / (double)T, 100.0 * (double)same / (double)T);
}
printf("\n★ 「套在里面」那一列几乎不随 n 变,而且收敛到 1/8 = %.2f%%(算得出来,见文件开头)——\n",
100.0 / 8.0);
printf(" 所以「操作之间的包含关系」是随机数据免费送的,不必专门去造。\n");
printf("⚠ 「完全相同」那一列倒是随 n 掉得很快,可它掉到零也没关系:\n");
printf(" wrongApply 要的是「同一个**节点**被连着两次全覆盖」,不是「同一个区间」。\n");
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
n 后一个套在前一个里面 两个完全相同
8 23.49% 4.256%
20 16.84% 0.900%
80 13.60% 0.081%
400 12.73% 0.003%
2 000 12.51% 0.000%
10 000 12.50% 0.000%

★★ 它不但不随 n 下降,还收敛到一个常数 —— 而且那个常数正好是 1/8,能算出来。 生成器里那句最顺手的 l = rnd(1,n); r = rnd(l,n) 是尺度不变的: n 只是刻度的粗细,n 一大就收敛到同一个连续分布(推导写在 nest.cpp 开头,四行积分)。

⇒ 「后一个区间套在前一个里面」这种关系,是随机数据免费送的。 专门去造它,只会把操作挤到同一小片区域里,把多样性丢了。

★ 顺带又占了一次第 37 章那条规矩:能证的就证 —— 「证出来的常数」和「实测出来的常数」是同一个数(1/8 = 12.50%)。

gen.cpp(十二个档位)八处改动全部可重跑,包括那个被撤回的主角和它的诊断档
★★ 这一章关于生成器的收获,是一句以前没写过的话

前面十三章一路在总结「怎么把数据调狠」。这一章第一次遇到的是另一种局面:

★★ 我提前想清楚了 bug 靠什么现形(那一步是对的、也是必要的), 可「靠什么现形」和「该拧哪个旋钮」之间,还隔着一个必须实测的问题: 这个性质,随机数据是不是本来就免费送?

  • 第 38 章那次(下标的二进制形状):随机数据不送 —— n = 2^k 在 [6,12] 里只占 1/7, 而它是那个 bug 的生死线 ⇒ 必须专门造;
  • 这一章(区间的包含关系):随机数据白送 12.5% ⇒ 专门造纯属白干,还有副作用。

⚠ 两次的前半段完全一样(都把 bug 靠什么现形写清楚了), 分岔点在后半段 —— 而那一半只能靠量。

13★ 回头看第 38 章:树状数组代码短、常数小,线段树能干的事多

① 同一道题:把第 38 章那道题用线段树再做一遍

single.cpp 的输入输出和 code/38-fenwick/ 那几份一字不差, 所以能直接拿第 38 章的生成器跨章节对拍 300 轮(那个生成器一个字都没改)。

single.cpp★ 线段树版的「单点改 + 区间和」——「区间加」的特例(l = r),所以用不着懒标记
// ★ 跨章节:拿线段树把**第 38 章那道题**(单点改 + 区间和)原样再做一遍。
//
// 为什么要再做一遍(第 38 章章末就预告过这件事):
// 这一章一路在说「线段树能干树状数组干不了的事」,
// 那反过来那笔账也得算清楚 —— **在树状数组干得了的那道题上,它到底强在哪。**
//
// · 输入输出和 `code/38-fenwick/` 里那几份**一字不差**,
// 所以能直接拿第 38 章的 `gen.cpp` 跨章节对拍 300 轮(那个生成器一个字都不用改);
// · 代码行数、碰的格子数、耗时,正文里并排摆出来。
//
// ★ 结论先写在这儿(数字在正文那张表里):
// **树状数组代码短、常数小;线段树能干的事多。**
// ⚠ 而「常数小」这件事在这道题上是**量得出来的**,不是口口相传的印象 ——
// 第 29、32、34 章那条规矩:口诀要拿实测复核。
//
// ⚠ 单点改是「区间加」的特例(l = r),所以这份代码干脆不用懒标记:
// 一路递归到那个叶子改掉,回溯时 pushup。这也正是 noLazy.cpp 在区间长度 = 1 时的样子 ——
// ★ count.cpp 那张表的第一行(三棵树的数字一字不差)说的就是这件事。
//
// 输入格式(=第 38 章):n m / a[1..n] / m 行 "1 p x" 或 "2 l r";末行输出整个数组的和。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;
long long sm[MAXN * 4], a[MAXN];
int n, m;
void build(int o, int l, int r) {
if (l == r) { sm[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
sm[o] = sm[o * 2] + sm[o * 2 + 1];
}
void add(int o, int l, int r, int p, long long x) {
if (l == r) { sm[o] += x; return; }
int mid = (l + r) / 2;
if (p <= mid) add(o * 2, l, mid, p, x);
else add(o * 2 + 1, mid + 1, r, p, x);
sm[o] = sm[o * 2] + sm[o * 2 + 1];
}
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sm[o];
int mid = (l + r) / 2;
long long s = 0;
if (ql <= mid) s += query(o * 2, l, mid, ql, qr);
if (qr > mid) s += query(o * 2 + 1, mid + 1, r, ql, qr);
return s;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op;
cin >> op;
if (op == 1) {
int p; long long x;
cin >> p >> x;
add(1, 1, n, p, x);
} else {
int l, r;
cin >> l >> r;
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
out += to_string(query(1, 1, n, 1, n)); // ★ 第 38 章题面多问的那一行
out += '\n';
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

并排量一次(./genBig 200000 200000 1,也就是第 38 章那份大数据;跑 20 次取总耗时):

cd code/38-fenwick && g++ -O2 -o genBig genBig.cpp && g++ -O2 -o fen fast.cpp && g++ -O2 -o count count.cpp
g++ -O2 -o seg ../39-segment-tree/single.cpp
./genBig 200000 200000 1 > big.txt
time (for i in $(seq 1 20); do ./fen < big.txt > /dev/null; done)
树状数组(第 38 章) 线段树(本章)
算法本身的代码 9 行(lowbit / add / sum) 22 行(build / add / query)
数组开多大 c[n+1] ⚠ sm[4n](4 倍)
20 次总耗时(含读入 0.55 秒) 0.68 秒 1.44 秒
★ 减掉读入之后 0.13 秒 0.89 秒(约 6.8 倍)

★ 所以那句口口相传的「树状数组常数小」,在这道题上是量得出来的:约 6~7 倍。 ⚠ 但要说清它为什么小:树状数组一次 sum(r) 只走 popcount(r) ≈ log₂n / 2 格连续数组, 线段树一次查询要递归进 O(4 log n) 个节点、每一步都是一次跳转。 不是「线段树写得差」,是它多做了「能做更多事」所必须的那些工作。

② 反过来那笔账:把「和」换成「最大值」,线段树只改两处
maxSeg.cpp★ 区间加 + 区间最大值:整个框架照搬,只改了两处
// ★ 树状数组做不了的那件事:把「区间和」换成「区间最大值」——
// 而线段树只要改**两处**(合并方式 + 标记怎么作用),别的一个字都不动。
//
// ============ 改了什么 ============
//
// ① pushup:`sm[o] = sm[2o] + sm[2o+1]` → `mx[o] = max(mx[2o], mx[2o+1])`
// ② 标记落到节点上:`sm[o] += x * len` → `mx[o] += x`
// ★ **和长度无关了** —— 一整段每个数都加 x,最大值也就是加 x。
// (wrongLen 那个 bug 在这道题里根本不存在,因为压根没有那个 len。)
// ③ 查询里的 0 要换成 −∞(求和的单位元是 0,求最大值的单位元是 −∞)。
//
// 其余的 —— 什么时候打标记、什么时候必须下推、下推完要销账 —— **一个字都没变**。
// ★ 这就是「线段树能干的事多」的具体含义:它维护的东西只要满足
// 「两个儿子的答案能合并成父亲的答案」,框架就照搬。
//
// ⚠ 而树状数组做不到这一点:`c[i]` 只攒一段前缀式的和,
// 靠「减掉 sum(l−1)」拿区间和 —— **最大值没有减法**(第 38 章那句「可以修改的前缀和」,
// 前缀这两个字是有代价的)。
//
// 输入格式和 fast.cpp **完全一样**(所以 gen.cpp 一个字都不用改,直接拿来对拍):
// 1 l r x → [l,r] 每个数加 x;2 l r → 输出 [l,r] 的**最大值**;末行输出整个数组。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const long long NEG = LLONG_MIN / 4;
long long mx[MAXN * 4], lz[MAXN * 4], a[MAXN];
int n, m;
inline void apply1(int o, long long x) { mx[o] += x; lz[o] += x; } // ★ 和区间长度无关
inline void pushdown(int o) {
if (!lz[o]) return;
apply1(o * 2, lz[o]);
apply1(o * 2 + 1, lz[o]);
lz[o] = 0;
}
void build(int o, int l, int r) {
lz[o] = 0;
if (l == r) { mx[o] = a[l]; return; }
int mid = (l + r) / 2;
build(o * 2, l, mid);
build(o * 2 + 1, mid + 1, r);
mx[o] = max(mx[o * 2], mx[o * 2 + 1]);
}
void update(int o, int l, int r, int ql, int qr, long long x) {
if (ql <= l && r <= qr) { apply1(o, x); return; }
pushdown(o);
int mid = (l + r) / 2;
if (ql <= mid) update(o * 2, l, mid, ql, qr, x);
if (qr > mid) update(o * 2 + 1, mid + 1, r, ql, qr, x);
mx[o] = max(mx[o * 2], mx[o * 2 + 1]);
}
long long query(int o, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return mx[o];
pushdown(o);
int mid = (l + r) / 2;
long long s = NEG; // ⚠ 求最大值的「零」是 −∞
if (ql <= mid) s = max(s, query(o * 2, l, mid, ql, qr));
if (qr > mid) s = max(s, query(o * 2 + 1, mid + 1, r, ql, qr));
return s;
}
void flush(int o, int l, int r, string& out) {
if (l == r) { out += to_string(mx[o]); out += (l == n ? '\n' : ' '); return; }
pushdown(o);
int mid = (l + r) / 2;
flush(o * 2, l, mid, out);
flush(o * 2 + 1, mid + 1, r, out);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
string out;
for (int q = 0; q < m; q++) {
int op, l, r;
cin >> op >> l >> r;
if (op == 1) {
long long x;
cin >> x;
update(1, 1, n, l, r, x);
} else {
out += to_string(query(1, 1, n, l, r));
out += '\n';
}
}
flush(1, 1, n, out);
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

改的是这两处(其余一个字都没动):

区间和 区间最大值
合并两个儿子 sm[o] = sm[2o] + sm[2o+1] mx[o] = max(mx[2o], mx[2o+1])
标记落到节点上 sm[o] += x * len mx[o] += x ★ 和长度无关

★ 什么时候打标记、什么时候必须下推、下推完要销账 —— 一句都没变。 这就是「线段树能干的事多」的具体含义:只要「两个儿子的答案能合并成父亲的答案」,框架就照搬。

⚠ 而树状数组做不到:它靠「sum(r) − sum(l−1)」拿区间和, 而最大值没有减法 —— 第 38 章那句「可以修改的前缀和」,前缀这两个字是有代价的。

check:viz 里 300 轮对拍:maxBrute ↔ maxSeg, ★ 而且生成器一个字都没改 —— 同一串操作、同一批数据,只换了合并方式。

14自测

自测清单0 / 11
配套练习
  • 洛谷 P3372 【模板】线段树 1解析 → —— 本章那道题的原题(区间加 + 区间和)。动手前先把 lz[o] 的含义一字不差说出来 —— 主语是「儿子们」
  • 洛谷 P1531 I Hate It解析 → —— ★ 单点改 + 区间最大值 —— 自测清单里「只需改哪两处」的实测场。改完回头看:pushup 换了,而 apply 里那个「乘区间长度」为什么就没了
  • 洛谷 P3374 【模板】树状数组 1解析 → —— ★ 第 38 章交过的那道,这次用线段树再交一遍。本章第 13 步已经把两份代码摆在一起了,但亲手交两遍才有体感:行数、耗时、以及「哪一份改成区间最值只要动两处」
  • 洛谷 P1198 [JSOI2008] 最大数解析 → —— ★ 强制在线(上一次询问的结果参与下一次输入),离线排序那条路被堵死了。顺带练「只往末尾插入」的建树写法
  • 洛谷 P3373 【模板】线段树 2解析 → —— ★★ 加法和乘法两个懒标记,难点全在下推顺序:先乘后加,而且乘的时候加标记要跟着一起乘。做完这道,「lz 的主语是儿子」才算刻进去
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 懒标记 = 一句欠着的修改:「我自己的和算清了,我的儿子还不知道。」 而「必须下推」的理由永远只有一条 —— 你要去读儿子的值。理由消失,要求就消失(mark.cpp)。
  2. 懒标记省的是「改」,不是「查」;而且区间长度是 1 的时候,它一格都省不了。 同一份代码,区间拉到整段就省 459 倍 —— 所以那张表必须给出旋钮的两端。
  3. 「我知道这个 bug 靠什么现形」不等于「我该去造那个性质」。 中间还隔着一句必须实测的话:随机数据是不是本来就免费送? 这一章送 12.5%(而且能证),第 38 章一点都不送 —— 前半段一样,分岔全在后半段。

⚠ 数据结构这一块(第 35~39 章)到这里就收尾了。回头看这五章, 真正反复出现的不是某个结构,是那把尺子:单调栈、并查集、堆、树状数组、线段树, 五章里有四章的关键结论秒表都答不了,全靠「数次数」。