伸展樹Splay
平衡樹的旋轉
一般的平衡樹通過旋轉來維持樹的動態平衡。
回顧二叉搜索樹的性質,無論什麽時候都需要保證左子節點小於根節點,右子節點大於根節點。我們需要在維護平衡的過程中保持該性質不變。
旋轉分為左旋與右旋。
總結起來,樹的旋轉需要以下幾步:
1.(以如圖右旋為例)將Q的左兒子設定為B。
2.將P的右兒子設定為Q
3.如果旋轉以後P稱為了根節點,進行特殊判斷。
很明顯,旋轉以後仍然保持BST的性質。
Splay的伸展
當我們把某一個點x一路旋轉至某個位置時,我們把這稱為x的一次伸展。Splay通過將查詢頻率高的節點伸展至根節點,來提高操作效率。可見Splay的平衡具有隨機性,是一種動態平衡。
如何伸展?一種很自然的想法:x不停旋轉到父親的位置,直到到達指定節點。但是這樣的操作不夠優。我們可以考慮一次性進行兩次旋轉來讓伸展之後的樹更平衡。
我們一般進行如下的分類討論:
- 若x的父節點即為伸展目標:直接旋轉x即可。
- 若x的父節點不是伸展目標,且x、x的父親、x的父親的父親三點共線(即x是x的父親的左兒子,x的父親的父親也是x的父親的左兒子; 或x是x的父親的右兒子,x的父親的父親也是x的父親的右兒子)。這時先旋轉x的父親,再旋轉x。
- 若三點不共線,對x進行兩次旋轉即可。
仔細觀察發現,我們僅僅只優化了三點共線的情況。為什麽這樣做有用?試想一種鏈狀
的情況。若單旋,則最終還是一條鏈。若雙旋,情況就不一樣了。
Splay的插入、刪除與前驅後繼的求解
Splay的插入
Splay的插入幾乎與BST相同。但是多了一步,就是插入完成之後將插入的節點伸展至根節點,以加速操作效率。
Splay的刪除
相比插入,Splay的刪除較為復雜。分一下步驟:
- 找到要刪除的節點,將其旋轉至根。
- 若x只有一個兒子,那麽直接處理即可。
- 若x有兩個兒子,我們則需要找一個合適的節點來代替x。我們采用前驅。我們通過與BST一樣的方式來查找前驅。但由於x是根節點了,所以就可以不用考慮前驅在上面的情況。直接找到左子樹中的最大值即可。找到前驅之後,將前驅伸展至根的左子節點(只有可能在左邊)
- 註意:由於前驅是左子樹中最大的,所以前驅不存在右兒子。此時我們直接將前驅更新為根節點即可。
Splay的前驅與後繼
完全可以按照BST的做法去求解。但是如果要查找前驅的值根本不存在於數中呢?例如:假設數中已經有2,4,6,8四個節點,要查找5的前驅。很明顯是4,但是怎麽找呢?
我們可以將5插入到這棵樹中(註意5會被伸展到根),然後查找左子樹中的最大值,最後將5刪除。通過這樣簡便的方法求解前驅即可。後繼也一樣。
用Splay求解排名有關問題
求解x的排名
求解x的排名可以這樣做:將x伸展到根,然後求出左子樹中的節點個數+1.(左子樹中的節點個數即為小於x的數的個數)
問題轉化為了如何求解一個節點的兒子總數?
針對這個問題,我們可以在splay的結構體中加入size和cnt兩個成員。Size統計包括當前節點的兒子總數,cnt統計值為當前節點的值的數有幾個。例如存在五個5,而平衡樹絕對不允許存在相同的數,這時候只能利用cnt來統計了。
所以對於一個節點x,x的size值 = 左兒子的size + 右兒子的size + x的cnt。
而問題不會那麽簡單。在旋轉的過程中,size的值會不停改變。
其實手畫一棵平衡樹模擬一下就會知道:
在一次旋轉中,只有被旋轉的節點&被旋轉的節點的父親 的size會改變。
所以我們只需要在每一次旋轉之後更新一下這兩個節點的size值就可以了。
查詢排名為k的值
查找排名為k的值聽上去很復雜,但是有了size和cnt的幫助,問題也就不那麽難了。
從根節點開始向下查詢。分三類:
- 若當前節點的左兒子的size就已經>=k了,直接往左邊查詢。
- 若左兒子的size < k,但是左兒子的size加上當前節點的cnt大於等於k,說明排名為k的值就是當前節點。
- 對於剩下的情況(也就是左兒子的size加上當前節點的cnt的總和還是不足k),那麽向右查找。這時候,我們相當於在右子樹中有了一個全新的起點。我們只需要在右子樹中查找出排名為(k - 左兒子的size – 當前節點的cnt)的值即可。而這只需要依靠前面的方法就可以了。
區間翻轉問題
Splay最大的優點就是可以利用它求解區間有關的問題。
給出一個1~n的有序序列,要求進行m次區間翻轉(例如翻轉1,2,3,4,5的[2,4]區間,就會得到1,4,3,2,5.)。要求輸出m次翻轉以後的序列。——註意,翻轉之後的序列不一定有序。
很顯然,既然會存在翻轉,我們一定不能再按照點的權值來構造Splay了。換一種思路,利用它的排名來構造Splay。例如1,4,3,2,5, 4的排名是2,所以它在Splay中的值實際上代表2.
繼續考慮如何找到一個特定區間。根據Splay的性質,我們容易得到根節點的右兒子的左子樹一定大於根節點,卻小於根節點的右兒子。根據這一性質,如果我們要查詢區間[l, r](註意這裏的[l, r]表示排名第l至排名第r的所有點,並非值為l與r之間的所有點。例如在1,4,3,2,5中,[2,3]代表的是4,3兩個點),就可以把l-1首先伸展到根,r+1伸展到根的右兒子(r>l),那麽r+1的左子樹就是l~r的所有了。
但是考慮到如果需要翻轉[1, n]怎麽辦,並不存在排名為0和n+1的節點。這時候的處理方法一般是加入哨兵節點。即插入inf與-inf。這樣在做[1,x]的時候先把-inf伸展到根,在伸展x+1即可。
現在具體考慮如何翻轉。既然以在序列中值的排名作為標準來建樹,那麽翻轉一個區間相當於交換這個區間的每一個節點的左兒子與右兒子。考慮一下現在的Splay,對於每一個節點u,翻轉相當於原來在u之後的點全部都到了u的前面,原來在u之前的點全部到了u的後面。這也可以形象在樹上表示出來,也就是對於一個節點u,交換其所有節點的左右兒子。
但是這樣很明顯會超時的,所以我們借鑒之前線段樹的做法:增設一個翻轉懶標記lazy tag。當對於一個區間要全部進行翻轉的時候,在這個區間的根節點上打上懶標記。在標記下傳的時候交換左右子樹即可。
標記什麽時候下傳?當需要查找x的時候,自頂向下查找,若沿途發現了未下傳的標記,即下傳。保證查找的x的排名正確即可。
最後的答案怎麽來找?鑒於有可能標記可能未曾全部下傳,我們選擇查找排名為2~n+1(哨兵節點的存在)的點,在這個過程中就能保證答案的正確性了。
伸展樹Splay