[线段树]区间修改&区间查询问题

区间修改&区间查询问题

【引言】信息学奥赛中常见有区间操做问题,这种类型的题目通常数据规模极大,没法用简单的模拟经过,所以本篇论文将讨论关于能够实现区间修改和区间查询的一部分算法的优越与否。算法

【关键词】区间修改、区间查询、线段树、树状数组、分块编程

 

【例题】数组

题目描述:数据结构

如题,已知一个数列,你须要进行下面两种操做:性能

1.将某区间每个数加上x学习

2.求出某区间每个数的和优化

输入格式:ui

第一行包含两个整数NM,分别表示该数列数字的个数和操做的总个数。spa

第二行包含N个用空格分隔的整数,其中第i个数字表示数列第i项的初始值。blog

接下来M行每行包含34个整数,表示一个操做,具体以下:

操做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<=8M<=10

对于70%的数据:N<=1000M<=10000

对于100%的数据:N<=100000M<=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\n",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

以上全部时间复杂度均源自一本通

【参考文献】:百度百科、一本通、博客园

相关文章
相关标签/搜索