开始前,会这些就够了
- 会读数组下标、整数加减和循环
- 先看 复杂度与操作次数 的前缀和
每天新增订单数分别记在数组里。如果只查“前几天一共多少单”,预先保存每日累计数很方便;但补记第3天的订单后,第3天及之后的所有累计数都要更新。查询快,修改却可能牵动整张表。
Fenwick 树换一种记账:有的位置只保存一天,有的保存连续两天、四天或八天。查询时拼几块不重叠的小计,修改时只找到真正包含那一天的块。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- fenwick.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B fenwick.py解压到独立练习文件夹,在含 fenwick.py 的目录打开终端运行。一基整数数组的单点增量和闭区间求和;主数组上限10000;负数允许但没有累计秩反查、并发和持久化保证。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
查前七项,为什么只读 7、6、4?
原始数组按第 1 项到第 8 项编号。下面给某一项增加一次数值,再比较更新前后的前缀和。tree[i] 保存一段区间的小计,不是原数组第 i 项。
当前原始数组:[3, 1, 4, 1, 5, 9, 2, 6]
调整八项原始值(整数 −20 到 20)
这里的更新是“增加 10”,不是“赋值为 10”。调整控件会从当前原始数组重新演示一次,不会累计执行。
查询路线:每次减去 lowbit
7 → 6 → 4 → 0(停止)
读取区间 [7, 7]、[5, 6]、[1, 4],两端都包含。
更新路线:每次加上 lowbit
3 → 4 → 8 → 16(超过 8,停止)
这些格子的覆盖区间都包含第 3 项,所以每格都增加 10。
| 格子 | lowbit | 覆盖原数组 | 更新前 → 后 | 查询顺序 | 更新顺序 |
|---|---|---|---|---|---|
| tree[1] | 1 | [1, 1] | 3 → 3 | 不读取 | 不经过 |
| tree[2] | 2 | [1, 2] | 4 → 4 | 不读取 | 不经过 |
| tree[3] | 1 | [3, 3] | 4 → 14 | 不读取 | 第 1 步 |
| tree[4] | 4 | [1, 4] | 9 → 19 | 第 3 步 | 第 2 步 |
| tree[5] | 1 | [5, 5] | 5 → 5 | 不读取 | 不经过 |
| tree[6] | 2 | [5, 6] | 14 → 14 | 第 2 步 | 不经过 |
| tree[7] | 1 | [7, 7] | 2 → 2 | 第 1 步 | 不经过 |
| tree[8] | 8 | [1, 8] | 31 → 41 | 不读取 | 第 3 步 |
增加后的原数组:[3, 1, 14, 1, 5, 9, 2, 6]
第 3 项:4 + (10) = 14。
前 7 项,更新前:(2) + (14) + (9) = 25;更新后:(2) + (14) + (19) = 35。
第 3 项位于查询范围内,前缀和改变 10。查询路线与更新路线按各自起点决定,不要求相同。
本例固定八项,支持负数和零。区间 [l, r] 两端都包含;lowbit(i) = i & −i,仅在正下标上推进。0 是查询停止标记,不是可更新的位置。这是求和演示,没有实现根据累计值反查排名。
原始数值与累计数,各有一端省事
直接保存每天数值,修改某天很方便,但查询前 r 天要相加 r 项。保存所有前缀累计数,查询可直接读取,但修改某天需要调整后面的累计数。若两种操作都频繁,仅选择这两种极端会让某一边反复扫描。
本例原始数组是 [3,1,4,1,5,9,2,6],按第1天到第8天编号。补记第3天10单是“增加10”,不是“把它改成10”;更新后第3天应为14。先说清增量和赋值,才能避免维护一张看似正常但总数错误的表。
每个格子负责多长,最低的那一位1说了算
令 lowbit(i)=i & -i,它取出正整数 i 的二进制最低位1所代表的数值。i=6 的二进制110,lowbit为2;i=4 的100则得到4。树数组 tree[i] 保存原数组第 i-lowbit(i)+1 到 i 项的总和,两个端点都包含。
因此 tree[7] 只管第7项,tree[6] 管第5至6项,tree[4] 管第1至4项。查询前7项时依次取7、6、4这三格,覆盖[7,7]、[5,6]、[1,4],不重不漏。索引每次减 lowbit,直到0;0只是停止标记,不存数据。
修改第3项,为什么访问的是3、4、8?
tree[3] 管第3项,tree[4] 管第1至4项,tree[8] 管第1至8项;它们都包含第3项,均需加10。tree[6] 只管第5至6项,就不该改。更新路线从3开始,每次加 lowbit,得到3→4→8→16,16已越过数组长度便停止。
原始前7项总和是25。更新时虽也改了 tree[8],查询前7项仍取 tree[7]+tree[6]+tree[4],其中只有 tree[4] 比原来多10,所以新总和35。更新与查询的路线不同,但依据的是同一份覆盖区间。
用最朴素的切片求和作独立账本
运行 python -B fenwick.py。prefix_7_before 输出25和访问位置[7,6,4];add_10_at_3_touched 输出[3,4,8];prefix_7_after 输出35以及同一查询路线。tree_after 不是每天原始订单数,而是这些区间小计。
程序枚举长度0至5、元素取-1/0/2的全部364个初始数组;对每个合法位置分别增加-3与4,逐一核对所有前缀和全部非空连续区间,再撤销增量。参考只扫描原始数组并调用 sum,不使用 lowbit 或树覆盖公式,能发现更新漏格和区间端点错误。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Point increments and prefix sums with visible Fenwick coverage."""
import itertools
import json
class Fenwick:
def __init__(self, values):
values = tuple(values)
if len(values) > 10000 or any(type(x) is not int for x in values):
raise ValueError("at most 10000 integer entries")
self.n = len(values)
self.tree = [0] * (self.n + 1)
for i, value in enumerate(values, 1):
self.add(i, value)
def add(self, index, delta):
if type(index) is not int or not 1 <= index <= self.n or type(delta) is not int:
raise ValueError("one-based index in range and integer delta required")
touched = []
while index <= self.n:
self.tree[index] += delta
touched.append(index)
index += index & -index
return touched
def prefix(self, count):
if type(count) is not int or not 0 <= count <= self.n:
raise ValueError("prefix count outside 0..n")
total, visited = 0, []
while count:
total += self.tree[count]
visited.append(count)
count -= count & -count
return total, visited
def range_sum(self, left, right):
if any(type(x) is not int for x in (left, right)) or not 1 <= left <= right <= self.n:
raise ValueError("inclusive range requires 1 <= left <= right <= n")
return self.prefix(right)[0] - self.prefix(left - 1)[0]
def main():
checks, cases = 0, 0
def check(ok):
nonlocal checks
if not ok:
raise AssertionError("Fenwick check failed")
checks += 1
for n in range(6):
for values in itertools.product((-1, 0, 2), repeat=n):
tree = Fenwick(values)
# Oracle scans raw values, never uses lowbit or tree coverage.
for r in range(n + 1):
check(tree.prefix(r)[0] == sum(values[:r]))
for i in range(1, n + 1):
for delta in (-3, 4):
changed = list(values)
changed[i - 1] += delta
touched = tree.add(i, delta)
for r in range(n + 1):
check(tree.prefix(r)[0] == sum(changed[:r]))
for left in range(1, n + 1):
for right in range(left, n + 1):
check(tree.range_sum(left, right) == sum(changed[left-1:right]))
check(len(touched) == len(set(touched)))
tree.add(i, -delta)
cases += 1
demo = Fenwick([3, 1, 4, 1, 5, 9, 2, 6])
before = demo.prefix(7)
touched = demo.add(3, 10)
after = demo.prefix(7)
check(before == (25, [7, 6, 4]))
check(after == (35, [7, 6, 4]))
check(touched == [3, 4, 8])
check(Fenwick(iter([1, 2])).prefix(2)[0] == 3)
invalid = [lambda: demo.add(0, 1), lambda: demo.add(True, 1),
lambda: demo.add(9, 1), lambda: demo.prefix(-1),
lambda: demo.range_sum(3, 2), lambda: Fenwick([False])]
for action in invalid:
try:
action()
except ValueError:
check(True)
else:
raise AssertionError("invalid operation accepted")
print(json.dumps({"checks_passed": checks, "exhaustive_initial_arrays": cases,
"prefix_7_before": before, "add_10_at_3_touched": touched,
"prefix_7_after": after, "tree_after": demo.tree,
"scope": "one-based point increments; inclusive range sums; integers; no concurrent updates"}, indent=2))
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B fenwick.py。2026-10-18 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。一基整数数组的单点增量和闭区间求和;主数组上限10000;负数允许但没有累计秩反查、并发和持久化保证。
运行后对照结果
{
"checks_passed": 67901,
"exhaustive_initial_arrays": 364,
"prefix_7_before": [
25,
[
7,
6,
4
]
],
"add_10_at_3_touched": [
3,
4,
8
],
"prefix_7_after": [
35,
[
7,
6,
4
]
],
"tree_after": [
0,
3,
4,
14,
19,
5,
14,
2,
41
],
"scope": "one-based point increments; inclusive range sums; integers; no concurrent updates"
}一段范围为何能用两个前缀相减?
想查第 l 到 r 项,先算第1到r项,再减去第1到l-1项,刚好留下目标段。因此 range_sum(3,6) 包含第3和第6项。prefix(0) 返回0,正好支持从第1项开始的范围。空数组只允许查询 prefix(0),没有可更新的位置。
本文允许负数增量与负的数组值,因为普通加法前缀和仍成立。但如果要根据累计值查“第几个订单落在哪一天”,通常需要非负计数保证累计数不下降;本例没有实现这种反查,不把求和能力扩大成任意查找。
为什么跳转次数按对数增长?
查询每一步删掉二进制中的一个1,最多处理下标的二进制位数。更新每一步让最低有效1向更高位推进,因此访问次数也在 O(log n) 量级。单点增加、前缀和、两前缀相减都按这个数量级访问树格,空间是 O(n)。
构造器为了易读,逐项调用 add,初始化花 O(n log n) 次量级的树格访问;并没有使用线性建树优化。这里按整数操作计数,巨大整数的位数仍影响每次运算;没有并发保证、数据库持久化或性能基准。程序拒绝0位置更新,防止 lowbit(0)=0 让循环停不下来。
记住这三点
- tree[i]存的是一段小计,不能当原数组值读取。
- 查询减lowbit,点增量加lowbit;一基下标的0只作停止标记。
- 求和允许负数,不代表累计值反查也能直接照搬。
别把例子用过头
- 把
add(i,delta)当成把第i项赋成delta。 - 混用0基与1基公式,或允许0进入更新循环。
- 把完整初始化也写成
O(n),忽略逐项add。
自己试一小步
只写访问路线,你能预测哪些小计需要修改吗?
- 自己选八个整数,记录tree每格覆盖的闭区间。
- 先预测第5项增加7时的更新路线,再运行核对。
- 手工拆出前6项查询块,并计算第2到6项总和。
做到这些,就算完成
- 交付原数组、覆盖区间、两条路线和求和过程。
- 不要把tree格子内容和原始值混为一谈。
- 把每一步访问下标与对应区间配对,并确认各查询块不重叠。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Fenwick 1994 原论文:出版方摘要 (新标签页)↗
支持按下标二进制分解累计频率的来源和用途。
- Algorithms:FenwickTree.java (新标签页)↗
支持一基下标的查询减 lowbit、更新加 lowbit。
- CP-Algorithms 作者:Fenwick Tree (新标签页)↗
支持一基数组覆盖区间、位运算跳转以及点更新和区间查询。