線段樹詳解 (原理,實現與應用)

巖之痕發表於2015-09-09

原文連結:https://blog.csdn.net/zearot/article/details/48299459#

 

線段樹詳解

 

By 巖之痕

 

目錄:

一:綜述

二:原理

三:遞迴實現

四:非遞迴原理

五:非遞迴實現

六:線段樹解題模型

七:掃描線

八:可持久化 (主席樹)

九:練習題

 

一:綜述

假設有編號從1到n的n個點,每個點都存了一些資訊,用[L,R]表示下標從L到R的這些點。

線段樹的用處就是,對編號連續的一些點進行修改或者統計操作,修改和統計的複雜度都是O(log2(n)).

線段樹的原理,就是,將[1,n]分解成若干特定的子區間(數量不超過4*n),然後,將每個區間[L,R]都分解為

少量特定的子區間,通過對這些少量子區間的修改或者統計,來實現快速對[L,R]的修改或者統計。

由此看出,用線段樹統計的東西,必須符合區間加法,否則,不可能通過分成的子區間來得到[L,R]的統計結果。

 

符合區間加法的例子:

數字之和——總數字之和 = 左區間數字之和 + 右區間數字之和

最大公因數(GCD)——總GCD = gcd( 左區間GCD , 右區間GCD );

最大值——總最大值=max(左區間最大值,右區間最大值)

不符合區間加法的例子:

眾數——只知道左右區間的眾數,沒法求總區間的眾數

01序列的最長連續零——只知道左右區間的最長連續零,沒法知道總的最長連續零

一個問題,只要能化成對一些連續點的修改和統計問題,基本就可以用線段樹來解決了,具體怎麼轉化在第六節會講。

由於點的資訊可以千變萬化,所以線段樹是一種非常靈活的資料結構,可以做的題的型別特別多,只要會轉化。

線段樹當然是可以維護線段資訊的,因為線段資訊也是可以轉換成用點來表達的(每個點代表一條線段)。

所以在以下對結構的討論中,都是對點的討論,線段和點的對應關係在第七節掃描線中會講。

本文二到五節是講對線段樹操作的原理和實現。

六到八節介紹了線段樹解題模型,以及一些例題。

初學者可以先看這篇文章: 線段樹從零開始

 

二:原理

(注:由於線段樹的每個節點代表一個區間,以下敘述中不區分節點和區間,只是根據語境需要,選擇合適的詞)

線段樹本質上是維護下標為1,2,..,n的n個按順序排列的數的資訊,所以,其實是“點樹”,是維護n的點的資訊,至於每個點的資料的含義可以有很多,

在對線段操作的線段樹中,每個點代表一條線段,在用線段樹維護數列資訊的時候,每個點代表一個數,但本質上都是每個點代表一個數。以下,在討論線段樹的時候,區間[L,R]指的是下標從L到R的這(R-L+1)個數,而不是指一條連續的線段。只是有時候這些數代表實際上一條線段的統計結果而已。

 

線段樹是將每個區間[L,R]分解成[L,M]和[M+1,R] (其中M=(L+R)/2 這裡的除法是整數除法,即對結果下取整)直到 L==R 為止。 

開始時是區間[1,n] ,通過遞迴來逐步分解,假設根的高度為1的話,樹的最大高度為(n>1)。

線段樹對於每個n的分解是唯一的,所以n相同的線段樹結構相同,這也是實現可持久化線段樹的基礎。

下圖展示了區間[1,13]的分解過程:

 

上圖中,每個區間都是一個節點,每個節點存自己對應的區間的統計資訊。

 

(1)線段樹的點修改:

 

假設要修改[5]的值,可以發現,每層只有一個節點包含[5],所以修改了[5]之後,只需要每層更新一個節點就可以線段樹每個節點的資訊都是正確的,所以修改次數的最大值為層數

複雜度O(log2(n))


(2)線段樹的區間查詢:

 

線段樹能快速進行區間查詢的基礎是下面的定理:

定理:n>=3時,一個[1,n]的線段樹可以將[1,n]的任意子區間[L,R]分解為不超過個子區間。

這樣,在查詢[L,R]的統計值的時候,只需要訪問不超過個節點,就可以獲得[L,R]的統計資訊,實現了O(log2(n))的區間查詢。

 

下面給出證明:

 

(2.1)先給出一個粗略的證明(結合下圖):

先考慮樹的最下層,將所有在區間[L,R]內的點選中,然後,若相鄰的點的直接父節點是同一個,那麼就用這個父節點代替這兩個節點(父節點在上一層)。這樣操作之後,本層最多剩下兩個節點。若最左側被選中的節點是它父節點的右子樹,那麼這個節點會被剩下。若最右側被選中的節點是它的父節點的左子樹,那麼這個節點會被剩下。中間的所有節點都被父節點取代。

對最下層處理完之後,考慮它的上一層,繼續進行同樣的處理,可以發現,每一層最多留下2個節點,其餘的節點升往上一層,這樣可以說明分割成的區間(節點)個數是大概是樹高的兩倍左右。

 

下圖為n=13的線段樹,區間[2,12],按照上面的敘述進行操作的過程圖:

由圖可以看出:在n=13的線段樹中,[2,12]=[2] + [3,4] + [5,7] + [8,10] + [11,12] 。

 

 

(2.2)然後給出正式一點的證明:

定理:n>=3時,一個[1,n]的線段樹可以將[1,n]的任意子區間[L,R]分解為不超過個子區間。

 

用數學歸納法,證明上面的定理:

首先,n=3,4,5時,用窮舉法不難證明定理成立。

假設對於n= 3,4,5,...,k-1上式都成立,下面來證明對於n=k ( k>=6 )成立:

分為4種情況來證明:

 

情況一:[L,R]包含根節點(L=1且R=n),此時,[L,R]被分解為了一個節點,定理成立。

 

情況二:[L,R]包含根節點的左子節點,此時[L,R]一定不包含根的右子節點(因為如果包含,就可以合併左右子節點,

用根節點替代,此時就是情況一)。這時,以右子節點為根的這個樹的元素個數為

[L,R]分成的子區間由兩部分組成:

一:根的左子結點,區間數為1 

二:以根的右子節點為根的樹中,進行區間查詢,這個可以遞迴使用本定理。

由歸納假設可得,[L,R]一共被分成了個區間。

情況三:跟情況二對稱,不一樣的是,以根的左子節點為根的樹的元素個數為

[L,R]一共被分成了個區間。

從公式可以看出,情況二的區間數小於等於情況三的區間數,於是只需要證明情況三的區間數符合條件就行了。

於是,情況二和情況三定理成立。

 

情況四:[L,R]不包括根節點以及根節點的左右子節點。

於是,剩下的層,每層最多兩個節點(參考粗略證明中的內容)。

於是[L,R]最多被分解成了個區間,定理成立。

 

 

上面只證明了是上界,但是,其實它是最小上界。

n=3,4時,有很多組區間的分解可以達到最小上界。

當n>4時,當且僅當n=2^t (t>=3),L=2,R=2^t -1 時,區間[L,R]的分解可以達到最小上界

就不證明了,有興趣可以自己去證明。

下圖是n=16 , L=2 , R=15 時的操作圖,此圖展示了達到最小上界的樹的結構。

 

 

 

 

 

(3)線段樹的區間修改:

線段樹的區間修改也是將區間分成子區間,但是要加一個標記,稱作懶惰標記。

標記的含義:

本節點的統計資訊已經根據標記更新過了,但是本節點的子節點仍需要進行更新。

即,如果要給一個區間的所有值都加上1,那麼,實際上並沒有給這個區間的所有值都加上1,而是打個標記,記下來,這個節點所包含的區間需要加1.打上標記後,要根據標記更新本節點的統計資訊,比如,如果本節點維護的是區間和,而本節點包含5個數,那麼,打上+1的標記之後,要給本節點維護的和+5。這是向下延遲修改,但是向上顯示的資訊是修改以後的資訊,所以查詢的時候可以得到正確的結果。有的標記之間會相互影響,所以比較簡單的做法是,每遞迴到一個區間,首先下推標記(若本節點有標記,就下推標記),然後再打上新的標記,這樣仍然每個區間操作的複雜度是O(log2(n))。

 

標記有相對標記絕對標記之分:

相對標記是將區間的所有數+a之類的操作,標記之間可以共存,跟打標記的順序無關(跟順序無關才是重點)。

所以,可以在區間修改的時候不下推標記,留到查詢的時候再下推。

      注意:如果區間修改時不下推標記,那麼PushUp函式中,必須考慮本節點的標記。

                 而如果所有操作都下推標記,那麼PushUp函式可以不考慮本節點的標記,因為本節點的標記一定已經被下推了(也就是對本節點無效了)

絕對標記是將區間的所有數變成a之類的操作,打標記的順序直接影響結果,

所以這種標記在區間修改的時候必須下推舊標記,不然會出錯。

 

注意,有多個標記的時候,標記下推的順序也很重要,錯誤的下推順序可能會導致錯誤。

 

之所以要區分兩種標記,是因為非遞迴線段樹只能維護相對標記。

因為非遞迴線段樹是自底向上直接修改分成的每個子區間,所以根本做不到在區間修改的時候下推標記。

非遞迴線段樹一般不下推標記,而是自下而上求答案的過程中,根據標記更新答案。

 

(4)線段樹的儲存結構:

線段樹是用陣列來模擬樹形結構,對於每一個節點R ,左子節點為 2*R (一般寫作R<<1)右子節點為 2*R+1(一般寫作R<<1|1)

然後以1為根節點,所以,整體的統計資訊是存在節點1中的。

這麼表示的原因看下圖就很明白了,左子樹的節點標號都是根節點的兩倍,右子樹的節點標號都是左子樹+1:

線段樹需要的陣列元素個數是:,一般都開4倍空間,比如: int A[n<<2];

 

 

三:遞迴實現

以下以維護數列區間和的線段樹為例,演示最基本的線段樹程式碼。

(0)定義:

#define maxn 100007  //元素總個數
#define ls l,m,rt<<1
#define rs m+1,r,rt<<1|1
int Sum[maxn<<2],Add[maxn<<2];//Sum求和,Add為懶惰標記 
int A[maxn],n;//存原陣列資料下標[1,n] 

(1)建樹:

//PushUp函式更新節點資訊 ,這裡是求和
void PushUp(int rt){Sum[rt]=Sum[rt<<1]+Sum[rt<<1|1];}
//Build函式建樹 
void Build(int l,int r,int rt){ //l,r表示當前節點區間,rt表示當前節點編號
	if(l==r) {//若到達葉節點 
		Sum[rt]=A[l];//儲存陣列值 
		return;
	}
	int m=(l+r)>>1;
	//左右遞迴 
	Build(l,m,rt<<1);
	Build(m+1,r,rt<<1|1);
	//更新資訊 
	PushUp(rt);
}

(2)點修改:

假設A[L]+=C:

void Update(int L,int C,int l,int r,int rt){//l,r表示當前節點區間,rt表示當前節點編號
	if(l==r){//到葉節點,修改 
		Sum[rt]+=C;
		return;
	}
	int m=(l+r)>>1;
	//根據條件判斷往左子樹呼叫還是往右 
	if(L <= m) Update(L,C,l,m,rt<<1);
	else       Update(L,C,m+1,r,rt<<1|1);
	PushUp(rt);//子節點更新了,所以本節點也需要更新資訊 
} 

(3)區間修改:

假設A[L,R]+=C

void Update(int L,int R,int C,int l,int r,int rt){//L,R表示操作區間,l,r表示當前節點區間,rt表示當前節點編號 
	if(L <= l && r <= R){//如果本區間完全在操作區間[L,R]以內 
		Sum[rt]+=C*(r-l+1);//更新數字和,向上保持正確
		Add[rt]+=C;//增加Add標記,表示本區間的Sum正確,子區間的Sum仍需要根據Add的值來調整
		return ; 
	}
	int m=(l+r)>>1;
	PushDown(rt,m-l+1,r-m);//下推標記
	//這裡判斷左右子樹跟[L,R]有無交集,有交集才遞迴 
	if(L <= m) Update(L,R,C,l,m,rt<<1);
	if(R >  m) Update(L,R,C,m+1,r,rt<<1|1); 
	PushUp(rt);//更新本節點資訊 
} 

 

(4)區間查詢:

詢問A[L,R]的和

首先是下推標記的函式:

void PushDown(int rt,int ln,int rn){
	//ln,rn為左子樹,右子樹的數字數量。 
	if(Add[rt]){
		//下推標記 
		Add[rt<<1]+=Add[rt];
		Add[rt<<1|1]+=Add[rt];
		//修改子節點的Sum使之與對應的Add相對應 
		Sum[rt<<1]+=Add[rt]*ln;
		Sum[rt<<1|1]+=Add[rt]*rn;
		//清除本節點標記 
		Add[rt]=0;
	}
}


然後是區間查詢的函式:

int Query(int L,int R,int l,int r,int rt){//L,R表示操作區間,l,r表示當前節點區間,rt表示當前節點編號
	if(L <= l && r <= R){
		//在區間內,直接返回 
		return Sum[rt];
	}
	int m=(l+r)>>1;
	//下推標記,否則Sum可能不正確
	PushDown(rt,m-l+1,r-m); 
	
	//累計答案
	int ANS=0;
	if(L <= m) ANS+=Query(L,R,l,m,rt<<1);
	if(R >  m) ANS+=Query(L,R,m+1,r,rt<<1|1);
	return ANS;
} 

 

(5)函式呼叫:

	
	//建樹 
	Build(1,n,1); 
	//點修改
	Update(L,C,1,n,1);
	//區間修改 
	Update(L,R,C,1,n,1);
	//區間查詢 
	int ANS=Query(L,R,1,n,1);

感謝幾位網友指出了我的錯誤。

我說相對標記在Update時可以不下推,這一點是對的,但是原來的程式碼是錯誤的。

因為原來的程式碼中,PushUP函式是沒有考慮本節點的Add值的,如果Update時下推標記,那麼PushUp的時候,節點的Add值一定為零,所以不需要考慮Add。

但是,如果Update時暫時不下推標記的話,那麼PushUp函式就必須考慮本節點的Add值,否則會導致錯誤。

為了簡便,上面函式中,PushUp函式沒有考慮Add標記。所以無論是相對標記還是絕對標記,在更新資訊的時候,

到達的每個節點都必須呼叫PushDown函式來下推標記,另外,程式碼中,點修改函式中沒有PushDown函式,因為這裡假設只有點修改一種操作,

如果題目中是點修改和區間修改混合的話,那麼點修改中也需要PushDown。


 

四:非遞迴原理

非遞迴的思路很巧妙,思路以及部分程式碼實現 來自  清華大學 張昆瑋 《統計的力量》 ,有興趣可以去找來看。

非遞迴的實現,程式碼簡單(尤其是點修改和區間查詢),速度快,建樹簡單,遍歷元素簡單。總之能非遞迴就非遞迴吧。

不過,要支援區間修改的話,程式碼會變得複雜,所以區間修改的時候還是要取捨。有個特例,如果區間修改,但是隻需要

在所有操作結束之後,一次性下推所有標記,然後求結果,這樣的話,非遞迴寫起來也是很方便的。

下面先講思路,再講實現。

 

點修改:

非遞迴的思想總的來說就是自底向上進行各種操作。回憶遞迴線段樹的點修改,首先由根節點1向下遞迴,找到對應的葉

節點,然後,修改葉節點的值,再向上返回,在函式返回的過程中,更新路徑上的節點的統計資訊。而非遞迴線段樹的思路是,

如果可以直接找到葉節點,那麼就可以直接從葉節點向上更新,而一個節點找父節點是很容易的,編號除以2再下取整就行了。

那麼,如何可以直接找到葉節點呢?非遞迴線段樹擴充了普通線段樹(假設元素數量為n),使得所有非葉結點都有兩個子結點且葉子結點都在同一層。

來觀察一下擴充後的性質:

 

可以注意到紅色和黑色數字的差是固定的,如果事先算出這個差值,就可以直接找到葉節點。

 

注意:區分3個概念:原陣列下標,線段樹中的下標和儲存下標。

原陣列下標,是指,需要維護統計資訊(比如區間求和)的陣列的下標,這裡都預設下標從1開始(一般用A陣列表示)

線段樹下標,是指,加入線段樹中某個位置的下標,比如,原陣列中的第一個數,一般會加入到線段樹中的第二個位置,

為什麼要這麼做,後面會講。

儲存下標,是指該元素所在的葉節點的編號,即實際儲存的位置。

 

【在上面的圖片中,紅色為原陣列下標,黑色為儲存下標】

 

有了這3個概念,下面開始講區間查詢。

 

點修改下的區間查詢:

首先,區間的劃分沒有變,現在關鍵是如何直接找到被分成的區間。原來是遞迴查詢,判斷左右子區間跟[L,R]是否有交點,

若有交點則向下遞迴。現在要非遞迴實現,這就是巧妙之處,見下圖,以查詢[3,11]為例子。

 

 

 

其實,容易發現,紫色部分的變化,跟原來分析線段樹的區間分解的時候是一樣的規則,圖中多的藍色是什麼意思呢?

首先注意到,藍色節點剛好在紫色節點的兩端。

回憶一下,原來線段樹在區間逐層被替代的過程中,哪些節點被留了下來?最左側的節點,若為其父節點的右子節點,則留下。

最右側的節點,若為其父節點的左子節點則留下。那麼對於包裹著紫色的藍色節點來看,剛好相反。

比如,以左側的的藍色為例,若該節點是其父節點的右子節點,就證明它右側的那個紫色節點不會留下,會被其父替代,所以沒必要在這一步計算,若該節點是其父節點的左子節點,就證明它右側的那個紫色節點會留在這一層,所以必須在此刻計算,否則以後都不會再計算這個節點了。這樣逐層上去,容易發現,對於左側的藍色節點來說,只要它是左子節點,那麼就要計算對應的右子節點。同理,對於右側的藍色節點,只要它是右子節點,就需要計算它對應的左子節點。這個計算一直持續到左右藍色節點的父親為同一個的時候,才停止。於是,區間查詢,其實就是兩個藍色節點一路向上走,在路徑上更新答案。這樣,區間修改就變成了兩條同時向根走的鏈,明顯複雜度O(log2(n))。並且可以非遞迴實現。

至此,區間查詢也解決了,可以直接找到所有分解成的區間。

但是有一個問題,如果要查詢[1,5]怎麼辦?[1]左邊可是沒地方可以放置藍色節點了。

問題的解決辦法簡單粗暴,原陣列的1到n就不存線上段樹的1到n了,而是存線上段樹的2到n+1,

而開始要建立一顆有n+2個元素的樹,空出第一個和最後一個元素的空間。

 

現在來講如何對線段樹進行擴充。

再來看這個二叉樹,令N=8;注意到,該樹可以存8個元素,並且[1..7]是非葉節點,[8..15]是葉節點。

也就是說,左下角為N的二叉樹,可以存N個元素,並且[1..N-1]是非葉節點,[N..2N-1]是葉節點。

並且,線段樹下標+N-1=儲存下標 (還記不記得原來對三個下標的定義)

 

這時,這個線段樹存在兩段座標對映:

原陣列下標+1=線段樹下標

線段樹下標+N-1=儲存下標 

聯立方程得到:原陣列下標+N=儲存下標

於是從原陣列下標到儲存下標的轉換及其簡單。

 

下一個問題:N怎麼確定?

上面提到了,N的含義之一是,這棵樹可以存N個元素,也就是說N必須大於等於n+2

於是,N的定義,N是大於等於n+2的,某個2的次方。

 

區間修改下的區間查詢:

方法之一:如果題目許可,可以直接打上標記,最後一次下推所有標記,然後就可以遍歷葉節點來獲取資訊。

方法之二:如果題目查詢跟修改混在一起,那麼,採用標記永久化思想。也就是,不下推標記。

遞迴線段樹是在查詢區間的時候下推標記,使得到達每個子區間的時候,Sum已經是正確值。

非遞迴沒法這麼做,非遞迴是從下往上,遇到標記就更新答案。

這題是Add標記,一個區間Add標記表示這個區間所有元素都需要增加Add

Add含義不變,Add仍然表示本節點的Sum已經更新完畢,但是子節點的Sum仍需要更新.

現在就是如何在查詢的時候根據標記更新答案。

觀察下圖:

左邊的藍色節點從下往上走,在藍色節點到達[1,4]時,注意到,左邊藍色節點之前計算過的所有節點(即[3,4])都是目前藍色節點的子節點也就是說,當前藍色節點的Add是要影響這個節點已經計算過的所有數。多用一個變數來記錄這個藍色節點已經計算過多少個數,根據個數以及當前藍色節點的Add,來更新最終答案。

更新完答案之後,再加上[5,8]的答案,同時當前藍色節點計算過的個數要+4(因為[5,8]裡有4個數)

然後當這個節點到達[1,8]節點時,可以更新[1,8]的Add.

這裡,本來左右藍色節點相遇之後就不再需要計算了,但是由於有了Add標記,左右藍色節點的公共祖先上的Add標記會影響目前的所有數,所以還需要一路向上查詢到根,沿路根據Add更新答案。

 

區間修改:

這裡講完了查詢,再來講講修改

修改的時候,給某個區間的Add加上了C,這個區間的子區間向上查詢時,會經過這個節點,也就是會計算這個Add,但是

如果路徑經過這個區間的父節點,就不會計算這個節點的Add,也就會出錯。這裡其實跟遞迴線段樹一樣,改了某個區間的Add

仍需要向上更新所有包含這個區間的Sum,來保持上面所有節點的正確性。

 

五:非遞迴實現

以下以維護數列區間和的線段樹為例,演示最基本的非遞迴線段樹程式碼。

(0)定義

// 
#define maxn 100007
int A[maxn],n,N;//原陣列,n為原陣列元素個數 ,N為擴充元素個數 
int Sum[maxn<<2];//區間和 
int Add[maxn<<2];//懶惰標記 

 

(1)建樹:

//
void Build(int n){
	//計算N的值 
	N=1;while(N < n+2) N <<= 1;
	//更新葉節點 
	for(int i=1;i<=n;++i) Sum[N+i]=A[i];//原陣列下標+N=儲存下標
	//更新非葉節點 
	for(int i=N-1;i>0;--i){
		//更新所有非葉節點的統計資訊 
		Sum[i]=Sum[i<<1]+Sum[i<<1|1];
		//清空所有非葉節點的Add標記 
		Add[i]=0;
	}
} 

 

(2)點修改:

A[L]+=C

//
void Update(int L,int C){
	for(int s=N+L;s;s>>=1){
		Sum[s]+=C;
	}
} 

(3)點修改下的區間查詢:

求A[L..R]的和(點修改沒有使用Add所以不需要考慮)

程式碼非常簡潔,也不難理解,

s和t分別代表之前的論述中的左右藍色節點,其餘的程式碼根據之前的論述應該很容易看懂了。

s^t^1 在s和t的父親相同時值為0,終止迴圈。

兩個if是判斷s和t分別是左子節點還是右子節點,根據需要來計算Sum

//
int Query(int L,int R){
	int ANS=0;
	for(int s=N+L-1,t=N+R+1;s^t^1;s>>=1,t>>=1){
		if(~s&1) ANS+=Sum[s^1];
		if( t&1) ANS+=Sum[t^1];
	}
	return ANS;
} 

(4)區間修改:

A[L..R]+=C

<span style="font-size:14px;">//
void Update(int L,int R,int C){
	int s,t,Ln=0,Rn=0,x=1;
	//Ln:  s一路走來已經包含了幾個數
	//Rn:  t一路走來已經包含了幾個數
	//x:   本層每個節點包含幾個數
	for(s=N+L-1,t=N+R+1;s^t^1;s>>=1,t>>=1,x<<=1){
		//更新Sum
		Sum[s]+=C*Ln;
		Sum[t]+=C*Rn;
		//處理Add
		if(~s&1) Add[s^1]+=C,Sum[s^1]+=C*x,Ln+=x;
		if( t&1) Add[t^1]+=C,Sum[t^1]+=C*x,Rn+=x;
	}
	//更新上層Sum
	for(;s;s>>=1,t>>=1){
		Sum[s]+=C*Ln;
		Sum[t]+=C*Rn;
	} 
} </span>

(5)區間修改下的區間查詢:

求A[L..R]的和

//
int Query(int L,int R){
	int s,t,Ln=0,Rn=0,x=1;
	int ANS=0;
	for(s=N+L-1,t=N+R+1;s^t^1;s>>=1,t>>=1,x<<=1){
		//根據標記更新 
		if(Add[s]) ANS+=Add[s]*Ln;
		if(Add[t]) ANS+=Add[t]*Rn;
		//常規求和 
		if(~s&1) ANS+=Sum[s^1],Ln+=x;
		if( t&1) ANS+=Sum[t^1],Rn+=x; 
	}
	//處理上層標記
	for(;s;s>>=1,t>>=1){
		ANS+=Add[s]*Ln;
		ANS+=Add[t]*Rn;
	}
	return ANS;
}

六:線段樹解題模型

給出線段樹解題模型以及一些例題。

先對圖中各個名字給出定義:

問題:可能可以用線段樹解決的問題

目標資訊:由問題轉換而成的,為了解決問題而需要統計的資訊(可能不滿足區間加法)。

點資訊:每個點儲存的資訊

區間資訊:每個區間維護的資訊(線段樹節點定義) (必須滿足區間加法)

區間資訊包括 統計資訊標記

--------統計資訊:統計節點代表的區間的資訊,一般自下而上更新

--------標記:對操作進行標記(在區間修改時需要),一般自上而下傳遞,或者不傳遞

區間加法:實現區間加法的程式碼

查詢:實現查詢操作的程式碼

修改:實現修改操作的程式碼

 

圖中紫線右邊是實際線段樹的實現,左邊是對問題的分析以及轉換。

 

一個問題,若能轉換成對一些連續點的修改或者統計,就可以考慮用線段樹解決。

首先確定目標資訊點資訊,然後將目標資訊轉換成區間資訊(必要時,增加資訊,使之符合區間加法)。

之後就是線段樹的程式碼實現了,包括:

1.區間加法 

2.建樹,點資訊到區間資訊的轉換 

3.每種操作(包括查詢,修改)對區間資訊的呼叫,修改

 

這樣,點的資訊不同,區間資訊不同,線段樹可以維護很多種類的資訊,所以是一種非常實用的資料結構。

可以解決很多問題,下面給出幾個例子來說明。

 

(1):字串雜湊

題目:URAL1989 Subpalindromes    題解

給定一個字串(長度<=100000),有兩個操作。   1:改變某個字元。 2:判斷某個子串是否構成迴文串。 

直接判斷會超時。這個題目,是用線段樹維護字串雜湊

對於一個字串a[0],a[1],...,a[n-1] 它對應的雜湊函式為a[0]+a[1]*K + a[2]*K^2 +...+a[n-1]*K^(n-1)

再維護一個從右往左的雜湊值:a[0]*K^(n-1) + a[1]*K^(n-2) +...+a[n-1]

若是迴文串,則左右的雜湊值會相等。而左右雜湊值相等,則很大可能這是迴文串。

若出現誤判,可以再用一個K2,進行二次雜湊判斷,可以減小誤判概率。

實現上,雜湊值最好對某個質數取餘數,這樣分佈更均勻。

 

 

解題模型:

問題經過轉換之後:

目標資訊:某個區間的左,右雜湊值

點資訊:一個字元

目標資訊已經符合區間加法,所以區間資訊=目標資訊

所以線段樹的結構為:

區間資訊:區間雜湊值

點資訊:一個字元

程式碼主要需要注意2個部分:

1.區間加法 :(PushUp函式,Pow[a]=K^a)

2.點資訊->區間資訊:(葉節點上,區間只包含一個點,所以需要將點資訊轉換成區間資訊)

修改以及查詢,在有了區間加法的情況下,沒什麼難度了。

 

可以看出,上述解題過程的核心,就是找到區間資訊, 寫好區間加法

下面是維護區間和的部分,下面的程式碼沒有取餘,也就是實際上是對2^32取餘數,這樣其實分佈不均勻,容易出現誤判:

// 
#define K 137
#define maxn 100001 
char str[maxn];
int Pow[maxn];//K的各個次方 
struct Node{
	int KeyL,KeyR;
	Node():KeyL(0),KeyR(0){}
	void init(){KeyL=KeyR=0;}
}node[maxn<<2];
void PushUp(int L,int R,int rt){
	node[rt].KeyL=node[rt<<1].KeyL+node[rt<<1|1].KeyL*Pow[L];
	node[rt].KeyR=node[rt<<1].KeyR*Pow[R]+node[rt<<1|1].KeyR;
}

(2):最長連續零

題目:Codeforces 527C Glass Carving   題解

題意是給定一個矩形,不停地縱向或橫向切割,問每次切割後,最大的矩形面積是多少。

最大矩形面積=最長的長*最寬的寬

這題,長寬都是10^5,所以,用01序列表示每個點是否被切割,然後,

最長的長就是長的最長連續0的數量+1

最長的寬就是寬的最長連續0的數量+1

於是用線段樹維護最長連續零

 

問題轉換成:

目標資訊:區間最長連續零的個數

點資訊:0 或 1

由於目標資訊不符合區間加法,所以要擴充目標資訊。

 

轉換後的線段樹結構

區間資訊:從左,右開始的最長連續零,本區間是否全零,本區間最長連續零。

點資訊:0 或 1

然後還是那2個問題:

 

1.區間加法:

這裡,一個區間的最長連續零,需要考慮3部分:

-(1):左子區間最長連續零

-(2):右子區間最長連續零

-(3):左右子區間拼起來,而在中間生成的連續零(可能長於兩個子區間的最長連續零)

而中間拼起來的部分長度,其實是左區間從右開始的最長連續零+右區間從左開始的最長連續零。

所以每個節點需要多兩個量,來存從左右開始的最長連續零。

然而,左開始的最長連續零分兩種情況,

--(1):左區間不是全零,那麼等於左區間的左最長連續零

--(2):左區間全零,那麼等於左區間0的個數加上右區間的左最長連續零

於是,需要知道左區間是否全零,於是再多加一個變數。

最終,通過維護4個值,達到了維護區間最長連續零的效果。

 

2.點資訊->區間資訊 : 

如果是0,那麼  最長連續零=左最長連續零=右最長連續零=1 ,全零=true。

如果是1,那麼  最長連續零=左最長連續零=右最長連續零=0, 全零=false。

 

至於修改和查詢,有了區間加法之後,機械地寫一下就好了。

由於這裡其實只有對整個區間的查詢,所以查詢函式是不用寫的,直接找根的統計資訊就行了。

 

程式碼如下:

//
#define maxn 200001
using namespace std;
int L[maxn<<2][2];//從左開始連續零個數 
int R[maxn<<2][2];//從右 
int Max[maxn<<2][2];//區間最大連續零 
bool Pure[maxn<<2][2];//是否全零 
int M[2];
void PushUp(int rt,int k){//更新rt節點的四個資料  k來選擇兩棵線段樹
	Pure[rt][k]=Pure[rt<<1][k]&&Pure[rt<<1|1][k]; 
	Max[rt][k]=max(R[rt<<1][k]+L[rt<<1|1][k],max(Max[rt<<1][k],Max[rt<<1|1][k]));
	L[rt][k]=Pure[rt<<1][k]?L[rt<<1][k]+L[rt<<1|1][k]:L[rt<<1][k];
	R[rt][k]=Pure[rt<<1|1][k]?R[rt<<1|1][k]+R[rt<<1][k]:R[rt<<1|1][k];
}
 

(3):計數排序

題目:Codeforces 558E A Simple Task  題解

給定一個長度不超過10^5的字串(小寫英文字母),和不超過5000個操作。

每個操作 L R K 表示給區間[L,R]的字串排序,K=1為升序,K=0為降序。

最後輸出最終的字串。

 

題目轉換成:

目標資訊:區間的計數排序結果

點資訊:一個字元

這裡,目標資訊是符合區間加法的,但是為了支援區間操作,還是需要擴充資訊。

 

轉換後的線段樹結構

區間資訊:區間的計數排序結果,排序標記,排序種類(升,降)

點資訊:一個字元

 

程式碼中需要解決的四個問題(難點在於標記下推和區間修改):

1.區間加法

對應的字元數量相加即可(注意標記是不上傳的,所以區間加法不考慮標記)。

2.點資訊->區間資訊:把對應字元的數量設定成1,其餘為0,排序標記為false。

3.標記下推

明顯,排序標記是絕對標記,也就是說,標記對子節點是覆蓋式的效果,一旦被打上標記,下層節點的一切資訊都無效。

下推標記時,根據自己的排序結果,將元素分成對應的部分,分別裝入兩個子樹。

4.區間修改

這個是難點,由於要對某個區間進行排序,首先對各個子區間求和(求和之前一定要下推標記,才能保證求的和是正確的)

由於使用的計數排序,所以求和之後,新順序也就出來了。然後按照排序的順序按照每個子區間的大小來分配字元。

操作後,每個子區間都被打上了標記。

 

最後,在所有操作結束之後,一次下推所有標記,就可以得到最終的字元序列。

 

這裡只給出節點定義。

// 
struct Node{
	int d[26];//計數排序 
	int D;//總數
	bool sorted;//是否排好序 
	bool Inc;//是否升序
};

(4)總結:

總結一下,線段樹解題步驟。

 

:將問題轉換成點資訊目標資訊

即,將問題轉換成對一些點的資訊的統計問題。

 

:將目標資訊根據需要擴充成區間資訊

1.增加資訊符合區間加法。

2.增加標記支援區間操作。

 

:程式碼中的主要模組:

1.區間加法 

2.標記下推 

3.點資訊->區間資訊 

4.操作(各種操作,包括修改和查詢)

 

 

完成第一步之後,題目有了可以用線段樹解決的可能。

完成第二步之後,題目可以由線段樹解決。

第三步就是慢慢寫程式碼了。
 

七:掃描線

線段樹的一大應用是掃描線。

 

先把相關題目給出,有興趣可以去找來練習:

 

POJ 1177 Picture:給定若干矩形求合併之後的圖形周長    題解

HDU 1255 覆蓋的面積:給定平面上若干矩形,求出被這些矩形覆蓋過至少兩次的區域的面積.   題解

HDU 3642 Get The Treasury:給定若干空間立方體,求重疊了3次或以上的體積(這個是掃描面,每個面再掃描線)題解

再補充一道稍微需要一點模型轉換的掃描線題:

POJ 2482 Stars in your window : 給定一些星星的位置和亮度,求用W*H的矩形能夠框住的星星亮度之和最大為多少。

這題是把星星轉換成了矩形,把矩形框轉換成了點,然後再掃描線。  題解

 

 

掃描線求重疊矩形面積:

考慮下圖中的四個矩形:

 

 

觀察第三個圖:

掃描線的思路:使用一條垂直於X軸的直線,從左到右來掃描這個圖形,明顯,只有在碰到矩形的左邊界或者右邊界的時候,

這個線段所掃描到的情況才會改變,所以把所有矩形的入邊,出邊按X值排序。然後根據X值從小到大去處理,就可以

用線段樹來維護掃描到的情況。如上圖,X1到X8是所有矩形的入邊,出邊的X座標。

而紅色部分的線段,是這樣,如果碰到矩形的入邊,就把這條邊加入,如果碰到出邊,就拿走。紅色部分就是有線段覆蓋的部分。

要求面積,只需要知道圖中的L1到L8。而線段樹就是用來維護這個L1到L8的。

掃描線演算法流程:

 

X1:首先遇到X1,將第一條線段加入線段樹,由線段樹統計得到線段長度為L1.

 

X2:然後繼續掃描到X2,此時要進行兩個動作:

1.計算面積,目前掃過的面積=L1*(X2-X1)

2.更新線段。由於X2處仍然是入邊,所以往線段樹中又加了一條線段,加的這條線段可以參考3幅圖中的第一幅。

然後線段樹自動得出此時覆蓋的線段長度為L2 (注意兩條線段有重疊部分,重疊部分的長度只能算一次)

 

X3:繼續掃描到X3,步驟同X2

先計算 掃過的面積+=L2*(X3-X2)

再加入線段,得到L3.

 

X4:掃描到X4有些不一樣了。

首先還是計算  掃過的面積+=L3*(X4-X3)

然後這時遇到了第一個矩形的出邊,這時要從線段樹中刪除一條線段。

刪除之後的結果是線段樹中出現了2條線段,線段樹自動維護這兩條線段的長度之和L4

 

講到這裡演算法流程應該很清晰了。

首先將所有矩形的入邊,出邊都存起來,然後根據X值排序。

這裡用一個結構體,來存這些資訊,然後排序。

//
struct LINE{
	int x;//橫座標 
	int y1,y2;//矩形縱向線段的左右端點 
	bool In;//標記是入邊還是出邊 
	bool operator < (const Line &B)const{return x < B.x;}
}Line[maxn]; 


然後掃描的時候,需要兩個變數,一個叫PreL,存前一個x的操作結束之後的L值,和X,前一個橫座標。

假設一共有Ln條線段,線段下標從0開始,已經排好序。

那麼演算法大概是這樣:

//
int PreL=0;//前一個L值,剛開始是0,所以第一次計算時不會引入誤差 
int X;//X值 
int ANS=0;//存累計面積
int I=0;//線段的下標 
 
while(I < Ln){
	//先計算面積 
	ANS+=PreL*(Line[I].x-X);
	X=Line[I].x;//更新X值
	//對所有X相同的線段進行操作 
	while(I < Ln && Line[I].x == X){
		//根據入邊還是出邊來選擇加入線段還是移除線段 
		if(Line[I].In) Cover(Line[I].y1,Line[I].y2-1,1,n,1);
		else         Uncover(Line[I].y1,Line[I].y2-1,1,n,1);
		++I;
	} 
}

無論是求面積還是周長,掃描線的結構大概就是上面的樣子。

 

需要解決的幾個問題:

現在有兩點需要說明一下。

(1):線段樹進行線段操作時,每個點的含義(比如為什麼Cover函式中,y2後面要-1)。

(2):線段樹如何維護掃描線過程中的覆蓋線段長度。

(3):線段樹如何維護掃描線過程中線段的數量。

(1):線段樹中點的含義

線段樹如果沒有離散化,那麼線段樹下標為1,就代表線段[1,2)

線段樹下標為K的時候,代表的線段為[K,K+1) (長度為1)

所以,將上面的所有線段都化為[y1,y2)就可以理解了,線段[y1,y2)只包括線段樹下標中的y1,y1+1,...,y2-1

當y值的範圍是10^9時,就不能再按照上面的辦法按值建樹了,這時需要離散化。

下面是離散化的程式碼:

//
int Rank[maxn],Rn;
void SetRank(){//呼叫前,所有y值被無序存入Rank陣列,下標為[1..Rn] 
	int I=1;
	//第一步排序 
	sort(Rank+1,Rank+1+Rn); 
	//第二步去除重複值 
	for(int i=2;i<=Rn;++i) if(Rank[i]!=Rank[i-1]) Rank[++I]=Rank[i];
	Rn=I;
	//此時,所有y值被從小到大無重複地存入Rank陣列,下標為[1..Rn] 
} 
int GetRank(int x){//給定x,求x的下標 
	//二分法求下標 
	int L=1,R=Rn,M;//[L,R] first >=x
	while(L!=R){
		M=(L+R)>>1;
		if(Rank[M]<x) L=M+1;
		else R=M;
	}
	return L;
}

此時,線段樹的下標的含義就變成:如果線段樹下標為K,代表線段[ Rank[K] , Rank[K+1] )。

下標為K的線段長度為Rank[K+1]-Rank[K]

所以此時葉節點的線段長度不是1了。

這時,之前的掃描線演算法的函式呼叫部分就稍微的改變了一點:

// 
if(Line[I].In) Cover(GetRank(Line[I].y1),GetRank(Line[I].y2)-1,1,n,1);
else         Uncover(GetRank(Line[I].y1),GetRank(Line[I].y2)-1,1,n,1);

看著有點長,其實不難理解,只是多了一步從y值到離散之後的下標的轉換。

 

注意一點,如果下標為K的線段長度為Rank[K+1]-Rank[K],那麼下標為Rn的線段樹的長度呢?

其實這個不用擔心,Rank[Rn]作為所有y值中的最大值,它肯定是一個線段的右端點,

而右端點求完離散之後的下標還要-1,所以上面的線段覆蓋永遠不會覆蓋到Rn。

所以線段樹其實只需要建立Rn-1個元素,因為下標為Rn的無法定義,也不會被訪問。

不過有時候留著也有好處,這個看具體實現時自己取捨。

 

(2):如何維護覆蓋線段長度

先提一個小技巧,一般,利用兩個子節點來更新本節點的函式寫成PushUp();

但是,對於比較複雜的子區間合併問題,在區間查詢的時候,需要合併若干個子區間。

而合併子區間是沒辦法用PushUp函式的。於是,對於比較複雜的問題,把單個節點的資訊寫成一個結構體。

在結構體內過載運算子"+",來實現區間合併。這樣,不僅在PushUp函式可以呼叫這個加法,區間詢問時也可以

呼叫這個加法,這樣更加方便。

 

下面給出維護線段覆蓋長度的節點定義:

//
struct Node{
	int Cover;//區間整體被覆蓋的次數 
	int L;//Length : 所代表的區間總長度
	int CL;//Cover Length :實際覆蓋長度
	Node operator +(const Node &B)const{
		Node X;
		X.Cover=0;//因為若上級的Cover不為0,不會呼叫子區間加法函式 
		X.L=L+B.L;
		X.CL=CL+B.CL;
		return X;
	}
}K[maxn<<2];

這樣定義之後,區間的資訊更新是這樣的:

若本區間的覆蓋次數大於0,那麼令CL=L,直接為全覆蓋,不管下層是怎麼覆蓋的,反正本區間已經全被覆蓋。

若本區間的覆蓋次數等於0,那麼呼叫上面結構體中的加法函式,利用子區間的覆蓋來計算。

加入一條線段就是給每一個分解的子區間的Cover+1,刪除線段就-1,每次修改Cover之後,更新區間資訊。

這裡完全沒有下推標記的過程。

查詢的程式碼如下:

如果不把區間加法定義成結構體內部的函式,而是定義在PushUp函式內,那麼這裡幾乎就要重寫一遍區間合併。

因為PushUp在這裡用不上。

//
Node Query(int L,int R,int l,int r,int rt){
	if(L <= l && r <= R){
		return K[rt];
	}
	int m=(l+r)>>1;
	Node LANS,RANS;
	int X=0;
	if(L <= m) LANS=Query(L,R,ls),X+=1;
	if(R >  m) RANS=Query(L,R,rs),X+=2;
	if(X==1) return LANS;
	if(X==2) return RANS;
	return LANS+RANS;
}

維護線段覆蓋3次或以上的長度:

//
struct Nodes{  
    int C;//Cover  
    int CL[4];//CoverLength[0~3]  
    //CL[i]表示被覆蓋了大於等於i次的線段長度,CL[0]其實就是線段總長 
}ST[maxn<<2];  
void PushUp(int rt){  
    for(int i=1;i<=3;++i){  
        if(ST[rt].C < i) ST[rt].CL[i]=ST[rt<<1].CL[i-ST[rt].C]+ST[rt<<1|1].CL[i-ST[rt].C];  
        else ST[rt].CL[i]=ST[rt].CL[0];  
    }  
}  


這裡給出節點定義和PushUp().

更新節點資訊的思路大概就是:

假設要更新CL[3],然後發現本節點被覆蓋了2次,那麼本節點被覆蓋三次或以上的長度就等於子節點被覆蓋了1次或以上的長度之和。

而CL[0]建樹時就賦值,之後不需要修改。

 

(3):如何維護掃描線過程中線段的數量


 
// 
struct Node{  
    int cover;//完全覆蓋層數  
    int lines;//分成多少個線段  
    bool L,R;//左右端點是否被覆蓋  
    Node operator +(const Node &B){//連續區間的合併   
        Node C;  
        C.cover=0;  
        C.lines=lines+B.lines-(R&&B.L);  
        C.L=L;C.R=B.R;  
        return C;  
    }  
}K[maxn<<2];  


要維護被分成多少個線段,就需要記錄左右端點是否被覆蓋,知道了這個,就可以合併區間了。

左右兩個區間合併時,若左區間的最右側有線段且右區間的最左側也有線段,那麼這兩個線段會合二為一,於是匯流排段數量會少1.

 

掃描線求重疊矩形周長:

這個圖是在原來的基礎上多畫了一些東西,這次是要求周長。

所有的橫向邊都畫了紫色,所有的縱向邊畫了綠色。

 

先考慮綠色的邊,由圖可以觀察到,綠色邊的長度其實就是L的變化值。

比如考慮X1,本來L是0,從0變到L1,所以綠色邊長為L1.

再考慮X2,由L1變成了L2,所以綠色邊長度為L2-L1,

於是,綠色邊的長度就是L的變化值(注意上圖中令L0=0,L9=0)。

因為長度是從0開始變化,最終歸0.

 

再考慮紫色的邊,要計算紫色邊,其實就是計算L的線段是有幾個線段組成的,每個線段會貢獻兩個端點(紫色圓圈)

而每個端點都會向右延伸出一條紫色邊一直到下一個X值。

 

所以周長就是以上兩部分的和。而兩部分怎麼維護,前面都講過了,下面給出程式碼。

// 
struct Node{  
    int cover;//完全覆蓋層數  
    int lines;//分成多少個線段  
    bool L,R;//左右端點是否被覆蓋  
    int CoverLength;//覆蓋長度   
    int Length;//總長度   
    Node(){}  
    Node(int cover,int lines,bool L,bool R,int CoverLength):cover(cover),lines(lines),L(L),R(R),CoverLength(CoverLength){}  
    Node operator +(const Node &B){//連續區間的合併   
        Node C;  
        C.cover=0;  
        C.lines=lines+B.lines-(R&&B.L);  
        C.CoverLength=CoverLength+B.CoverLength;  
        C.L=L;C.R=B.R;  
        C.Length=Length+B.Length;  
        return C;  
    }  
}K[maxn<<2];  
void PushUp(int rt){//更新非葉節點   
    if(K[rt].cover){  
        K[rt].CoverLength=K[rt].Length;  
        K[rt].L=K[rt].R=K[rt].lines=1;  
    }  
    else{  
        K[rt]=K[rt<<1]+K[rt<<1|1];  
    }  
}  

 

掃描的程式碼:

        int PreX=L[0].x;//前X座標   
        int ANS=0;//目前累計答案   
        int PreLength=0;//前線段總長  
        int PreLines=0;//前線段數量   
        Build(1,20001,1);  
        for(int i=0;i<nL;++i){  
            //操作   
            if(L[i].c) Cover(L[i].y1,L[i].y2-1,1,20001,1);  
            else Uncover(L[i].y1,L[i].y2-1,1,20001,1);  
            //更新橫向的邊界   
            ANS+=2*PreLines*(L[i].x-PreX);  
            PreLines=K[1].lines;  
            PreX=L[i].x;  
            //更新縱向邊界   
            ANS+=abs(K[1].CoverLength-PreLength);  
            PreLength=K[1].CoverLength;  
        }  
        //輸出答案   
        printf("%d\n",ANS);  


 

求立方體重疊3次或以上的體積:

這個首先掃描面,每個面內求重疊了3次或以上的面積,然後乘以移動距離就是體積。

面內掃描線,用線段樹維護重疊了3次或以上的線段長度,然後用長度乘移動距離就是重疊了3次或以上的面積。

掃描面基本原理都跟掃描線一樣,就是巢狀了一層而已,寫的時候細心一點就沒問題了。


 

八:可持久化 (主席樹)

可持久化線段樹,也叫主席樹。

可持久化資料結構思想,就是保留整個操作的歷史,即,對一個線段樹進行操作之後,保留訪問操作前的線段樹的能力。

最簡單的方法,每操作一次,建立一顆新樹。這樣對空間的需求會很大。

而注意到,對於點修改,每次操作最多影響個節點,於是,其實操作前後的兩個線段樹,結構一樣,

而且只有個節點不同,其餘的節點都一樣,於是可以重複利用其餘的點。

這樣,每次操作,會增加個節點。

於是,這樣的線段樹,每次操作需要O(log2(n))的空間。

 

題目:HDU 2665 Kth number      題解

給定10萬個數,10萬個詢問。

每個詢問,問區間[L,R]中的數,從小到大排列的話,第k個數是什麼。

 

這個題,首先對十萬個數進行離散化,然後用線段樹來維護數字出現的次數。

每個節點都存出現次數,那麼查詢時,若左節點的數的個數>=k,就往左子樹遞迴,否則往右子樹遞迴。

一直到葉節點,就找到了第k大的數。

 

這題的問題是,怎麼得到一個區間的每個數出現次數。

注意到,數字的出現次數是滿足區間減法的。

於是要求區間[L,R]的數,其實就是T[R]-T[L-1]  ,其中T[X]表示區間[1,X]的數形成的線段樹。

現在的問題就是,如何建立這10萬個線段樹。

 

由之前的分析,需要O(n log2(n))的空間

下面是程式碼:

//主席樹   
int L[maxnn],R[maxnn],Sum[maxnn],T[maxn],TP;//左右子樹,總和,樹根,指標   
void Add(int &rt,int l,int r,int x){//建立新樹,l,r是區間, x是新加入的數字的排名
    ++TP;L[TP]=L[rt];R[TP]=R[rt];Sum[TP]=Sum[rt]+1;rt=TP;//複製&新建  
    if(l==r) return;  
    int m=(l+r)>>1;  
    if(x <= m) Add(L[rt],l,m,x);  
    else       Add(R[rt],m+1,r,x);  
}  
int Search(int TL,int TR,int l,int r,int k){//區間查詢第k大  
    if(l==r) return l;//返回第k大的下標  
    int m=(l+r)>>1;  
    if(Sum[L[TR]]-Sum[L[TL]]>=k) return Search(L[TL],L[TR],l,m,k);  
    else return Search(R[TL],R[TR],m+1,r,k-Sum[L[TR]]+Sum[L[TL]]);  
}  


以上就是主席樹部分的程式碼。

熟悉SBT的,應該都很熟悉這種表示方法。

L,R是偽指標,指向左右子節點。

特殊之處是,0 表示空樹,並且 L[0]=R[0]=0.

也就是說,空樹的左右子樹都是空樹。

而本題中,每一顆樹其實都是完整的,剛開始有一顆空樹。

但是剛開始的空樹,真的需要用空間去存嗎?

其實不需要,剛開始的空樹有這些性質:

1.每個節點的Sum值為0

2.每個非葉節點的左右子節點的Sum值也是0

 

而SBT的空樹剛好滿足這個性質。而線段樹不依賴L,R指標來結束遞迴。

線段樹是根據區間l,r來結束的,所以不會出現死迴圈。

所以只需要把Sum[0]=0;那麼剛開始就不需要建樹了,只有每個操作的個節點。

 

這個線段樹少了表示父節點的int rt,因為不需要(也不能夠)通過rt來找子節點了,而是直接根據L,R來找。

 

-----------------------------     補充     -------------------------------------

 

終於又找到一道可以用主席樹的題目了:Codeforces 650D.Zip-line  題解

做這題之前需要會求普通的LIS問題(最長上升子序列問題)。

九:練習題

適合非遞迴線段樹的題目:

 

Codeforces 612D The Union of k-Segments :  題解

題意:線段求交,給定一堆線段,按序輸出被覆蓋k次或以上的線段和點。

基礎題,先操作,最後一次下推標記,然後輸出,

維護兩個線段樹,一個線段覆蓋,一個點覆蓋。

 

Codeforces 35E Parade : 題解

題意:給定若干矩形,下端挨著地面,求最後的輪廓形成的折線,要求輸出每一點的座標。

思路:雖然是區間修改的線段樹,但只需要在操作結束後一次下推標記,然後輸出,所以適合非遞迴線段樹。

 

URAL 1846 GCD2010 :  題解

題意:總共10萬個操作,每次向集合中加入或刪除一個數,求集合的最大公因數。(規定空集的最大公因數為1)

 

Codeforces 12D Ball :   題解

 

題意:

給N (N<=500000)個點,每個點有x,y,z ( 0<= x,y,z <=10^9 )

對於某點(x,y,z),若存在一點(x1,y1,z1)使得x1 > x && y1 > y && z1 > z 則點(x,y,z)是特殊點。

問N個點中,有多少個特殊點。

 

提示:排序+線段樹

 

Codeforces 19D Points : 題解

 

題意:

給定最多20萬個操作,共3種:

1.add x y         :加入(x,y)這個點

2.remove x y  :刪除(x,y)這個點

3.find x y         :找到在(x,y)這點右上方的x最小的點,若x相同找y最小的點,輸出這點座標,若沒有,則輸出-1.

 

提示:排序,線段樹套平衡樹

 

Codeforces 633E Startup Funding : 題解

這題需要用到一點概率論,組合數學知識,和二分法。

非遞迴線段樹在這題中主要解決RMQ問題(區間最大最小值問題),由於不帶修改,這題用Sparse Table求解RMQ是標答。

因為RMQ詢問是在二分法之內求的,而Sparse Table可以做到O(1)查詢,所以用Sparse Table比較好,總複雜度O(n*log(n))。

不過非遞迴線段樹也算比較快的了,雖然複雜度是O(n*log(n)*log(n)),還是勉強過了這題。

 

掃描線題目:

 

POJ 1177 Picture:給定若干矩形求合併之後的圖形周長    題解

HDU 1255 覆蓋的面積:給定平面上若干矩形,求出被這些矩形覆蓋過至少兩次的區域的面積.   題解

HDU 3642 Get The Treasury:給定若干空間立方體,求重疊了3次或以上的體積(這個是掃描面,每個面再掃描線)題解

POJ 2482 Stars in your window : 給定一些星星的位置和亮度,求用W*H的矩形能夠框住的星星亮度之和最大為多少。  題解

 

遞迴線段樹題目:

Codeforces 558E A Simple Task  題解

給定一個長度不超過10^5的字串(小寫英文字母),和不超過5000個操作。

每個操作 L R K 表示給區間[L,R]的字串排序,K=1為升序,K=0為降序。

最後輸出最終的字串。

 

Codeforces 527C Glass Carving  :  題解

給定一個矩形,不停地縱向或橫向切割,問每次切割後,最大的矩形面積是多少。

 

URAL1989 Subpalindromes    題解

給定一個字串(長度<=100000),有10萬個操作。

操作有兩種:   

1:改變某個字元。 

2:判斷某個子串是否構成迴文串。 

 

HDU 4288 Coder :  題解

 題意:對一個集合進行插入與刪除操作。要求詢問某個時刻,集合中的元素從小到大排序之後,序號%5 ==3 的元素值之和。

這題其實不一定要用線段樹去做的,不過線段樹還是可以做的。

 

HDU 2795 BillBoard : 題解

題意:有一個板,h行,每行w長度的位置。每次往上面貼一張海報,長度為1*wi .

每次貼的時候,需要找到最上面的,可以容納的空間,並且靠邊貼。

 

Codeforces 374D Inna and Sequence :題解

題意:給定百萬個數a[m],然後有百萬個操作,每次給現有序列加一個字元(0或1),或者刪掉已有序列中,第 a[0] 個,第a[1]個,...,第a[m]個。

 

Codeforces 482B Interesting Array:  題解

題意就是,給定n,m.

滿足m個條件的n個數,或說明不存在。

每個條件的形式是,給定 Li,Ri,Qi ,要求   a[Li]&a[Li+1]&...&a[Ri] = Qi ;

Codeforces 474E Pillar (線段樹+動態規劃):  題解

題意就是,給定10^5 個數(範圍10^15),求最長子序列使得相鄰兩個數的差大於等於 d。

 

POJ 2777  Count Color :   題解

給線段塗顏色,最多30種顏色,10萬個操作。

每個操作給線段塗色,或問某一段線段有多少種顏色。

30種顏色用int的最低30位來存,然後線段樹解決。

 

URAL 1019 Line Painting: 線段樹的區間合併  題解

給一段線段進行黑白塗色,最後問最長的一段白色線段的長度。

 

Codeforces 633H Fibonacci-ish II  :題解

這題需要用到莫隊演算法(Mo's Algorithm)+線段樹區間修改,不過是單邊界的區間,寫起來挺有趣。

另一種解法就是暴力,很巧妙的方法,高複雜度+低常數居然就這麼給過了。

 

樹套樹題目:

ZOJ 2112 Dynamic Rankings 動態區間第k大  題解

做法:樹狀陣列套主席樹 或者 線段樹套平衡樹

 

Codeforces 605D Board Game :  題解

做法:廣度優先搜尋(BFS)  +  線段樹套平衡樹

 

 

Codeforces 19D Points : 題解

 

題意:

給定最多20萬個操作,共3種:

1.add x y         :加入(x,y)這個點

2.remove x y  :刪除(x,y)這個點

3.find x y         :找到在(x,y)這點右上方的x最小的點,若x相同找y最小的點,輸出這點座標,若沒有,則輸出-1.

 

提示:排序,線段樹套平衡樹

 

相關文章