數據結構の學習記錄(進階篇2):20餘張圖帶你詳盡領略AVL樹的美

本文閱讀大概需要45分鐘,獨立編程需要兩天,建議預留充足的時間和咖啡。

(友情提示,筆者基本未參考網絡資料,邊思考邊寫代碼,100%乾貨,學AVL看這一篇就夠了)

學樹的順序,一般來說是:二叉樹->二叉查找樹->AVL樹->2-3-4樹->紅黑樹。它們的難度依次遞增。不得不說的是,樹是計算機科學最重要研究課題之一。在算法類面試當中,樹的考察也是不可或缺的。

先簡單回顧一下二叉查找樹:

一棵空樹,或者是具有下列性質的二叉樹

(1)若左子樹不空,則左子樹上所有結點的值均小於它的根結點的值;

(2)若右子樹不空,則右子樹上所有結點的值均大於它的根結點的值;

(3)左、右子樹也分別爲二叉排序樹;

(4)沒有鍵值相等的結點。

讓我們介紹一種全新的自平衡二叉樹(斜體加粗表示這玩意很酷炫)。相信在你學完並且理解之後。會覺得之前學的樹都太low了。

你可能覺得AVL是某些niubilious(自造詞,牛批的意思)的英文縮寫。事實是,AVL樹的名字來源於它的發明作者G.M. Adelson-Velsky 和 E.M. Landis。AVL樹是最先發明的自平衡二叉查找樹(Self-Balancing Binary Search Tree,簡稱平衡二叉樹)。

它的特點是:左右子樹高度差(平衡因子)的絕對值小於等於1. (紅色加粗的字體表明你應該記住這句話)

下面邀請插畫師turtle配合演示,事實上,整個AVL樹的插入和刪除操作我們都會作圖演示,保證你能看懂:

感謝turtle先生,你今天的粉色畫筆非常好看!

 數字代表val,字母代表payload。你可以藉助字典中的key和value來理解。最下面的數字,我們稱爲平衡因子(balance factor,bf)。只有一棵樹所有節點平衡因子在1,0,-1之間,這棵樹纔是平衡的。 所謂的樹的高度,就是垂直方向,從當前節點到根節點的最大深度。平衡因子等於左右子樹高度差,葉子節點平衡因子一定爲0。

爲什麼要滿足這個條件呢?(斜體表示你問了一個問題)因爲我們希望一棵樹儘量對稱均勻,看起來漂亮(刪除線表示這是錯誤的理解)我們先區分兩個概念

  1. 滿二叉樹:除最後一層無任何子節點外,每一層上的所有結點都有兩個子結點的二叉樹爲滿二叉樹(國內定義)
  2. 完全二叉樹:若設二叉樹的深度爲k,除第 k 層外,其它各層 (1~k-1) 的結點數都達到最大個數,第k 層所有的結點都連續集中在最左邊,這就是完全二叉樹。
滿二叉樹
完全二叉樹

AVL樹是一種完全二叉樹,因爲這個特性,我們發現它的插入,刪除操作最好最壞的情況都是log(n)。

這是怎麼得來的? 你如果只是死記而不理解的話,這會很糟糕,你可能會記混淆。一棵完全二叉樹的深度設爲k , 那麼前k-1層一共有2^(k-1)-1個節點,最後一層節點數最少爲0,最多爲2^(k-1)。設節點總數爲N。那麼2^(k-1)-1<=N<=2^(k-1)-1+2^(k-1)=2^k-1。解得:  k-1<Log2(N+1)<k,按照層數遞歸的思想,我們的時間複雜度就是O(logN)量級的。

由於之前的博客已經講解了二叉查找樹。下面讓我們思考,在插入過程如何保持二叉樹動態平衡。

希望你能保持足夠的耐心,關閉音樂,集中注意力,只要不是咖啡撒到鍵盤上就行。

    def put (self, val, payload):
        if self.root:
            self._put(val, payload,self.root)
        else:
            self.root = BSTNode(val,payload)
    
    def _put(self,val,payload,currentNode):
        if val <= currentNode.val:
            if not currentNode.left_child:
                left_new = BSTNode(val,payload,parent=currentNode)
                currentNode.left_child = left_new
            else:
                self._put(val,payload,currentNode.left_child)
        else:
            if not currentNode.right_child:
                right_new = BSTNode(val,payload,parent=currentNode)
                currentNode.right_child = right_new
            else:
                self._put(val,payload,currentNode.right_child)

這是BST插入的相關代碼。下面介紹添加節點時的平衡操作:

Put方法實現

  • 我們先考慮當前節點bf的變化,如果當前節點的bf大於1或小於-1,說明我們需要進行旋轉操作,下面我會詳細介紹;否則在當前節點爲左子節點時,我們將其父節點的bf加 1,當前節點爲右子節點,其父節點減1. 這裏其實是一個遞歸操作,每當子節點改變,其上層的所有父節點需要改變,除非某個父節點的平衡因子爲0.
  • 我們先介紹幾種平衡方法:左旋(L_Rot),右旋(R_Rot),LR雙旋(LR_doubRot),RL雙旋(RL_doubRot)。它們是你前所未見的炫操作。

1.左旋(感謝Sun_TTTT博客精彩配圖)

左旋

簡而言之就是某個節點的父節點變成了其左子節點,對於原來的左子節點,變成原來父節點的右節點。

2.右旋

右旋

簡而言之就是某個節點的父節點變成了其右子節點,對於原來的右子節點,變成原來父節點的左節點。

3.LR雙旋

它是左旋和右旋的結合版。

4.RL雙旋

LR雙旋的相反版本。

至於AVL是如何設計出這樣的結構,這不是人類考慮的問題了。但是筆者理解是,爲了保持節點平衡,儘可能降低樹的高度,在紅黑樹中也有類似的操作。

好了,介紹了這幾種旋轉方法,我猜你已經暈頭轉向了。但是好戲剛剛開始。

  • 關於旋轉:什麼時候進行什麼樣的旋轉?我們需要一點想象力(Ignite your Imagination)。
  • 對於直線型結構和迴旋鏢型結構,如下圖所示。在當前節點的平衡因子小於-1且左子節點不存在並且當前節點的右子節點的左子節點存在,那麼表示我們需要進行RL雙旋,否則進行左旋;在當前節點的平衡因子大於1且右子節點不存在並且當前節點的左子節點的右子節點存在,那麼表示我們需要進行LR雙旋,否則進行右旋。

     

代碼如下,供參考:

    def renew_balance_factor(self,currNode:AVLNode):
        if currNode.balance_factor>1 or currNode.balance_factor<-1:
            if self.isrebalance: self.rebalance(currNode)
            return 
        if currNode.isLeftChild(): 
            currNode.parent.balance_factor+=1
        elif currNode.isRightChild():
            currNode.parent.balance_factor-=1
        if currNode.parent!=None and currNode.parent.balance_factor != 0 :         
            self.renew_balance_factor(currNode.parent)

   def rebalance(self,currNode:AVLNode):
        if currNode.balance_factor>1:# left-heavy sub tree
            if not currNode.right_child and currNode.left_child.right_child:
                self.LR_doubRot(currNode)
                return 
            else:
                self.R_Rot(currNode)
        elif currNode.balance_factor<-1:# right-heavy sub tree
            if not currNode.left_child and currNode.right_child.left_child:
                self.RL_doubRot(currNode)
            else:
                self.L_Rot(currNode)
  • 旋轉後平衡因子如何更新

我們在BST中插入節點,如果發現某個節點的平很因子大於1或小於-1。就表示我們該進行自平衡操作了。進行R單旋的情況是直線型結構:

我們只需要將當前節點16繞7旋轉至其右節點即可。其他節點不作改變。我們旋轉的同時也必須考慮平衡因子的變化,這裏會涉及

到比較複雜的數學推導,不過沒必要緊張,你完全可以畫圖理解。

我們只需要更新A和B的平衡因子即可。

上面的new old 分別表示新老節點,h(·)表示節點高度。你看到這可能已經躍躍欲試了,數學可是我的強項呀! L單旋同理。

很不幸的是,對於大部分人,包括筆者,數學都不是我們的強項!所以我們需要調動程序員思維。正所謂“車到山前必有路”。

思考替代方案中。。。


很棒,你已經有了思路,我可以直接獲取節點的高度啊!只需要編寫一個get_node_bf()函數就行了。需要注意的是,我們需要得到的是該節點左右子樹中高度的最大值。所以我們必須進行遞歸左右子樹,左右子樹的“路徑”,個數我們不知道:按照h(Node) = 1 + max(h(left_child),h(right_child)). 因此我們需要用兩個列表path_level_l 和 path_level_r 來分別存儲左右子樹各個路徑的高度。最後套用公式就是該節點的高度。

我們稍微整理一下思路寫出代碼:

def get_level(node,depth,path_level):
            if not node: return 0
            if node.isLeaf(): path_level.append(depth);return  
            depth+=1
            if node.left_child:
                get_level(node.left_child,depth,path_level)
            if node.right_child:
                get_level(node.right_child,depth,path_level)

如果是葉子節點其高度爲1,bf爲0,我們再寫一個求節點bf的函數:

def get_node_bf(self,currNode:AVLNode)->int:
        # obtain current node's bf
        max_l = max_r =0
        path_level_l = []
        path_level_r = []
        if currNode.isLeaf():
            return (max_l-max_r)
        else:
            get_level(currNode.left_child,1,path_level_l)
            get_level(currNode.right_child,1,path_level_r)
            if not path_level_l: max_l = 0
            else:max_l = max(path_level_l)
            if not path_level_r: max_r = 0
            else: max_r = max(path_level_r)
            return (max_l - max_r)
  • 進行LR單旋的是“迴旋鏢型結構”:

我們同樣進行平衡因子的動態更新,不過這次只用調用get_node_bf函數即可。注意C先變爲B的父節點,B爲C左子節點,A再變爲C的右子節點,C的父節點改爲A的父節點。A的父節點改爲C是不是非常簡單呢?

 

  • 下面我們將上述過程可視化:

以下面數據爲例:

data = {16:'A',3:'B',7:'C',11:'D',9:'E',26:'F',18:'G',14:'H',15:'I'}

 大家可以自行驗證另一個完全相反的過程,檢查自己的代碼有無紕漏。

 

好了,非常高興你能堅持看到這,如果你覺得困的話,可以明天再看刪除操作:

Del 方法實現

你已經喝完了咖啡,是否還感覺困呢?咖啡不要放太多糖。

Del 方法比put方法稍微複雜一點,但是我們有了put的相關方法,因而不會太麻煩。我們構建了上述二叉樹,現在嘗試依次刪除150,130,160,140,155,120,157。

刪除有三種情況:

  1. 刪除葉子節點;
  2. 刪除節點只有一個子節點;
  3. 刪除節點有兩個子節點。

如果你認真看了我關於二叉查找樹的博客的話,會發現萬變不離其宗。我們只需要在每次刪除節點後更新bf即可。爲此我們設計函數update_del_bf()。如果我們發現當前節點不平衡,就將其通過旋轉的方式平衡,繼續更新它的父節點;如果節點平衡,我們依次更新父節點,直到父節點爲None止。

代碼如下:

    def update_del_bf(self,currNode:AVLNode):
        if not currNode:return
        currNode.balance_factor = self.get_node_bf(currNode)
        if currNode.balance_factor>1 or currNode.balance_factor<-1:
            if self.isrebalance: self.rebalance(currNode)
            self.update_del_bf(currNode.parent)
            return 
        
        if currNode.parent:#update all parent bf         
            self.update_del_bf(currNode.parent)

好了刪除操作我們也完成了,下面以圖片的形式展示整個刪除過程。

上面就是,整個插入和刪除的過程,相信你一定會有許多收穫或者疑問,歡迎留言或者email [email protected]

源代碼在這裏!

下一期介紹紅黑樹*

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章