树状数组:从零开始理解这个“小而美”的数据结构

说实话,我第一次接触树状数组的时候,心里想的是:这玩意儿到底有什么用?

那时候我在做一个在线评测系统的排名模块,需要频繁更新选手分数,还要快速查询某个区间的总分。用普通数组吧,更新快但查询慢;用前缀和数组吧,查询快但更新慢。两头都不讨好。

后来一位老同事跟我说:“你试试树状数组。” 嗯,一试就回不去了。

树状数组是什么?

树状数组,英文叫 Fenwick Tree,也叫 Binary Indexed Tree(BIT)。它本质上是一个数组,但通过巧妙的索引设计,能同时支持快速的单点更新和区间查询。

说白了,它就是用一种“二进制思维”来管理数据。你想想看,计算机最擅长的就是二进制运算,树状数组正好利用了这一点。

核心思想:每个位置 i 存储的是原数组中某一段连续区间的和。这段区间的长度由 i 的二进制最低位 1 决定。

比如,位置 6(二进制 110),最低位 1 对应的是 2,所以它存储的是原数组第 5 到第 6 个元素的和。位置 8(二进制 1000),最低位 1 对应的是 8,所以它存储的是原数组第 1 到第 8 个元素的和。

这个“最低位 1”对应的值,有个专门的函数叫 lowbit:

int lowbit(int x) {
    return x & (-x);
}

我个人习惯把这个函数记成“取最后一个 1 的位置”。你试试看,6 & (-6) 等于 2,8 & (-8) 等于 8,没错吧?

树状数组的结构图

下面这张图展示了树状数组的存储结构。每个节点代表树状数组中的一个位置,它覆盖了原数组中的一段连续区间。

树状数组结构示意图 原数组 a[1..8]: a[1] a[2] a[3] a[4] a[5] a[6] a[7] a[8] 树状数组 c[1..8]: c[1]=a[1] c[2]=a[1..2] c[3]=a[3] c[4]=a[1..4] c[5]=a[5] c[6]=a[5..6] c[7]=a[7] c[8]=a[1..8] 覆盖关系(箭头指向被覆盖的元素): c[i] 覆盖的区间长度 = lowbit(i) 例如:c[6] 覆盖 a[5..6],长度为 2(因为 lowbit(6)=2) lowbit 计算示例 lowbit(6) = 6 & (-6) = 2,lowbit(8) = 8 & (-8) = 8

单点更新与区间查询

这是树状数组最经典的应用场景。我当年做排名系统用的就是这一套。

单点更新

假设我们要把 a[i] 加上一个值 val。普通数组直接改就行,但树状数组需要更新所有“覆盖了 i 这个位置”的 c[j]。

怎么找到这些 c[j]?很简单:从 i 开始,每次加上 lowbit(i),直到超过 n。

void add(int i, int val) {
    while (i <= n) {
        c[i] += val;
        i += lowbit(i);
    }
}

举个例子:更新 a[3],需要更新 c[3]、c[4]、c[8]。因为 lowbit(3)=1,3+1=4;lowbit(4)=4,4+4=8;8 超过 n 就停。

小技巧:我刚开始学的时候,总记不住是加 lowbit 还是减 lowbit。后来我总结了一个口诀:更新往上走,查询往左走。更新时 i += lowbit(i),查询时 i -= lowbit(i)。

区间查询

查询前缀和 [1..i] 也很直接:从 i 开始,每次减去 lowbit(i),累加 c[i]。

int sum(int i) {
    int res = 0;
    while (i > 0) {
        res += c[i];
        i -= lowbit(i);
    }
    return res;
}

要查询 [l..r] 的区间和,就用 sum(r) - sum(l-1)。

时间复杂度:单点更新 O(log n),区间查询 O(log n)。

区间更新与单点查询

这个场景稍微绕一点,但也很实用。比如你要给数组的某个区间 [l..r] 统一加上一个值,然后查询某个位置的值。

怎么做?用差分思想。

我们维护一个差分数组 d,其中 d[i] = a[i] - a[i-1]。那么给 [l..r] 加上 val,等价于 d[l] += val,d[r+1] -= val。查询 a[i] 就是求 d[1..i] 的前缀和。

你看,这不就变成单点更新 + 区间查询了吗?只不过这次树状数组维护的是差分数组。

// 区间更新 [l..r] 加上 val
void range_add(int l, int r, int val) {
    add(l, val);
    add(r + 1, -val);
}

// 单点查询 a[i]
int point_query(int i) {
    return sum(i);
}

注意:这里 add 和 sum 函数跟前面一模一样,只是维护的对象变成了差分数组。我曾经在项目里搞混过,把差分数组当成原数组来更新,结果查出来的数据全乱了。嗯,调试了整整一个下午才找到问题。

树状数组与线段树的比较

很多初学者会问:有了线段树,为什么还要学树状数组?

我的回答是:杀鸡不用牛刀

来看个对比表格:

特性 树状数组 线段树
代码量 极短(核心代码不到10行) 较长(建树、更新、查询各需递归)
时间复杂度 O(log n) O(log n)
空间复杂度 O(n) O(4n)
支持的操作 前缀和、单点更新、区间更新(需差分) 区间和、区间最值、区间更新(懒标记)
适用场景 求和类问题,操作简单 复杂区间操作,需要懒标记
常数因子 小(位运算,无递归) 大(递归调用,函数开销)

我个人习惯是:能用树状数组解决的问题,绝不用线段树。为什么?因为树状数组的代码太清爽了,调试起来也快。线段树写起来动辄几十行,一个递归边界写错就够你喝一壶的。

但话说回来,如果问题涉及区间最值、区间覆盖、或者需要同时维护多种信息,那还是老老实实用线段树吧。树状数组毕竟只擅长求和。

我的建议:面试或竞赛中,先判断问题是不是求和类。如果是,优先考虑树状数组。不是?再上线段树。这个顺序能帮你省下不少时间。

避坑指南

最后分享几个我踩过的坑:

  • 数组下标从 1 开始:树状数组的下标必须从 1 开始,否则 lowbit(0)=0 会死循环。我刚开始写的时候习惯从 0 开始,结果程序直接卡死。
  • 注意数据范围:树状数组的求和结果可能很大,记得用 long long。我曾经在排名系统里用 int 存总分,结果溢出变成负数,排名直接乱套。
  • 初始化别偷懒:有些人喜欢用 memset 清零,但树状数组的初始化最好用循环逐个 add。因为 memset 是按字节赋值的,对 int 数组没问题,但对 long long 数组要小心。
  • 区间更新别搞反:差分数组的更新是 d[l] += val, d[r+1] -= val,顺序别写反了。我犯过这个错,查了半天才发现是符号搞反了。

好了,树状数组的基本概念和操作就讲到这里。这东西看着简单,但用好了真的能解决很多实际问题。下次遇到需要频繁更新和查询的场景,不妨试试它。


公众号:蓝海资料掘金营,微信 deep3321