简单树状数组

树状数组

概述

树状数组是一种基于倍增和二进制划分思想,用于维护简单区间操作的数据结构,短小精悍

我们知道,每一个数都可以使用二进制表示为\(a_0a_1a_2a_3…a_k(\forall i\in[0,k],a_i=0/1)\)(由低位到高位共\(k+1\)位)的形式,其中第\(i\)位所表示的二进制的值为\(2^i\times a_i\),根据二进制的性质,第\(i\)位若为1,那么比其余的低位的权值全部加起来都至少大\(1\),这样的话我们可以使用二进制的形式来表示区间

我们假设以这个数\(n\)二进制每一个为1的位都表示区间,设这个数二进制下为1的位每一位为\(b_i,i\in[1,m]\),那么我们可以将一个长度为\(n\)的区间表示为:
\([1,2^{b_1}]\)
\([2^{b_1}+1,2^{b_2}]\)
\([2^{b_1}+2^{b_2}+1,2^{b_3}]\)
……
\([1+\sum_{q=1}^{i-1}2^{b_q},2^i]\)

这样可以不重不漏的把整个长度为\(n\)的区间分为\(m\)个长度分别为\(2^{b_i}\)的小区间,树状数组就是基于这样的区间划分

具体的,区间\([1,x]\)的最后一个区间即为\([x-\operatorname{lowbit}(x)+1,x]\),我们使用这样就可以倒序遍历出\([1,x]\)分成的区间

#define lowbit(x) (x&-x)
while(x){  
    printf("[%d,%d]\n",x-lowbit(x)+1,x);
    x-=lowbit(x);
}

而这样的子区间我们就视为树状数组里\(x\)的子节点,下面附上一个长度为16的树状数组的样子(\(c\)即为树状数组)

该结构满足的性质有:

  1. 树的深度为\(O(\log n)\)
  2. 每个节点\(x\)的子节点个数为\(\log_2(lowbit(x))+1\)
  3. 除根节点外每个节点\(x\)的父亲为\(x+lowbit(x)\)

基本操作

树状数组满足两个基本操作,分别是前缀和的查询和单点修改,此时每个\(c[x]=\sum_{i=x-lowbit(x)+1}^xa[i],a[i]为原序列\)
前缀和查询:
前缀和查询其实很简单,假设我们需要查询\([1,x]\)的和,只需要将\([1,x]\)分成的若干个小区间的和加上去就完事了,肥肠简单

int ask(int x){//1~x
    int ans=0;
    while(x){  
        ans+=c[x];
        x-=lowbit(x);
    }
    return ans;
}

单点修改呢,其实也是比较简单的,因为若我们要将\(a[x]+d\),我们只需要把\(x\)在树状数组上所有包含它的\(c\)全部加上\(d\)即可,至于这个的办法,我们知道,第一个包含\(a[x]\)的区间(最小的)就是\(c[x]\),所以我们只需要把\(c[x]\)及其所有的祖先节点全部加上\(d\)即可

void add(int x,int d){  
    for(int i=x;i<=n;i+=lowbit(i))c[i]+=d;
}

至于区间和的查询void sum(int l,int r){return ask(r)-ask(l-1);}

扩展应用

区间修改+单点查询

记得怎么把区间修改变成单点修改吗,差分撒
所以说我们的树状数组就变成了原序列\(a\)的一个差分数组,特别的,令\(c[1]=a[1]\)
那么对于区间修改就差分正常操作了,对于单点查询的话就是查前缀和了

int main(){  
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++)scanf("%d",&a[i]),a[i]=a[i]-a[i-1];
    c[1]=a[1];
    while(m--){  
        int opt,l,r,d;
        scanf("%d%d",&opt,&l);
        if(opt==1){//区间修改  
            scanf("%d%d",&r,&d);
            add(l,d);
            add(r+1,-d);
        }
        else {  
            printf("%d\n",ask(l));
        }
    }
}

区间修改+区间查询

区间修改还要区间查询比较复杂,在区间修改+单点查询的时候我们维护了差分数组,那么我们这里考虑也从上题开始扩展
若我们设\(d\)数组表示每一次区间修改的差分操作,最初全是0,那么对于\(a[x]\),它经过历史修改后就应该是\(a[x]+\sum_{i=1}^xd[x]\),那么对于区间\([1,x]\),整体增加的值为:

\[\sum_{k=1}^x\sum_{i=1}^kd[i] \]

变式得:

\[\sum_{i=1}^x(x-i+1)·d[i]=(x+1)\sum_{i=1}^xd[i]-\sum_{i=1}^xi·d[i] \]

所以等同于我们要维护两边的式子,看样子都是一个差分序列或者变式,那么就等同于需要两个树状数组进行维护,前一个式子就使用树状数组\(c_1\)像单点查询那样进行维护,后一个式子比较麻烦,我们需要维护一个当前位置×当前权值的一个数组,采用树状数组\(c_2\),对于一次差分操作,执行add2(l,l* d),add2(r+1,-r*d-d),所以说总的来说,修改操作是这样的:设对区间\([l,r]+d\)

add1(l,d);
add1(r+1,-d);
add2(l,l*d);
add2(r+1,-(r+1)*d);

查询:
我们设原序列的前缀和数组为\(s\),若查询区间和\([l,r]\):

l--;
int ansl=s[l-1]+l*ask1(l-1)+ask2(l-1);
int ansr=s[r]+(r+1)*ask1(r)+ask2(r);
ans=ansr-ansl;

在具体实现中,为了代码方便,我们直接使用一个二维数组\(c[N][2]\)表示两个树状数组

void add(int k,int x,int a){
	while(x<=n){
		c[x][k]+=a;
		x+=lowbit(x);
	}
} 
int ask(int k,int x){
	int ans=0;
	while(x){
		ans+=c[x][k];
		x-=lowbit(x);
	}
	return ans;
}
signed main(){
	scanf("%lld%lld",&n,&m);
	for(int i=1;i<=n;i++){
		scanf("%lld",&a[i]);
		sum[i]=sum[i-1]+a[i];
	}
	char q;
	int l,r,d;
	while(m--){
		cin>>q;
		if(q=='C'){
			scanf("%lld%lld%lld",&l,&r,&d);
			add(0,l,d);
			add(0,r+1,-d);
			add(1,l,d*l);
			add(1,r+1,-d*(r+1));
		}
		else {
			scanf("%lld%lld",&l,&r);
			long long ans=sum[r]+(r+1)*ask(0,r)-ask(1,r);
			ans-=sum[l-1]+l*ask(0,l-1)-ask(1,l-1);
			printf("%lld\n",ans);
		}
	}
}

对于第二个树状数组正确性的证明,估计很多人都看不明白,(鄙人也是随便证证,错误见谅)
其实大家都走进一个误区,那就是\(\sum_{i=1}^xi·d[i]\)它其实上不要忘记了\(d\)是一个差分数组,我们只是需要将新的维护这个式子的差分数组\(c2\)的每一次修改都×上节点位置即可,注意是\(d[i]\)而不是\(d\)啊大哥大姐们,我们又不需要它相互抵消之类的,从本质上来讲\(c2\)并不是一个差分数组,它只是差分数组的一个变形,即在原差分数组上每一个数都乘上它所在的位置即可

权值树状数组

顾名思义,就是建立一个值域上的树状数组,对于这种事情一般会离散化再做,即我们的权值树状数组从根本来说支持的操作就是
1.查询小于\(x\)的数的个数
2.插入\(m\)个值为\(x\)的数
原理是在普通树状数组上进行变形,即普通树状数组维护序列,权值树状数组维护的是值域,其本质也可以看作序列,只是序列的大小与值域相同
对于操作1,即查询前缀和\([1,x]\)
对于操作2,即add(x,m);

逆序对求法

回想起逆序对的定义,若\(i<j\)\(a[i]>a[j]\)\(i,j\)构成逆序对,那么其实对于这个,归并排序比较复杂,这时候权值树状数组是一个不错的选择(常数还要比归并小)

具体的,根据逆序对的定义,我们只需要从左往右扫描\(a\)数组,然后\(\forall i\in[1,n]\),统计当前树状数组中大于\(a[i]\)的数的个数(用\(i-1-ask(a[i])\)),累加上答案,然后add(a[i],1);

int ans=0;
for(int i=1;i<=n;i++)ans+=(i-1-ask(a[i])),add(a[i],1);
printf("%d",ans);

肥肠之简单

树状数组与倍增

我们知道,在树状数组划分区间本质上是基于二进制划分和倍增思想,导致树状数组的结构本身很适合倍增,即我们倍增\(2^p\)几乎相当于直接加上了一个区间,因为每个区间的大小都是2的整次幂
例题:你需要维护一个01序列,每一次需要查找第\(k\)个1的位置,并且需要支持修改

修改就不说了,板子问题

做法1:树状数组+二分
我们可以二分前缀和查找第\(k\)个1的位置,复杂度是\(O(n\log^2 n)\)

做法2:树状数组+倍增

用树状数组\(c\)维01序列\(a\)的前缀和,在每次查询时:

  1. 初始化两个变量\(ans=sum=0\);
  2. \(\lfloor\log n\rfloor\)到0倒序考虑每一个整数\(p\)
  3. 对于每一个\(p\),若\(ans+2^p\le n且sum+c[ans+2^p]<k\),则\(sum=sum+c[ans+2^p],ans=ans+2^p\)
  4. 最后\(ans+1\)即为所求

\(2\)的整次幂数为步长,能累加则累加,树状数组恰好已经存有关于2的幂的一些信息,直接结合树状数组使用,即可快速求解,得到一个复杂度为\(O(n\log n)\)的优秀算法

posted @ 2022-11-30 22:26  spdarkle  阅读(17)  评论(0编辑  收藏  举报