【树状数组详解】从入门到各种实用技巧

入门级

引入

先看一道模板题洛谷P3374
题意是:维护一个序列,要求支持两种操作:

  1. 把元素 x x x的值修改成 y y y
  2. 查询区间 [ x , y ] [x,y] [x,y]的和
    依然可以用暴力,时间复杂度 O ( N 2 ) O(N^2) O(N2),太慢了,出题人不会那么善意的让暴力过掉的
    那么,我们需要优化到 O ( N   log ⁡   N ) O(N~\log~N) O(N log N),才能过
    看过线段树的同学肯定一下看出来可以用线段树做,但是,线段树的常数比较大,
    一些比较恶意的出题人依然会把它卡掉,所以我们需要常数更小的做法
    而且代码量那么大,能用树状数组我才懒得打线段树

我们先不考虑修改操作,先考虑查询操作
因为我们查询的是和,我们就可以先预处理出前缀和,然后查询就会很方便
这时候我们再来考虑修改操作,正常更新前缀和的时间复杂度是 O ( n ) O(n) O(n)
但是树状数组可以神奇地把它优化到 O ( l o g   n ) O(log~n) O(log n),代价是查询变成 O ( l o g   n ) O(log~n) O(log n),但比我们的暴力还是要优秀不少的
然后我们就要用到树状数组了

正题

1. lowbit函数

这个函数非常重要,它几乎贯彻整个树状数组

int lowbit(int x){
	return x&(-x);
}

l o w b i t ( x ) = 2 x 在 二 进 制 下 从 右 往 左 数 第 一 个 1 的 位 置 − 1 lowbit(x)=2^{x在二进制下从右往左数第一个1的位置-1} lowbit(x)=2x11
比如 5 ( 10 ) = 10 1 ( 2 ) 5_{(10)}=101_{(2)} 5(10)=101(2)那么 l o w b i t ( 5 ) = 1 ( 2 ) = 1 ( 10 ) lowbit(5)=1_{(2)}=1_{(10)} lowbit(5)=1(2)=1(10)
        6 ( 10 ) = 11 0 ( 2 ) ~~~~~~~6_{(10)}=110_{(2)}        6(10)=110(2)那么 l o w b i t ( 6 ) = 1 0 ( 2 ) = 2 ( 10 ) lowbit(6)=10_{(2)}=2_{(10)} lowbit(6)=10(2)=2(10)

2. 树状数组的结构

树状数组算是树型结构了,但在代码中是以数组的形式体现
大小为 8 8 8树状数组长这个样子(红色的是边)

不难看出第 i i i号节点的父亲是第 i + l o w b i t ( i ) i+lowbit(i) i+lowbit(i)号节点
树状数组的第 x 个 节 点 x个节点 x表示的是 Σ i = x − l o w b i t ( x ) + 1 x a [ i ] \Sigma_{i=x-lowbit(x)+1}^{x}a[i] Σi=xlowbit(x)+1xa[i]
乍一看好像没什么用,还很复杂,事实上是非常好用的东西

3. 重点:原理

不把这里看懂的话,大概两天之后你就会忘掉到底要怎么写树状数组
树状数组利用了类似于倍增 大概是吧 的想法,结构神奇,但是非常巧妙

查询

假如我们要查询11~ 7 7 7的前缀和,那么我们需要的值就是 7 7 7号节点的值、 6 6 6号节点的值和 4 4 4号节点的值
这些节点之间看起来没有什么关系    ~~   反正我第一次没看出什么关系~~,但是我们把它们转成二进制:
7 10 = 11 1 2 7_{10}=111_{2} 710=1112
6 10 = 11 0 2 6_{10}=110_{2} 610=1102
4 10 = 10 0 2 4_{10}=100_{2} 410=1002
0 10 = 00 0 2 0_{10}=000_{2} 010=0002
如果没看出关系的同学,可以再看下一组数据:
假如我们要查询 1 1 1~ 5 5 5的前缀和,那么我们需要的值就是 5 5 5号节点的值和 4 4 4号节点的值
5 10 = 10 1 2 5_{10}=101_{2} 510=1012
4 10 = 10 0 2 4_{10}=100_{2} 410=1002
0_{10}=000_{2}$

可以再举一个例子:
假如我们要查询 1 1 1~ 45 45 45的前缀和,那么我们需要的值就是 45 45 45号节点的值、 44 44 44号节点的值
4 5 10 = 10110 1 2 45_{10}=101101_{2} 4510=1011012
4 4 10 = 10110 0 2 44_{10}=101100_{2} 4410=1011002
4 0 10 = 10100 0 2 40_{10}=101000_{2} 4010=1010002
3 2 10 = 10000 0 2 32_{10}=100000_{2} 3210=1000002
0 10 = 0000 0 2 0_{10}=00000_{2} 010=000002
规律就出来了:下一个所查找的数的下标=这个数的下标- l o w b i t ( lowbit( lowbit(这个数的下标 ) ) )
简单来说就是把现在的数的下标在二进制中的最后一个 1 1 1变成 0 0 0,直到这个数为0为止
这就和我们树状数组的结构很有关系了:

树状数组的第 x 个 节 点 x个节点 x表示的是 Σ i = x − l o w b i t ( x ) + 1 x a [ i ] \Sigma_{i=x-lowbit(x)+1}^{x}a[i] Σi=xlowbit(x)+1xa[i]

这样统计前缀和的方式是非常巧妙的,因为我们如果有 n n n个元素,那么它最多有 ⌈ l o g 2 n ⌉ \lceil log_2n\rceil log2n个1,则时间复杂度是 O ( log ⁡   N ) O(\log~N) O(log N)


修改

当我们要修改一个值的时候,我们还要把它影响的值全部修改
假如我们要把第 7 7 7号的节点的值加上 x x x,那么编号为 7 7 7 8 8 8的节点的值均要加上 x x x
同样是换成二进制:
7 10 = 011 1 2 7_{10}=0111_{2} 710=01112
8 10 = 100 0 2 8_{10}=1000_{2} 810=10002
再来两个例子:
假如我们要把第 5 5 5号的节点的值加上 x x x,那么编号为 5 5 5 6 6 6 8 8 8的节点的值均要加上 x x x
5 10 = 010 1 2 5_{10}=0101_{2} 510=01012
6 10 = 011 0 2 6_{10}=0110_{2} 610=01102
8 10 = 100 0 2 8_{10}=1000_{2} 810=10002
假如我们要把第 45 45 45号的节点的值加上 x x x,那么编号为 7 7 7 8 8 8的节点的值均要加上 x x x
4 5 10 = 010110 1 2 45_{10}=0101101_{2} 4510=01011012
4 6 10 = 010111 0 2 46_{10}=0101110_{2} 4610=01011102
4 8 10 = 011000 0 2 48_{10}=0110000_{2} 4810=01100002
6 4 10 = 100000 0 2 64_{10}=1000000_{2} 6410=10000002
规律是:下一个所修改的数的下标=这个数的下标+ l o w b i t ( lowbit( lowbit(这个数的下标 ) ) )
同样,时间复杂度是 O ( log ⁡   N ) O(\log~N) O(log N)

4. 树状数组的查询(代码)

树状数组不支持任意区间查询,只支持查询前缀
但是查询到前缀后,我们就可以得出答案
巧妙的使用 l o w b i t lowbit lowbit函数
时间复杂度 O ( log ⁡   N ) O(\log~N) O(log N)

void get_sum(int x){//查询1到x的和
	int re=0;
	while(x){
		re=re+sum[x];
		x=x-lowbit(x);
	}
}
5. 树状数组的修改(代码)

树状数组不支持区间修改,只支持单点修改
要修改一个点的值之后,还需要把它到根的路径上的点进行维护
时间复杂度 O ( log ⁡   N ) O(\log~N) O(log N)

void update(int x,int y){//把第x个元素加y
	while(x<=n){
		sum[x]=sum[x]+y;
		x=x+lowbit(x);
	}
}
6. 模板题代码

于是我们就把模板题做出来了:

#include<bits/stdc++.h>

using namespace std;

const int MAXN=500001;

int n,m,x,y,t,sum[MAXN];

int lowbit(int x){
	return x&(-x);
}

void update(int x,int y){//把第x个元素加y
	while(x<=n){
		sum[x]=sum[x]+y;
		x=x+lowbit(x);
	}
}

int get_sum(int x){//查询1到x的和
	int re=0;
	while(x){
		re=re+sum[x];
		x=x-lowbit(x);
	}
	return re;
}

int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++){
		scanf("%d",&x);
		update(i,x);
	}
	while(m--){
		scanf("%d",&t);
		if(t==1){
			scanf("%d%d",&x,&y);
			update(x,y);
		}else{
			scanf("%d%d",&x,&y);
			printf("%d\n",get_sum(y)-get_sum(x-1));
		}
	}
}

提高级

先看一道模板题洛谷P3368

题意是:
维护一个长度为 n n n的区间,要求支持两个操作

  1. 把区间 [ l , r ] [l,r] [l,r]之间的值加上 x x x
  2. 输出第 x x x号节点的值

分析
很明显出题人不会那么善意的让暴力过掉, n , m ≤ 500000 n,m\le 500000 n,m500000的大数据让我们必须要拿出 O ( log ⁡   N ) O(\log~N) O(log N)或更优的算法
那么我们又怎么和树状数组扯上关系呢
我们知道树状数组只可以解决有关于前缀的问题
那么我们考虑把区间修改操作换成两个修改后缀的操作
也就是,我们可以把区间 [ l , r ] [l,r] [l,r]的值加上 x x x的操作改成 [ l , n ] [l,n] [l,n]加上 x x x, [ r + 1 , n ] [r+1,n] [r+1,n]加上 − x -x x
那么我们就可以很容易用树状数组操作了
上代码

#include<bits/stdc++.h>

using namespace std;

const int MAXN=5000002;

int n,m,l,r,t,x,sum[MAXN];

int lowbit(int x){return x&(-x);}

void update(int x,int y){while(x<=n){sum[x]=sum[x]+y;x=x+lowbit(x);}}

int get_sum(int x){int re=0;while(x){re=re+sum[x];x=x-lowbit(x);}return re;}

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++){
        scanf("%d",&x);
        update(i,x);update(i+1,-x);
    }
    while(m--){
        scanf("%d",&t);
        if(t==1){
            scanf("%d%d%d",&l,&r,&x);
            update(l,x);update(r+1,-x);
        }else{
            scanf("%d",&x);
            printf("%d\n",get_sum(x));
        }
    }
}

再补充一种神奇的用法:

Apple tree

翻译后:

在卡卡的家门前有一棵苹果树,每个秋天都会结许多苹果。卡卡非常喜欢苹果,所以他总是悉心照料这棵大苹果树。

这棵树有n个分叉点,并且它们之间有树枝连接。卡卡将这些分叉点编号,并且树根的编号总是1。苹果就长在这些分叉点上,当然一个分叉点不会长出两个及以上的苹果。卡卡想要知道一棵子树中有多少苹果,以此来了解这棵苹果树的生产能力。

现在的麻烦是,有些时候,卡卡会从树上摘下苹果,而有些时候,一个没有苹果的分叉点上又会长出苹果。你能帮卡卡处理这个问题吗?

img

Input
输入文件第一行是一个正整数n(1<=n<=100000),代表苹果树的分叉点数。

接下来n-1行,每行两个整数u和v,代表分叉点u和v之间有一根树枝相连。

第n行包含一个正整数m(1<=m<=100000),代表操作的数目。

接下来m行,每行代表一个操作。操作可以是以下两种之一:

(1)“C x”代表在分叉点x上的苹果状态被改变了。也就是说,如果之前分叉点x上有苹果,那么现在就被摘掉了;反之,如果以前没有苹果,那么现在就长出了一个苹果。

(2)“Q x”代表查询以分叉点x为根的子树中一共有多少苹果(包括x上的苹果,如果分叉点上x上有苹果的话)

一开始,树上长满了苹果。

Output

对每个查询,输出一行一个整数,代表该子树上的苹果个数。

Sample Input

3
1 2
1 3
3
Q 1
C 2
Q 1

Sample Output

3
2

题解:
先想一下暴力算法,一共有两种:

  1. 更改直接更改节点的值,查询时遍历整颗子树,更改 O ( 1 ) O(1) O(1),查询 O ( N ) O(N) O(N),可以卡掉
  2. 更改时遍历更改节点的所有祖先,查询就可以做到 O ( 1 ) O(1) O(1),但是修改可以卡到 O ( N ) O(N) O(N)(当树退化成链),照样卡掉

那么我们可以将查询的时间复杂度与修改的时间复杂度均衡一下,都变成 O ( log ⁡ N ) O(\log N) O(logN),就可以过了

下面才是正题:

我们考虑把树用dfn序表示

(这里是对dfn序的介绍,了解过的同学可以跳过)

dfn序就是我们在对树进行遍历时的遍历出来的序列,顺序是先遍历根,后遍历子树,类似于二叉树的先序遍历,但我们现在所讨论的遍历是对于多叉树的

比如我们对下面的树进行遍历时,dfn序就是ABEGHCDF

不难看出dfn序的一个性质:一个节点的后代在dfn序中是相邻的

写一下遍历的代码:

void dfs(int now){//有根树的遍历,无根树在遍历时要加上到父亲的特判
    dfn[++cnt]=now;//cnt是时间戳,dfn数组里存的是dfn序
    in[now]=cnt;//in数组存的是now的子树在dfn序中对应的序列的开头的下标
    for(int i=head[now];~i;i=nxt[i])dfs(child[now]);//对儿子进行遍历
    out[now]=cnt;//out数组存的是now的子树在dfn序中对应的序列的末尾的下标
}

我们可以巧妙领dfn序的性质,把树表示成一维的,同时使用查分的思想,把答案统计转换成前缀和的统计

然后就可以用树状数组处理这一题了,查询和修改的时间复杂度都是 O ( log ⁡ N ) O(\log N) O(logN)

上面可能讲的不够清楚,不懂得同学可以对照代码理解一下

#include<iostream>

using namespace std;

int n,x,y,q,cnt,a[1000001],in[1000001],out[1000001],v[1000001],nxt[1000001],head[1000001];
bool tf[1000001];//tf数组储存第i号节点有没有苹果
char st[2];

void add_edge(int x,int y){//加双向边
	v[++cnt]=x;nxt[cnt]=head[y];head[y]=cnt;
	v[++cnt]=y;nxt[cnt]=head[x];head[x]=cnt;
}

int low_bit(int x){
	return x&(-x);
}

void add(int x,int y){//树状数组更新
	while(x<=n){
		a[x]=a[x]+y;
		x=x+low_bit(x);
	}
}

int sum(int x){//树状数组求和
	int ans=0;
	while(x>0){
		ans=ans+a[x];
		x=x-low_bit(x);
	}
	return ans;
}

int ans(int x,int y){//查分
	return sum(y)-sum(x-1);
}

void dfs(int now,int la){//处理出dfn序
	in[now]=++cnt;
	for(int i=head[now];i!=0;i=nxt[i])if(v[i]!=la)dfs(v[i],now);
	out[now]=cnt;
}

int main(){
	scanf("%d",&n);
	for(int i=1;i<n;i++){
		scanf("%d%d",&x,&y);
		add_edge(x,y);
	}
	cnt=0;
	dfs(1,-1);//dfn序
	for(int i=1;i<=n;i++)add(i,1),tf[i]=true;//一开始树的每个节点都有苹果
	scanf("%d",&q);
	for(int i=1;i<=q;i++){
		scanf("%s%d",st,&x);
		if(st[0]=='Q')printf("%d\n",ans(in[x],out[x]));else{
			if(tf[in[x]])add(in[x],-1);//有苹果就摘掉
			else add(in[x],1);//没苹果就长出来
			tf[in[x]]=!tf[in[x]];
		}
	}
}

小结:

树状数组的基本知识到这里就差不多讲完了,实际应用很广,不可能一篇博客写完,遇到可以查分的问题可以向查分想想,大概就是这样了

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值