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

树状数组

★ 关键一步是 lowbit —— c[i] 只攒 lowbit(i) 那么长的一段,于是它成了「可以修改的前缀和」。⚠ 而这一章要还第 6 章那张选择表欠下的账:前缀和的死穴不是「慢」,是「一改就作废」。

例题:单点修改 + 区间求和 建议用时:130 分钟
第 6 章欠下的那笔账,这一章来还

第 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]

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

这道题的原型是洛谷 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 / 0300 / 263 / 300 / 300 / 300 / 179
★ 加上最后那一行300 / 187 / 276 / 169 / 0 / 0300 / 280 / 300 / 300 / 300 / 207

★★ 弱数据上它把那个 bug 从 98 抬到 187(接近翻倍), 数据够狠之后只值 263 → 280。 ★ 而它兜的正是「随机数据碰不到的那个边界」(r = n)—— 操作一多,随机查询里迟早会撞上 r = n,它的边际价值自然就掉下来了。

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

★ 这 7 步里埋了三件事,第 11 步会挨个用到
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。

brute.cpp标准答案:什么都不攒 —— 改 O(1)、查 O(区间长)
输入(stdin)
输出
点「运行 ▶」看结果

第二个暴力是第 6 章那份前缀和原封不动搬过来,加上「改完整段重算」:

prefix.cpp第 6 章的前缀和 + 改完重算 —— 改 O(n)、查 O(1)
输入(stdin)
输出
点「运行 ▶」看结果
★ 这两份不是「一个快一个慢」,是「一个的天堂是另一个的坟墓」
做法改一次查一次
brute(原数组)O(1)O(区间长)
prefix(前缀和)O(n)O(1)

⚠ 所以「这两个暴力哪个更慢」这个问题本身没有答案 —— 除非你先说清楚操作配比是多少。 这件事下一步就要量出来,而它决定了这一章的耗时表必须有两端

4 实测慢:而且「谁更慢」取决于配比

genBig.cpp 的旋钮不是规模,是操作配比

genBig.cpp(四个配比档位)档位 0 全查询 / 1 一半一半 / 2 全修改 / 3 最坏下标

本机实测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 以来的老规矩:次数可复现,秒数不可复现)。

口径很简单:读或写数组里的一个元素,算碰一格

count.cpp三种做法并排跑,数碰的格子(附「只读入」开关)
输入(stdin)
输出
点「运行 ▶」看结果

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

操作配比brute 原数组prefix 前缀和fast 树状数组
全是查询100 702 92240 001285 551
一半一半49 909 536101 220 994219 440
全是修改40 000200 708 713153 496
最坏下标165 121 414198 490 078289 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 决定「攒多长」

★★ 一句话:c[i] 只攒 lowbit(i) 那么长的一段

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 把这张表打出来,而且每一行都当场验算一遍(不是「打印一下」就完了):

cover.cpp每格管哪一段 + 两条路的长度,全部硬验证
输入(stdin)
输出
点「运行 ▶」看结果
  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 正解

fast.cpp正解:树状数组(含 O(n) 建树那三行)
输入(stdin)
输出
点「运行 ▶」看结果
★ 建树只要 O(n),而且那个数是能算准的

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 − 5check:viz 钉着这条)。

⚠ 对照一下「老老实实做 n 次 add」的写法:同样是 n = 2×10⁴,它要碰 153 456 格, 是 O(n) 建树的 3.84 倍

buildSlow.cpp⚠ 不是 bug:它的输出和 c[] 数组都和正解完全一样,只是建树慢了一个 log
输入(stdin)
输出
点「运行 ▶」看结果

★ 注意 buildSlow.cpp 不是错误版本 —— 它 300 轮逐字节和正解相同。 对拍在它身上一个字都看不见,这一章后面还会碰到同样的事。

8 动画一:那条高亮的路,就是 O(log n) 里的 log

阶梯图:第 i 行那根横条就是 c[i] 管的那一段,长度 = lowbit(i)
全程碰格 21(改 8 / 查 12 / 末行 1)
第 1 / 9 步
这一步碰了 0★ 累计碰格 0n = 8,log₂n ≈ 3.00
1
2
3
4
5
6
7
8
c[1]
3
lowbit = 1
c[2]
4
lowbit = 2
c[3]
4
lowbit = 1
c[4]
9
lowbit = 4
c[5]
5
lowbit = 1
c[6]
14
lowbit = 2
c[7]
2
lowbit = 1
c[8]
31
lowbit = 8
a[i]
3
1
4
1
5
9
2
6
高亮 = 这一步走过的格子(查询有两条路:sum(r)sum(l−1)) ★ 结论:每格最多被交给爸爸一次,所以建树是 O(n),不是 O(n log n)。
建树完毕:c[i] 攒的是 a[i−lowbit(i)+1] … a[i] 这一段,长度正好 lowbit(i)。
怎么看这个动画
  • 阶梯图的第 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 数组 / 结论),不只比最终答案。

trace.cpp动画照着它画:每一步的两条路、碰格数、c 数组
输入(stdin)
输出
点「运行 ▶」看结果

9 ★ 上界证出来了,随机数据却永远碰不到它

★★ 这是第三次现场了(第 33 章、第 37 章各一次)

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

steps.cpp★ 这张表不带随机:1..n 全枚举,逐位可复现
输出
点「运行 ▶」看结果
nlog₂n查:平均(全枚举)查:最坏(r=2^k−1)改:平均(全枚举)改:最坏(p=1)
1 024105.00106.0011
4 096126.00127.0013
16 384147.00148.0015
65 536168.00169.0017
262 144189.001810.0019
1 048 5762010.002011.0021

★★ 两列的平均,都正好是各自最坏的一半 —— 而且这个「一半」是能证的,不是量出来碰巧。

⚠ 所以要让 O(log n) 这条界现形,得自己造那个下标:查要 r = 2^k − 1,改要 p = 1genBig.cpp 的档位 3 就是干这个的)。随机数据答不了「这条界紧不紧」。

这一章比第 33、37 章的现场更彻底:那两次是「随机数据碰不到最坏情况」, 这一次连平均值是多少都能精确算出来 —— 而它恰好只有上界的一半。

⚠ 我在这一步的预判是错的,值得记一笔

动笔前我以为 add 那一列「平均只走两三步」(想当然:一半的 p 是偶数,开局就跳过好几层)。 跑完才发现它是干干净净的 log₂n / 2 + 1,而且和「查」那一列一模一样的形状

先跑再写(第 16 章立的规矩)。这一章又兑现了一次 —— 而且这次的收获不是「数字不一样」,是那个错误的直觉让我差点漏掉「两列同形状」这个结论

10 动画二:三种做法并排跑 —— 而它们的答案逐字节相同

同一串操作,三种做法各碰了多少格 —— ★ 而它们的答案逐字节相同
第 1 / 9 步
原数组:改 O(1)、查 O(区间长)
累计碰格 0 这一步 +0
前缀和:改 O(n)、查 O(1)
累计碰格 0 这一步 +0
✓ 树状数组:两边都 O(log n)
累计碰格 0 这一步 +0
1
2
3
4
5
6
7
8
a[i]
s[i]
c[i]
点亮 = 这一步读或写过的格子。★ 三行的答案完全一样,差的只是点亮了几格 —— 所以这三行的差距,随机对拍一个字都看不见。
⚠ 计数器比画面多两格:s[i] 那一行改数时还要改 a[p] 本身, 查 [1,r] 时读的是 s[0] —— 这两格画不进来,但 count.cpp 数着它们。
同一串操作,三种做法并排跑。盯住右边那三个「累计碰过的格子」。
★★ 对拍看不见「慢」—— 这一章有两个现场
  • 现场一(这个动画)brute / prefix / fast三个完全不同的做法, 可它们的输出逐字节相同。上面那张碰格表里两个数量级的差距,对拍一个字都看不见。
  • 现场二buildSlow.cpp(n 次 add 建树)和正解连 c[] 数组都一模一样。

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

⚠ 把动画切到「全是查询」和「全是修改」两档看一眼: 两个暴力各有一档几乎不动,另一档满屏点亮 —— 而树状数组两档都是稀稀拉拉几格。 这就是「两端都不烂」在画面上的样子。

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

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

这是第 37 章立的规矩(写在 gen.cpp 开头)。这一章的清单是:

错误版本靠什么现形
wrongDir 两条路方向记反什么数据都行 —— ★ 它是基线,抓得住它什么都不能说明
wrongEnd add 写成 i < nn 的二进制形状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 章从没有过的旋钮:下标的二进制位形状。 以前的旋钮是值域 / 规模 / 结构角色 / 操作序列,说的都是「数据长什么样」; 这一次说的是「下标长什么样」—— 它和数值一点关系都没有。

wrongDir.cpp✗ 两条路的方向记反了(基线)
输入(stdin)
输出
点「运行 ▶」看结果
wrongEnd.cpp✗ add 写成 i < n —— 前四行一个字都不错
输入(stdin)
输出
点「运行 ▶」看结果
wrongVar.cpp✗ 循环里 lowbit 的参数没跟着 i 变
输入(stdin)
输出
点「运行 ▶」看结果
wrongRange.cpp✗ 区间和写成 sum(r) − sum(l)
输入(stdin)
输出
点「运行 ▶」看结果
wrongBuild.cpp✗ 建树时直接把 a[i] 塞进 c[i]
输入(stdin)
输出
点「运行 ▶」看结果
wrongInt.cpp✗ c[] 忘了开 long long —— 在这组数据上一个字都不错
输入(stdin)
输出
点「运行 ▶」看结果
★★ 默认那组数据上,wrongEnd 和 wrongVar 的错法正好互补
前四行最后那一行
正解31 24 2 1627
wrongEnd31 24 2 1631
wrongVar31 28 19 2627
  • 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。

对拍器
★ 这个生成器有六个旋钮,其中「n 的二进制形状」有两个方向完全相反的极端 —— 而且我第一次把它加进去时,量出来比不加还差。下面三张表把每一处的账都摆出来。

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

故意写错的地方被抓第几轮
wrongDir(方向记反)300 / 300第 1 轮
wrongVarlowbit(r)300 / 300第 1 轮
wrongRangesum(l)300 / 300第 1 轮
wrongBuildc[i] = a[i]300 / 300第 1 轮
wrongEndi < n280 / 300第 2 轮
wrongInt(忘开 long long207 / 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 改了什么DirEndVarRngBldInt
0(顺手写法)n, m ∈ [6,12]、★ 初始数组全 0、值域 [1,100] 全正、改查各半30018727616900
1放开那个顺手:初始数组随机非零3001722992993000
2n 只取 2 的幂(4 / 8)29930017022400
3n 一个 2 的幂都不取(3/5/7/9)29717727921600
4三种形状混着取(2^k / 2^k−1 / 2^k+1)29821124522100
5操作序列拉长 m ∈ [20,40]30027230030000
6值域拉到 [−10⁹, 10⁹]300187276169028

档位 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 掉到 170r = 2^k 时它是一份正确的程序)。 n 一个 2 的幂都不取:正好倒过来 —— wrongEnd 掉到 177wrongVar 涨到 279

★★ 同一个旋钮的两头,两个 bug 正好交换位置。 这是第 31 章那条「某一支永远走不到是坑,某一支占得太多也是坑」在这一章的样子, 而且形状比第 31 章那次干净得多:这里不是「多了挤掉别人」, 是一端直接把另一个 bug 变成了正确的代码。 ⇒ 所以档位 4 那样三种都造,才是唯一说得过去的选择。

★★ 第二张表:合起来,以及一处「量出来是负分」的改动
档位内容DirEndVarRngBldInt
71 + 4 + 5(非零 + 混形状 + 拉长)3002803003003000
8(最终档)7 + 6(值域也拉开)300280300300300207
9(对照)8 减去「二进制形状」(n 退回随机 [6,12])300272300300300222
10(诊断)8,但 n 的基数放大到 16300251300300300224

① 值域拉开:单独加只值 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^kc[n] 一格盖住整个数组,是这道题结构上最特殊的一支, 而顺手随机 [6,12] 里它只占 1/7。最终档 300 轮里有 91 轮 n 是 2 的幂。 ⚠ 这笔账要明写,不许粉饰成「改了就是更好」(第 27、35 章那条)。

gen.cpp(十一个档位)六处改动全部可重跑,包括那次「差点撤回」的诊断档
★ 题面多问那一行值多少 —— 两笔账都要摆

把同一批数据只比前面那几行(把最后那行总和去掉):

档位DirEndVarRngBldInt
0(顺手) 只比前几行2699824516900
0 ★ 加上最后那一行30018727616900
8(最终档) 只比前几行300263300300300179
8 ★ 加上最后那一行300280300300300207

★★ 弱数据上它接近翻倍(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,重复元素多得很,这个错当场就会被打出来。

inv.cpp★ 树状数组 + 离散化:输入输出和第 11 章一字不差
输入(stdin)
输出
点「运行 ▶」看结果

check:viz 里 300 轮三方对拍:第 11 章的 O(n²) 暴力 / 第 11 章的归并版 / 这一份, 答案完全一致 —— 而且生成器也是第 11 章那份,一个字没改。 ⚠ 顺带钉了一条:这 300 组里有 169 组答案非 0, 免得「全是升序数据、答案全是 0」这种废数据蒙混过关(第 24 章那条:生成器造完先看一眼答案像不像话)。

14 自测

自测清单0 / 10
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. c[i] 只攒 lowbit(i) 那么长的一段 —— 攒得少,所以改得起;攒得刚好,所以查也不慢。 前缀和不是「慢」,是「攒得太多」。
  2. 两条路都是 O(log n),可随机数据只跑在界的一半上。 sum(r) 的步数恰好等于 popcount(r) —— 要让界现形,得自己造 r = 2^k − 1
  3. 生成器这一章多了一类旋钮:下标的二进制形状。 而它的两头分别是两个 bug 的天堂和坟墓 —— 三种都得造。

⚠ 下一章(线段树)会把这道题再做一遍,而且能做树状数组做不了的事(区间改、区间最值)。 到那时值得回头对比一次:树状数组代码短、常数小,线段树能干的事多。