学习笔记:树状数组
树状数组
引入
树状数组是一种支持 单点修改 和 区间查询 的,代码量小的数据结构。树状数组和线段树具有相似的功能,但他俩毕竟还有一些区别:树状数组能有的操作,线段树一定有;线段树有的操作,树状数组不一定有。但是树状数组的代码要比线段树短,思维更清晰,速度也更快,在解决一些单点修改的问题时,树状数组是不二之选。那么,什么是单点修改和区间查询呢?
已知一个数列 ,你需要进行下面两种操作:
- 给定 ,将 自增 。
- 给定 ,求解 的和。
其中第一种操作就是「单点修改」,第二种操作就是「区间查询」。
类似地,还有:「区间修改」、「单点查询」。它们分别的一个例子如下:
- 区间修改:给定 ,将 中的每个数都分别自增 ;
- 单点查询:给定 ,求解 的值。
注意到,区间问题一般严格强于单点问题,因为对单点的操作相当于对一个长度为 的区间操作。
普通树状数组维护的信息及运算要满足 结合律 且 可差分,如加法(和)、乘法(积)、异或等。
- 结合律:,其中 是一个二元运算符。
- 可差分:具有逆运算的运算,即已知 和 可以求出 。
原理
初步感受
先来举个例子:我们想知道 的前缀和,怎么做?
一种做法是:,需要求 个数的和。
那如果我告诉你三个数 ,,, 的和, 的总和, 的总和(其实就是 自己)。你会怎么算?你一定会回答:,只需要求 个数的和。
这就是树状数组能快速求解信息的原因:我们总能将一段前缀 拆成 不多于 段区间,使得这 段区间的信息是 已知的。
于是,我们只需合并这 段区间的信息,就可以得到答案。相比于原来直接合并 个信息,效率有了很大的提高。
不难发现信息必须满足结合律,否则就不能像上面这样合并了。
下面这张图展示了树状数组的工作原理:
最下面的八个方块代表原始数据数组 。上面参差不齐的方块(与最上面的八个方块是同一个数组)代表数组 的上级—— 数组。
数组就是用来储存原始数组 某段区间的和的,也就是说,这些区间的信息是已知的,我们的目标就是把查询前缀拆成这些小区间。
例如,从图中可以看出:
- 管辖的是 ;
- 管辖的是 ;
- 管辖的是 ;
- 管辖的是 ;
- 剩下的 管辖的都是 自己(可以看做 的长度为 的小区间)。
不难发现, 管辖的一定是一段右边界是 的区间总信息。我们先不关心左边界,先来感受一下树状数组是如何查询的。
举例:计算 的和。
过程:从 开始往前跳,发现 只管辖 这个元素;然后找 ,发现 管辖的是 ,然后跳到 ,发现 管辖的是 这些元素,然后再试图跳到 ,但事实上 不存在,不跳了。
我们刚刚找到的 是 ,事实上这就是 拆分出的三个小区间,合并得到答案是 。
举例:计算 的和。
我们还是从 开始跳,跳到 再跳到 。此时我们发现它管理了 的和,但是我们不想要 这一部分,怎么办呢?很简单,减去 的和就行了。
那不妨考虑最开始,就将查询 的和转化为查询 的和,以及查询 的和,最终将两个结果作差。
管辖区间
那么问题来了, 管辖的区间到底往左延伸多少?也就是说,区间长度是多少?
树状数组中,规定 管辖的区间长度为 ,其中:
- 设二进制最低位为第 位,则 恰好为 二进制表示中,最低位的
1
所在的二进制位数; - ( 的管辖区间长度)恰好为 二进制表示中,最低位的
1
以及后面所有0
组成的数。
举个例子, 管辖的是哪个区间?
因为 ,其二进制最低位的 1
以及后面的 0
组成的二进制是 1000
,即 ,所以 管辖 个 数组中的元素。
因此, 代表 的区间信息。
我们记 二进制最低位 1
以及后面的 0
组成的数为 ,那么 管辖的区间就是 。
这里注意: 指的不是最低位 1
所在的位数 ,而是这个 1
和后面所有 0
组成的 。
怎么计算 lowbit
?根据位运算知识,可以得到 lowbit(x) = x & -x
。
int lowbit(int x){return x & -x;}
区间查询
接下来我们来看树状数组具体的操作实现,先来看区间查询。
回顾查询 的过程,我们是将它转化为两个子过程:查询 和查询 的和,最终作差。
其实任何一个区间查询都可以这么做:查询 的和,就是 的和减去 的和,从而把区间问题转化为前缀问题,更方便处理。
事实上,将有关 的区间询问转化为 和 的前缀询问再差分,在竞赛中是一个非常常用的技巧。
那前缀查询怎么做呢?回顾下查询 的过程:
从 往前跳,发现 只管辖 这个元素;然后找 ,发现 管辖的是 ,然后跳到 ,发现 管辖的是 这些元素,然后再试图跳到 ,但事实上 不存在,不跳了。
我们刚刚找到的 是 ,事实上这就是 拆分出的三个小区间,合并一下,答案是 。
观察上面的过程,每次往前跳,一定是跳到现区间的左端点的左一位,作为新区间的右端点,这样才能将前缀不重不漏地拆分。比如现在 管的是 ,下一次就跳到 ,即访问 。
我们可以写出查询 的过程:
- 从 开始往前跳,有 管辖 ;
- 令 ,如果 说明已经跳到尽头了,终止循环;否则回到第一步。
- 将跳到的 合并。
实现时,我们不一定要先把 都跳出来然后一起合并,可以边跳边合并。
比如我们要维护的信息是和,直接令初始 ,然后每跳到一个 就 ,最终 就是所有合并的结果。
int query(int x){
int res = 0;
for(int i = x ; i >= 1 ; i -= lowbit(i))
res += c[i];
return res;
}
单点修改
现在来考虑如何单点修改 。
我们的目标是快速正确地维护 数组。为保证效率,我们只需遍历并修改管辖了 的所有 ,因为其他的 显然没有发生变化。
管辖 的 一定包含 (根据性质 ),所以 在树状数组树形态上是 的祖先。因此我们从 开始不断跳父亲,直到跳得超过了原数组长度为止。
设 表示 的大小,不难写出单点修改 的过程:
- 初始令 。
- 修改 。
- 令 ,如果 说明已经跳到尽头了,终止循环;否则回到第二步。
区间信息和单点修改的种类,共同决定 的修改方式。下面给几个例子:
- 若 维护区间和,修改种类是将 加上 ,则修改方式则是将所有 也加上 。
- 若 维护区间积,修改种类是将 乘上 ,则修改方式则是将所有 也乘上 。
然而,单点修改的自由性使得修改的种类和维护的信息不一定是同种运算,比如,若 维护区间和,修改种类是将 赋值为 ,可以考虑转化为将 加上 。如果是将 乘上 ,就考虑转化为 加上 。
下面以维护区间和,单点加为例给出实现。
void update(int x, int k){
for(int i = x ; i <= n ; i += lowbit(i))
c[i] += k;
}
建树
也就是根据最开始给出的序列,将树状数组建出来( 全部预处理好)。
一般可以直接转化为 次单点修改,时间复杂度 。
比如给定序列 要求建树,直接看作对 单点加 ,对 单点加 ,对 单点加 即可。
代码
这里给出板子题的代码:
#include <iostream>
#define int long long
#define MAXN 500005
using namespace std;
int n, m, op, x, y, t;
int c[MAXN];
int read(){
int t = 1, x = 0;char ch = getchar();
while(!isdigit(ch)){if(ch == '-')t = -1;ch = getchar();}
while(isdigit(ch)){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
return x * t;
}
void write(int x){
if(x < 0){putchar('-');x = -x;}
if(x >= 10)write(x / 10);
putchar(x % 10 + '0');
}
int lowbit(int x){return x & -x;}
void update(int x, int k){
for(int i = x ; i <= n ; i += lowbit(i))c[i] += k;
}
int query(int x){
int ret = 0;
for(int i = x ; i >= 1 ; i -= lowbit(i))ret += c[i];
return ret;
}
signed main(){
n = read();m = read();
for(int i = 1 ; i <= n ; i ++)
t = read(),update(i, t);
for(int i = 1 ; i <= m ; i ++){
op = read();x = read();y = read();
if(op == 1)update(x, y);
else write(query(y) - query(x - 1)),putchar('\n');
}
return 0;
}
复杂度分析
空间复杂度显然 。
时间复杂度:
- 对于区间查询操作:整个 的迭代过程,可看做将 二进制中的所有 ,从低位到高位逐渐改成 的过程,拆分出的区间数等于 二进制中 的数量(即 )。因此,单次查询时间复杂度是 ;
- 对于单点修改操作:跳父亲时,访问到的高度一直严格增加,且始终有 。由于点 的高度是 ,所以跳到的高度不会超过 ,所以访问到的 的数量是 级别。因此,单次单点修改复杂度是 。
区间加区间和
该问题可以使用两个树状数组维护差分数组解决。
考虑序列 的差分数组 ,其中 。由于差分数组的前缀和就是原数组,所以 。
一样地,我们考虑将查询区间和通过差分转化为查询前缀和。那么考虑查询 的和,即 ,进行推导:
观察这个式子,不难发现每个 总共被加了 次。接着推导:
并不能推出 的值,所以要用两个树状数组分别维护 和 的和信息。
那么怎么做区间加呢?考虑给原数组 区间加 给 带来的影响。
因为差分是 ,
- 多了 而 不变,所以 的值多了 。
- 不变而 多了 ,所以 的值少了 。
- 对于不等于 且不等于 的任意 , 和 要么都没发生变化,要么都加了 , 还是 ,所以其它的 均不变。
那就不难想到维护方式了:对于维护 的树状数组,对 单点加 , 单点加 ;对于维护 的树状数组,对 单点加 , 单点加 。
而更弱的问题,「区间加求单点值」,只需用树状数组维护一个差分数组 。询问 的单点值,直接求 的和即可。
这里直接给出「区间加区间和」的代码:
#include <iostream>
#define int long long
#define MAXN 500005
using namespace std;
int n, m, op, x, y, k;
int a[MAXN], c[MAXN];
int read(){
int t = 1, x = 0;char ch = getchar();
while(!isdigit(ch)){if(ch == '-')t = -1;ch = getchar();}
while(isdigit(ch)){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
return x * t;
}
void write(int x){
if(x < 0){putchar('-');x = -x;}
if(x >= 10)write(x / 10);
putchar(x % 10 ^ 48);
}
int lowbit(int x){return x & -x;}
void update(int x, int k){
for(int i = x ; i <= n ; i += lowbit(i))c[i] += k;
}
int query(int x){
int ret = 0;
for(int i = x ; i >= 1 ; i -= lowbit(i))ret += c[i];
return ret;
}
signed main(){
n = read();m = read();
for(int i = 1 ; i <= n ; i ++)a[i] = read();
for(int i = 1 ; i <= m ; i ++){
op = read();
if(op == 1){
x = read();y = read();k = read();
update(x, k);update(y + 1, -k);
}else{
x = read();write(a[x] + query(x));putchar('\n');
}
}
return 0;
}
根据这个原理,应该可以实现「区间乘区间积」,「区间异或一个数,求区间异或值」等,只要满足维护的信息和区间操作是同种运算即可。
逆序对
这个笔者之前写过,这里给出代码,有需要的可以戳这里 link。
#include <iostream>
#include <algorithm>
#define int long long
#define MAXN 500005
using namespace std;
int n, ans;
struct node{
int a, b;
bool friend operator<(node a, node b){
if(a.a == b.a)return a.b > b.b;
else return a.a > b.a;
}
}a[MAXN];
int c[MAXN];
int read(){
int t = 1, x = 0;char ch = getchar();
while(!isdigit(ch)){if(ch == '-')t = -1;ch = getchar();}
while(isdigit(ch)){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
return x * t;
}
int lowbit(int x){return x & -x;}
void update(int x, int k){
for(int i = x ; i <= n ; i += lowbit(i))
c[i] += k;
}
int query(int x){
int res = 0;
for(int i = x ; i >= 1 ; i -= lowbit(i))
res += c[i];
return res;
}
signed main(){
n = read();
for(int i = 1 ; i <= n ; i ++)a[i].a = read();
for(int i = 1 ; i <= n ; i ++)a[i].b = i;
sort(a + 1, a + n + 1);
for(int i = 1 ; i <= n ; i ++)
update(a[i].b, 1),ans += query(a[i].b - 1);
cout << ans << endl;return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
· AI 智能体引爆开源社区「GitHub 热点速览」