第 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]。
这道题的原型是洛谷 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 步
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 两个暴力,而且这次它们没有一个是「有一端便宜」的
第一个暴力最老实:数组原样放着,改就一格格加,查就一格格累。
点「运行 ▶」看结果
第二个暴力已经是一棵线段树了,只是不打标记:区间加老老实实一路递归到每个叶子。
点「运行 ▶」看结果
它 300 轮和正解逐字节相同。放它进来是因为这一章要回答的问题很具体:
懒标记到底省了什么?
而 noLazy 和正解的差别只有一句话:全覆盖的时候要不要记一笔账就走。
框架、递归、pushup 全都一样。所以拿它当对照,量出来的差距就只能归给懒标记。
⚠ 代价是:这个差距对拍一个字都看不见(第 36 章立的第四个盲区)。第 5 步要换尺子。
4 实测慢:而且旋钮不是 n,是「修改区间有多长」
genBig.cpp 的旋钮是修改区间的长度:
本机实测(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对两个儿子的读写(各一格)—— 那两下是实打实动了数据,不算进去这张表就是在给懒标记放水。
点「运行 ▶」看结果
实测(./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 步的事。
把最后那一行的两个暴力放在一起看(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 ★ 关键一步:懒标记 = 一句「欠着的修改」
区间加 [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 正解
点「运行 ▶」看结果
① 数组开 4n,不是 2n。
递归线段树按 o / 2o / 2o+1 编号,n 不是 2 的幂时树不满,最下面一层会「错位」,
编号最坏能用到 4n。开小了就是 RE,而且样例多半还过得去。
② apply 里那个 len 不能忘。
一整段每个数都加 x,这一段的和要加的是 x × 段长。
sm[o] += x 是这一章最常见的错,而且它在叶子上一点毛病都没有(叶子的段长是 1)——
所以只看末行是抓不到它的,第 11 步那张表会印证这一点。
8 ★★ 「必须下推」这句话的边界:一份一次都不下推的正解
点「运行 ▶」看结果
必须下推的理由只有一条:你要读儿子的 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 动画一:账挂在哪儿,什么时候被迫还
- 一个节点画成一根横条,横条盖住的就是它管的那一段 —— 沿用第 38 章那张阶梯图的语言。 越往下的行条越短,这就是线段树。
- 实心 = 这一步停住的节点(全覆盖:记一笔账 / 直接取和); 描边 = 半覆盖,只能继续往下;橙色 = ★ 被迫下推。
- 红色徽章就是
lz—— 盯住它什么时候出现、什么时候消失。 - 切到「★ 只改不查」那一档:账一路挂着,一次都不用下推; 再切到「★ 改一段、马上查它里面的一小段」:每一次都被迫下推。 这两档就是第 6 步那张表的画面版。
⚠ trace.cpp 就是这个动画的文字版,check:viz 拿它和动画逐步比
(访问序列 / 下推了谁 / 停在哪 / 碰格 / 累计 / 整个 sm / 整个 lz / 结论),不只比最终答案。
点「运行 ▶」看结果
10 动画二:打不打标记 —— 而它们的答案逐字节相同
count.cpp 一字不差), 所以它偶尔会比点亮的格子多。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 ★ 对拍:七个错误版本,各自靠什么现形
这是第 37 章立的规矩(写在 gen.cpp 开头),第 38 章验过一次。这一章的清单天然分成两半:
| 错误版本 | 靠什么现形 |
|---|---|
wrongLen 标记落 sum 时忘了乘长度 | 一次「长度 > 1 的全覆盖修改」+ 有人读到它 —— ★ 基线 |
wrongMid 查询递归写成 if / else | ★ 查询区间要跨过某个 mid(半覆盖那一支要够多) |
wrongUp 改完忘了 pushup | 一次半覆盖的修改 + 之后读那个祖先 |
wrongPushQ 查询里忘了下推 | ★★ 改在某节点停住,之后查询钻进它的子树 |
wrongPushU 修改里忘了下推 | ★★ 同上,第二个操作换成修改 —— 全章最难抓的一个 |
wrongKeep 下推后没销账 | ★★ 同一个节点被穿过两次(三件事按顺序发生) |
wrongApply 标记写成 = | ★★ 同一节点连着两次全覆盖,而且中间没人穿过去 |
★★ 上面三个只要「单个操作长什么样」对;下面四个要的是「这个操作和上一个操作之间是什么关系」。 ⚠ 我动笔前据此写下的预判是「这一章要拧一类新旋钮:操作之间的相关性」—— 第 12 步会告诉你这个预判是错的,而且错得能说清。
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
| 五行答案 | 末行(整个数组) | |
|---|---|---|
| 正解 | 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,乘不乘一个样。
所以「最后打印一遍数组看看对不对」这种自查方式,对它完全无效。
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 章那条规矩救了它一次,但救不活它。 「夹带了第二个旋钮」是一种解释,不是免罪符:拆干净之后该撤还是得撤。
④ 而这一次,「为什么白干」是能算出来的。
点「运行 ▶」看结果
| 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%)。
前面十三章一路在总结「怎么把数据调狠」。这一章第一次遇到的是另一种局面:
★★ 我提前想清楚了 bug 靠什么现形(那一步是对的、也是必要的), 可「靠什么现形」和「该拧哪个旋钮」之间,还隔着一个必须实测的问题: 这个性质,随机数据是不是本来就免费送?
- 第 38 章那次(下标的二进制形状):随机数据不送 ——
n = 2^k在 [6,12] 里只占 1/7, 而它是那个 bug 的生死线 ⇒ 必须专门造; - 这一章(区间的包含关系):随机数据白送 12.5% ⇒ 专门造纯属白干,还有副作用。
⚠ 两次的前半段完全一样(都把 bug 靠什么现形写清楚了), 分岔点在后半段 —— 而那一半只能靠量。
13 ★ 回头看第 38 章:树状数组代码短、常数小,线段树能干的事多
single.cpp 的输入输出和 code/38-fenwick/ 那几份一字不差,
所以能直接拿第 38 章的生成器跨章节对拍 300 轮(那个生成器一个字都没改)。
点「运行 ▶」看结果
并排量一次(./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) 个节点、每一步都是一次跳转。 不是「线段树写得差」,是它多做了「能做更多事」所必须的那些工作。
点「运行 ▶」看结果
改的是这两处(其余一个字都没动):
| 区间和 | 区间最大值 | |
|---|---|---|
| 合并两个儿子 | 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 自测
- 懒标记 = 一句欠着的修改:「我自己的和算清了,我的儿子还不知道。」
而「必须下推」的理由永远只有一条 —— 你要去读儿子的值。理由消失,要求就消失(
mark.cpp)。 - 懒标记省的是「改」,不是「查」;而且区间长度是 1 的时候,它一格都省不了。 同一份代码,区间拉到整段就省 459 倍 —— 所以那张表必须给出旋钮的两端。
- 「我知道这个 bug 靠什么现形」不等于「我该去造那个性质」。 中间还隔着一句必须实测的话:随机数据是不是本来就免费送? 这一章送 12.5%(而且能证),第 38 章一点都不送 —— 前半段一样,分岔全在后半段。
⚠ 数据结构这一块(第 35~39 章)到这里就收尾了。回头看这五章, 真正反复出现的不是某个结构,是那把尺子:单调栈、并查集、堆、树状数组、线段树, 五章里有四章的关键结论秒表都答不了,全靠「数次数」。