数据结构の学习记录(进阶篇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]

源代码在这里!

下一期介绍红黑树*

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