树状数组详解

例题:树状数组 1(P3374)、树状数组 2(P3368)、守墓人(P2357)

Posted by TH911 on December 14, 2024

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

防止日后忘记。

引入

例题 $1$:

已知一个数列,你需要进行下面两种操作:

  • 将某一个数加上 $x$。
  • 求出某区间每一个数的和。

对于操作 $1$,我们可以直接通过普通数组 $\mathcal O(1)$ 实现修改。

对于操作 $2$,我们可以通过前缀和 $\mathcal O(1)$ 实现查询。

然而,若使用普通数组,查询复杂度为 $\mathcal O(n)$;若使用前缀和数组,修改复杂度为 $\mathcal O(n)$。


树状数组就是一种兼具修改、查询的高效数据结构,修改、查询复杂度均为 $\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)$。

如图:

图片来源: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$。

x&-x 的原理也很简单。首先你要知道在计算机中,数字以补码的形式存储。

(此处的二进制数有效位数只有 $4$ 位,最高位为符号位)

那么对于正数(树状数组中 $\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

参考代码

1
2
3
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)$ 的时间内查询。

参考代码

1
2
3
4
5
6
7
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$ 为自然数,$s$ 为实数

则 $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<x-\operatorname{lowbit}(x)+1$。

又考虑到 $y$ 显然大于 $x$,因此 $t[x]$ 被 $t[x+\operatorname{lowbit}(x)]$ 包含。

因此,我们仅仅需要令 $x\leftarrow x+\operatorname{lowbit}(x)$ 直到 $x>n$ 即可。

参考代码

1
2
3
4
5
6
void add(int x,int k){
	while(x<=n){
		t[x]+=k;
		x+=lowbit(x);
	}
}

例题 AC 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
//#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 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
//#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 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
//#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$(或其他初始值)。