阶段 7 · 数据结构 · 第 39 章

线段树入门

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

例题:区间加 + 区间求和 建议用时: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 / 59299 / 300 / 300 / 299 / 292 / 298 / 295
★ 加上最后那一行160 / 277 / 197 / 164 / 51 / 238 / 150299 / 300 / 300 / 299 / 292 / 300 / 299

★★ 弱数据上它把两个 bug 抬了 1.6 倍和 2.5 倍(151 → 238、59 → 150), 数据够狠之后只值 298 → 300295 → 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(区间长) —— 两边都是线性
输入(stdin)
输出
点「运行 ▶」看结果

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

noLazy.cpp⚠ 不是 bug:答案和正解逐字节相同,只是每次修改要碰 O(区间长) 个节点
输入(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 0000.33 秒0.39 秒0.04 秒0.03 秒0.01 秒
n/10 = 10 0000.41 秒3.62 秒0.04 秒0.04 秒0.01 秒
n(整段)1.26 秒36.0 秒0.03 秒0.03 秒0.01 秒
⚠ 这张表有三件事要单独说,一件都不能省

① 第一行里 noLazyfast 一模一样(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四种做法并排跑,数碰的格子(附「只读入」开关)
输入(stdin)
输出
点「运行 ▶」看结果

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

修改区间长度brutenoLazyfastmark
1(=单点改)49 909 536550 973550 973550 973
n/100 = 20052 218 5564 535 0161 014 442683 020
n/10 = 2 00070 081 04340 808 9561 321 294746 090
n(整段)251 066 841402 744 320876 724404 438

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

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

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

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

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

碰的格子本机耗时每秒能碰多少格
brute 原数组251 066 8410.05 秒50.2 亿 / 秒
noLazy 线段树不打标记402 744 3201.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)
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 两个几乎人人都要栽一次的细节

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

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

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

mark.cpp(选讲)★ 标记永久化:账永远挂在原地,谁路过谁把它补进答案 —— 300 轮和正解逐字节相同
输入(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
输入(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,忘了乘区间长度(基线)
输入(stdin)
输出
点「运行 ▶」看结果
wrongMid.cpp✗ 查询递归写成 if / else —— 跨过 mid 的区间丢了一半
输入(stdin)
输出
点「运行 ▶」看结果
wrongUp.cpp✗ 改完忘了 pushup
输入(stdin)
输出
点「运行 ▶」看结果
wrongPushQ.cpp✗ 查询里忘了下推(改那边还留着)
输入(stdin)
输出
点「运行 ▶」看结果
wrongPushU.cpp✗ 修改里忘了下推 —— ★ 它错在哪很反直觉,看文件开头
输入(stdin)
输出
点「运行 ▶」看结果
wrongKeep.cpp✗ 下推之后忘了 lz[o] = 0
输入(stdin)
输出
点「运行 ▶」看结果
wrongApply.cpp✗ lz[o] = x 而不是 +=(两笔账没叠起来)
输入(stdin)
输出
点「运行 ▶」看结果
★★ 默认那组数据上,七个 bug 的「指纹」互不相同
五行答案末行(整个数组)
正解31 39 21 25 2894 102 120 17 13 17 10 14
wrongLen31 24 21 25 268✓ 一个字不错
wrongMid31 39 5 9 289✓ 一个字不错
wrongUp31 9 21 25 31✓ 一个字不错
wrongPushQ31 39 21 11 289✓ 一个字不错
wrongPushU31 39 21 25 259✓ 一个字不错
wrongKeep31 39 36 25 2894 102 150 47 27 31 24 28
wrongApply31 39 11 25 2694 102 110 7 13 17 10 14

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

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

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

对拍器
★ 这个生成器有八个旋钮,其中一个是我动笔前认定的「这一章的主角」—— 实测它是负分,最后撤回了。下面三张表把每一处的账都摆出来。

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

故意写错的地方被抓第几轮
wrongMidif / else300 / 300第 1 轮
wrongUp(忘了 pushup300 / 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 改了什么LenMidUpPushQPushUKeepApply
0(顺手写法)n, m ∈ [6,12]、区间独立随机、值域 [1,100]、改查各半16027719716451238150
1操作序列拉长 m ∈ [20,40]283299298292237299274
2区间相关(35% 复用锚点 + 35% 取锚点的子区间)20023315117854245200
3区间偏短(长度 ≤ 3)622451281081217661
4n 只取 2 的幂(4 / 8)14423720517257236106
5n 一个 2 的幂都不取(5/7/9/11)14726120017859242134
6n 放大到 [40,80]25729824323788279248
7值域拉到 [−10⁴, 10⁴](含 0 和负数)16027719716451238150
8改查配比拧到「修改占七成」13721416814856272205

① 真正的功臣只有两个,而且都不新鲜。 序列拉长把最弱的那一支从 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 章那条「某一支占得太多也是坑」的又一次。

★★ 第二张表:我认定的「这一章的主角」,实测是负分
档位内容LenMidUpPushQPushUKeepApply最弱支
9(最终档)1 + 6(序列拉长 + n 放大)299300300299292300299292
10(对照)9 + 2(区间相关)299300295298271299299271
11(诊断)10,但锚点跟着子区间一起缩298298274296211298295211

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

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

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

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

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

nest.cpp★ 量一量:随机两个区间,后一个落在前一个里面的概率
输出
点「运行 ▶」看结果
n后一个套在前一个里面两个完全相同
823.49%4.256%
2016.84%0.900%
8013.60%0.081%
40012.73%0.003%
2 00012.51%0.000%
10 00012.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),所以用不着懒标记
输入(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★ 区间加 + 区间最大值:整个框架照搬,只改了两处
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

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

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

14 自测

自测清单0 / 10
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 懒标记 = 一句欠着的修改:「我自己的和算清了,我的儿子还不知道。」 而「必须下推」的理由永远只有一条 —— 你要去读儿子的值。理由消失,要求就消失(mark.cpp)。
  2. 懒标记省的是「改」,不是「查」;而且区间长度是 1 的时候,它一格都省不了。 同一份代码,区间拉到整段就省 459 倍 —— 所以那张表必须给出旋钮的两端。
  3. 「我知道这个 bug 靠什么现形」不等于「我该去造那个性质」。 中间还隔着一句必须实测的话:随机数据是不是本来就免费送? 这一章送 12.5%(而且能证),第 38 章一点都不送 —— 前半段一样,分岔全在后半段。

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