我自己在學這些數據結構以及算法的時候,網上的博客很多都是給出一個大致思想,然后就直接給代碼了,可能是我智商太低,思維跳躍沒有那么大,沒法直接代碼實現,而且有些學完之后也沒有得到深層次的理解和運用,還是停留在只會使用模板的基礎上。所以我希望我寫的東西能讓更多的人看明白,我會盡量寫詳細,也會寫出我初學的時候哪些地方沒有理解或者難以運用,又是怎樣去熟練的使用這些東西的。可能還是不能讓所有的人都讀明白,但我盡量做的更好。
一、什么是線段樹?
- 線段樹是怎樣的樹形結構?
線段樹是一種二叉搜索樹,什么叫做二叉搜索樹,首先滿足二叉樹,每個結點度小於等於二,即每個結點最多有兩顆子樹,何為搜索,我們要知道,線段樹的每個結點都存儲了一個區間,也可以理解成一個線段,而搜索,就是在這些線段上進行搜索操作得到你想要的答案。
- 線段樹能夠解決什么樣的問題。
線段樹的適用范圍很廣,可以在線維護修改以及查詢區間上的最值,求和。更可以擴充到二維線段樹(矩陣樹)和三維線段樹(空間樹)。對於一維線段樹來說,每次更新以及查詢的時間復雜度為O(logN)。
- 線段樹和其他RMQ算法的區別
常用的解決RMQ問題有ST算法,二者預處理時間都是O(NlogN),而且ST算法的單次查詢操作是O(1),看起來比線段樹好多了,但二者的區別在於線段樹支持在線更新值,而ST算法不支持在線操作。
這里也存在一個誤區,剛學線段樹的時候就以為線段樹和樹狀數組差不多,用來處理RMQ問題和求和問題,但其實線段樹的功能遠遠不止這些,我們要熟練的理解線段這個概念才能更加深層次的理解線段樹。
二、線段樹的基本內容
現在請各位不要帶着線段樹只是為了解決區間問題的數據結構,事實上,是線段樹多用於解決區間問題,並不是線段樹只能解決區間問題,首先,我們得先明白幾件事情。
每個結點存什么,結點下標是什么,如何建樹。
下面我以一個簡單的區間最大值來闡述上面的三個概念。
對於A[1:6] = {1,8,6,4,3,5}來說,線段樹如上所示,紅色代表每個結點存儲的區間,藍色代表該區間最值。
可以發現,每個葉子結點的值就是數組的值,每個非葉子結點的度都為二,且左右兩個孩子分別存儲父親一半的區間。每個父親的存儲的值也就是兩個孩子存儲的值的最大值。
上面的每條結論應該都容易看出來。那么結點到底是如何存儲區間的呢,以及如何快速找到非葉子結點的孩子以及非根節點的父親呢,這里也就是理解線段樹的重點以及難點所在,如同樹狀數組你理解了lowbit就能很快理解樹狀數組一樣,線段樹你只要理解了結點與結點之間的關系便能很快理解線段樹的基本知識。
對於一個區間[l,r]來說,最重要的數據當然就是區間的左右端點l和r,但是大部分的情況我們並不會去存儲這兩個數值,而是通過遞歸的傳參方式進行傳遞。這種方式用指針好實現,定義兩個左右子樹遞歸即可,但是指針表示過於繁瑣,而且不方便各種操作,大部分的線段樹都是使用數組進行表示,那這里怎么快速使用下標找到左右子樹呢。
對於上述線段樹,我們增加綠色數字為每個結點的下標
則每個結點下標如上所示,這里你可能會問,為什么最下一排的下標直接從9跳到了12,道理也很簡單,中間其實是有兩個空間的呀!!雖然沒有使用,但是他已經開了兩個空間,這也是為什么無優化的線段樹建樹需要2*2k(2k-1 < n < 2k)空間,一般會開到4*n的空間防止RE。
仔細觀察每個父親和孩子下標的關系,有發現什么聯系嗎?不難發現,每個左子樹的下標都是偶數,右子樹的下標都是奇數且為左子樹下標+1,而且不難發現以下規律
- l = fa*2 (左子樹下標為父親下標的兩倍)
- r = fa*2+1(右子樹下標為父親下標的兩倍+1)
具體證明也很簡單,把線段樹看成一個完全二叉樹(空結點也當作使用)對於任意一個結點k來說,它所在此二叉樹的log2(k) 層,則此層共有2log2(k)個結點,同樣對於k的左子樹那層來說有2log2(k)+1個結點,則結點k和左子樹間隔了2*2log2(k)-k + 2*(k-2log2(k))個結點,然后這就很簡單就得到k+2*2log2(k)-k + 2*(k-2log2(k)) = 2*k的關系了吧,右子樹也就等於左子樹結點+1。
是不是覺得其實很簡單,而且因為左子樹都是偶數,所以我們常用位運算來尋找左右子樹
- k<<1(結點k的左子樹下標)
- k<<1|1(結點k的右子樹下標)
整理一下思緒,現在已經明白了數組如何存在線段樹,結點間的關系,以及使用遞歸的方式建立線段樹,那么具體如何建立線段樹,我們來看代碼,代碼中不清楚的地方都有詳細的注釋說明。
1 const int maxn = 100005; 2 int a[maxn],t[maxn<<2]; //a為原來區間,t為線段樹 3 4 void Pushup(int k){ //更新函數,這里是實現最大值 ,同理可以變成,最小值,區間和等 5 t[k] = max(t[k<<1],t[k<<1|1]); 6 } 7 8 //遞歸方式建樹 build(1,1,n); 9 void build(int k,int l,int r){ //k為當前需要建立的結點,l為當前需要建立區間的左端點,r則為右端點 10 if(l == r) //左端點等於右端點,即為葉子節點,直接賦值即可 11 t[k] = a[l]; 12 else{ 13 int m = l + ((r-l)>>1); //m則為中間點,左兒子的結點區間為[l,m],右兒子的結點區間為[m+1,r] 14 build(k<<1,l,m); //遞歸構造左兒子結點 15 build(k<<1|1,m+1,r); //遞歸構造右兒子結點 16 Pushup(k); //更新父節點 17 } 18 }
現在再來看代碼,是不是覺得清晰很多了,使用遞歸的方法建立線段樹,確實清晰易懂,各位看到這里也請自己試着實現一下遞歸建樹,若是哪里有卡點再來看一下代碼找到哪里出了問題。那線段樹有沒有非遞歸的方式建樹呢,答案是有,但是非遞歸的建樹方式會使得線段樹的查詢等操作和遞歸建樹方式完全不一樣,由簡至難,后面我們再說非遞歸方式的實現。
到現在你應該可以建立一顆線段樹了,而且知道每個結點存儲的區間和值,如果上述操作還不能實現或是有哪里想不明白,建議再翻回去看一看所講的內容。不要急於看完,理解才更重要。
三、線段樹的基本操作
基本操作有哪些,你應該也能想出來,在線的二叉搜索樹,所擁有的操作當然有,更新和詢問兩種。
1.點更新
如何實現點更新,我們先不急看代碼,還是對於上面那個線段樹,假使我把a[3]+7,則更新后的線段樹應該變成
更新了a[3]后,則每個包含此值的結點都需要更新,那么有多少個結點需要更新呢?根據二叉樹的性質,不難發現是log(k)個結點,這也正是為什么每次更新的時間復雜度為O(logN),那應該如何實現呢,我們發現,無論你更新哪個葉子節點,最終都是會到根結點的,而把這個往上推的過程逆過來就是從根結點開始,找到左子樹還是右子樹包含需要更新的葉子節點,往下更新即可,所以我們還是可以使用遞歸的方法實現線段樹的點更新
1 //遞歸方式更新 updata(p,v,1,n,1); 2 void updata(int p,int v,int l,int r,int k){ //p為下標,v為要加上的值,l,r為結點區間,k為結點下標 3 if(l == r) //左端點等於右端點,即為葉子結點,直接加上v即可 4 a[k] += v,t[k] += v; //原數組和線段樹數組都得到更新 5 else{ 6 int m = l + ((r-l)>>1); //m則為中間點,左兒子的結點區間為[l,m],右兒子的結點區間為[m+1,r] 7 if(p <= m) //如果需要更新的結點在左子樹區間 8 updata(p,v,l,m,k<<1); 9 else //如果需要更新的結點在右子樹區間 10 updata(p,v,m+1,r,k<<1|1); 11 Pushup(k); //更新父節點的值 12 } 13 }
看完代碼是不是很清晰,這里也建議自己再次手動實現一遍理解遞歸的思路。
2.區間查詢
說完了單點更新肯定就要來說區間查詢了,我們知道線段樹的每個結點存儲的都是一段區間的信息 ,如果我們剛好要查詢這個區間,那么則直接返回這個結點的信息即可,比如對於上面線段樹,如果我直接查詢[1,6]這個區間的最值,那么直接返回根節點信息返回13即可,但是一般我們不會湊巧剛好查詢那些區間,比如現在我要查詢[2,5]區間的最值,這時候該怎么辦呢,我們來看看哪些區間是[2,5]的真子集,
一共有5個區間,而且我們可以發現[4,5]這個區間已經包含了兩個子樹的信息,所以我們需要查詢的區間只有三個,分別是[2,2],[3,3],[4,5],到這里你能通過更新的思路想出來查詢的思路嗎? 我們還是從根節點開始往下遞歸,如果當前結點是要查詢的區間的真子集,則返回這個結點的信息且不需要再往下遞歸了,這樣從根節點往下遞歸,時間復雜度也是O(logN)。那么代碼則為
1 //遞歸方式區間查詢 query(L,R,1,n,1); 2 int query(int L,int R,int l,int r,int k){ //[L,R]即為要查詢的區間,l,r為結點區間,k為結點下標 3 if(L <= l && r <= R) //如果當前結點的區間真包含於要查詢的區間內,則返回結點信息且不需要往下遞歸 4 return t[k]; 5 else{ 6 int res = -INF; //返回值變量,根據具體線段樹查詢的什么而自定義 7 int mid = l + ((r-l)>>1); //m則為中間點,左兒子的結點區間為[l,m],右兒子的結點區間為[m+1,r] 8 if(L <= m) //如果左子樹和需要查詢的區間交集非空 9 res = max(res, query(L,R,l,m,k<<1)); 10 if(R > m) //如果右子樹和需要查詢的區間交集非空,注意這里不是else if,因為查詢區間可能同時和左右區間都有交集 11 res = max(res, query(L,R,m+1,r,k<<1|1)); 12 13 return res; //返回當前結點得到的信息 14 } 15 }
如果你能理解建樹和更新的過程,那么這里的區間查詢也不會太難理解。還是建議再次手動實現。
3.區間更新
樹狀數組中的區間更新我們用了差分的思想,而線段樹的區間更新相對於樹狀數組就稍微復雜一點,這里我們引進了一個新東西,Lazy_tag,字面意思就是懶惰標記的意思,實際上它的功能也就是偷懶= =,因為對於一個區間[L,R]來說,我們可能每次都更新區間中的沒個值,那樣的話更新的復雜度將會是O(NlogN),這太高了,所以引進了Lazy_tag,這個標記一般用於處理線段樹的區間更新。
線段樹在進行區間更新的時候,為了提高更新的效率,所以每次更新只更新到更新區間完全覆蓋線段樹結點區間為止,這樣就會導致被更新結點的子孫結點的區間得不到需要更新的信息,所以在被更新結點上打上一個標記,稱為lazy-tag,等到下次訪問這個結點的子結點時再將這個標記傳遞給子結點,所以也可以叫延遲標記。
也就是說遞歸更新的過程,更新到結點區間為需要更新的區間的真子集不再往下更新,下次若是遇到需要用這下面的結點的信息,再去更新這些結點,所以這樣的話使得區間更新的操作和區間查詢類似,復雜度為O(logN)。
1 void Pushdown(int k){ //更新子樹的lazy值,這里是RMQ的函數,要實現區間和等則需要修改函數內容 2 if(lazy[k]){ //如果有lazy標記 3 lazy[k<<1] += lazy[k]; //更新左子樹的lazy值 4 lazy[k<<1|1] += lazy[k]; //更新右子樹的lazy值 5 t[k<<1] += lazy[k]; //左子樹的最值加上lazy值 6 t[k<<1|1] += lazy[k]; //右子樹的最值加上lazy值 7 lazy[k] = 0; //lazy值歸0 8 } 9 } 10 11 //遞歸更新區間 updata(L,R,v,1,n,1); 12 void updata(int L,int R,int v,int l,int r,int k){ //[L,R]即為要更新的區間,l,r為結點區間,k為結點下標 13 if(L <= l && r <= R){ //如果當前結點的區間真包含於要更新的區間內 14 lazy[k] += v; //懶惰標記 15 t[k] += v; //最大值加上v之后,此區間的最大值也肯定是加v 16 } 17 else{ 18 Pushdown(k); //重難點,查詢lazy標記,更新子樹 19 int m = l + ((r-l)>>1); 20 if(L <= m) //如果左子樹和需要更新的區間交集非空 21 update(L,R,v,l,m,k<<1); 22 if(m < R) //如果右子樹和需要更新的區間交集非空 23 update(L,R,v,m+1,r,k<<1|1); 24 Pushup(k); //更新父節點 25 } 26 }
注意看Pushdown這個函數,也就是當需要查詢某個結點的子樹時,需要用到這個函數,函數功能就是更新子樹的lazy值,可以理解為平時先把事情放着,等到哪天要檢查的時候,就臨時再去做,而且做也不是一次性做完,檢查哪一部分它就只做這一部分。是不是感受到了什么是Lazy_tag,實至名歸= =。
值得注意的是,使用了Lazy_tag后,我們再進行區間查詢也需要改變。區間查詢的代碼則變為
1 //遞歸方式區間查詢 query(L,R,1,n,1); 2 int query(int L,int R,int l,int r,int k){ //[L,R]即為要查詢的區間,l,r為結點區間,k為結點下標 3 if(L <= l && r <= R) //如果當前結點的區間真包含於要查詢的區間內,則返回結點信息且不需要往下遞歸 4 return t[k]; 5 else{ 6 Pushdown(k); /**每次都需要更新子樹的Lazy標記*/ 7 int res = -INF; //返回值變量,根據具體線段樹查詢的什么而自定義 8 int mid = l + ((r-l)>>1); //m則為中間點,左兒子的結點區間為[l,m],右兒子的結點區間為[m+1,r] 9 if(L <= m) //如果左子樹和需要查詢的區間交集非空 10 res = max(res, query(L,R,l,m,k<<1)); 11 if(R > m) //如果右子樹和需要查詢的區間交集非空,注意這里不是else if,因為查詢區間可能同時和左右區間都有交集 12 res = max(res, query(L,R,m+1,r,k<<1|1)); 13 14 return res; //返回當前結點得到的信息 15 } 16 }
其實變動也不大,就是多了一個臨時更新子樹的值的過程。
四、線段樹的其他操作
如果你明白了上述線段樹處理區間最值的所有操作,那么轉變成求最小值以及區間和問題應該也能很快解決,請手動再實現一下查詢區間最小值的線段樹和查詢區間和的線段樹。
區間和線段樹等代碼不再給出,自行實現,若不能實現可以去網上搜索模板對比自己為何不能實現。這里便不再浪費篇幅講述。
這里我便是想說一下線段樹還能處理的問題以及一些具體問題講解。上述我們只是再講線段樹處理裸區間問題,但是大部分問題不會是讓你直接更新查詢,而是否真正理解線段樹便在於思維是否能從區間跳到線段。
區間只是一個線段的一小部分,還有一些非區間問題也可以演變成一段一段的線段,然后再通過線段樹進行各種操作。下面針對幾道例題講解一下線段樹的其他具體用法。
下面三道題講解並非自己所寫,而是摘取了另一篇線段樹的博客,特此聲明,原博客地址:https://blog.csdn.net/whereisherofrom/article/details/78969718
1.區間染色
給定一個長度為n(n <= 100000)的木板,支持兩種操作:
1、P a b c 將[a, b]區間段染色成c;
2、Q a b 詢問[a, b]區間內有多少種顏色;
保證染色的顏色數少於30種。
對比區間求和,不同點在於區間求和的更新是對區間和進行累加;而這類染色問題則是對區間的值進行替換(或者叫覆蓋),有一個比較特殊的條件是顏色數目小於30。
我們是不是要將30種顏色的有無與否都存在線段樹的結點上呢?答案是肯定的,但是這樣一來每個結點都要存儲30個bool值,空間太浪費,而且在計算合並操作的時候有一步30個元素的遍歷,大大降低效率。然而30個bool值正好可以壓縮在一個int32中,利用二進制壓縮可以用一個32位的整型完美的存儲30種顏色的有無情況。
因為任何一個整數都可以分解成二進制整數,二進制整數的每一位要么是0,要么是1。二進制整數的第i位是1表示存在第i種顏色;反之不存在。
數據域需要存一個顏色種類的位或和colorBit,一個顏色的lazy標記表示這個結點被完全染成了lazy,基本操作的幾個函數和區間求和非常像,這里就不出示代碼了。
和區間求和不同的是回溯統計的時候,對於兩個子結點的數據域不再是加和,而是位或和。
2.區間第K大
給定n個數,每次詢問問[l,r]區間內的第K大數,這個問題有很多方法,但是用線段樹應該如何解決呢。
利用了線段樹划分區間的思想,線段樹的每個結點存的不只是區間端點,而是這個區間內所有的數,並且是按照遞增順序有序排列的,建樹過程是一個歸並排序的過程,從葉子結點自底向上進行歸並,對於一個長度為6的數組[4, 3, 2, 1, 5, 6],建立線段樹如圖所示。
從圖中可以看出,線段樹的任何一個結點存儲了對應區間的數,並且進行有序排列,所以根結點存儲的一定是一個長度為數組總長的有序數組,葉子結點存儲的遞增序列為原數組元素。
每次詢問,我們將給定區間拆分成一個個線段樹上的子區間,然后二分枚舉答案T,再利用二分查找統計這些子區間中大於等於T的數的個數,從而確定T是否是第K大的。
對於區間K大數的問題,還有很多數據結構都能解決,這里僅作簡單介紹。
3.矩陣面積並
對於給定的n(n<=100000)個平行於XY軸的矩形,求他們的面積並。
這是一個二維的問題,如果我告訴你這道題使用線段樹解決,你該如何入手呢,首先線段樹是一維的,所以我們需要化二維為一維,所以我們可以使用x的坐標或者y的坐標建立線段樹,另一坐標用來進行枚舉操作。
我們用x的坐標來建樹的化,那么我們把矩陣平行於x軸的線段舍去,則變成了
每個矩形都剩下兩條邊,定義x坐標較小的為入邊(值為+1),較大為出邊(值為-1),然后用x的升序,記第i條線段的x坐標即為X[i]
接下來將所有矩形端點的y坐標進行重映射(也可以叫離散化),原因是坐標有可能很大而且不一定是整數,將原坐標映射成小范圍的整數可以作為數組下標,更方便計算,映射可以將所有y坐標進行排序去重,然后二分查找確定映射后的值,離散化的具體步驟下文會詳細講解。如圖所示,藍色數字表示的是離散后的坐標,即1、2、3、4分別對應原先的5、10、23、25(需支持正查和反查)。假設離散后的y方向的坐標個數為m,則y方向被分割成m-1個獨立單元,下文稱這些獨立單元為“單位線段”,分別記為<1-2>、<2-3>、<3-4>。
以x坐標遞增的方式枚舉每條垂直線段,y方向用一個長度為m-1的數組來維護“單位線段”的權值,如圖所示,展示了每條線段按x遞增方式插入之后每個“單位線段”的權值。
當枚舉到第i條線段時,檢查所有“單位線段”的權值,所有權值大於零的“單位線段”的實際長度之和(離散化前的長度)被稱為“合法長度”,記為L,那么(X[i] - X[i-1]) * L,就是第i條線段和第i-1條線段之間的矩形面積和,計算完第i條垂直線段后將它插入,所謂"插入"就是利用該線段的權值更新該線段對應的“單位線段”的權值和(這里的更新就是累加)。
如圖四-4-6所示:紅色、黃色、藍色三個矩形分別是3對相鄰線段間的矩形面積和,其中紅色部分的y方向由<1-2>、<2-3>兩個“單位線段”組成,黃色部分的y方向由<1-2>、<2-3>、<3-4>三個“單位線段”組成,藍色部分的y方向由<2-3>、<3-4>兩個“單位線段”組成。特殊的,在計算藍色部分的時候,<1-2>部分的權值由於第3條線段的插入(第3條線段權值為-1)而變為零,所以不能計入“合法長度”。
以上所有相鄰線段之間的面積和就是最后要求的矩形面積並。
優化自然就是用線段樹了,之前提到了降維的思想,x方向我們繼續采用枚舉,而y方向的“單位線段”則可以采用線段樹來維護,
然后通過一個掃描線來求掃描線覆蓋的y的長度。線段的掃描按照x的大小從小到大掃描,求出當前掃描線覆蓋的矩陣豎線的長度,然后乘以下條線段的跨度,則為這個區域矩陣覆蓋的面積,具體關於掃描線的操作這里不再闡述。這里只講明白如何建樹。
五、線段樹的一些重難點以及技巧
1.離散化
離散化常用於二維狀態在一維線段樹建樹,所謂離散化就是將無限的個體映射到有限個體中,提高算法效率,而且支持正查和反查(從開始遍歷和從末尾遍歷),可用Hash等實現。
2.Lazy_tag
這個標記就是用於線段樹的區間更新,上面已經提到,便不再累贅,但是區間更新並不局限於使用Lazy_tag,還有一種不使用Lazy_tag的區間更新方法,會在提高篇中講到。
3.空間優化
父節點k,左兒子k<<1,右兒子k<<1|1,則需要n<<2的空間,但我們知道並不是所有的葉子節點都占用到了2*n+1 —— 4*n的范圍,造成了大量空間浪費。這時候就要考慮離散化,壓縮空間。或者使用dfs序作為結點下標,父親k,左兒子k+1,右兒子k+左兒子區間長度*2,具體實現不再累贅,可自行通過修改左右兒子的下標推出。
4.多維推廣
例如矩陣樹,空間樹,這些便是線段樹的拓展,比如要在兩種不同的參數找到最適變量,例如對於一個人的身高和體重,找到一定范圍內且年齡最小的人,就可以用到多維推廣了。
5.可持久化
主席樹。以后講= =
6.非遞歸形式
前面提到過這個概念,非遞歸形式的某些操作會快於遞歸形式,以后將會專門將非遞歸形式。
7.子樹收縮
就是子樹繼承的逆過程,繼承是為了得到父節點信息,而收縮則是在回溯時候,如果兩棵子樹擁有相同數據的時候在將數據傳遞給父結點,子樹的數據清空,這樣下次在訪問的時候就可以減少訪問的結點數。
六、相關例題
- codevs 1080 (單點修改+區間查詢)
- codevs 1081 (區間修改+單點查詢)
- codevs 1082 (區間修改+區間查詢)
- codevs 3981 (區間最大子段和)
- Bzoj 3813 (區間內某個值是否出現過)
- Luogu P2894 (區間連續一段空的長度)
- codevs 2000 (區間最長上升子序列)
- codevs 3044 (矩陣面積求並)
- Hdu 1698 (區間染色+單次統計)
- Poj 2777 (區間染色+批量統計)
- Hdu 4419 (多色矩形面積並)
- Poj 2761 (區間第K大)
- Hdu 2305 (最值維護)
暫時只寫了這么多,這算是基本的線段樹內容,主要就是要明白建樹以及各種操作的過程,並且做題時候想這道題是否可以化成線段樹建樹來解決。