树状数组
2026-05-24 01:06:10
发布于:广东
一、课程基本信息
课程主题: 树状数组(Binary Indexed Tree / Fenwick Tree)
适用对象: 已掌握数组、前缀和、差分、基础循环、函数封装,了解简单时间复杂度分析的学生。
课程定位: 树状数组是竞赛算法和工程数据处理中的基础数据结构,主要用于解决“动态前缀和”“动态区间和”“频次数组统计”“逆序对”“区间加与区间求和”等问题。
二、课程目标
1. 知识目标
学生应理解:
- 为什么普通前缀和不能高效支持修改。
- 树状数组解决的是哪一类问题。
lowbit(x)的含义与作用。- 树状数组中
tree[i]保存的区间含义。 - 单点修改、前缀查询、区间查询的基本操作。
- 树状数组和前缀和、差分、线段树之间的关系。
2. 能力目标
学生应能够:
- 独立写出树状数组的基础模板。
- 用树状数组完成“单点修改 + 区间求和”。
- 用树状数组完成“区间修改 + 单点查询”。
- 用两个树状数组完成“区间修改 + 区间查询”。
- 用树状数组统计逆序对。
- 结合离散化处理数值范围较大的问题。
- 判断一道题是否适合使用树状数组。
3. 思维目标
学生应形成以下意识:
- 树状数组本质上不是一棵真正显式建出来的树,而是用二进制规律组织数组。
- 树状数组适合解决“可拆成前缀信息”的动态问题。
- 树状数组的核心思想是:用若干个长度为 2 的幂次的区间,快速拼出前缀和。
- 不要只背模板,要能解释每一步为什么这样跳。
三、先修知识回顾
1. 普通数组求区间和
给定数组:
A = [a1, a2, a3, ..., an]
如果直接求区间 [l, r] 的和,需要从 l 加到 r,时间复杂度为:
O(r - l + 1)
最坏情况下是:
O(n)
如果查询很多次,就会很慢。
2. 前缀和
定义:
S[i] = a1 + a2 + ... + ai
那么区间和可以快速得到:
sum(l, r) = S[r] - S[l - 1]
查询复杂度:
O(1)
但是如果数组中某个值发生修改,例如:
a[pos] += delta
那么所有 S[pos]、S[pos + 1]、...、S[n] 都要变化,修改复杂度为:
O(n)
所以普通前缀和适合“静态数组”,不适合“频繁修改”。
3. 问题引入
我们希望支持两种操作:
1. 修改某个位置的值
2. 查询某个区间的和
如果用普通数组:
修改 O(1),查询 O(n)
如果用前缀和:
修改 O(n),查询 O(1)
有没有一种结构可以让修改和查询都比较快?
树状数组可以做到:
单点修改:O(log n)
前缀查询:O(log n)
区间查询:O(log n)
空间复杂度:O(n)
四、树状数组的核心思想
1. 树状数组解决什么问题
树状数组主要解决一类问题:
一个数组需要不断修改,同时又需要快速查询前缀和或区间和。
基础版本支持:
单点修改 + 前缀查询
单点修改 + 区间查询
通过差分和双树状数组,还可以支持:
区间修改 + 单点查询
区间修改 + 区间查询
2. 树状数组不是普通前缀和
普通前缀和 S[i] 保存的是:
[1, i] 的总和
树状数组 tree[i] 保存的是:
[i - lowbit(i) + 1, i] 这一段的和
也就是说,tree[i] 保存的是一个以 i 结尾、长度为 lowbit(i) 的小区间。
五、lowbit 详解
1. lowbit 是什么
lowbit(x) 表示整数 x 的二进制表示中,最低位的 1 所代表的数值。
公式:
lowbit(x) = x & -x
例如:
| x | 二进制 | lowbit(x) | 含义 |
|---|---|---|---|
| 1 | 0001 | 1 | 最低位的 1 是 1 |
| 2 | 0010 | 2 | 最低位的 1 是 2 |
| 3 | 0011 | 1 | 最低位的 1 是 1 |
| 4 | 0100 | 4 | 最低位的 1 是 4 |
| 5 | 0101 | 1 | 最低位的 1 是 1 |
| 6 | 0110 | 2 | 最低位的 1 是 2 |
| 7 | 0111 | 1 | 最低位的 1 是 1 |
| 8 | 1000 | 8 | 最低位的 1 是 8 |
2. 为什么 x & -x 可以得到 lowbit
在计算机中,负数通常用补码表示。
例如:
x = 6
二进制:0000 0110
-x 的补码可以理解为:按位取反再加 1
~x = 1111 1001
~x + 1 = 1111 1010
然后:
0000 0110
& 1111 1010
= 0000 0010
所以:
lowbit(6) = 2
直观理解:
x & -x会保留 x 二进制中最靠右的那个 1,并把其他位都变成 0。
3. lowbit 在树状数组中的作用
lowbit(i) 决定了 tree[i] 管辖的区间长度。
例如:
| i | lowbit(i) | tree[i] 保存的区间 |
|---|---|---|
| 1 | 1 | [1, 1] |
| 2 | 2 | [1, 2] |
| 3 | 1 | [3, 3] |
| 4 | 4 | [1, 4] |
| 5 | 1 | [5, 5] |
| 6 | 2 | [5, 6] |
| 7 | 1 | [7, 7] |
| 8 | 8 | [1, 8] |
| 9 | 1 | [9, 9] |
| 10 | 2 | [9, 10] |
| 12 | 4 | [9, 12] |
| 16 | 16 | [1, 16] |
规律:
tree[i] = a[i - lowbit(i) + 1] + ... + a[i]
六、树状数组的结构理解
假设原数组下标从 1 开始:
a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8]
树状数组各位置表示:
tree[1] = a[1]
tree[2] = a[1] + a[2]
tree[3] = a[3]
tree[4] = a[1] + a[2] + a[3] + a[4]
tree[5] = a[5]
tree[6] = a[5] + a[6]
tree[7] = a[7]
tree[8] = a[1] + a[2] + ... + a[8]
可以看出:
- 有些位置只管自己。
- 有些位置管两个数。
- 有些位置管四个数。
- 有些位置管八个数。
这些区间长度正好是 lowbit(i)。
七、前缀和查询操作
1. 目标
查询:
a[1] + a[2] + ... + a[x]
即:
prefixSum(x)
2. 查询思想
树状数组用若干个不重叠的小区间拼出 [1, x]。
例如查询 sum(7):
sum(7) = a[1] + a[2] + ... + a[7]
树状数组拆成:
tree[7] 管 [7, 7]
tree[6] 管 [5, 6]
tree[4] 管 [1, 4]
所以:
sum(7) = tree[7] + tree[6] + tree[4]
下标变化:
7 -> 6 -> 4 -> 0
每次执行:
x -= lowbit(x);
3. 查询代码
int sum(int x) {
int res = 0;
while (x > 0) {
res += tree[x];
x -= x & -x;
}
return res;
}
4. 查询过程示例
假设查询 sum(13)。
二进制:
13 = 1101
查询路径:
13 -> 12 -> 8 -> 0
对应区间:
tree[13] 管 [13, 13]
tree[12] 管 [9, 12]
tree[8] 管 [1, 8]
合并后正好是:
[1, 13]
所以:
sum(13) = tree[13] + tree[12] + tree[8]
八、单点修改操作
1. 目标
执行:
a[pos] += delta
也就是把位置 pos 上的值增加 delta。
由于 tree[i] 保存的是某个区间的和,所以凡是包含 pos 的 tree[i] 都需要更新。
2. 修改思想
例如修改 a[3] += delta。
哪些 tree[i] 包含 a[3]?
tree[3] 管 [3, 3]
tree[4] 管 [1, 4]
tree[8] 管 [1, 8]
所以需要更新:
tree[3] += delta
tree[4] += delta
tree[8] += delta
下标变化:
3 -> 4 -> 8 -> 16 -> ...
每次执行:
x += lowbit(x);
3. 修改代码
void add(int x, int delta) {
while (x <= n) {
tree[x] += delta;
x += x & -x;
}
}
4. 修改过程示例
假设 n = 16,执行:
add(6, delta)
修改路径:
6 -> 8 -> 16
说明:
tree[6] 包含 a[6]
tree[8] 包含 a[6]
tree[16] 包含 a[6]
因此都要加上 delta。
九、区间查询
1. 基本公式
查询区间 [l, r] 的和:
sum(l, r) = prefixSum(r) - prefixSum(l - 1)
树状数组中写成:
int rangeSum(int l, int r) {
return sum(r) - sum(l - 1);
}
2. 为什么可以这样做
prefixSum(r) 表示:
a[1] + a[2] + ... + a[r]
prefixSum(l - 1) 表示:
a[1] + a[2] + ... + a[l - 1]
两者相减,剩下:
a[l] + a[l + 1] + ... + a[r]
十、基础模板
#include <bits/stdc++.h>
using namespace std;
const int N = 100000 + 5;
int n, q;
long long tree[N];
int lowbit(int x) {
return x & -x;
}
void add(int x, long long delta) {
while (x <= n) {
tree[x] += delta;
x += lowbit(x);
}
}
long long sum(int x) {
long long res = 0;
while (x > 0) {
res += tree[x];
x -= lowbit(x);
}
return res;
}
long long rangeSum(int l, int r) {
return sum(r) - sum(l - 1);
}
int main() {
cin >> n >> q;
for (int i = 1; i <= n; i++) {
long long x;
cin >> x;
add(i, x);
}
while (q--) {
int op;
cin >> op;
if (op == 1) {
int pos;
long long delta;
cin >> pos >> delta;
add(pos, delta);
} else if (op == 2) {
int l, r;
cin >> l >> r;
cout << rangeSum(l, r) << '\n';
}
}
return 0;
}
十一、建树方法
方法一:逐个 add 建树
for (int i = 1; i <= n; i++) {
cin >> a[i];
add(i, a[i]);
}
复杂度:
O(n log n)
优点:
简单,最常用,不容易错。
方法二:线性建树
因为:
tree[i] 保存一段区间和
可以先:
tree[i] += a[i]
再把当前节点的信息传给父节点:
int j = i + lowbit(i);
if (j <= n) tree[j] += tree[i];
完整代码:
for (int i = 1; i <= n; i++) {
tree[i] += a[i];
int j = i + lowbit(i);
if (j <= n) {
tree[j] += tree[i];
}
}
复杂度:
O(n)
适合:
n 很大,并且需要更快初始化的情况。
课堂建议:
初学者先掌握 add 建树,线性建树作为提高内容。
十二、树状数组的典型用途一:单点修改 + 区间查询
1. 问题描述
有一个数组,支持两种操作:
1 pos delta:将 a[pos] 增加 delta
2 l r:查询 a[l] + ... + a[r]
这是树状数组最基础的用途。
2. 解题思路
- 修改一个点:使用
add(pos, delta)。 - 查询区间:使用
sum(r) - sum(l - 1)。
复杂度:
每次修改 O(log n)
每次查询 O(log n)
3. 适用场景
例如:
- 动态维护商品库存总量。
- 动态维护游戏中若干玩家积分区间和。
- 在线统计某一段时间内的访问量。
- 动态维护一段序列的权值和。
- 竞赛题中的区间求和模板题。
十三、树状数组的典型用途二:频次数组统计
1. 问题背景
有时我们不直接维护原数组,而是维护一个“频次数组”。
例如,数字出现次数:
cnt[x] = 数字 x 出现了多少次
树状数组可以维护 cnt,快速回答:
小于等于 x 的数字有多少个?
即:
cnt[1] + cnt[2] + ... + cnt[x]
2. 插入一个数字
如果插入数字 x:
add(x, 1);
表示:
cnt[x] += 1
3. 查询小于等于 x 的个数
sum(x)
表示:
cnt[1] + cnt[2] + ... + cnt[x]
4. 查询小于 x 的个数
sum(x - 1)
5. 查询大于 x 的个数
如果当前已经插入 total 个数,那么:
total - sum(x)
6. 适用场景
- 统计逆序对。
- 动态统计排名。
- 动态维护每个分数段的人数。
- 查询当前比某个数小或大的数有多少个。
- 解决“左边有多少个数小于当前值”“右边有多少个数大于当前值”等问题。
十四、树状数组的典型用途三:统计逆序对
1. 逆序对定义
给定数组 a,如果满足:
i < j 且 a[i] > a[j]
那么 (i, j) 是一个逆序对。
例如:
[3, 1, 2]
逆序对有:
(3, 1), (3, 2)
所以逆序对数量为 2。
2. 朴素做法
双重循环:
for i in 1..n:
for j in i+1..n:
if a[i] > a[j]: ans++
复杂度:
O(n^2)
当 n 很大时会超时。
3. 树状数组思路
从左到右扫描数组。
当处理 a[i] 时,前面已经出现过 i - 1 个数。
我们想知道:
前面已经出现的数中,有多少个 > a[i]
如果树状数组维护的是出现次数,则:
前面出现过的总数 = i - 1
前面 <= a[i] 的个数 = sum(a[i])
前面 > a[i] 的个数 = (i - 1) - sum(a[i])
然后把当前数加入:
add(a[i], 1)
4. 为什么要离散化
如果 a[i] 很大,例如:
10^9
不能直接开一个大小为 10^9 的树状数组。
这时需要离散化。
离散化的作用:
保留大小关系,把原来的大数映射成较小的排名。
例如:
原数组:[100, 10, 50, 1000]
排序后:[10, 50, 100, 1000]
映射:10 -> 1, 50 -> 2, 100 -> 3, 1000 -> 4
离散化后:[3, 1, 2, 4]
大小关系没有变,但值域变小了。
5. C++ 代码:逆序对
#include <bits/stdc++.h>
using namespace std;
const int N = 200000 + 5;
int n;
vector<long long> a, b;
long long tree[N];
int lowbit(int x) {
return x & -x;
}
void add(int x, int delta) {
while (x <= n) {
tree[x] += delta;
x += lowbit(x);
}
}
long long sum(int x) {
long long res = 0;
while (x > 0) {
res += tree[x];
x -= lowbit(x);
}
return res;
}
int main() {
cin >> n;
a.resize(n + 1);
b.resize(n);
for (int i = 1; i <= n; i++) {
cin >> a[i];
b[i - 1] = a[i];
}
sort(b.begin(), b.end());
b.erase(unique(b.begin(), b.end()), b.end());
long long ans = 0;
for (int i = 1; i <= n; i++) {
int rank = lower_bound(b.begin(), b.end(), a[i]) - b.begin() + 1;
ans += (i - 1) - sum(rank);
add(rank, 1);
}
cout << ans << '\n';
return 0;
}
6. 逆序对用途
- 衡量一个序列的混乱程度。
- 判断排序需要交换的次数。
- 数据排序稳定性分析。
- 竞赛中常用于排列、排名、交换次数等问题。
- 某些推荐系统、排名比较中可以用来衡量两个排序的差异。
十五、树状数组的典型用途四:区间修改 + 单点查询
1. 问题描述
支持两种操作:
1 l r delta:将区间 [l, r] 每个数都加 delta
2 pos:查询 a[pos]
如果直接对 [l, r] 每个位置修改,复杂度是 O(n)。
树状数组可以借助差分做到:
区间修改 O(log n)
单点查询 O(log n)
2. 差分数组回顾
定义差分数组:
d[i] = a[i] - a[i - 1]
则:
a[i] = d[1] + d[2] + ... + d[i]
也就是说:
a[i] 是差分数组 d 的前缀和
3. 区间加如何转化为差分修改
如果要给 [l, r] 整体加 delta:
a[l] += delta
a[l + 1] += delta
...
a[r] += delta
在差分数组中只需要:
d[l] += delta
d[r + 1] -= delta
原因:
- 从
l开始,前缀和多了delta。 - 从
r + 1开始,把这个影响抵消掉。
4. 树状数组维护差分
如果树状数组维护的是 d,那么:
查询 a[pos] = d[1] + d[2] + ... + d[pos]
也就是:
sum(pos)
区间修改:
add(l, delta);
add(r + 1, -delta);
单点查询:
sum(pos);
5. C++ 模板
void rangeAdd(int l, int r, long long delta) {
add(l, delta);
add(r + 1, -delta);
}
long long pointQuery(int pos) {
return sum(pos);
}
6. 适用场景
- 一段时间内给所有用户增加积分,然后查询某个用户积分。
- 区间批量调整库存,查询某个商品当前库存。
- 游戏中给某一段编号的角色统一加属性,查询单个角色属性。
- 维护区间标记、区间增量、某点状态。
十六、树状数组的典型用途五:区间修改 + 区间查询
1. 问题描述
支持两种操作:
1 l r delta:将区间 [l, r] 每个数都加 delta
2 l r:查询区间 [l, r] 的和
这比“区间修改 + 单点查询”更难。
需要使用两个树状数组。
2. 推导思路
仍然使用差分数组 d:
a[i] = d[1] + d[2] + ... + d[i]
我们要求前缀和:
S[x] = a[1] + a[2] + ... + a[x]
展开:
S[x] = (d[1])
+ (d[1] + d[2])
+ (d[1] + d[2] + d[3])
+ ...
+ (d[1] + d[2] + ... + d[x])
观察每个 d[i] 出现了多少次:
d[1] 出现 x 次
d[2] 出现 x - 1 次
d[3] 出现 x - 2 次
...
d[i] 出现 x - i + 1 次
所以:
S[x] = Σ d[i] * (x - i + 1)
= Σ d[i] * (x + 1 - i)
= (x + 1) * Σd[i] - Σ(d[i] * i)
因此,只要维护两个东西:
B1 维护 d[i]
B2 维护 d[i] * i
前缀和为:
prefixSum(x) = (x + 1) * query(B1, x) - query(B2, x)
区间和:
rangeSum(l, r) = prefixSum(r) - prefixSum(l - 1)
3. 区间加的更新方式
区间 [l, r] 加 delta,差分变化为:
d[l] += delta
d[r + 1] -= delta
因此:
B1 在 l 加 delta
B1 在 r + 1 加 -delta
B2 在 l 加 delta * l
B2 在 r + 1 加 -delta * (r + 1)
4. C++ 模板
const int N = 100000 + 5;
int n;
long long B1[N], B2[N];
int lowbit(int x) {
return x & -x;
}
void add(long long tree[], int x, long long delta) {
while (x <= n) {
tree[x] += delta;
x += lowbit(x);
}
}
long long query(long long tree[], int x) {
long long res = 0;
while (x > 0) {
res += tree[x];
x -= lowbit(x);
}
return res;
}
void rangeAdd(int l, int r, long long delta) {
add(B1, l, delta);
add(B1, r + 1, -delta);
add(B2, l, delta * l);
add(B2, r + 1, -delta * (r + 1));
}
long long prefixSum(int x) {
return (x + 1) * query(B1, x) - query(B2, x);
}
long long rangeSum(int l, int r) {
return prefixSum(r) - prefixSum(l - 1);
}
5. 初始化原数组
如果原数组一开始不是全 0,可以对每个位置执行:
rangeAdd(i, i, a[i]);
或者先构造差分数组再加入。
初学阶段建议使用:
for (int i = 1; i <= n; i++) {
cin >> a[i];
rangeAdd(i, i, a[i]);
}
虽然是 O(n log n),但清晰可靠。
6. 适用场景
- 多次区间加工资,查询某段员工工资总和。
- 多次区间增加访问量,查询某个时间段总访问量。
- 批量调整成绩或权值,查询区间总分。
- 区间累计贡献问题。
- 线段树懒标记能做的部分区间加、区间和问题,树状数组也能做。
十七、树状数组的典型用途六:查询第 k 小 / 第 k 个位置
1. 问题描述
假设树状数组维护的是频次数组 cnt。
现在要查询:
当前所有数中,第 k 小的数是多少?
或者:
找到最小的位置 pos,使得 sum(pos) >= k
2. 朴素做法
可以二分答案:
二分 pos,判断 sum(pos) >= k
每次 sum(pos) 是 O(log n),二分也是 O(log n),所以总复杂度:
O(log^2 n)
3. 树状数组倍增查找
可以利用树状数组结构,用类似二进制跳跃的方法做到:
O(log n)
核心思想:
从高位到低位尝试往右跳,如果跳过去之后前缀和仍然小于 k,说明答案还在更右边。
4. C++ 代码
int kth(long long k) {
int pos = 0;
long long cur = 0;
// bit 取不超过 n 的最大 2 的幂
for (int bit = 1 << 20; bit > 0; bit >>= 1) {
int next = pos + bit;
if (next <= n && cur + tree[next] < k) {
pos = next;
cur += tree[next];
}
}
return pos + 1;
}
如果 n <= 100000,1 << 20 足够。
更稳妥的写法:
int kth(long long k) {
int pos = 0;
long long cur = 0;
int bit = 1;
while ((bit << 1) <= n) bit <<= 1;
for (; bit > 0; bit >>= 1) {
int next = pos + bit;
if (next <= n && cur + tree[next] < k) {
pos = next;
cur += tree[next];
}
}
return pos + 1;
}
5. 适用场景
- 动态排名系统。
- 动态中位数。
- 多重集合中查询第 k 小。
- 根据频率找第 k 个元素。
- 抽奖、权重选择、订单排位等问题。
十八、树状数组的典型用途七:二维树状数组
1. 问题描述
一维树状数组维护的是数组。
二维树状数组维护的是矩阵。
支持:
单点修改
查询左上角到某点的矩形和
查询任意子矩形和
2. 二维前缀和回顾
矩形 [x1, y1] 到 [x2, y2] 的和:
S[x2][y2]
- S[x1 - 1][y2]
- S[x2][y1 - 1]
+ S[x1 - 1][y1 - 1]
二维树状数组也遵循这个容斥公式。
3. 单点修改
void add(int x, int y, long long delta) {
for (int i = x; i <= n; i += lowbit(i)) {
for (int j = y; j <= m; j += lowbit(j)) {
tree[i][j] += delta;
}
}
}
4. 查询左上角矩形和
long long sum(int x, int y) {
long long res = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
for (int j = y; j > 0; j -= lowbit(j)) {
res += tree[i][j];
}
}
return res;
}
5. 查询任意矩形和
long long rectSum(int x1, int y1, int x2, int y2) {
return sum(x2, y2)
- sum(x1 - 1, y2)
- sum(x2, y1 - 1)
+ sum(x1 - 1, y1 - 1);
}
6. 复杂度
单点修改:O(log n * log m)
矩形查询:O(log n * log m)
空间复杂度:O(nm)
7. 适用场景
- 动态矩阵求和。
- 棋盘区域统计。
- 图像像素区域统计。
- 地图网格热度统计。
- 二维坐标点权值维护。
十九、树状数组的典型用途八:离线查询
1. 什么是离线查询
在线查询:
每个询问必须按照输入顺序立刻回答。
离线查询:
可以先把所有询问读进来,重新排序或预处理后再回答。
树状数组常和离线排序结合。
2. 常见问题类型
例如:
给定若干点,每次询问矩形区域中有多少点。
可以将询问按横坐标排序,将点逐渐加入树状数组,用树状数组维护纵坐标出现次数。
3. 适用场景
- 区间内大于某值的数有多少个。
- 二维偏序问题。
- 静态数组多次条件查询。
- 扫描线问题。
- 按时间、坐标、权值排序后逐步加入数据。
二十、树状数组和线段树的比较
| 对比项 | 树状数组 | 线段树 |
|---|---|---|
| 代码长度 | 短 | 较长 |
| 理解难度 | 中等 | 较高 |
| 常数 | 小 | 较大 |
| 空间 | O(n) | 通常 O(4n) |
| 单点修改 + 区间和 | 支持 | 支持 |
| 区间加 + 区间和 | 双树状数组支持 | 懒标记支持 |
| 区间最值 | 不适合普通动态最值 | 适合 |
| 复杂区间操作 | 不适合 | 更适合 |
| 第 k 小 | 可支持频次数组 | 也可支持 |
| 二维扩展 | 可支持但空间可能大 | 可支持但复杂 |
结论
树状数组适合:
问题可以拆成前缀和,并且操作比较简单。
线段树适合:
操作更复杂,例如区间最大值、区间最小值、区间赋值、区间乘法、多种懒标记等。
二十一、树状数组能做和不能做的事情
1. 非常适合做的事情
- 单点加,区间求和。
- 区间加,单点查询。
- 区间加,区间求和。
- 频次数组维护。
- 逆序对统计。
- 动态排名。
- 查找第 k 小。
- 离散化后处理大值域数据。
- 二维矩阵单点修改和矩形查询。
- 一些离线扫描线问题。
2. 不太适合做的事情
- 区间最大值和最小值的复杂动态维护。
- 区间赋值后再求和。
- 同时有区间乘法、区间加法、区间求和的复杂问题。
- 需要维护复杂结构信息的问题。
- 无法转化为前缀信息的问题。
这里空空如也




















有帮助,赞一个