線段樹詳解

我本身在學這些數據結構以及算法的時候,網上的博客不少都是給出一個大體思想,而後就直接給代碼了,多是我智商過低,思惟跳躍沒有那麼大,無法直接代碼實現,並且有些學完以後也沒有獲得深層次的理解和運用,仍是停留在只會使用模板的基礎上。因此我但願我寫的東西能讓更多的人看明白,我會盡可能寫詳細,也會寫出我初學的時候哪些地方沒有理解或者難以運用,又是怎樣去熟練的使用這些東西的。可能仍是不能讓全部的人都讀明白,但我儘可能作的更好。算法


 

1、什麼是線段樹?

  • 線段樹是怎樣的樹形結構?

  線段樹是一種二叉搜索樹,什麼叫作二叉搜索樹,首先知足二叉樹,每一個結點度小於等於二,即每一個結點最多有兩顆子樹,何爲搜索,咱們要知道,線段樹的每一個結點都存儲了一個區間,也能夠理解成一個線段,而搜索,就是在這些線段上進行搜索操做獲得你想要的答案。數組

  • 線段樹可以解決什麼樣的問題。

  線段樹的適用範圍很廣,能夠在線維護修改以及查詢區間上的最值,求和。更能夠擴充到二維線段樹(矩陣樹)和三維線段樹(空間樹)。對於一維線段樹來講,每次更新以及查詢的時間複雜度爲O(logN)。數據結構

  • 線段樹和其餘RMQ算法的區別

  經常使用的解決RMQ問題有ST算法,兩者預處理時間都是O(NlogN),並且ST算法的單次查詢操做是O(1),看起來比線段樹好多了,但兩者的區別在於線段樹支持在線更新值,而ST算法不支持在線操做。函數

  這裏也存在一個誤區,剛學線段樹的時候就覺得線段樹和樹狀數組差很少,用來處理RMQ問題和求和問題,但其實線段樹的功能遠遠不止這些,咱們要熟練的理解線段這個概念才能更加深層次的理解線段樹。優化

2、線段樹的基本內容

  如今請各位不要帶着線段樹只是爲了解決區間問題的數據結構,事實上,是線段樹多用於解決區間問題,並非線段樹只能解決區間問題,首先,咱們得先明白幾件事情。ui

  每一個結點存什麼,結點下標是什麼,如何建樹。spa

  下面我以一個簡單的區間最大值來闡述上面的三個概念。.net

  對於A[1:6] = {1,8,6,4,3,5}來講,線段樹如上所示,紅色表明每一個結點存儲的區間,藍色表明該區間最值。指針

  能夠發現,每一個葉子結點的值就是數組的值,每一個非葉子結點的度都爲二,且左右兩個孩子分別存儲父親一半的區間。每一個父親的存儲的值也就是兩個孩子存儲的值的最大值。code

  上面的每條結論應該都容易看出來。那麼結點究竟是如何存儲區間的呢,以及如何快速找到非葉子結點的孩子以及非根節點的父親呢,這裏也就是理解線段樹的重點以及難點所在,如同樹狀數組你理解了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 }

 

  如今再來看代碼,是否是以爲清晰不少了,使用遞歸的方法創建線段樹,確實清晰易懂,各位看到這裏也請本身試着實現一下遞歸建樹,如果哪裏有卡點再來看一下代碼找到哪裏出了問題。那線段樹有沒有非遞歸的方式建樹呢,答案是有,可是非遞歸的建樹方式會使得線段樹的查詢等操做和遞歸建樹方式徹底不同,由簡至難,後面咱們再說非遞歸方式的實現。

  到如今你應該能夠創建一顆線段樹了,並且知道每一個結點存儲的區間和值,若是上述操做還不能實現或是有哪裏想不明白,建議再翻回去看一看所講的內容。不要急於看完,理解才更重要。

3、線段樹的基本操做

  基本操做有哪些,你應該也能想出來,在線的二叉搜索樹,所擁有的操做固然有,更新和詢問兩種。

  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[l] += v,t[l] += 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 }

  其實變更也不大,就是多了一個臨時更新子樹的值的過程。

4、線段樹的其餘操做

  若是你明白了上述線段樹處理區間最值的全部操做,那麼轉變成求最小值以及區間和問題應該也能很快解決,請手動再實現一下查詢區間最小值的線段樹和查詢區間和的線段樹。

  區間和線段樹等代碼再也不給出,自行實現,若不能實現能夠去網上搜索模板對比本身爲什麼不能實現。這裏便再也不浪費篇幅講述。

  這裏我即是想說一下線段樹還能處理的問題以及一些具體問題講解。上述咱們只是再講線段樹處理裸區間問題,可是大部分問題不會是讓你直接更新查詢,而是否真正理解線段樹便在於思惟是否能從區間跳到線段。

  區間只是一個線段的一小部分,還有一些非區間問題也能夠演變成一段一段的線段,而後再經過線段樹進行各類操做。下面針對幾道例題講解一下線段樹的其餘具體用法。

  下面三道題講解並不是本身所寫,而是摘取了另外一篇線段樹的博客,特此聲明,原博客地址:https://blog.csdn.net/whereisherofrom/article/details/78969718

  1.區間染色

  給定一個長度爲n(n <= 100000)的木板,支持兩種操做:
  一、P a b c       將[a, b]區間段染色成c;
  二、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座標進行排序去重,而後二分查找肯定映射後的值,離散化的具體步驟下文會詳細講解。如圖所示,藍色數字表示的是離散後的座標,即一、二、三、4分別對應原先的五、十、2三、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的大小從小到大掃描,求出當前掃描線覆蓋的矩陣豎線的長度,而後乘如下條線段的跨度,則爲這個區域矩陣覆蓋的面積,具體關於掃描線的操做這裏再也不闡述。這裏只講明白如何建樹。

5、線段樹的一些重難點以及技巧

  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.子樹收縮

  就是子樹繼承的逆過程,繼承是爲了獲得父節點信息,而收縮則是在回溯時候,若是兩棵子樹擁有相同數據的時候在將數據傳遞給父結點,子樹的數據清空,這樣下次在訪問的時候就能夠減小訪問的結點數。

6、相關例題

  • 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 (最值維護)

 

  暫時只寫了這麼多,這算是基本的線段樹內容,主要就是要明白建樹以及各類操做的過程,而且作題時候想這道題是否能夠化成線段樹建樹來解決。

相關文章
相關標籤/搜索