第 6 章章末那张选择表,最后一行写着:
| 场景 | 用什么 |
|---|---|
| 只查不改 | 前缀和 O(1) 查 |
| 只改不查(最后才输出) | 差分 O(1) 改 |
| 又改又查 | 树状数组 / 线段树(第 38、39 章) |
那一章还说了一句更要紧的:「到那时你会发现,它们干的事情本质上就是『可以修改的前缀和』。」
★ 这一章就是把那句话变成代码。而真正的转折只有一句:
前缀和的死穴不是「慢」,是「攒得太多」 —— 每个
s[i]都从 1 一路攒到 i, 所以随便改一个数,后面一大片全部作废。 树状数组把「攒多少」这件事按下标的二进制位调了一下:c[i]只攒lowbit(i)那么长的一段。 攒得少,所以改得起;而攒得刚刚好,所以查也不慢。
1 一句话问题
给定长度 n 的数组
a[1..n](n ≤ 2×10⁵,|a_i| ≤ 10⁹)。接下来 m 次操作(m ≤ 2×10⁵):
1 p x:把a[p]加上 x(|x| ≤ 10⁹);2 l r:输出a[l] + a[l+1] + … + a[r]。★ 全部操作做完之后,再输出一行:整个数组的和
a[1] + … + a[n]。
这道题的原型是洛谷 P3374,那道题只有前两条。这里多要了一行「整个数组的和」。
它看起来像凑数,其实是专门为一个 bug 准备的一条腿:
后面会证明,sum(r) 那条路只有在 r = n 时才碰得到 c[n] ——
而随机造一个 l ≤ r 出来,r 正好等于 n 的概率是 (1 + 1/2 + … + 1/n) / n ≈ ln n / n
(n = 8 时还有 34%,n = 2×10⁵ 时只剩 0.006%)。
这一行是唯一保证 r = n 的那次查询。
⚠ 这是第 35、36、37 章那条「题面多问一句,对拍就多一条腿」的第四次现场。 第 37 章给它划过一条边界(它是给数据不够狠的时候兜底的,不是万能药), 这一章的账正好复刻了那条边界,而且这次能说清它兜的到底是什么:
| 顺手写的生成器(档位 0) | 调狠之后(最终档 8) | |
|---|---|---|
| 只比前面那几行 | 269 / 98 / 245 / 169 / 0 / 0 | 300 / 263 / 300 / 300 / 300 / 179 |
| ★ 加上最后那一行 | 300 / 187 / 276 / 169 / 0 / 0 | 300 / 280 / 300 / 300 / 300 / 207 |
★★ 弱数据上它把那个 bug 从 98 抬到 187(接近翻倍), 数据够狠之后只值 263 → 280。 ★ 而它兜的正是「随机数据碰不到的那个边界」(r = n)—— 操作一多,随机查询里迟早会撞上 r = n,它的边际价值自然就掉下来了。
2 手算一遍:默认那 8 格 + 7 步
8 7
3 1 4 1 5 9 2 6
2 1 8 → 31 整个数组的和
1 3 5 → a[3] = 4+5 = 9 ★ 这次修改要跑三格:c[3] → c[4] → c[8]
2 3 6 → 24 9+1+5+9
2 7 7 → 2 ★ sum(7) 要走三格(7 的二进制 111,三个 1)—— n=8 时最长的一条查询路
1 1 -3 → a[1] = 0 ★ 这次修改要跑四格:c[1] → c[2] → c[4] → c[8](最长)
1 8 -6 → a[8] = 0 ★ 这次只跑一格(c[8] 就到头了)—— 最短
2 2 5 → 16 1+9+1+5答案:31 24 2 16,最后一行 27。
- 第 4 条操作
2 7 7,是「查询路径最长」的现场(popcount(7) = 3); - 第 5、6 条那两次修改,一次跑四格一次跑一格 —— ★ 同一个 n,同一种操作,开销差四倍, 这件事第 9 步会变成一整张表;
- 而最后那个 27,是「
add循环写成i < n」唯一的现场 —— ★ 那个 bug 的前四行输出一个字都不错。
3 两个暴力,而且它们是一对反面
第一个暴力最老实:数组原样放着,改就直接改,查就从 l 加到 r。
点「运行 ▶」看结果
第二个暴力是第 6 章那份前缀和原封不动搬过来,加上「改完整段重算」:
点「运行 ▶」看结果
| 做法 | 改一次 | 查一次 |
|---|---|---|
brute(原数组) | O(1) | O(区间长) |
prefix(前缀和) | O(n) | O(1) |
⚠ 所以「这两个暴力哪个更慢」这个问题本身没有答案 —— 除非你先说清楚操作配比是多少。 这件事下一步就要量出来,而它决定了这一章的耗时表必须有两端。
4 实测慢:而且「谁更慢」取决于配比
genBig.cpp 的旋钮不是规模,是操作配比:
本机实测(n = m = 2×10⁵,命令写在下面,读者可以自己复现):
g++ -O2 -o genBig genBig.cpp && g++ -O2 -o brute brute.cpp && g++ -O2 -o prefix prefix.cpp && g++ -O2 -o fast fast.cpp
./genBig 200000 200000 1 > big1.txt # 档位:0 全查询 / 1 一半一半 / 2 全修改 / 3 最坏下标
time ./fast < big1.txt > /dev/null
| 操作配比 | brute 原数组 | prefix 前缀和 | ✓ fast 树状数组 | 只读入不算 |
|---|---|---|---|---|
| 全是查询(档位 0) | 3.54 秒 | ✓ 0.04 秒 | 0.04 秒 | 0.03 秒 |
| 一半一半(档位 1) | 1.78 秒 | 3.90 秒 | 0.04 秒 | 0.03 秒 |
| 全是修改(档位 2) | ✓ 0.03 秒 | 7.79 秒 | 0.04 秒 | 0.03 秒 |
| 最坏下标(档位 3) | 4.59 秒 | 7.85 秒 | 0.03 秒 | 0.03 秒 |
「只读入」那一列是 ./count io(读完输入就退出,什么都不算)。
它一直是 0.03 秒 —— 也就是说 fast 那一列全程都在噪声里:
n = 2×10⁵ 时树状数组做的活,和「把这堆数读进来」一样便宜。
★ 第 29、34、36、37 章那条「量之前先确认你量的就是它」的第五次现场。 而这次它的形状是第 36、37 章那个:秒表在正解身上直接失灵。 所以下一步得换尺子。
⚠ 顺带一件很容易忽略的事:档位 0 和档位 2 各有一个暴力跑得和正解一样快。 它们不是「不行的做法」,是「只在一端行的做法」。
5 慢在哪:前缀和攒得太多了
秒表失灵,那就数次数(第 21 章 stairsCount.cpp 以来的老规矩:次数可复现,秒数不可复现)。
口径很简单:读或写数组里的一个元素,算碰一格。
点「运行 ▶」看结果
实测(./genBig 20000 20000 档位,n = m = 2×10⁴,全部写进了 check:viz):
| 操作配比 | brute 原数组 | prefix 前缀和 | ✓ fast 树状数组 |
|---|---|---|---|
| 全是查询 | 100 702 922 | ✓ 40 001 | 285 551 |
| 一半一半 | 49 909 536 | 101 220 994 | ✓ 219 440 |
| 全是修改 | ✓ 40 000 | 200 708 713 | 153 496 |
| 最坏下标 | 165 121 414 | 198 490 078 | ✓ 289 928 |
★★ 这张表比秒表诚实得多,而且它说的话有点反直觉:
树状数组在任何一端都不是最优的。 全查询时前缀和比它少七倍(4 万 vs 28.6 万),全修改时原数组比它少四倍(4 万 vs 15.3 万)。 ★ 它赢的地方是「两端都不烂」 —— 一半一半的时候,两个暴力一个 4991 万、一个 1.01 亿, 而它 21.9 万,少了两个数量级。
(第 37 章那句「堆赢在两列同时是 O(log n)」的加强版:那一章堆在两列上都不输, 这一章树状数组在每一端都输一点点,可它是唯一一个中间不崩的。)
⚠ 两张表的规模不一样,这是故意的,得说明白:
碰格数可复现,所以选了跑得起的规模并写成断言(n = m = 2×10⁴);
耗时和机器有关,断言不了,所以要拧到足够大才看得出差别(n = m = 2×10⁵)。
于是问题变得很具体了:
prefix每改一个数要碰n − p + 1格,是因为每个s[i]都从 1 攒到 i。 那么 —— 能不能让每个格子少攒一点?
6 ★ 关键一步:lowbit 决定「攒多长」
lowbit(i) = i & -i —— i 的二进制里最低位的那个 1(连同它后面的 0)。
lowbit(6) = lowbit(0b110) = 0b10 = 2
lowbit(7) = lowbit(0b111) = 0b01 = 1
lowbit(8) = lowbit(0b1000) = 8★ 定义:c[i] = a[i−lowbit(i)+1] + … + a[i],也就是以 i 结尾、长度正好 lowbit(i) 的那一段。
对照一下就明白它到底改了什么:
| 第 i 格攒的区间 | 长度 | |
|---|---|---|
第 6 章的前缀和 s[i] | [1, i] | i(一路涨) |
本章的 c[i] | (i − lowbit(i), i] | lowbit(i)(时长时短) |
攒得少,所以改一个数只会影响少数几格。
cover.cpp 把这张表打出来,而且每一行都当场验算一遍(不是「打印一下」就完了):
点「运行 ▶」看结果
i 二进制 lowbit 管辖的区间 长度 c[i]
1 000001 1 [1, 1] 1 3
2 000010 2 [1, 2] 2 4
3 000011 1 [3, 3] 1 4
4 000100 4 [1, 4] 4 9
5 000101 1 [5, 5] 1 5
6 000110 2 [5, 6] 2 14
7 000111 1 [7, 7] 1 2
8 001000 8 [1, 8] 8 31
① 查前缀和 sum(r) = a[1] + … + a[r]
c[r] 已经管住了 (r−lowbit(r), r],剩下的是 (0, r−lowbit(r)] ——
同一个问题,规模变小了(第 1、2 章那句「把大问题切成同形状的小问题」)。于是:
for (int i = r; i > 0; i -= lowbit(i)) s += c[i];★ i -= lowbit(i) 干的事是「抹掉二进制里最低位的那个 1」,
所以循环转的圈数恰好等于 r 的二进制里 1 的个数(popcount)——
⚠ 注意这不是「不超过」,是等号。它 ≤ ⌊log₂r⌋ + 1,于是 O(log n)。
sum(7) = c[7] + c[6] + c[4] 3 格,popcount(7) = 3 ← n=8 时最长
sum(8) = c[8] 1 格,popcount(8) = 1 ← 一步到头② 改 a[p] += x:所有管到 p 的格子都要跟着加 x。
for (int i = p; i <= n; i += lowbit(i)) c[i] += x;★ i += lowbit(i) 干的事是「进位」:设 lowbit(i) = 2^k,那么 i 的第 k 位是 1,
加上 2^k 必然往上进位,第 k 位(连同它上面那串连续的 1)全变成 0 ——
所以新的 lowbit 严格比旧的大。最低位的 1 一路往高位挪,最多挪 ⌊log₂n⌋+1 次就超过 n 了。
add(1): c[1] → c[2] → c[4] → c[8] 4 格 ← 最长
add(8): c[8] 1 格 ← 最短⚠ 为什么「管到 p 的格子」正好是那条路? 两句话:
- 每一步都合法:
i' = i + lowbit(i),因为lowbit(i') > lowbit(i)且i' − lowbit(i') < i − lowbit(i) < p ≤ i < i',所以c[i']也管着 p; - 一个都不漏:设 j 管着 p 且
j > i,那 j 的区间完整地盖住 i 所在的位置, 于是j ≥ i'—— 因为这些区间要么互相套住、要么完全不相交,永远不会「半重叠」。 ★ 这条性质是整个 lowbit 结构的地基。
⇒ 改 O(log n)、查 O(log n)。两边同时是 log —— 这就是它比那两个暴力强的全部原因。
7 正解
点「运行 ▶」看结果
c[i] 管的那一段 = a[i] 加上「它下面挂着的几个子段」。
顺着 i 从小到大扫,先把 a[i] 放进 c[i],再把 c[i] 整个交给它爸爸 i + lowbit(i):
for (int i = 1; i <= n; i++) {
c[i] += a[i];
int f = i + lowbit(i);
if (f <= n) c[f] += c[i];
}一遍扫完就建好了。碰的格子数是 n(放进去)+ 有爸爸的格子数,而:
★★ 没有爸爸的格子(
i + lowbit(i) > n),恰好就是sum(n)那条路上的格子。 所以建树碰的格子数 = 2n − popcount(n),一个数都不多。
n = 2×10⁴ 时 popcount = 5,实测正好 39 995 = 40 000 − 5(check:viz 钉着这条)。
⚠ 对照一下「老老实实做 n 次 add」的写法:同样是 n = 2×10⁴,它要碰 153 456 格,
是 O(n) 建树的 3.84 倍。
点「运行 ▶」看结果
★ 注意 buildSlow.cpp 不是错误版本 —— 它 300 轮逐字节和正解相同。
对拍在它身上一个字都看不见,这一章后面还会碰到同样的事。
8 动画一:那条高亮的路,就是 O(log n) 里的 log
- 阶梯图的第 i 行,就是
c[i]管的那一段,长度正好lowbit(i)。 对照第 6 章的前缀和 —— 那里第 i 根横条一律从 1 拉到 i,所以改一个数要动一大片。 - 改的时候高亮从 p 往上跳,查的时候从端点往下跳;
⚠ 查询是两条路(
sum(r)和sum(l−1)),画面上用两种颜色分开。 - 切到「★ 最坏的两个下标」那一档:改一律打
p = 1、查一律用r = 15(二进制全是 1)—— 那是这条 log 上界唯一被顶紧的时候。再切到「⚠ 最省事的两个下标」对比一下, 同一个 n、同样的操作条数,碰的格子数差三倍多。
⚠ trace.cpp 就是这个动画的文字版,check:viz 拿它和动画逐步比
(两条路径 / 碰格 / 累计 / 整个 c 数组 / 结论),不只比最终答案。
点「运行 ▶」看结果
9 ★ 上界证出来了,随机数据却永远碰不到它
sum(r) 的步数恰好等于 popcount(r)。那么随机取一个 r,它平均是多少?
二进制里 1 的个数,期望正好是位数的一半。 也就是说:
随机查询平均只走 log₂n ÷ 2 步 —— 只有
r = 2^k − 1(二进制全是 1)才顶到 log₂n。
add 那一边也一样,而且 n = 2^k 时同样有精确形式:
add(p) 的步数 = popcount(n − p) + 1(p 每加一次 lowbit,n − p 就少掉一个 1),
所以平均是 log₂n ÷ 2 + 1,最坏(p = 1)是 log₂n + 1。
点「运行 ▶」看结果
| n | log₂n | 查:平均(全枚举) | 查:最坏(r=2^k−1) | 改:平均(全枚举) | 改:最坏(p=1) |
|---|---|---|---|---|---|
| 1 024 | 10 | 5.00 | 10 | 6.00 | 11 |
| 4 096 | 12 | 6.00 | 12 | 7.00 | 13 |
| 16 384 | 14 | 7.00 | 14 | 8.00 | 15 |
| 65 536 | 16 | 8.00 | 16 | 9.00 | 17 |
| 262 144 | 18 | 9.00 | 18 | 10.00 | 19 |
| 1 048 576 | 20 | 10.00 | 20 | 11.00 | 21 |
★★ 两列的平均,都正好是各自最坏的一半 —— 而且这个「一半」是能证的,不是量出来碰巧。
⚠ 所以要让 O(log n) 这条界现形,得自己造那个下标:查要 r = 2^k − 1,改要 p = 1
(genBig.cpp 的档位 3 就是干这个的)。随机数据答不了「这条界紧不紧」。
★ 这一章比第 33、37 章的现场更彻底:那两次是「随机数据碰不到最坏情况」, 这一次连平均值是多少都能精确算出来 —— 而它恰好只有上界的一半。
动笔前我以为 add 那一列「平均只走两三步」(想当然:一半的 p 是偶数,开局就跳过好几层)。
跑完才发现它是干干净净的 log₂n / 2 + 1,而且和「查」那一列一模一样的形状。
先跑再写(第 16 章立的规矩)。这一章又兑现了一次 —— 而且这次的收获不是「数字不一样」,是那个错误的直觉让我差点漏掉「两列同形状」这个结论。
10 动画二:三种做法并排跑 —— 而它们的答案逐字节相同
⚠ 计数器比画面多两格:
s[i] 那一行改数时还要改 a[p] 本身, 查 [1,r] 时读的是 s[0] —— 这两格画不进来,但 count.cpp 数着它们。- 现场一(这个动画):
brute/prefix/fast是三个完全不同的做法, 可它们的输出逐字节相同。上面那张碰格表里两个数量级的差距,对拍一个字都看不见。 - 现场二:
buildSlow.cpp(n 次add建树)和正解连c[]数组都一模一样。
★ 这是第 36 章立的「随机对拍第四个盲区」,第 37 章证明了它比想象的宽 (
linear/sorted/ 堆三个不同算法答案相同),这一章是第三次 —— 只要它们解的是同一道题,对拍就一个字都看不见。 唯一的出路还是那一条:换尺子,数次数。
⚠ 把动画切到「全是查询」和「全是修改」两档看一眼: 两个暴力各有一档几乎不动,另一档满屏点亮 —— 而树状数组两档都是稀稀拉拉几格。 这就是「两端都不烂」在画面上的样子。
11 ★ 对拍:六个错误版本,各自靠什么现形
这是第 37 章立的规矩(写在 gen.cpp 开头)。这一章的清单是:
| 错误版本 | 靠什么现形 |
|---|---|
wrongDir 两条路方向记反 | 什么数据都行 —— ★ 它是基线,抓得住它什么都不能说明 |
wrongEnd add 写成 i < n | ★ n 的二进制形状(n = 2^k 时 add 必经 c[n])+ 一次 r = n 的查询 |
wrongVar i -= lowbit(r) | ★ r 不是 2 的幂(r = 2^k 时它其实是对的)—— ⚠ 和上一条正好相反 |
wrongRange sum(r) − sum(l) | a[l] ≠ 0 |
wrongBuild c[i] = a[i] | 初始数组不全是 0 |
wrongInt c[] 用 int | 部分和超过 2³¹,别的一概不管 |
★★ 这一章冒出来一类前 37 章从没有过的旋钮:下标的二进制位形状。 以前的旋钮是值域 / 规模 / 结构角色 / 操作序列,说的都是「数据长什么样」; 这一次说的是「下标长什么样」—— 它和数值一点关系都没有。
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
点「运行 ▶」看结果
| 前四行 | 最后那一行 | |
|---|---|---|
| 正解 | 31 24 2 16 | 27 |
wrongEnd | ✓ 31 24 2 16 | ✗ 31 |
wrongVar | ✗ 31 28 19 26 | ✓ 27 |
wrongEnd只错最后那一行:因为 n = 8,每次add都要经过c[8](它漏掉了), 而只有sum(8)才会去读c[8]—— 也就是题面多问的那一行。wrongVar只有最后那一行对:它每次减掉的是同一个lowbit(r), 而r = 8是 2 的幂,一步就减到 0,加的正好只有c[8]—— 完全正确。
★★ 同一个事实(n = 8 是 2 的幂)同时是一个 bug 的死刑和另一个 bug 的赦免。 这就是下一步整张档位表的主线。
⚠ 还有一件事要单独说:wrongInt 在这组数据上一个字都不错。
它要的是「部分和超过 2³¹」,而这里最大的和才 31。
300 轮实测(种子 1..300,最终档 8):
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
wrongDir(方向记反) | 300 / 300 | 第 1 轮 |
wrongVar(lowbit(r)) | 300 / 300 | 第 1 轮 |
wrongRange(sum(l)) | 300 / 300 | 第 1 轮 |
wrongBuild(c[i] = a[i]) | 300 / 300 | 第 1 轮 |
wrongEnd(i < n) | 280 / 300 | 第 2 轮 |
wrongInt(忘开 long long) | 207 / 300 | 第 1 轮 |
buildSlow(只是建树慢) | ★ 0 / 300 | — |
从第 6、11 章起这本书就一路写着「对拍查不出溢出 —— 这只能靠脑子」,
第 35 章还有过一次干干净净的 0 / 300 现场(wrongInt.cpp 用 int 存面积)。
可这一章它是 207 / 300。
两次都没错,差别在一个具体的量上:
★★ 「对拍抓不到溢出」不是定律,是一个条件 —— 条件是「溢出所需要的数据规模,超出了对拍小数据的规模」。
- 第 35 章那道题:面积 = 高 × 宽,要 2×10⁵ 根柱子才溢出 —— 小数据永远碰不到。
- 这一章:三个 10⁹ 加起来就溢出了 —— 只要生成器的值域拧到 10⁹,当场就抓得到。
⚠ 但别把这句话记反了:真正该做的动作没变 ——
先估一估「多大的数据才会溢出」,再决定能不能指望对拍。
这道题 2×10⁵ × 10⁹ = 2×10¹⁴,int 上限约 2.1×10⁹ —— 估这一下只要三秒钟,
而它比任何对拍都可靠。(而且下一张表会看到:值域拧不拧满,是一个和别的 bug 完全无关的旋钮 ——
现实里你多半是为了别的 bug 在调生成器,顺手就把它漏掉了。)
12 ★★ 生成器:十一个档位,一次只改一处
gen.cpp 带了十一个档位(./gen 种子 档位),种子固定 1..300:
| 档位 | 相对档位 0 改了什么 | Dir | End | Var | Rng | Bld | Int |
|---|---|---|---|---|---|---|---|
| 0(顺手写法) | n, m ∈ [6,12]、★ 初始数组全 0、值域 [1,100] 全正、改查各半 | 300 | 187 | 276 | 169 | ★ 0 | ★ 0 |
| 1 | ★ 放开那个顺手:初始数组随机非零 | 300 | 172 | 299 | 299 | 300 | 0 |
| 2 | ★ n 只取 2 的幂(4 / 8) | 299 | ★ 300 | ★ 170 | 224 | 0 | 0 |
| 3 | ★ n 一个 2 的幂都不取(3/5/7/9) | 297 | ★ 177 | ★ 279 | 216 | 0 | 0 |
| 4 | ★ 三种形状混着取(2^k / 2^k−1 / 2^k+1) | 298 | 211 | 245 | 221 | 0 | 0 |
| 5 | 操作序列拉长 m ∈ [20,40] | 300 | 272 | 300 | 300 | 0 | 0 |
| 6 | 值域拉到 [−10⁹, 10⁹] | 300 | 187 | 276 | 169 | 0 | 28 |
★ 档位 0 那两个 0 是这一章的起点。「初始数组全 0」是我随手就写下来的
(「反正要什么值都能用操作加出来」)—— 可题面写的是 |a_i| ≤ 10⁹,0 只是其中一个值。
★ 第 27~34、37 章那条「生成器里那个不假思索的顺手写法,会悄悄给数据加一条题目里没有的性质」, 这一章第十次。这次加的那条性质是:「数组从空的开始」。 它一口气打掉了
wrongBuild(0 / 300),还顺手压住了wrongRange(169,放开后 299)。
★★ 档位 2 和 3 是这一章真正的新东西,请对着看:
n 只取 2 的幂:
wrongEnd冲到 300 / 300(每次 add 都必经c[n]), 可wrongVar掉到 170(r = 2^k时它是一份正确的程序)。 n 一个 2 的幂都不取:正好倒过来 ——wrongEnd掉到 177,wrongVar涨到 279。★★ 同一个旋钮的两头,两个 bug 正好交换位置。 这是第 31 章那条「某一支永远走不到是坑,某一支占得太多也是坑」在这一章的样子, 而且形状比第 31 章那次干净得多:这里不是「多了挤掉别人」, 是一端直接把另一个 bug 变成了正确的代码。 ⇒ 所以档位 4 那样三种都造,才是唯一说得过去的选择。
| 档位 | 内容 | Dir | End | Var | Rng | Bld | Int |
|---|---|---|---|---|---|---|---|
| 7 | 1 + 4 + 5(非零 + 混形状 + 拉长) | 300 | 280 | 300 | 300 | 300 | 0 |
| 8(最终档) | 7 + 6(值域也拉开) | 300 | 280 | 300 | 300 | 300 | ★ 207 |
| 9(对照) | 8 减去「二进制形状」(n 退回随机 [6,12]) | 300 | 272 | 300 | 300 | 300 | 222 |
| 10(诊断) | 8,但 n 的基数放大到 16 | 300 | ★ 251 | 300 | 300 | 300 | 224 |
① 值域拉开:单独加只值 28,放进最终环境是 0 → 207。
档位 6 单独加时 wrongInt 只有 28 / 300;档位 7 → 8 是 0 → 207。
★ 第 32、34、35、36、37 章那条「调优不可加」的第六次 —— 这次是正面的那一种:序列一长,部分和才真的堆得起来,值域拧满才有意义。
② ⚠ 而「n 的二进制形状」这一处,我差点做错。
第一版的混合档基数取到 16(也就是档位 10),在最终环境里量出来 wrongEnd = 251 ——
比什么都不改的对照档(272)还差。按第 34、35、37 章那条规矩,看着就该撤回。
可它同时改了两件事:形状(我想要的)和规模(顺手带上的,n 最大到 17)。 把规模那一半收回去(基数只到 8),同一处改动立刻变成 280 —— 反超对照档。
★★ 「量到主语」那条规矩(第 22、29、34、36 章)第一次用在生成器上: 一处改动量出来是负分时,先问它是不是偷偷夹带了第二个旋钮。
③ 最后定档:为什么留着「二进制形状」。
最终档(8)和对照档(9)的账很接近,而且一列涨一列跌:
wrongEnd 280 vs 272(涨),wrongInt 207 vs 222(跌)。按「让最弱的那一支尽量强」的标准,
对照档反而略胜(222 > 207)。它还是被留下了,理由不是抓获率,是边界覆盖:
n = 2^k时c[n]一格盖住整个数组,是这道题结构上最特殊的一支, 而顺手随机 [6,12] 里它只占 1/7。最终档 300 轮里有 91 轮 n 是 2 的幂。 ⚠ 这笔账要明写,不许粉饰成「改了就是更好」(第 27、35 章那条)。
把同一批数据只比前面那几行(把最后那行总和去掉):
| 档位 | Dir | End | Var | Rng | Bld | Int |
|---|---|---|---|---|---|---|
| 0(顺手) 只比前几行 | 269 | ★ 98 | 245 | 169 | 0 | 0 |
| 0 ★ 加上最后那一行 | 300 | ★ 187 | 276 | 169 | 0 | 0 |
| 8(最终档) 只比前几行 | 300 | 263 | 300 | 300 | 300 | 179 |
| 8 ★ 加上最后那一行 | 300 | 280 | 300 | 300 | 300 | 207 |
★★ 弱数据上它接近翻倍(98 → 187),数据够狠之后只值 263 → 280。 和第 37 章那次(5~11 倍 vs 283 → 290)形状完全一致,这是第二次现场。
★ 而这次能说清它兜的到底是什么:那一行是 sum(n),
是唯一保证 r = n 的那次查询 —— 而 r = n 正是随机查询碰不到的那个边界。
操作一多,随机查询里迟早会撞上 r = n,它的边际价值就掉了。
★★ 「题面多问一句」兜的是「随机数据碰不到的那个边界」。 所以它在弱数据上格外值钱,而现实里你的生成器多半就是档位 0 那个样子。
13 ★ 跨章节:用树状数组重做第 11 章的逆序对
第 11 章是「归并排序顺手数出来」(分治)。这一份换个角度:
从左往右扫,扫到
a[j]时问一句:前面已经放进去的数里,有几个比它大? 那些数下标更小、值更大 —— 每一个都是一个逆序对。「前面放进去的数里有几个 ≤ 某个值」正好是一句前缀和, 「放进去一个数」正好是一次单点 +1 —— 又改又查,正是这一章的题。
前面比 a[j] 大的个数 = (已经放进去的个数) − (已经放进去的 ≤ a[j] 的个数)
= (j − 1) − sum(rank[j])★ 「相等不算逆序对」这件事就藏在那个 ≤ 里:sum 数的是 ≤ a[j],
减掉之后剩下的才是严格大于。写成 < 就错了 —— 这是第 22 章
lower_bound / upper_bound 一个字母之差的同款。
⚠ 而第 11 章的生成器一半的数取自 0..5,重复元素多得很,这个错当场就会被打出来。
点「运行 ▶」看结果
check:viz里 300 轮三方对拍:第 11 章的 O(n²) 暴力 / 第 11 章的归并版 / 这一份, 答案完全一致 —— 而且生成器也是第 11 章那份,一个字没改。 ⚠ 顺带钉了一条:这 300 组里有 169 组答案非 0, 免得「全是升序数据、答案全是 0」这种废数据蒙混过关(第 24 章那条:生成器造完先看一眼答案像不像话)。
14 自测
c[i]只攒lowbit(i)那么长的一段 —— 攒得少,所以改得起;攒得刚好,所以查也不慢。 前缀和不是「慢」,是「攒得太多」。- 两条路都是 O(log n),可随机数据只跑在界的一半上。
sum(r)的步数恰好等于 popcount(r) —— 要让界现形,得自己造r = 2^k − 1。 - 生成器这一章多了一类旋钮:下标的二进制形状。 而它的两头分别是两个 bug 的天堂和坟墓 —— 三种都得造。
⚠ 下一章(线段树)会把这道题再做一遍,而且能做树状数组做不了的事(区间改、区间最值)。 到那时值得回头对比一次:树状数组代码短、常数小,线段树能干的事多。