qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

第3天补记10单,为什么累计表不必从第3天一路改到最后?

用三段小计回答前七天总和,再沿另一条路线更新受影响小计。把 Fenwick 树每个位置负责的区间写出来。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 原始数值与累计数,各有一端省事
  4. 每个格子负责多长,最低的那一位1说了算
  5. 修改第3项,为什么访问的是3、4、8?
  6. 用最朴素的切片求和作独立账本
  7. 一段范围为何能用两个前缀相减?
  8. 为什么跳转次数按对数增长?
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
lowbit
正整数二进制中最低的那一位1对应的数值。
前缀和
从第一项到指定位置的总和。
打开完整术语小抄 ↗

每天新增订单数分别记在数组里。如果只查“前几天一共多少单”,预先保存每日累计数很方便;但补记第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 或树覆盖公式,能发现更新漏格和区间端点错误。

展开收起代码 · 小实验python · 94 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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。

自己试一小步

只写访问路线,你能预测哪些小计需要修改吗?

  1. 自己选八个整数,记录tree每格覆盖的闭区间。
  2. 先预测第5项增加7时的更新路线,再运行核对。
  3. 手工拆出前6项查询块,并计算第2到6项总和。

做到这些,就算完成

  • 交付原数组、覆盖区间、两条路线和求和过程。
  • 不要把tree格子内容和原始值混为一谈。
  • 把每一步访问下标与对应区间配对,并确认各查询块不重叠。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Fenwick 1994 原论文:出版方摘要 (新标签页)↗Peter M. Fenwick / Wiley · 英文

    支持按下标二进制分解累计频率的来源和用途。

    2026-10-18 已实际打开核对:出版方摘要与书目信息;没有声称读完需另行获取的十页全文。

  • Algorithms:FenwickTree.java (新标签页)↗Ricardo Pacheco / Algorithms 项目 · 英文

    支持一基下标的查询减 lowbit、更新加 lowbit。

    2026-10-18 已实际打开核对:rsq 与 update 方法;注释中的区间文字有端点笔误,本篇按代码与明确数学区间重新核对。

  • CP-Algorithms 作者:Fenwick Tree (新标签页)↗CP-Algorithms 贡献者 · 英文

    支持一基数组覆盖区间、位运算跳转以及点更新和区间查询。

    2026-10-18 已实际打开核对:One-based indexing approach 与 Point Update and Range Query;本篇只实现点增量。