資料結構與演算法-二叉查詢樹平衡(DSW)

BackSlash發表於2019-01-02

上一節探討了二叉查詢樹的基本操作,二叉查詢樹的查詢效率在理想狀態下是O(lgn),使用該樹進行查詢總是比連結串列快得多。但是,該論點並不總是正確,因為查詢效率和二叉樹的形狀息息相關。就像這樣:

資料結構與演算法-二叉查詢樹平衡(DSW)

圖1-1給出了3顆二叉查詢樹,它們儲存著相同的資料,但很明顯,圖1-1(A)的樹是最好的。在最壞的情況下,圖A定位一個物件需要3次測試,圖C需要6次。原因在於,圖C的資料不是平均分佈的,該樹實際上已經退化成一個連結串列,已經失去了二叉查詢樹的優越性。

這裡需要引入一個新的概念,叫做平衡。如果樹中任一節點的兩個子樹的高度差為0或者1,該二叉樹就是高度平衡的或者簡稱平衡的。例如,圖B中的節點20,其子樹的高度差是1,這是可以接受的。 但是對於節點10,其子樹的高度差是3,這意味著整棵樹是不平衡的。另外,如果樹是平衡的,並且該樹所有葉節點都出現在一個或者兩個層次上,那麼該樹是完全平衡的。

那麼問題來了,如何得到一顆平衡的二叉查詢樹?

許多技術都可以適當的平衡二叉樹。一些技術對資料重新排序從而建立一顆平衡的二叉樹,另一些技術在由於插入或者刪除元素而導致樹不平衡時,會重新平衡樹。我們首先來探討如何建立一顆平衡二叉查詢樹,然後介紹如何重新平衡已有的二叉查詢樹。

想要建立一顆平衡二叉查詢樹,首先要觀察這種樹的特性,根據觀察到的規律總結出來的數學邏輯,其實就是演算法。就像下面:

資料結構與演算法-二叉查詢樹平衡(DSW)

發現了嗎?如果將一顆完美的平衡的二叉查詢樹壓平,將資料線性列出,你會發現它是有序的,並且根節點30處於陣列A中間的位置。不止如此,根節點30的左子樹的根節點20處於陣列B的中間位置,根節點30的右子樹的根節點47處於陣列C的中間位置。以此類推,所有子樹的根節點總是處於某個陣列的中間位置。這很類似二分查詢的邏輯,並且你是否發覺,陣列A其實是該二叉樹中序遍歷的結果。

這裡有個定論:二叉查詢樹的中序遍歷可以得到有序的資料流

證明也很容易,這裡使用自然語言簡單描述下:

假如要對一顆二叉查詢樹進行中序遍歷,首先將其分解成根節點,左子樹,右子樹。因為中序遍歷的邏輯是首先遍歷左子樹,然後是根節點,最後是右子樹。我們可以將它們放到一個棧中,大概長得是這個樣子:

資料結構與演算法-二叉查詢樹平衡(DSW)

根據二叉查詢樹的定義,左子樹所有節點小於根節點,右子樹所有節點大於根節點。因此,棧此時的狀態是有序的。最後,我們將棧中的子樹全部分解成新的根節點、左子樹、右子樹,並且按照上圖所示的順序放入棧中,直到棧中不再存在子樹,全部分解成了根節點。最後是這個樣子的:

資料結構與演算法-二叉查詢樹平衡(DSW)

棧中儲存的資料流就是中序遍歷的結果。由於從分解開始是有序的,並且隨後的每一次分解都是有序的,最終形成的資料流也是有序的。

這就證明了上面的定論: 二叉查詢樹的中序遍歷可以得到有序的資料流

好吧,說了這麼多,其實是在總結我們觀察平衡二叉查詢樹找到的規律:

將平衡二叉查詢樹進行中序遍歷,可以得到一個有序的資料流,並且根節點處於資料流的中間位置。以此類推,右子樹的根節點處於右子樹資料流的中間位置,左子樹的根節點處於左子樹資料流的中間位置。

根據以上規律,我們可以得到一個建立平衡二叉查詢樹的演算法,首先用自然語言進行描述:

假設我們有一個有序的陣列,陣列中元素個數為n。我們可以將陣列中間元素指定為根,這個陣列現在包含兩個子陣列:一個包含從陣列的開始到剛剛選為根的元素之間的所有元素,另一個包含剛剛選為根的元素到陣列的末尾之間的所有元素。根的左子節點指定為第一個子陣列的中間元素,根的右子節點指定為第二個子陣列的中間元素。以此類推,陣列中的每個元素都可以放到二叉樹中,最終形成的二叉樹就是一顆平衡二叉查詢樹。

程式碼如下:

void balance(int data[], int first, int last) { 
        if (first <= last) {
                int middle = (first + last)/2;         
                insert(data[middle]);
                balance(data, first, middle - 1);
                balance(data, middle + 1, last);   
        }
}複製程式碼

程式碼中使用了遞迴,遞迴可以使程式邏輯變得簡單,但是會加大執行時棧的負擔,慎用。我們這裡只是探討演算法,因此,使用遞迴實現是可以的。

該演算法存在嚴重的缺陷:在建立樹之前,所有的資料都必須放在陣列中。當必須使用樹,但是準備儲存到樹中的資料仍然在輸入的時候,該演算法就不太合適了。我們可以使用折中的方法,如果資料在持續輸入,我們可以按照建立二叉查詢樹的方法,將資料儲存到二叉樹中。資料輸入完畢之後,只需要對該樹進行中序遍歷,就可以得到有序的資料流,然後使用上述的演算法,就可以得到一顆平衡的二叉查詢樹。

上訴討論的演算法效率有點低,因為在建立完全平衡的樹之前,需要使用一個額外的有序陣列。為了避免排序,這一演算法需要破壞樹並用中序遍歷把元素放在陣列中,然後重建該樹,這樣做效率並不高,除非樹很小。然而,存在幾乎不需要儲存中間變數也不需要排序過程的演算法。這就是DSW演算法。該演算法可以對已經存在的二叉查詢樹進行平衡,並且不需要中間變數。

老規矩,在不勞而獲的獲取DSW演算法之前,我們先自己分析一下如何將一顆二叉查詢樹進行平衡。先思考一個問題:

資料結構與演算法-二叉查詢樹平衡(DSW)

如何將上圖中的二叉查詢樹重新構建成一顆平衡的二叉查詢樹,為了方便找到規律,這裡給出平衡之後的二叉查詢樹:

資料結構與演算法-二叉查詢樹平衡(DSW)

是不是發現很相似?如果將平衡之後的二叉查詢樹從右上角到左下角壓平,可以得到和原始二叉查詢樹相似的結構。那麼,通過哪些操作可以將原始二叉樹轉變成平衡二叉樹呢?我們首先觀察左上角的4個節點:5、10、20、15。可以遮蔽掉其他節點,把它們當做不存在。如果你瞭解二叉樹節點的左旋操作,馬上就能明白,只要將20節點圍繞其父節點10左旋轉,馬上就可以得到平衡之後的二叉樹。什麼是左旋?左旋有什麼作用?我們首先來探討左旋的作用,然後探討左旋的原理。左旋可以提升根節點左子樹的高度,降低根節點右子樹的高度,並且左旋之後依然是二叉查詢樹。比如,5、10、20、15。10作為根節點,左子樹高度為1,右子樹高度為2。經過左旋之後,20成為新的根節點,左子樹高度提升為2,右子樹高度降低為0。可以發現,左旋之後的二叉樹依然是二叉查詢樹。那麼左旋到底是什麼呢?左旋其實與二叉樹節點的合併刪除演算法非常相似,並且原理是一致的,在資料結構與演算法-二叉查詢樹這篇文章中,詳細講解了合併刪除的原理。這裡簡單描述下左旋的操作以及原理。依然以5、10、20、15節點為例,左旋是針對右子樹的根節點來說的,對稱的右旋是針對左子樹的根節點來說的。在這裡,20節點作為10節點的右子節點,可以圍繞10節點進行左旋操作。首先,將根節點10以及左子樹作為A組,將20節點所在的右子樹作為B組,左旋就是將A組合併到B組上。將A組設定為20節點的左子樹,將20節點原有的左子樹設定為A組的右子樹。就像這樣:

資料結構與演算法-二叉查詢樹平衡(DSW)

將A組合併到B組,原則上來講,只要20節點到10節點的路徑中不存在右指標即可,因為,一旦出現右指標,就意味著,B組中存在節點小於A組節點,但是我們都知道,A組是二叉查詢樹的根節點以及左子樹組成,所有節點都小於B組節點(右子樹)。在左旋操作中,將A組設定為20節點的左子樹,只有一個左指標,因此,該操作是合法的。20節點原有的左子樹需要合併到A組上,原則上來講,只要10節點到15節點的路徑中不存在左指標即可,原理和上訴類似。因為10的右子樹為空,所以這裡就將15節點直接設定為10節點的右子樹。到此為止,左旋操作完畢,因為左旋操作本質是二叉查詢樹中合法的子樹合併操作,所以最後的二叉樹也是合法的二叉查詢樹,但是左旋提高了左子樹的高度,降低了右子樹的高度,左旋和右旋是對稱的,有興趣的可以自行了解。將目光放到平衡二叉查詢樹上,比較平衡之前的二叉樹以及平衡之後的二叉樹。可以發現,只要進行兩步操作就能實現轉變。第一步,分別對20節點、30節點、49節點進行左旋操作。第二步,繼續對30節點進行左旋操作。搞定收工,下面總結通用演算法。

如果忽略最後一層的葉子節點,剩餘的二叉樹是一個完美二叉樹的線性排列。那麼,該完美二叉樹的元素個數是多少呢?我們假設原二叉查詢樹元素個數為n,完美二叉樹的高度為h,那麼可以得到不等式2^h - 1 <= n,即h <= lg(n+1),只要不等式向下取整,就可以獲取到完美二叉樹的高度。那麼,完美二叉樹元素個數m為2^h - 1。一眼就能看出m是個奇數,並且在第一次左旋時,是從上到下第二個節點開始的,做多少次左旋呢?其實是m/2次。帶入到上述二叉查詢樹中,完美二叉樹高度h為lg(12 + 1),向下取整為3,完美二叉樹元素個數m為2^h - 1,即7,第一次左旋次數為m/2,即3。上述平衡二叉查詢樹過程中,一共有兩步,第一步做了3次左旋,第二步做了1次左旋。可以發現,根據不同高度(h)的完美二叉樹,需要做不同的步數(p),它們的關係是p = h - 1。當然,終止條件也可以是另一種。可以發現,假設完美二叉樹元素個數為m,那麼第一步左旋次數為m/2,記為m1,第二步左旋次數為m1/2,記為m2,以此類推,如果m(n)小於1,證明已經平衡完畢。

到此為止,我們已經總結出了平衡二叉查詢樹的關鍵邏輯。問題從平衡二叉查詢樹轉變成了如何獲取類似下圖的二叉樹?

資料結構與演算法-二叉查詢樹平衡(DSW)

可以發現,二叉查詢樹總的元素個數n為12,從右上到左下,最外層的節點數m是7。發現了嗎?其實7就是該二叉查詢樹包含的完美二叉樹元素個數。通過lg(n + 1)向下取整可以得到完美二叉樹的高度h,通過2^h - 1可以得到完美二叉樹元素個數m。將12帶入公式,可以得到h為3,m為7。內層有5個節點5、15、23、28、40,如果分別對它們進行右旋操作,可以得到以下圖形:

資料結構與演算法-二叉查詢樹平衡(DSW)

是不是很熟悉?這不就是二叉查詢樹最差的連結串列形式嘛!真是造化弄人,我們在平衡二叉查詢樹的過程中,竟然還需要藉助二叉樹的連結串列形式。從連結串列形式的第二個節點開始,每次隔一個節點進行n - m次左旋,即5次左旋,就能得到我們想要的二叉樹形式。現在問題又轉變成了,如何從一個普通的二叉查詢樹獲取到最差的連結串列形式?答案是從根節點開始,沿著右子樹,不停的右旋(提高右子樹高度,降低左子樹高度),直至所有左指標為空。就像下面動圖:

資料結構與演算法-二叉查詢樹平衡(DSW)

到目前為止,我們已經探討出從普通二叉查詢樹到平衡二叉查詢樹的過程。其實這就是DSW演算法。總結如下:

  • 建立主鏈

建立主鏈就是從普通二叉查詢樹轉變到只有右指標的鏈式結構,虛擬碼如下:

createMainChain(root) {
        tmp = root;     
        while(tmp != 0) {
                if tmp有左子節點
                        圍繞tmp右旋左子節點;             //這樣左子節點將成為tmp的父節點
                        tmp設定為剛剛成為父節點的子節點;
                else  將tmp設定為它的右子節點; 
        }
}複製程式碼
可以發現,建立主鏈的過程就是不停的右旋,直至二叉樹中不存在左指標。
  • 主鏈轉換成平衡樹

主鏈轉換成平衡樹其實有兩步,第一步就是從主鏈轉變成類似下面的圖形:

資料結構與演算法-二叉查詢樹平衡(DSW)

二叉樹一共有n(12)個節點,外層節點數n(7)是該二叉樹包含的完美二叉樹個數,計算方式在上面已經探討過,就是lg(n + 1)向下取整獲取完美二叉樹高度h(3),然後由2^h - 1獲取到完美二叉樹個數m(7),那麼內層葉子節點個數就是n - m,即5個。我們只要從主鏈的第二個節點開始,每隔一個節點進行一次左旋,一共進行n - m次(5次)即可。

第二步就是將以上圖形轉變成平衡二叉查詢樹,演算法邏輯已經探討過了。其實就是將外層節點從第二個開始,每隔一個節點進行一次左旋,一共進行m/2次,記為m1。這時,最外層節點個數成為m1,繼續從第二個開始,每隔一個節點進行一次左旋,一共進行m1/2次,記為m2。以此類推,終止條件是m(n)小於1,或者是迴圈h - 1次。

演算法中的邏輯,也就是為什麼這麼做?全部已經在上面探討清楚,可以反覆閱讀加深理解。

第二步的邏輯總結一下就是不停的左旋(提高左子樹高度,降低右子樹高度),直至二叉查詢樹平衡。

虛擬碼如下:

createBalanceTree() {     
        n = 節點數;     
        m = 2^h - 1;    
        從主鏈的頂部第二個節點開始,每隔一個節點進行左旋,一共進行n - m次; 
        while(m > 1) {
                m = m/2;
                從主鏈的頂部第二個節點開始,每隔一個節點進行左旋,一共進行m次;       
        }
}複製程式碼

DSW演算法已經探討完畢。DSW演算法是對已有的二叉查詢樹進行全域性平衡的演算法,二叉樹中的每個節點都有可能改變位置。它的演算法複雜度是O(n),這一時間隨著n線性增長,而且只需要很小且固定的儲存空間。總之,DSW是一個非常優秀的對二叉樹進行全域性平衡的演算法。

到目前為止,我們探討了從陣列中如何建立一顆平衡的二叉查詢樹以及如何對已有的普通二叉查詢樹進行全域性平衡。但是,還有一個問題沒有解決,二叉查詢樹之所以不平衡,通常是由於插入或者刪除操作造成的。這種不平衡通常是區域性不平衡,這種時候不需要使用DSW演算法對全域性進行平衡。重新平衡只需要在區域性進行即可,這就是大名鼎鼎的AVL樹,這是我們下節需要探討的內容。

資料結構與演算法-二叉查詢樹平衡(AVL)


相關文章