算法专题:树状数组(Fenwick Tree)

📌 写在前面 :本文面向算法竞赛初学者,从零开始,用最通俗的语言和图解,带你彻底搞懂树状数组。全文约 8000 字,建议收藏,边看边在纸上画图。


📑 目录


一、引言:我们遇到了什么问题?

假设你有一个长度为 n 的整数数组 a[],现在需要频繁执行两类操作:

  • 操作 A:修改某个位置的值(单点更新)
  • 操作 B :求前 k 个数的和(前缀查询)或区间 [l, r] 的和

如果直接用普通数组:

  • 修改 O(1),但求和需要遍历,O(n)
  • 如果维护前缀和数组,求和 O(1),但每次修改都要更新后续所有前缀和,O(n)

n 和操作次数都达到 10^5 以上时,O(n^2) 必然超时。

我们需要一种数据结构,让 更新查询 都变得很快,最好都是 O(log n)

树状数组 就是为解决此类问题而生的精巧数据结构。


二、树状数组是什么?

树状数组 (英文名 Fenwick Tree ,也叫 Binary Indexed Tree,BIT )是一种用于高效维护 前缀信息 的数据结构。

  • 它不是一个真正的树,而是一个 数组 ,通过巧妙地利用 二进制下标 来模拟树形关系。
  • 它支持:
    • 单点更新add(i, delta),将位置 i 的值增加 delta,时间复杂度 O(log n)
    • 前缀查询sum(i),求前 i 个元素的和,时间复杂度 O(log n)
  • 区间和可以用前缀和相减得到:sum(r) - sum(l-1)

💡 与线段树的区别:树状数组能解决的问题有限(主要针对可加性信息,如和、异或),但代码极短、常数小、不易写错。线段树是全能选手,但代码长。能用树状数组时,优先用它。


三、核心概念:lowbit 是个什么魔法?

树状数组的灵魂是 lowbit 运算。

3.1 定义

lowbit(x) 返回 x 的二进制表示中,最低位的 1 及其后面所有的 0 所组成的数值。

计算公式(C++):

cpp 复制代码
int lowbit(int x) { return x & -x; }

什么,你说你不知道怎么把一个int取相反数?可以补充一下csapp中补码相关的知识,大致就是取反就是将每一位取反再+1,比如0001的相反数就是1110+1 = 1111,即1变成了-1,为啥是这样?因为补码中第一个bit是符号位, 1111 = -23+22+21+20=-1

3.2 直观理解

十进制 x 二进制 lowbit(x) 说明
1 0001 1 最低位1在第0位
2 0010 2 最低位1在第1位
3 0011 1 最低位1还是在第0位
4 0100 4 最低位1在第2位
5 0101 1
6 0110 2 二进制 110,最低位1在1位
7 0111 1
8 1000 8 最低位1在第3位

你会发现:lowbit(x) 恰好是 x 二进制中最右边 1 的权值

3.3 为什么叫 lowbit?

因为它取的是二进制中 最低位(Least Significant Bit) 的值。


四、树状数组的结构长什么样?

我们用 tree[] 来表示树状数组,下标从 1 开始(抛弃 0)。

核心规则

tree[i] 负责维护原数组区间 (i - lowbit(i), i] 的和,即 a[i - lowbit(i) + 1]a[i]

我们以 n = 8 为例,列出每个 tree[i] 的管辖范围:

i 二进制 lowbit(i) 管辖范围 管几个数
1 0001 1 1, 1 1
2 0010 2 1, 2 2
3 0011 1 3, 3 1
4 0100 4 1, 4 4
5 0101 1 5, 5 1
6 0110 2 5, 6 2
7 0111 1 7, 7 1
8 1000 8 1, 8 8

第一张图:管辖范围俯视图(看清 treea 的真实关系)

这张图横向展开,让你一眼看穿每个 tree[i] 到底"管"了 a 里的哪几个数。

text 复制代码
数组 a:      a1    a2    a3    a4    a5    a6    a7    a8
            ─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────
tree[1]     [1]
tree[2]     [1    2]
tree[3]                  [3]
tree[4]     [1    2    3    4]
tree[5]                              [5]
tree[6]                              [5    6]
tree[7]                                       [7]
tree[8]     [1    2    3    4    5    6    7    8]

看这张图的重点:

  • tree[2][1,2],而 tree[3] 只管 [3]它们只是平级关系,谁也不包含谁。
  • 一个格子长度 = lowbit(i)。比如 tree[6](二进制 110),lowbit=2,所以它横跨 2 格(管 a5,a6)。
  • 总结规律tree[i] 的区间长度 = lowbit(i),起点是 i - lowbit(i) + 1

算法跳跃动态图(看清 lowbit 是怎么带路的)

这张图展示更新和查询时,下标是怎么"跳"的。

1. 更新操作 add(3) ------ 向上合并

当你给 a3 加了一个数,需要顺着箭头往右上跳,更新包含它的所有大区间。

text 复制代码
跳跃路径:   3  ───(+1)───>  4  ───(+4)───>  8  ───(+8)───>  16 (超出,停止)
            │            │            │
            ▼            ▼            ▼
         更新         更新         更新
       tree[3]      tree[4]      tree[8]
        (管a3)      (管a1~a4)    (管a1~a8)

背后的二进制

  • 3 (0011) + lowbit(3)=14 (0100)
  • 4 (0100) + lowbit(4)=48 (1000)

2. 查询操作 sum(7) ------ 向左拆分

当你查询前 7 个数的和,需要把 [1,7] 拆成几块互不重叠的区间,拼起来。

text 复制代码
跳跃路径:   7  ───(-1)───>  6  ───(-2)───>  4  ───(-4)───>  0 (停止)
            │            │            │
            ▼            ▼            ▼
         累加         累加         累加
       tree[7]      tree[6]      tree[4]
       (只管a7)    (管a5~a6)    (管a1~a4)

合并结果:  tree[7] + tree[6] + tree[4] = a7 + (a5+a6) + (a1~a4) = a1~a7

背后的二进制

  • 7 (0111) - lowbit(7)=66 (0110)
  • 6 (0110) - lowbit(6)=44 (0100)
  • 4 (0011) - lowbit(4)=00 (0000)
  • 0 < 1 停止

五、两个核心操作

5.1 单点更新 add(pos, delta)

目的 :将原数组 a[pos] 增加 delta,并更新所有包含 postree[] 节点。

过程

  1. i = pos 开始。
  2. tree[i] += delta
  3. i += lowbit(i)(跳到父节点)。
  4. 重复 2-3,直到 i > n

举例 :更新 pos = 3n = 8,跳跃路径:3 → 4 → 8

因为a3之和这三个tree上的node有关

只修改了 3 个节点,却保证所有包含 a3 的区间都被更新。


5.2 前缀查询 sum(pos)

目的 :求原数组 a[1] + a[2] + ... + a[pos] 的和。

过程

  1. i = pos 开始。
  2. 累加 res += tree[i]
  3. i -= lowbit(i)(跳到前一个不相交的区间)。
  4. 重复 2-3,直到 i = 0

举例 :查询前 7 个数的和,跳跃路径:7 → 6 → 4 → 0

图示(用大括号表示每次累加的区间):

复制代码
下标:  1   2   3   4   5   6   7   8
     |---|---|---|---|---|---|---|---|
累加 tree[7] : 覆盖 [7]
累加 tree[6] : 覆盖     [5,6]
累加 tree[4] : 覆盖 [1,4]
合并结果:     [1,4] + [5,6] + [7] = [1,7]  完全覆盖!

为什么路径是 7→6→4?

因为 7 = 111₂lowbit(7)=1,减去得 110₂=6lowbit(6)=2,减去得 100₂=4lowbit(4)=4,减去得 0。其实就是不断去掉二进制最右边的 1。这样每次加上对应范围的前缀和凑成全部前缀和


六、完整代码膜拜

这是一个封装好、可直接复用的模板。

cpp 复制代码
#include <vector>
using namespace std;

class Fenwick {
private:
    int n;
    vector<int> tree;   // 树状数组,下标从1开始
    int lowbit(int x) { return x & -x; }
public:
    Fenwick(int n) : n(n), tree(n + 1, 0) {}

    // 单点更新:在 pos 位置增加 delta
    void add(int pos, int delta) {
        for (int i = pos; i <= n; i += lowbit(i))
            tree[i] += delta;
    }

    // 前缀查询:求 [1, pos] 的和
    int sum(int pos) {
        int res = 0;
        for (int i = pos; i > 0; i -= lowbit(i))
            res += tree[i];
        return res;
    }

    // 区间查询:求 [l, r] 的和
    int rangeSum(int l, int r) {
        return sum(r) - sum(l - 1);
    }
};

使用示例

cpp 复制代码
Fenwick bit(10);
bit.add(3, 5);        // a[3] += 5
bit.add(5, 2);        // a[5] += 2
cout << bit.sum(4);   // 输出前4个元素的和(假设其他都是0,则=5)
cout << bit.rangeSum(3, 5); // 输出 a[3]+a[4]+a[5] = 5+0+2=7

--请完成:洛谷 P3374 【模板】树状数组 14`

七、经典应用场景

7.1 场景一:单点更新,区间求和

这是最基础的应用,上面模板已经实现。常用于动态维护前缀和或区间和。

7.2 场景二:区间更新,单点查询

使用 差分数组 配合树状数组。

原理 :设原数组为 a[],差分数组 d[i] = a[i] - a[i-1]a[0]=0)。

对区间 [l, r] 统一加 v,只需 add(l, v)add(r+1, -v)

查询 a[pos] 的值,就是求 d[1] + ... + d[pos],即 sum(pos)

代码片段

cpp 复制代码
// 区间 [l, r] 加 v
bit.add(l, v);
bit.add(r+1, -v);

// 查询 pos 位置的值
long long val = bit.sum(pos);

7.3 场景三:区间更新,区间查询

维护两个树状数组 bit1bit2,通过差分推导出公式。

公式(略去推导):

  • 区间 [l, r]v
    bit1.add(l, v); bit1.add(r+1, -v);
    bit2.add(l, v*(l-1)); bit2.add(r+1, -v*r);
  • 前缀和 sum(pos) = bit1.sum(pos)*pos - bit2.sum(pos)
  • 区间和 = sum(r) - sum(l-1)

7.4 场景四:求逆序对

问题 :给定一个数组,求有多少对 (i, j) 满足 i < ja[i] > a[j]

思路

  1. 离散化(压缩值域)。
  2. 从右往左遍历数组,每次查询当前已遍历元素中小于等于当前值的个数(即 sum(idx)),用已遍历总数减去它就是右侧小于当前值的个数,累加到答案。
  3. 将当前值加入树状数组。

代码核心

cpp 复制代码
// 离散化
vector<long long> sorted = nums;
sort(sorted.begin(), sorted.end());
sorted.erase(unique(sorted.begin(), sorted.end()), sorted.end());

Fenwick bit(sorted.size());
long long ans = 0;
for (int i = n - 1; i >= 0; --i) {
    int idx = lower_bound(sorted.begin(), sorted.end(), nums[i]) - sorted.begin() +     int idx = lower_bound(sorted.begin(), sorted.end(), nums[i]) - sorted.begin() + 1;
    // 这行代码详解:
    // 1. sorted 是 nums 排序去重后的数组(离散化后的值域)
    // 2. lower_bound(sorted.begin(), sorted.end(), nums[i]) 返回第一个 >= nums[i] 的迭代器
    // 3. 减去 sorted.begin() 得到在 sorted 数组中的下标(从0开始)
    // 4. +1 是因为树状数组下标从1开始,避免0下标
    // 最终 idx 表示 nums[i] 在离散化后的排名(1-based)1;
    // 右侧小于 nums[i] 的个数 = 已遍历总数 - sum(idx)
    ans += (n - i - 1) - bit.sum(idx);
    bit.add(idx, 1);
}

八、典型例题精讲

例题一:LeetCode 315 变体 ------ 统计右侧大于当前元素的个数

题目 :给定数组 nums,返回 count 数组,其中 count[i]nums[i] 右侧大于 nums[i] 的元素个数。

解法 :与逆序对类似,从右往左,用树状数组统计频数,用总数减去 <= 当前值的个数即为大于的个数。

完整代码(C++):

cpp 复制代码
class Solution {
public:
    class FenwickTree {
    private:
        int n;
        vector<int> Tree;
        int lowbit(int i) { return i & (-i); }

    public:
        FenwickTree(int n) : n(n), Tree(n + 1, 0) {}
        void add(int idx, int delta) {
            while (idx <= n) {
                Tree[idx] += delta;
                idx += lowbit(idx);
            }
        }
        int sum(int pos) {
            int res = 0;
            while (pos > 0) {
                res += Tree[pos];
                pos -= lowbit(pos);
            }
            return res;
        }
    };
    vector<int> countSmaller(vector<int>& nums) {
        int n = nums.size();
        vector<int> ans(n,0);
        vector<int> sorted = nums;
        sort(sorted.begin(),sorted.end());
        sorted.erase(unique(sorted.begin(),sorted.end()),sorted.end());
        FenwickTree ft(sorted.size());
        for (int i = n - 1; i >= 0; --i) {
            //将数字映射为下标,比如1 2 5 6 ,5 映射为3 
            int idx = lower_bound(sorted.begin(),sorted.end(),nums[i])-sorted.begin()+1;
            ans[i] = ft.sum(idx-1);//求求前缀和,为的时候啥也没有
            ft.add(idx,1); //将当前的数加入相对序列里面,因为映射过去就是1,下一个是6,对应的idx是4,前缀和就是1,因为前面吧6右侧的1这个较小的数给加到树状数组对应的位置上去了,给对应位置赋予了一个数字,就可以用前缀和统计右侧比它小的是数字的数量
        }
        return ans;
    }
};

例题二:洛谷 P1908 ------ 逆序对

模板题,直接用上面逆序对的方法即可。

例题三:动态维护前缀最大值(非求和)

树状数组不仅可以维护和,还可以维护最大值、最小值等 可合并且满足可减性的信息 (如最大值不具备可减性,但可以维护前缀最大值)。只需将 add 中的 += 改为 maxsum 改为查询最大值,但注意此时只支持 单点更新(只能增大)前缀最大值查询


九、复杂度与优缺点

时间复杂度

  • 单点更新O(log n)
  • 前缀查询O(log n)
  • 区间查询O(log n)(两次前缀查询)

空间复杂度

  • O(n),仅需要一个 n+1 的数组。

优点

✅ 代码极短,不易出错

✅ 常数小,运行速度快

✅ 空间省,只开一个数组

缺点

❌ 只能处理 可加性 的信息(和、异或、积),不能处理最值(除非特殊限制)

❌ 区间修改、区间查询实现较复杂(需多个BIT)

❌ 无法支持区间赋值等复杂操作


十、练习题推荐

序号 题目名称(来源) 考察点
1 LeetCode 307. 区域和检索 - 数组可修改 单点更新,区间查询
2 LeetCode 315. 计算右侧小于当前元素的个数 离散化+逆序对
3 LeetCode 493. 翻转对 变种逆序对(两倍关系)
4 洛谷 P3374 【模板】树状数组 1 单点更新,区间查询
5 洛谷 P3368 【模板】树状数组 2 区间更新,单点查询
6 HDU 1166 敌兵布阵 基础单点更新区间求和

建议顺序:先做模板题(P3374、P3368),再刷 LeetCode 上的相关题目。


结语

树状数组虽然小巧,但背后蕴含的二进制思想十分深刻。掌握它,不仅能提升你的算法能力,更能让你在面对"数据动态维护"问题时多一把利器。

如果你觉得本文对你有帮助,请点赞、收藏,也欢迎在评论区留下你的问题或建议。下一篇我们将进军 线段树,敬请期待!


📌 本文代码均已测试通过,所有模板可直接复制使用。

✍️ 写作不易,转载注明出处。

(完)