一、線段樹的定義
線段樹,又名區間樹,是一種二叉搜索樹。
那么問題來了,啥是二叉搜索樹呢?
對於一棵二叉樹,若滿足:
①它的左子樹不空,則左子樹上所有結點的值均小於它的根結點的值
②若它的右子樹不空,則右子樹上所有結點的值均大於它的根結點的值
③它的左、右子樹也分別為二叉搜索樹
那么這就是一棵二叉搜索樹。
扯完廢話,再回到線段樹這里。顧名思義,線段樹就是由線段構成的樹,它大概長成這樣:
對於每一棵線段樹上的節點,都有三個值:左區間、右區間以及權值。(當然,在某些情況下它只有左右區間,這個時候線段樹只是作為維護某個值而使用的數據結構,如掃描線)
線段樹有一個非常重要的性質,就是當父親節點的區間為[x,y]時,左孩子的區間就必定為[x,(x+y)/2],右孩子的區間必定為[(x+y)/2+1,y]
二、線段樹的基本操作
常見的應用在競賽中的操作分為:建樹,單點修改,區間求和,查詢區間最值,區間修改
我們先從建樹開始講起。
1.線段樹的建樹
線段樹的建樹是采用遞歸寫法來構建的。其核心思想就是:
遞歸左子樹,遞歸左子樹的左子樹...遞歸到左子樹的葉子結點,然后回溯到葉子結點的父節點的右子樹...以此類推。在每一次遞歸到葉子結點的時候就給該節點賦值(輸入或者0之類的)。
建樹的偽代碼很容易得出:
1 void Build() { 2 if(是葉子節點) 賦值 3 else { 4 遞歸左子樹; 5 遞歸右子樹; 6 } 7 }
那么問題出在這里:怎么判斷是葉子結點?怎么遞歸左右子樹?現在,往上翻,看看線段樹的性質。至於葉子節點的判斷,我們也可以利用線段樹的性質。葉子結點沒有子節點,那么它的左右區間必定相同(即一個點而不是一條線段),否則可以繼續向下遞歸。
另外,線段樹是一棵滿二叉樹,所以滿足滿二叉樹一個性質:父親節點編號為a,那么左子樹編號為2*a,右子樹編號為2*a+1
知道了這些性質,建樹就很好寫了。
1 /*i表示當前遞歸編號,l,r分別表示當前點的左右區間*/ 2 /*Tree數組是存儲線段樹的數組*/ 3 void Build(int i, int l, int r) { 4 if(l == r) { 5 scanf("%d", Tree[i]) 6 return; 7 } 8 int Mid = (l + r) / 2; 9 Build(i * 2, l, Mid); 10 Build(i * 2, Mid + 1, r); 11 PushUp(i) /*這是什么?往后看*/ 12 }
怎么樣?很簡單吧!
2.線段樹的單點修改
接下來來講講線段樹最基本操作之一 -- 單點修改。(前面講了怎么遞歸左右子樹,這里不再贅述)
單點修改在題目中一般以 "給定兩個數A, B,將樹上第A個修改為B"的形式存在。你可能認為:"這不是很Easy嗎?",然后立馬敲下了這一段代碼。
Tree[A] = B
這么寫就大錯特錯了!因為這里的"Tree[A]"不一定是我們需要找的那個'A',這么寫的話會導致整棵樹結構被打亂。
特別提醒:線段樹中的修改操作一定只能使用特別的操作來完成,千萬不要自以為是的寫一些似乎是對的代碼
那么怎么做呢?我們來分析一下。
如果要找到這個點A,我們必須要遞歸左右子樹來尋找。上面介紹了遞歸的方法,大家是否已經發現了這樣的遞歸很像某一種算法?沒錯,就是分治(如果要理解成二分也沒有問題),那么問題就很顯然了,每次都二分,如果要尋找的點A在當前區間的中點,即(l+r)/2之前,就遞歸左子樹,否則遞歸右子樹。那么寫成偽代碼是這樣的
void Quary_Single() { if(找到改點) 修改 if(查找點在當前區間前半部分) 遞歸左子樹 else 遞歸右子樹 }
這些操作我都介紹過了,那么寫成真正的代碼也不會很難吧。
1 /*i為當前編號,L,R為左右區間,A為修改點的編號,B為修改的值*/ 2 void Update_Single(int i, int L, int R, int A, int B) { 3 if(L == R) { 4 /*如果找到了,修改值*/ 5 Tree[i] == B; 6 return; 7 } 8 int Mid = (L + R) / 2; 9 if(A <= Mid) Update_Single(i * 2, L, Mid, A, B); /*遞歸左子樹*/ 10 else Update_Single(i * 2 + 1, Mid + 1, R, A, B); /*遞歸右子樹*/ 11 PushUp(i); /*這是什么?往后看*/ 12 }
大家應該都有一個想法吧:單點修改也不過如此。
的確,不過如此
3.線段樹的區間求和
首先我要介紹一個東西,叫做 "PushUp"函數。這個函數的作用是什么呢?應該有很多人都想到了,就是將子節點的信息"傳"給父親節點。具體寫起來也不難,我們可以將PushUp函數當做前綴和來處理(其實方便區間和,如果要求區間最值,PushUp函數就是處理最值了)
代碼大約是這樣:
/*區間最值處理*/ void PushUp(int Now) { Tree[Now] = Max(Tree[Now * 2], Tree[Now * 2 + 1]); } /*區間和處理*/ void PushUp(int Now) { Tree[Now] = Tree[Now * 2] + Tree[Now * 2 + 1]; }
這個東西要在什么地方加上呢?要在建樹以及修改之后,也就是上述的兩個操作之后。。
那么來講講區間求和問題吧。區間求和其實非常簡單,我們只需要查詢給定的區間,然后找到這個區間里面的所有葉子結點,把葉子結點的權值加起來,得到的結果就是我們所需要的區間和。那么要PushUp干嘛呢?PushUp簡化了這個過程。在原本的操作里,最差的情況是要遞歸一直到葉子結點,多么令人心痛的浪費時間!然而我們用PushUp預處理之后,就變成了前綴和問題,求和不就是小菜一碟嗎?
給出偽代碼
int Quary_Total() { if(在查詢區間內) 返回當前權值 if(當前區間中點在查詢區間的右邊) 遍歷左子樹,並求和 if(當前區間中點在查詢區間的左邊) 遍歷右子樹,並求和 return 答案 }
真代碼不需要我多說了吧。
1 /*i 為當前編號, L, R為查詢區間*/ 2 int Quary_Total(int i, int L, int R, int l, int r) { 3 if(l >= L && r <= R) return Tree[i]; /*如果在區間內*/ 4 int Mid = (L + R) / 2, Cnt = 0; /*初始化*/ 5 if(L <= Mid) Cnt += Quary_Total(i * 2, L, R, l, Mid); /*遞歸左子樹*/ 6 if(R > Mid) Cnt += Quary_Total(i * 2 + 1, L, R, Mid + 1, r); /*遞歸右子樹*/ 7 return Cnt; 8 }
就是這么簡單。
4.線段樹的區間最值
其實區間最值完全可以放在區間和里面講的,因為寫法幾乎一樣,唯一不同的是PushUp的方式以及判斷的方式。因為在PushUp的時候預處理每一棵子樹的最值,所以真正處理區間時只要把上面一層掃過去就可以了。
真代碼直接上:)
int Quary_RMQ(int i, int L, int R, int l, int r) { if(l >= L && r <= R) return Tree[i]; int Mid = (L + R) / 2, Cnt = 0; int A, B; A = Quary_RMQ(i * 2, L, R, l, Mid); B = Quary_RMQ(i * 2 + 1, L, R, Mid + 1, R); return Max(A, B); /*返回最大值*/ }
那么線段樹的四大基本操作就這么講完了
三、線段樹的優勢和劣勢
線段樹的優勢和劣勢都很明顯。
優勢:時間快,操作多
線段樹的優勢首先是時間快,上文也講過,線段樹的所有操作都是基於分治算法,再經過PushUp優化,整個算法就變得十分穩定。比起一般的數組暴力算法,線段樹是明顯更優的。看下表就知道

當然,在一些時候它也會劣於下面兩種算法,不過是在極少數時候。
另外,它操作多樣化,比起樹狀數組,多了區間最值一種操作。
劣勢:空間浪費
上面也介紹過了,線段樹一直是一棵滿二叉樹,所以無論如何,它所開的空間必須是四倍。但是在某些情況,線段樹會浪費三倍的空間(只有一條鏈等),但你又不能省掉這三倍空間,還是得苦逼的開四倍。
和樹狀數組比起來,一棵普通的線段樹是樹狀數組空間的四倍。
四、總結
線段樹是一種區間存儲結構,操作基本都有一個固定的模板,所以對於OIer的編碼能力要求並不強,只要掌握了,基本就是小菜一碟。只要注意空間上的問題,其他都沒什么困難的。
謝謝大家的收看!如有不對之處請指出! :)
本文作者: $xiaoyao24256$
