[線段樹]區間修改&區間查詢問題

區間修改&區間查詢問題

【引言】信息學奧賽中常見有區間操做問題,這種類型的題目通常數據規模極大,沒法用簡單的模擬經過,所以本篇論文將討論關於能夠實現區間修改和區間查詢的一部分算法的優越與否。算法

【關鍵詞】區間修改、區間查詢、線段樹、樹狀數組、分塊編程

 

【例題】數組

題目描述:數據結構

如題,已知一個數列,你須要進行下面兩種操做:性能

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

以上全部時間複雜度均源自一本通

【參考文獻】:百度百科、一本通、博客園

相關文章
相關標籤/搜索