文章

算法刷题系列(四十四):树状数组——维护前缀聚合与单点更新

算法刷题系列(四十四):树状数组——维护前缀聚合与单点更新

算法刷题系列(四十四):树状数组——维护前缀聚合与单点更新

如果数组会不断更新单个位置,同时需要频繁查询前缀和或区间和,直接累加前缀要 O(n),而树状数组(Fenwick Tree,也称 Binary Indexed Tree)能将更新与查询都降到 O(log n)。

树状数组的关键不是建立一棵显式树,而是利用二进制最低位 lowbit(x) = x & -x,让数组中每个节点负责一段长度为 lowbit(x) 的区间。

从前缀和拆成若干块

内部下标使用 1-based。tree[i] 保存原数组下标区间 [i - lowbit(i) + 1, i] 的和。查询前缀 sum(1..i) 后,令 i -= lowbit(i),逐块向左跳;更新下标 i 时,令 i += lowbit(i),访问所有覆盖该位置的节点。

本文对外提供 0-based 索引:

  • add(index, delta):给一个位置加上变化量;
  • prefix_sum(end):查询半开前缀 [0, end);
  • range_sum(left, right):查询半开区间 [left, right)。

不变量:每个树节点始终等于它负责区间内元素之和。更新沿覆盖路径修改这些块;前缀查询把目标前缀拆成互不重叠的负责区间。

Java 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Main {
    static class FenwickTree {
        private final long[] tree;
        private final int n;

        FenwickTree(int n) {
            if (n < 0) throw new IllegalArgumentException("negative size");
            this.n = n;
            this.tree = new long[n + 1];
        }
        void add(int index, long delta) {
            if (index < 0 || index >= n) throw new IndexOutOfBoundsException();
            for (int i = index + 1; i <= n; i += i & -i) tree[i] += delta;
        }
        long prefixSum(int end) {
            if (end < 0 || end > n) throw new IndexOutOfBoundsException();
            long sum = 0;
            for (int i = end; i > 0; i -= i & -i) sum += tree[i];
            return sum;
        }
        long rangeSum(int left, int right) {
            if (left < 0 || left > right || right > n) throw new IndexOutOfBoundsException();
            return prefixSum(right) - prefixSum(left);
        }
    }

    public static void main(String[] args) {
        FenwickTree bit = new FenwickTree(5);
        long[] values = {3, 1, 4, 1, 5};
        for (int i = 0; i < values.length; i++) bit.add(i, values[i]);
        System.out.println(bit.rangeSum(1, 4)); // 6
        bit.add(2, 2);                         // values[2] 从 4 变为 6
        System.out.println(bit.prefixSum(3));  // 10
    }
}

Python 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class FenwickTree:
    def __init__(self, n: int):
        if n < 0:
            raise ValueError("negative size")
        self.n = n
        self.tree = [0] * (n + 1)

    def add(self, index: int, delta: int) -> None:
        if not 0 <= index < self.n:
            raise IndexError("index out of range")
        i = index + 1
        while i <= self.n:
            self.tree[i] += delta
            i += i & -i

    def prefix_sum(self, end: int) -> int:
        if not 0 <= end <= self.n:
            raise IndexError("end out of range")
        total = 0
        i = end
        while i:
            total += self.tree[i]
            i -= i & -i
        return total

    def range_sum(self, left: int, right: int) -> int:
        if not 0 <= left <= right <= self.n:
            raise IndexError("invalid range")
        return self.prefix_sum(right) - self.prefix_sum(left)

bit = FenwickTree(5)
for i, value in enumerate([3, 1, 4, 1, 5]):
    bit.add(i, value)
assert bit.range_sum(1, 4) == 6
bit.add(2, 2)
assert bit.prefix_sum(3) == 10
assert bit.range_sum(5, 5) == 0

复杂度与适用边界

单点更新和前缀查询均为 O(log n),空间 O(n)。半开区间 [left,right) 用两个前缀相减,因此也为 O(log n)。空数组可以构造,但任何单点更新都会越界,空前缀查询返回 0。

Fenwick Tree 特别适合可逆的前缀聚合,如整数和、异或。最小值等不易通过两个前缀“相减”恢复区间结果的运算,不能直接使用相同接口。区间加、区间和可以用两棵树扩展;若需要复杂的区间赋值或多种聚合,线段树通常更灵活。

常见错误有混淆 0-based 与 1-based、把 prefix_sum(end) 误当成包含 end、更新时忘记加一,以及用“新值”直接更新却未转成与旧值的差。本文的 add 明确接收增量,赋值前应计算 delta = newValue - oldValue。

小结

树状数组把前缀分解成若干由二进制最低位确定的区间块。统一采用半开区间、明确更新传增量,可以降低边界错误,并让单点更新与前缀聚合都保持对数复杂度。

本文由作者按照 CC BY 4.0 进行授权