树状数组详解
防止日后忘记。
引入
例题 \(1\):
已知一个数列,你需要进行下面两种操作:
- 将某一个数加上 \(x\)。
- 求出某区间每一个数的和。
对于操作 \(1\),我们可以直接通过普通数组 \(\mathcal O(1)\) 实现修改。
对于操作 \(2\),我们可以通过前缀和 \(\mathcal O(1)\) 实现查询。
然而,若使用普通数组,查询复杂度为 \(\mathcal O(n)\);若使用前缀和数组,修改复杂度为 \(\mathcal O(n)\)。
而树状数组(Binary Indexed Tree,Fenwick 树)就是一种兼具修改、查询的高效数据结构,修改、查询复杂度均为 \(\mathcal O(\log n)\)。
事实上,树状数组能解决的问题是线段树能解决的问题的子集:树状数组能做的,线段树一定能做;线段树能做的,树状数组不一定可以。然而,树状数组的代码要远比线段树短,时间效率常数也更小,因此仍有学习价值。
原理
维护可差分信息
普通树状数组维护的信息及运算要满足 结合律 且 可差分,如加法(和)、乘法(积)、异或等。
- 结合律:\((x\circ y)\circ z=x\circ (y\circ z)\),其中 \(\circ\) 是一个二元运算符。
- 可差分:具有逆运算的运算,即已知 \(x\circ y\) 和 \(x\) 能求出 \(y\)。
区间划分
将一段长度为 \(n\) 的前缀 \([1,n]\) 划分为不超过 \(\log n\) 段区间,那么我们仅仅需要合并这 \(\log n\) 段区间的信息即可直到原来需要合并 \(n\) 个元素才能知道的信息(比如前缀和),而在修改时也仅仅需要至多修改 \(\log n\) 段区间,从而使得修改、查询复杂度均为 \(\mathcal O(\log n)\)。
如图:
如何划分
令树状数组所使用数组为 \(t\)。
那么 \(t[x]\) 表示的就是 \(\large \sum\limits_{i=x-\operatorname{lowbit}(x)+1}^{x}a[i]\)。
\(\operatorname{lowbit(x)}\)
在树状数组中,\(\operatorname{lowbit}(x)\) 为 \(x\) 的二进制最低位的 \(1\) 的位权,数值上为 x&-x。
比如说,\(x=5\),\(x\) 的二进制为 $$100_{(2)}$$,则 \(\operatorname{lowbit}(x)=2^2=4\)。
原理
很简单。首先你要知道在计算机中,数字以补码的形式存储。
(此处的二进制数有效位数只有 $3$ 位,最高位为符号位)
那么对于正数(树状数组中 $\operatorname{lowbit}(x)$ 只会出现正数,见下文),其补码就是符号位 $0$ 接上其二进制;例如 $5$,就是 $0100_{(2)}$。
而负数呢?
先是一个符号位 $1$,随后是二进制逐位取反,最后在加 $1$。那么 $-5$ 的补码便是 $1011_{(2)}+1=1100_{(2)}$。
可以发现,此时 x&-x 的确是 $100_{(2)}=2^2=4$。
因为在 $\operatorname{lowbit}(x)$ 的后面全是 $0$,取反得到的全是 $1$,然后再加 $1$,因此 $\operatorname{lowbit}(x)$ 所在位仍然是 $1$,而前面按位取反,全部不一样。
故 $\operatorname{lowbit}(x)$ 为 x&-x。
参考代码
int lowbit(int x){
return x&-x;
}
区间长度
每一个 \(t[x]\) 所表示的区间长度均为 \(2^k\),\(k\) 为自然数。
那么这个 \(2^k\) 也即 \(\operatorname{lowbit}(x)\)。
区间查询与单点修改(树状数组 1)
区间查询
普通树状数组维护数组,每次查询 \(query(x)\) 的值都是区间 \([1,x]\) 的值,想要查询 \([l,r]\),\(query(r)-query(l)\) 即可(此处维护的是前缀和,其余同理)。
那么 \(query(x)\) 是如何工作的呢?
上文中提到,每一个 \(t[x]\) 维护的区间长度均为 \(\operatorname{lowbit}(x)\),也即 \([x-\operatorname{lowbit}(x)+1,x]\)。
那么我们令 \(x\leftarrow x-\operatorname{lowbit}(x)\) 直到 \(x=0\) 即可。
这样,我们就能够在 \(\mathcal O(\log n)\) 的时间内查询。
参考代码
int query(int x){
int sum=0;
while(x){
sum+=t[x];
x-=lowbit(x);
}return sum;
}
单点修改
若是一个值 \(a[x]\) 修改了,那么包含其值的 \(t[y]\) 必然也需要修改。
而 \(t[x]\) 被 \(t[x+\operatorname{lowbit}(x)]\) 包含。
证明
令 $y=x+\operatorname{lowbit}(x),x=p\times 2^{k+1}+2^k$,其中 $k$ 为自然数,$p$ 为实数(也就是说,可以是小数)。
则 $y=(p+1)\times 2^{k+1},y-\operatorname{lowbit}(y)+1=p\times 2^{k+1}+1$。
考虑到 $x-\operatorname{lowbit}(x)+1=p\times 2^k+1$,因此 $y-\operatorname{lowbit}(y)+1$。
又考虑到 $y>x$,故 $t[x]$ 被 $t[x+\operatorname{lowbit}(x)]$ 包含。因此,我们仅仅需要令 \(x\leftarrow x+\operatorname{lowbit}(x)\) 直到 \(x>n\) 即可。
参考代码
void add(int x,int k){
while(x<=n){
t[x]+=k;
x+=lowbit(x);
}
}
例题 AC 代码
//#include<bits/stdc++.h>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<iomanip>
#include<cstdio>
#include<string>
#include<vector>
#include<cmath>
#include<ctime>
#include<deque>
#include<queue>
#include<stack>
#include<list>
using namespace std;
const int N=5e5;
int n,m,op,x,k,t[N+1];
int lowbit(int x){
return x&-x;
}
void build(){
scanf("%d %d",&n,&m);
for(int i=1;i<=n;i++){
scanf("%d",&x);
t[i]+=x;
int j=i+lowbit(i);
if(j<=n)t[j]+=t[i];
}
}
void add(int x,int k){
while(x<=n){
t[x]+=k;
x+=lowbit(x);
}
}
int query(int x){
int sum=0;
while(x){
sum+=t[x];
x-=lowbit(x);
}return sum;
}
int main(){
/*freopen("test.in","r",stdin);
freopen("test.out","w",stdout);*/
build();
while(m--){
scanf("%d %d %d",&op,&x,&k);
switch(op){
case 1:
add(x,k);
break;
case 2:
printf("%d\n",query(k)-query(x-1));
break;
}
}
/*fclose(stdin);
fclose(stdout);*/
return 0;
}
单点查询与区间修改(树状数组 2)
这很简单,同样以前缀和为例。
你只需要维护一个一阶差分数组即可。
比如你要给区间 \(a[l,r]\) 加上 \(5\),那么在差分数组 \(b\) 上你只需要:
这样就维护了区间修改。
对于单点查询,\(a[x]\) 的值显然就是 \(\sum\limits_{i=1}^x b[x]\),树状数组维护 \(b\) 的前缀和即可。
例题 AC 代码
//#include<bits/stdc++.h>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<iomanip>
#include<cstdio>
#include<string>
#include<vector>
#include<cmath>
#include<ctime>
#include<deque>
#include<queue>
#include<stack>
#include<list>
using namespace std;
const int N=5e5;
int n,m,op,x,y,k,b[N+1];
int lowbit(int x){
return x&-x;
}
void build(){
int x0=0;
scanf("%d %d",&n,&m);
for(int i=1;i<=n;i++){
scanf("%d",&x);
b[i]+=x-x0;
x0=x;
int j=i+lowbit(i);
if(j<=n)b[j]+=b[i];
}
}
void add(int x,int k){
while(x<=n){
b[x]+=k;
x+=lowbit(x);
}
}
int query(int x){
int sum=0;
while(x){
sum+=b[x];
x-=lowbit(x);
}return sum;
}
int main(){
/*freopen("test.in","r",stdin);
freopen("test.out","w",stdout);*/
build();
while(m--){
scanf("%d %d",&op,&x);
switch(op){
case 1:
scanf("%d %d",&y,&k);
add(x,k);add(y+1,-k);
break;
case 2:
printf("%d\n",query(x));
}
}
/*fclose(stdin);
fclose(stdout);*/
return 0;
}
区间修改与区间查询
这是个问题,我们以守墓人为例题。
我当时为那道题写了一个简单的个人记录:
对于数组 \(a\) 的差分数组 \(d\),我们可以使用 \(d\) 求出 \(a\) 的前缀和数组 \(s\)。
由于 \(d_k=a_k-a_{k-1}\),则:
\[a_k=d_1+d_2+d_3+\cdots+d_k \]那么:
\[\begin{aligned} s_k&=a_1+a_2+a_3+\cdots+a_k\\ &=d_1+(d_1+d_2)+(d_1+d_2+d_3)+\cdots+(d_1+d_2+d_3+\cdots+d_k)\\ &=k\times d_1+(k-1)\times d_2+(k-2)\times d_3+(k-3)\times d_4+\cdots+d_k\\ &=(k+1)(d_1+d_2+d_3+\cdots+d_k)-(1\times d_1+2\times d_2+3\times d_3+\cdots+k\times d_k)\\ &=(k+1)\sum_{i=1}^k d_i-\sum_{i=1}^k d_i\times i \end{aligned} \]
维护树状数组 \(d_i=a_i-a_{i-1}\) 和树状数组数组 \(c_i=d_i\times i\) 即可。
关于各个操作:
- 差分处理
- 由1同理
- 由1同理
- 计算前缀和
- 由4同理
首先,需要明确的是,单点操作可以视为长度为 \(1\) 的区间操作。
因此原题中的有效操作就两个:
- 操作 \(1\):将区间 \(a[l,r]\) 加上 \(k\)。
- 操作 \(4\):求 \(\sum\limits_{i=l}^r a_i\)。
我们可以维护一个一阶差分数组 \(d\) 。
那么,对于区间修改,我们仅仅需要像上一种情况中那样:
但是区间查询呢?
对于单点查询,可以通过 \(query(x)\) 实现 \(\mathcal O(\log n)\),但是显然不可能遍历 \([l,r]\),每次都调用查询,因为这样是 \(\mathcal O(n\log n)\) 的。
这时候就需要用到我们上面的个人记录中的结论了。我们可以使用 \(d\) 数组求出前缀和数组,前缀和数组上相减即可。
具体参见代码。
例题 AC 代码
//#include<bits/stdc++.h>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<iomanip>
#include<cstdio>
#include<string>
#include<vector>
#include<cmath>
#include<ctime>
#include<deque>
#include<queue>
#include<stack>
#include<list>
using namespace std;
typedef long long ll;
const ll N=2e5;
ll n,f,a[N+1],d[N+1],c[N+1];
ll lowbit(ll x){
return x&-x;
}
void build(ll a[]){
for(int i=1;i<=n;i++){
int j=i+lowbit(i);
if(j<=n)a[j]+=a[i];
}
}
void add(ll x,ll k){
ll pl=x;
while(x<=n){
d[x]+=k;
c[x]+=pl*k;
x+=lowbit(x);
}
}
ll query(ll x){
ll sum=0,pl=x+1;
while(x){
sum+=pl*d[x]-c[x];
x-=lowbit(x);
}return sum;
}
void s1(){
ll l,r,k;
scanf("%lld %lld %lld",&l,&r,&k);
add(l,k);
add(r+1,-k);
}
void s2(){
ll k;
scanf("%lld",&k);
add(1,k);
add(2,-k);
}
void s3(){
ll k;
scanf("%lld",&k);
add(1,-k);
add(2,k);
}
void s4(){
ll l,r;
scanf("%lld %lld",&l,&r);
printf("%lld\n",query(r)-query(l-1));
}
void s5(){
printf("%lld\n",query(1));
}
int main(){
/*freopen("test.in","r",stdin);
freopen("test.out","w",stdout);*/
scanf("%lld %lld",&n,&f);
for(int i=1;i<=n;i++){
scanf("%lld",a+i);
d[i]=a[i]-a[i-1];
c[i]=d[i]*i;
}build(d);build(c);
while(f--){
ll op;
scanf("%lld",&op);
switch(op){
case 1:s1();break;
case 2:s2();break;
case 3:s3();break;
case 4:s4();break;
case 5:s5();break;
}
}
/*fclose(stdin);
fclose(stdout);*/
return 0;
}
技巧
树状数组的清空(时间戳优化)
清空树状数组是 \(\mathcal O(n)\) 的,因此在多组数据时,这是一种常用的技巧。
即定义数组 \(tag[i]\) 表示 \(t[i]\) 的值是第几组数据的,若 \(i\) 不为当前数据组编号,则 \(t[i]\) 的值即初始值 \(0\)(或其他初始值)。

浙公网安备 33010602011771号