区间修改&区间查询问题
【引言】信息学奥赛中常见有区间操作问题,这种类型的题目一般数据规模极大,无法用简单的模拟通过,因此本篇论文将讨论关于可以实现区间修改和区间查询的一部分算法的优越与否。
【关键词】区间修改、区间查询、线段树、树状数组、分块
【例题】
题目描述:
如题,已知一个数列,你需要进行下面两种操作:
1.将某区间每一个数加上x
2.求出某区间每一个数的和
输入格式:
第一行包含两个整数N、M,分别表示该数列数字的个数和操作的总个数。
第二行包含N个用空格分隔的整数,其中第i个数字表示数列第i项的初始值。
接下来M行每行包含3或4个整数,表示一个操作,具体如下:
操作1: 格式:1 x y k 含义:将区间[x,y]内每个数加上k
操作2: 格式:2 x y 含义:输出区间[x,y]内每个数的和
输出格式:
输出包含若干行整数,即为所有操作2的结果。
输入样例:
5 5
1 5 4 2 3
2 2 4
1 2 3 2
2 3 4
1 1 5 1
2 1 4
输出样例:
11
8
20
说明
时空限制:1000ms,128M
数据规模:
对于30%的数据:N<=8,M<=10
对于70%的数据:N<=1000,M<=10000
对于100%的数据:N<=100000,M<=100000
(保证数据在int64/long long数据范围内)
@线段树
【分析】本题是典型的高性能题目,根据题中的数据规模,我们可以得出普通的模拟显然是不行的(如果出题人愿意,最大的数据可以使模拟程序的时间复杂度为O(nm)),因此需要一种更加高效的算法,我们最先不想想到的是线段树。
线段树的定义:
线段树是一种二叉搜索树,与区间树相似,它将一个区间划分成一些单元区间,每个单元区间都对应了线段树中的一个叶结点。
对于线段树中的每一个非叶子节点[a,b],它的左儿子表示的区间为[a,(a+b)/2],右儿子表示的区间为[(a+b)/2+1,b]。因此线段树是平衡二叉树,最后的子节点数目为N,即整个线段区间的长度。
使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为O(logN)。而未优化的空间复杂度为2N,因此有时需要离散化让空间压缩。
因此线段树是一种特别不高效的算法,但是需要的空间大小更多,能承受的数据量就相对于其他(高效的)算法要少。在线段树中,我们把当前节点所包含的区间分成两半,分别给左右子节点,一直到只包含一个元素为底部。
【程序】
#include<cstdio> #include<cstring> #include<algorithm> #define line_feed putchar(10) #define llt unsigned long long int #define maxn1 100005 #define chil1(x) (x<<1) #define chil2(x) (x<<1|1) using namespace std; llt edge[maxn1*4]; llt l[maxn1*4],r[maxn1*4]; llt n,m; llt x,y,t; inline void read(llt &x){//快读 char temp; while(temp=getchar()){ if(temp>='0'&&temp<='9'){ x=temp-'0'; break; } } while(temp=getchar()){ if(temp<'0'||temp>'9'){ break; } x=x*10+temp-'0'; } return ; } void init(llt now,llt x,llt y){//初始化节点所管的范围的下标 llt mid=(x+y)>>1; l[now]=x; r[now]=y; if(x==y){ return ; } init(chil1(now),x,mid); init(chil2(now),mid+1,y);//遍历左右子节点 return ; } void build(llt now,llt v){ if(r[now]<v||l[now]>v){ return ; } edge[now]+=t; if(l[now]==r[now]&&l[now]==v){ return ; } build(chil1(now),v); build(chil2(now),v); return ; } void change(llt now){ if(r[now]<x||l[now]>y){ return ; } if(l[now]==r[now]){ edge[now]+=t; return ; } change(chil1(now)); change(chil2(now));//遍历左右子节点 edge[now]=edge[chil1(now)]+edge[chil2(now)];//更新当前节点 return ; } llt get(llt now){ if(r[now]<x||l[now]>y){ return 0;//如果完全不包含则返回零 } if(l[now]>=x&&r[now]<=y){ return edge[now];//如果完全包含则返回节点值 } return get(chil1(now))+get(chil2(now));//有交集则继续遍历左右子节点 } int main(){ llt i; memset(edge,0,sizeof(edge)); read(n); read(m); init(1,1,n); for(i=1;i<=n;i++){ read(t); build(1,i); } for(i=1;i<=m;i++){ read(t); read(x); read(y); if(t==1){ read(t); change(1); } else{ printf("%lld",get(1)); line_feed;//高科技快速换行,目测要快一些(前面有定义putchar()换行) } } return 0; }
【分析】
但是这样的线段树任然无法在规定时间内完成数据为m=100000 n=100000的数据,因为区间修改和区间询问在单纯的线段树中无法高效解决问题,如果在递归时访问了所有被更改的节点,那么最坏情况下(依照书上说的)时间复杂度为O(mnlogn)qwq。于是我们想出了一种高科技算法——延迟标记(懒标记)。延迟标记即当整个区间都被操作时,就直接记录在公共祖先节点上;只修改了一部分,那么就记录在这部分的公共祖先上;如果四环以内只修改了自己的话,那就只改变自己。我们就需要在每次区间的查询修改时pushdown一次,以免重复或者冲突或者爆炸。pushdown其实就是纯粹的pushup的逆向思维。因为pushup是向上传导信息,所以开始回溯时执行pushup;但我们如果要让它向下更新,就要调整顺序,在向下递归的时候执行pushdown。其中延迟标记有两种算法——标记下传、标记永久化。
以下是第一种标记下传的代码。
【程序】
#include<cstdio> #include<cstring> #include<algorithm> #define line_feed putchar(10) #define llt unsigned long long int #define maxn1 100005 #define chil1(x) (x<<1) #define chil2(x) (x<<1|1) using namespace std; llt sum[maxn1*4],edge[maxn1*4]; llt l[maxn1*4],r[maxn1*4]; llt n,m; llt x,y,t; inline void read(llt &x){ char temp; while(temp=getchar()){ if(temp>='0'&&temp<='9'){ x=temp-'0'; break; } } while(temp=getchar()){ if(temp<'0'||temp>'9'){ break; } x=x*10+temp-'0'; } return ; } void init(llt k,llt x,llt y){//初始化左右范围下标 llt mid=(x+y)>>1; l[k]=x; r[k]=y; if(x==y){ return ; } init(chil1(k),x,mid); init(chil2(k),mid+1,y); return ; } void add(llt k,llt v){ sum[k]+=v; edge[k]+=(r[k]-l[k]+1)*v; return ; } void pushdown(llt k){//标记下传 if(!sum[k]){//如果没有标记就不用考虑 return ; } add(chil1(k),sum[k]); add(chil2(k),sum[k]);//遍历左右子节点 sum[k]=0;//清零标记 return ; } void change(llt k){//区间修改 if(l[k]>=x&&r[k]<=y){ add(k,t);//如果完全包含维护区间和 return ; } llt mid=(l[k]+r[k])>>1; pushdown(k);//下传标记 if(x<=mid){ change(chil1(k)); } if(mid<y){ change(chil2(k)); } edge[k]=edge[chil1(k)]+edge[chil2(k)]; return ; } llt get(llt k){//区间查询 if(l[k]>=x&&r[k]<=y){ return edge[k]; } pushdown(k);//下传标记 llt mid=(l[k]+r[k])>>1,reply=0; if(x<=mid){ reply+=get(chil1(k)); } if(mid<y){ reply+=get(chil2(k)); } return reply; } int main(){ llt i; memset(edge,0,sizeof(edge)); memset(sum,0,sizeof(sum)); read(n); read(m); init(1,1,n); for(i=1;i<=n;i++){ read(t); x=i; y=i; change(1); } for(i=1;i<=m;i++){ read(t); read(x); read(y); if(t==1){ read(t); change(1); } else{ printf("%lld",get(1)); line_feed;//高科技快速换行(前面有定义putchar()换行) } } return 0; }
【分析】
还有一种方案不需要下传延迟标记,即标记永久化。这种算法在询问操作中计算每遇到的节点对当前询问的影响。这种算法实际上是我自己想到的,但无奈自己的程序怎么都过不了,只好参考书上的程序。
【程序】
#include<cstdio> #include<cstring> #include<algorithm> #define line_feed putchar(10) #define llt unsigned long long int #define maxn1 100005 #define chil1(x) (x<<1) #define chil2(x) (x<<1|1) using namespace std; llt edge[maxn1*4],sum[maxn1*4]; llt l[maxn1*4],r[maxn1*4]; llt n,m; llt x,y,t; inline llt maxx(llt x,llt y){ return x>y?x:y; } inline llt minx(llt x,llt y){ return x<y?x:y; } inline void read(llt &x){ char temp; while(temp=getchar()){ if(temp>='0'&&temp<='9'){ x=temp-'0'; break; } } while(temp=getchar()){ if(temp<'0'||temp>'9'){ break; } x=x*10+temp-'0'; } return ; } void init(llt k,llt x,llt y){ llt mid=(x+y)>>1; l[k]=x; r[k]=y; if(x==y){ return ; } init(chil1(k),x,mid); init(chil2(k),mid+1,y); return ; } void change(llt k){ if(l[k]>=x&&r[k]<=y){ sum[k]+=t;//如果完全包含就直接加到延迟标记中并结束 return ; } edge[k]+=(minx(r[k],y)-maxx(l[k],x)+1)*t;//如果有交集则按线段树标准操作加上 /* 这个地方实际上我也想到了,并集的数量乘以区间操作加上的值便是该节点所增加的值 */ llt mid=(l[k]+r[k])>>1; if(x<=mid){ change(chil1(k)); } if(mid<y){ change(chil2(k)); } return ; } llt get(llt k){ if(l[k]>=x&&r[k]<=y){ return edge[k]+(r[k]-l[k]+1)*sum[k]; }//如果完全包含,直接输出该节点的包含区域的数据的和加上懒标记的值 llt mid=(l[k]+r[k])>>1; llt reply=(minx(r[k],y)-maxx(l[k],x)+1)*sum[k]; if(x<=mid){ reply+=get(chil1(k)); } if(mid<y){ reply+=get(chil2(k));//遍历左右子节点所包含的区间的和 } return reply; } int main(){ llt i; memset(edge,0,sizeof(edge)); memset(sum,0,sizeof(sum)); read(n); read(m); init(1,1,n); for(i=1;i<=n;i++){ read(t); x=i; y=i; change(1); } for(i=1;i<=m;i++){ read(t); read(x); read(y); if(t==1){ read(t); change(1); } else{ printf("%lld",get(1)); line_feed;//高科技快速换行(前面有定义putchar()换行) } } return 0; }
【分析】
以上便是线段树所有的操作及优化。
@树状数组
【分析】
现在我们来将线段树与一种特别高效的算法进行比较,那就是传说中的——树状数组。
树状数组的定义:
树状数组(Binary Indexed Tree(B.I.T), Fenwick Tree)是一个查询和修改复杂度都为log(n)的数据结构。主要用于查询任意两位之间的所有元素之和,但是每次只能修改一个元素的值;经过简单修改可以在log(n)的复杂度下进行范围修改,但是这时只能查询其中一个元素的值(如果加入多个辅助数组则可以实现区间修改与区间查询)。
注意:树状数组能处理的下标为1~n的数组,但不能处理下标为零的情况。因为lowbit(0)==0,这样就会陷入死循环(此句源自一本通)。各位喜欢开n-1的大佬勿入。
线段树所开的数组较大,数据承受的能力较小,一般线段树的数据承受能力大约是四位数,加上优化后十万级已经是极限,而树状数组可承受的数据规模较大,承受的数据范围约是百万级(整整十倍),并且树状数组编程与线段树相比之下较容易,同样可以轻松地扩展到多维。但是树状数组无法实现线段树的延迟标记优化,使用范围也比较小,求区间最值没有较好的方法。因此在某种程度上线段树更加优秀。
以下是树状数组的实现。
【程序】
#include<cstdio> #include<cstring> #include<algorithm> #define maxn1 100005 #define lowbit(x) (x&(-x)) #define line_feed putchar(10) #define llt unsigned long long int using namespace std; llt n,m; llt edge1[maxn1],edge2[maxn1];//edge2[i]==(i-1)*edge1[i] inline void read(llt &x){ char temp; while(temp=getchar()){ if(temp>='0'&&temp<='9'){ x=temp-'0'; break; } } while(temp=getchar()){ if(temp<'0'||temp>'9'){ break; } x=x*10+temp-'0'; } return ; }inline void add(llt*temp,llt x,llt y){//加法操作单点增加 while(x<=n){ temp[x]+=y; x+=lowbit(x); } } void update(llt x,llt v){ add(edge1,x,v); add(edge2,x,v*(x-1)); return ; } inline llt get(llt*temp,llt x){//查询前缀和 llt sum=0; while(x>0){ sum+=temp[x]; x-=x&(-x); } return sum; } llt answer(llt x,llt y){ return (y*get(edge1,y)-(x-1)*get(edge1,x-1))-(get(edge2,y)-get(edge2,x-1)); }//第x个数到第y个数的和即前y个数的前缀和减去前(x-1)个数的前缀和 int main(){ llt i; llt t,x=0,y; read(n); read(m); for(i=1;i<=n;i++){ y=x; read(x); y=x-y; update(i,y); } for(i=1;i<=m;i++){ read(t); read(x); read(y); if(t==1){ read(t); update(x,t); update(y+1,-t); } else{ printf("%lld",answer(x,y)); line_feed; } } return 0; }
@分块查找
【分析】
分块查找是折半查找和顺序查找的一种改进方法,分块查找由于只要求索引表是有序的,对块内节点没有排序要求,因此特别适合于节点动态变化的情况。当节点变化很频繁时,可能会导致块与块之间的节点数相差很大,没写快具有很多节点,而另一些块则可能只有很少节点,这将会导致查找效率的下降。
操作步骤
step1 先选取各块中的最大关键字构成一个索引表;
step2 查找分两个部分:先对索引表进行二分查找或顺序查找,以确定待查记录在哪一块中;
然后,在已确定的块中用顺序法进行查找。
线段树的复杂度为O(logn),而分块的时间复杂度为O(sqrt(n)),咋一看好像线段树的复杂度要低得多,但线段树(无优化的)如果要可持续化和树套树,占用空间非常大。分块的拓展性较强,可满足多种变形题型的解决。
由于我并未深入学习分块,以下程序借鉴作者ZJL_OIJR。
【程序】
#include<cstdio> #include<cstring> #include<algorithm> #include<cmath> #define maxn1 100005 #define maxn2 1005 #define llt long long unsigned int llt n,m; llt l,r; llt t,length,tot; llt ans; llt a[maxn1],sum[maxn2],inc[maxn2]; llt b[maxn1],left[maxn2],right[maxn2]; inline int minx(llt a,llt b){ return a<b?a:b; } inline int maxx(llt a,llt b) { return a>b?a:b; } inline void read(llt &x){ char temp; while(temp=getchar()){ if(temp>='0'&&temp<='9'){ x=temp-'0'; break; } } while(temp=getchar()){ if(temp<'0'||temp>'9'){ break; } x=x*10+temp-'0'; } return ; } int main(){ llt i; memset(a,0,sizeof(a)); memset(sum,0,sizeof(sum)); memset(inc,0,sizeof(inc)); memset(b,0,sizeof(b)); memset(left,0,sizeof(left)); memset(right,0,sizeof(right)); read(n); read(m); length=sqrt(n);//得到每一块的长度 tot=n/length;//求出块的个数 if(n%length){ //不能正好分割 tot++;//多一个不完整的块 } for(i=1;i<=n;i++){ read(*(a+i)); *(b+i)=(i-1)/length+1; sum[b[i]]+=a[i];//b[i]表示i所在的块 } for(i=1;i<=tot;i++){ left[i]=(i-1)*length+1,right[i]=i*length;//块的左右边界 } for(;m;m--){ read(t); read(l); read(r); if(t==1){ read(t); for(i=l;i<=minx(r,right[b[l]]);i++){ a[i]+=t; sum[b[i]]+=t;//左边多出来的部分加上 } for(i=r;i>=maxx(l,left[b[r]]);i--){ a[i]+=t; sum[b[i]]+=t;//右边多出来的部分加上 } for(i=b[l]+1;i<=b[r]-1;i++){ inc[i]+=t;//中间的块inc加上t } } else{ ans=0; for(i=l;i<=minx(r,right[b[l]]);i++){ ans+=a[i]+inc[b[i]];//左边的计入答案 } for(i=r;i>=maxx(l,left[b[r]]);i--){ ans+=a[i]+inc[b[i]];//右边的计入答案 } for(i=b[l]+1;i<=b[r]-1;i++){ ans+=sum[i]+inc[i]*(right[i]-left[i]+1);//将中间完整的块计入答案,注意inc要乘以区间长度 } if(b[l]==b[r]){ ans-=a[l]+inc[b[l]]+inc[b[r]]+a[r];//如果l,r在同一块就会重复,减去重复的两端 } printf("%lld ",ans); } } return 0; }//此程序借鉴作者ZJL_OIJR
【总结】
区间修改与区间查询的问题有四种算法可以实现(平衡数Treap没有在文中提到),但如果求区间最值树状数组就无法使用,但如果单纯地求区间和,树状数组是最优解,而分块查询的思想变通性较强,拓展性较强,使用题型较为广泛。线段树代码量较大,但是优化后的速度也较快,因此不同的算法适用于不同的题目。
以下给出本文提到的算法通过例题的总时间:
线段树(未优化): 不通过
线段树(标记下传): 用时:462ms 空间:11.89MB 代码量:1.69KB
线段树(标记永久化):用时:418ms 空间:11.77MB 代码量:1.72KB
树状数组: 用时:147ms 空间:03.28MB 代码量:1.18KB
分块查询: 用时:700ms 空间:02.27MB 代码量:1.68KB
BTW:
以上所有时间复杂度均源自一本通
【参考文献】:百度百科、一本通、博客园