树状数组详解

题目传送门:树状数组 1树状数组 2守墓人

防止日后忘记。

引入

例题 \(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)\)

如图:

010

图片来源:OI Wiki

如何划分

令树状数组所使用数组为 \(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\) 上你只需要:

\[b[l]\leftarrow b[l]+5\\ b[r+1]\leftarrow b[r+1]-5 \]

这样就维护了区间修改

对于单点查询,\(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. 差分处理
  2. 由1同理
  3. 由1同理
  4. 计算前缀和
  5. 由4同理

首先,需要明确的是,单点操作可以视为长度为 \(1\) 的区间操作

因此原题中的有效操作就两个:

  • 操作 \(1\):将区间 \(a[l,r]\) 加上 \(k\)
  • 操作 \(4\):求 \(\sum\limits_{i=l}^r a_i\)

我们可以维护一个一阶差分数组 \(d\)

那么,对于区间修改,我们仅仅需要像上一种情况中那样:

\[d[l]\leftarrow d[l]+k\\ d[r+1]\leftarrow d[r+1]-k \]

但是区间查询呢?

对于单点查询,可以通过 \(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\)(或其他初始值)。

posted @ 2025-07-20 19:18  TH911  阅读(13)  评论(0)    收藏  举报