j•jueansuyA LITTLE SPACE TO BUILD 回到学习与实践
PRACTICE NOTE / 003

树状数组:
为什么 lowbit 既能向前求和,又能向后更新

这是一道 CSP 备考中的 LeetCode 动态区间和练习。单点修改与区间查询看似是一对矛盾:朴素数组求和是 O(n),前缀和数组修改是 O(n)。复盘这道题时,关键是用 Binary Indexed Tree(树状数组)在两端取得平衡,依靠 x & -x 快速跳跃。

01 / THE PROBLEM

求和与修改,两头都要快。

题目要求维护一个整数数组,支持两个操作:把某个位置的数值改成 val,以及查询闭区间 [left, right] 内所有数字之和。

如果用普通数组,单点修改是 O(1),但每次区间求和都要从头加到尾,是 O(n);如果用普通前缀和,区间查询是 O(1),但修改一个数就要把后面所有前缀和都重算一遍,变成 O(n)。当两种操作频繁交替时,任何一种极端都会超时。树状数组(Fenwick Tree)用巧夺天工的二进制分段,让修改和求和都能在 O(log n) 内完成。

02 / STEP THROUGH IT

向后累加更新,向前跳跃求和。

标准样例 nums = [1, 3, 5, 7, 9]。点击“下一步”,观察原数组如何构建树状数组,以及执行 update 与 sumRange 时各个节点的树形传递过程。

树状数组 · 交互步进 初始化
原数组 nums (0-based)
树状数组 tree (1-based,每个节点管辖 lowbit(x) 个元素)
  • 当前操作涉及的元素 / 树节点
  • 树节点管辖区间
当前步骤

1 / 1

初始状态:树状数组为空。点击“下一步”开始建树与操作演示。

修改时沿 x += lowbit(x) 向后传递差值;求前缀和时沿 x -= lowbit(x) 向前收集区间和。

03 / THREE DECISIONS

用 lowbit,分出不重不漏的段落。

  1. 01

    树下标从 1 开始(1-based)

    树状数组的下标必须从 1 开始,因为 lowbit(0) = 0 会陷入死循环。原数组的 index 进入树状数组必须映射为 index + 1。

  2. 02

    lowbit(x) = x & (-x)

    计算机采用补码表示负数,-x = ~x + 1。两者按位与,恰好只保留了二进制中最右侧的那个 1。这个值就是节点 tree[x] 管辖的连续元素个数。

  3. 03

    区间和差分:两端前缀和相减

    任意闭区间 [left, right] 的和,等于 prefix(right + 1) - prefix(left)。通过向前跳步求出两个前缀和相减,无需维护复杂区间合并逻辑。

为什么跳步次数不超过 log n?

任何正整数在二进制下最多只有 $\log_2 n$ 个 1。求前缀和时每次抹去最低位的 1(向前跳),修改时每次把最低位的 1 进位(向后跳),跳步次数被严格限制在对数级别。

04 / C IMPLEMENTATION

两组循环,搞定向前与向后。

这段代码来自仓库解法。结构体同时维护当前数组与树状数组,add 负责向后传播差值,prefix 负责向前收集前缀和。

C / 树状数组核心操作节选查看完整代码 ↗
int lowbit(int x) {
    return x & -x;
}

/* 向后更新:沿父节点链将 delta 累加至所有包含 index 的区间 */
void add(NumArray *obj, int index, int delta) {
    while (index <= obj->n) {
        obj->tree[index] += delta;
        index += lowbit(index);
    }
}

/* 向前求和:沿二进制不相交区间累加 [1, i] 的前缀和 */
int prefix(NumArray *obj, int i) {
    int sum = 0;
    while (i > 0) {
        sum += obj->tree[i];
        i -= lowbit(i);
    }
    return sum;
}

void numArrayUpdate(NumArray *obj, int index, int val) {
    int delta = val - obj->num[index];
    add(obj, index + 1, delta);
    obj->num[index] = val;
}

int numArraySumRange(NumArray *obj, int left, int right) {
    return prefix(obj, right + 1) - prefix(obj, left);
}

仓库中使用 malloc 分配 n + 1 大小的 tree 数组,并在使用完毕后显式通过 numArrayFree 释放内存。

05 / NOTES TO KEEP

原复盘里,留下的五个提醒。

这五条真实记录在当时的题目笔记中。树状数组的代码极度精悍,但下标偏差、差值更新和内存管理很容易埋下隐患。

01 结构体需要同时存原数组
更新时需要计算差值 delta = val - oldValue,所以必须保留一份当前真实值的 num 数组,不能以为有了 tree 就能丢掉原数组。
02 建树时不能只开空间不插入
分配完 tree 数组清零后,必须对每个元素调用 add(obj, i + 1, nums[i]) 把初始值插入树中,只复制原数组相当于建了一棵全零树。
03 修改只需定位受影响节点
update 绝不能重新遍历数组重建整棵树。先算差值 delta,更新原数组,再把 delta 单路传给 add,复杂度才是真正的 O(log n)。
04 树下标与前缀和偏移别搞错
index 进入树要加 1;区间 [left, right] 的和对应的是 prefix(right + 1) - prefix(left),左端点 left 不能再加 1,否则会少扣除第 left 个数。
05 销毁时先释放内层动态数组
在 C 语言中,释放 obj 前必须先释放 obj->num 和 obj->tree,最后再释放 obj 本身,防止内存泄漏。
06 / CHECK THE EDGES

用几个典型情形,检查边界。

返回 5

只有一个元素

nums = [5], sumRange(0, 0)

树中只有 tree[1] = 5,单次前缀和直接返回 5。

正确累加

多次修改同一位置

update(1, 4) -> update(1, 9)

每次根据新值与当前存的旧值计算差值 delta,不会产生增量堆积。

全段覆盖

查询整个数组

sumRange(0, n - 1)

计算 prefix(n) - prefix(0),因为 prefix(0) = 0,直接给出全数组之和。

依然正确

数组包含负数

nums = [-2, 0, 3, -5]

lowbit 依赖的是数组下标的二进制,与元素正负无关,差值加减完全适用。

07 / TAKEAWAY

在两个极端之间,找到巧妙的平衡点。

普通数组查询慢($O(n)$),前缀和数组修改慢($O(n)$)。树状数组的核心思想是通过二进制的权值拆分,让每个节点只对特定的一部分区间负责,从而让修改和查询均平摊到优雅的 $O(\log n)$。

单次修改时间O(log n)

沿 lowbit 向后最多跳 $\log_2 n$ 次。

单次查询时间O(log n)

沿 lowbit 向前最多跳 $\log_2 n$ 次。

建树总时间为 O(n log n),额外空间复杂度为 O(n)。演示模块额外保留历史快照供前后步进。

SOURCE NOTES

题目与原始记录

本篇根据公开刷题仓库里的实现、样例与错误记录整理,保留原有解法的关键细节。