静态前缀和很舒服:问一段和只要相减。可一旦数组会改,旧前缀和就像一串被推倒的多米诺骨牌。今天先发明两种“只修少量摘要”的办法:Fenwick Tree 和 Segment Tree。
学完后,你能:
lowbit(i) 理解成 Fenwick 节点负责的区间长度,而不只是一段位运算;前置:数组、二进制最低位、区间和、递归的基本概念。
pre[r]-pre[l-1] 为什么能得到区间 $[l,r]$ 的和?a[2] 后,哪些 pre[i] 必须更新?i & -i 在二进制中会留下 i 的哪一部分?准备数组 a=[3,1,4,1,5,9],下标从 1 开始。每次结构中出现一个新格子,都在它下面写清楚:它汇总原数组的哪一段?不要只记录 tree[i] 的数值。纸上账本分成:原数组、摘要数组/节点、一次查询拿了哪些摘要、一次修改通知了哪些摘要。
本节的互动目标是从“完整前缀和”亲手发明“局部摘要”。若某个节点的负责区间说不出来,它还不是你真正会用的数据结构。
对 a=[3,1,4,1,5,9] 写 pre[0]=0,再逐项填 pre[i]=pre[i-1]+a[i]。用它回答 sum(2,5)。
停顿 0:为什么只需
pre[5]-pre[1]?请在两个和里圈出被抵消的元素,而不是只引用公式。
把 a[3] 从 4 改成 10。不要重算前缀和前先猜:pre[1],pre[2],pre[3] 到 pre[6] 中,哪些应改变?在每个应改变项后写 +6。
停顿 1:若有十万次“改单点、问区间”,完整前缀和的哪一部分会反复重做?我们真正想保留的是什么,又不想每次重算什么?
把下标 1..8 先粗暴分成 [1,4]、[5,8] 两块。问 prefix(6) 时,你会拿整块 [1,4],再处理 [5,6]。请继续把 [5,6] 看成一个摘要块。
现在写下 1..8 的二进制和 lowbit(i):
| i | 二进制 | lowbit(i) |
若 tree[i] 负责尾块,它负责哪段 |
|---|---|---|---|
| 1 | |||
| 2 | |||
| 4 | |||
| 6 | |||
| 8 |
停顿 2:从 6 不断减
lowbit会走到哪些下标?这些块为什么刚好不重不漏地覆盖[1,6]?
仍改位置 3,先问:哪些尾块包含 3?从下标 3 开始不断加 lowbit,把走到的下标和对应区间写出来。
停顿 3:为什么 3、4、8 都需要更新,而 6 不需要?请用“负责区间是否包含 3”解释,别只背跳法。
若查询的是区间最小值,写两个前缀最小值:min(1..r) 与 min(1..l-1)。请尝试从它们恢复 min(l..r),再构造一个反例说明失败。
然后把 [1,8] 二分为 [1,4],[5,8],继续二分。每个节点记录自己区间的最小值或和。请回答:查询 [2,7] 时,哪些完全包含的节点可以直接交答案,哪些边界节点还需继续拆?
原数组直接求区间和,每次查询 $O(n)$、修改 $O(1)$;完整前缀和反过来,查询 $O(1)$、修改 $O(n)$。如果有十万次查询和修改,任何一边全重算都会累趴。解决方案是保留许多局部摘要,一次操作只触碰对数层。
从 1 开始编号。tree[i] 不保存单点,而保存区间:
$$ [i-\operatorname{lowbit}(i)+1, i]. $$
求前缀和时,从 i 开始反复减去 lowbit(i),每次吃掉一个不重叠的尾部块;更新位置 i 时,反复加 lowbit(i),恰好通知所有包含它的摘要块。
prefix_sum(i):
result = 0
while i > 0:
result += tree[i]
i -= lowbit(i)
return result
add(i, delta):
while i <= n:
tree[i] += delta
i += lowbit(i)
lowbit 不是随机跳跃:二进制最低位 1 指出当前位置结尾、大小合适的块。每次跳会去掉一个 1,最多 $O(\log n)$ 次。
若操作不只是可相减的和,例如区间最小值,prefix_min(r)-prefix_min(l-1) 没有意义。线段树把 $[1,n]$ 反复二分,每个节点保存其区间的合并结果;查询 $[l,r]$ 时,完全落在目标内的节点直接拿摘要,相交一部分才继续向下问。
query(node, left, right, ql, qr):
if [left, right] is fully inside [ql, qr]:
return summary[node]
split at mid
combine answers from overlapping children
point_set(node, left, right, index, value):
if left == right: summary[node] = value; return
recurse into the child containing index
summary[node] = combine(left_child, right_child)
初始数组仍为 [3,1,4,1,5,9]。先完成 sum(2,5),再执行 set(3,10),最后再问一次 sum(2,5)。每次都填:
| 操作 | Fenwick 拿/改的块 | 线段树拿/重算的区间节点 | 结果 |
|---|---|---|---|
sum(2,5) |
|||
set(3,10) |
|||
sum(2,5) |
每层至多拆开目标区间两边,树高为 $O(\log n)$,所以单点改与区间问都是 $O(\log n)$。
Fenwick 最擅长可用前缀差还原区间的操作,例如和、计数、XOR;取最小值通常不能从两个前缀最小值相减出来。线段树只要求 combine 满足结合性,能维护和、最值、最大子段和等更丰富摘要,但常数和实现复杂度较高。
两者都不是“自动处理所有区间操作”的万能盒:若更新影响整段、查询也问整段,普通节点摘要不够,需要 Lazy 标记;若值域很大或版本很多,正式课会继续引入动态开点、可持久化与合并分裂。
i=0 时 lowbit(0)=0 会原地踏步,千万别让循环从 0 开始。prefix(r)-prefix(l-1),不要一会儿闭区间一会儿半开区间。combine、空区间单位元、叶子含义必须一致;求最小值的单位元不是 0。运行 会变化的区间和.py。它同时维护一份 Fenwick 与一棵线段树;可以输入:
sum l r:看 Fenwick 分解出的前缀块和线段树选中的节点;set i x:看一次修改向上的 Fenwick 摘要和线段树祖先;show:列出每个 tree[i] 真正负责的区间。直接回车会退出,适合先读默认摘要;推荐先输入 sum 2 5,再 set 3 10,再问一次同一区间。
Fenwick 的“按二进制块前缀分解”常用于离线计数和逆序关系;线段树的“摘要 + 合并”则是区间 DP、扫描线、可持久化结构的母语。两者共享的识别信号是:数组会变化,但每次只问一个可合并的区间性质。
tree[i] 为什么不是 a[i]?lowbit 和各自负责区间。prefix_sum(13)。