📌 写在前面 :本文面向算法竞赛初学者,从零开始,用最通俗的语言和图解,带你彻底搞懂树状数组。全文约 8000 字,建议收藏,边看边在纸上画图。
📑 目录
- 一、引言:我们遇到了什么问题?
- 二、树状数组是什么?
- [三、核心概念:lowbit 是个什么魔法?](#三、核心概念:lowbit 是个什么魔法?)
- [3.1 定义](#3.1 定义)
- [3.2 直观理解](#3.2 直观理解)
- [3.3 为什么叫 lowbit?](#3.3 为什么叫 lowbit?)
- 四、树状数组的结构长什么样?
- [第一张图:管辖范围俯视图(看清
tree和a的真实关系)](#第一张图:管辖范围俯视图(看清 tree 和 a 的真实关系)) - [算法跳跃动态图(看清
lowbit是怎么带路的)](#算法跳跃动态图(看清 lowbit 是怎么带路的))- [1. 更新操作
add(3)------ 向上合并](#1. 更新操作 add(3) —— 向上合并) - [2. 查询操作
sum(7)------ 向左拆分](#2. 查询操作 sum(7) —— 向左拆分)
- [1. 更新操作
- [第一张图:管辖范围俯视图(看清
- 五、两个核心操作
- [5.1 单点更新
add(pos, delta)](#5.1 单点更新 add(pos, delta)) - [5.2 前缀查询
sum(pos)](#5.2 前缀查询 sum(pos))
- [5.1 单点更新
- 六、完整代码膜拜
- 七、经典应用场景
- [7.1 场景一:单点更新,区间求和](#7.1 场景一:单点更新,区间求和)
- [7.2 场景二:区间更新,单点查询](#7.2 场景二:区间更新,单点查询)
- [7.3 场景三:区间更新,区间查询](#7.3 场景三:区间更新,区间查询)
- [7.4 场景四:求逆序对](#7.4 场景四:求逆序对)
- 八、典型例题精讲
- [例题一:LeetCode 315 变体 ------ 统计右侧大于当前元素的个数](#例题一:LeetCode 315 变体 —— 统计右侧大于当前元素的个数)
- [例题二:洛谷 P1908 ------ 逆序对](#例题二:洛谷 P1908 —— 逆序对)
- 例题三:动态维护前缀最大值(非求和)
- 九、复杂度与优缺点
- 十、练习题推荐
- 结语
一、引言:我们遇到了什么问题?
假设你有一个长度为 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 |
第一张图:管辖范围俯视图(看清 tree 和 a 的真实关系)
这张图横向展开,让你一眼看穿每个 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)=1→4(0100)4(0100) +lowbit(4)=4→8(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)=6→6(0110)6(0110) -lowbit(6)=4→4(0100)4(0011) -lowbit(4)=0→0(0000)0<1停止
五、两个核心操作
5.1 单点更新 add(pos, delta)
目的 :将原数组 a[pos] 增加 delta,并更新所有包含 pos 的 tree[] 节点。
过程:
- 从
i = pos开始。 - 将
tree[i] += delta。 i += lowbit(i)(跳到父节点)。- 重复 2-3,直到
i > n。
举例 :更新 pos = 3,n = 8,跳跃路径:3 → 4 → 8。
因为a3之和这三个tree上的node有关
只修改了 3 个节点,却保证所有包含 a3 的区间都被更新。
5.2 前缀查询 sum(pos)
目的 :求原数组 a[1] + a[2] + ... + a[pos] 的和。
过程:
- 从
i = pos开始。 - 累加
res += tree[i]。 i -= lowbit(i)(跳到前一个不相交的区间)。- 重复 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₂=6;lowbit(6)=2,减去得 100₂=4;lowbit(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 场景三:区间更新,区间查询
维护两个树状数组 bit1 和 bit2,通过差分推导出公式。
公式(略去推导):
- 区间
[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 < j 且 a[i] > a[j]。
思路:
- 离散化(压缩值域)。
- 从右往左遍历数组,每次查询当前已遍历元素中小于等于当前值的个数(即
sum(idx)),用已遍历总数减去它就是右侧小于当前值的个数,累加到答案。 - 将当前值加入树状数组。
代码核心:
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 中的 += 改为 max,sum 改为查询最大值,但注意此时只支持 单点更新(只能增大) 和 前缀最大值查询。
九、复杂度与优缺点
时间复杂度
- 单点更新 :
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 上的相关题目。
结语
树状数组虽然小巧,但背后蕴含的二进制思想十分深刻。掌握它,不仅能提升你的算法能力,更能让你在面对"数据动态维护"问题时多一把利器。
如果你觉得本文对你有帮助,请点赞、收藏,也欢迎在评论区留下你的问题或建议。下一篇我们将进军 线段树,敬请期待!
📌 本文代码均已测试通过,所有模板可直接复制使用。
✍️ 写作不易,转载注明出处。
(完)