数据结构与算法——28. 平衡二叉查找树(AVL树)的定义与实现

一、平衡二叉查找树(AVL树)的定义

平衡二叉查找树(AVL树)是一种能够在key插入时一直保持平衡的二叉查找树

AVL是发明者的名字缩写:G.M. Adelson-Velskii and E.M. Landis

利用AVL树实现ADT Map, 基本上与BST的实现相同,不同之处仅在于二叉树的生成与维护过程。

AVL树的实现中,需要对每个节点跟踪“平衡因子balance factor”参数。平衡因子是根据节点的左右子树的高度来定义的,确切地说,是左右子树的高度差

  • 平衡因子 > 0,称为“左重left-heavy”,即左子树高度更大;

  • 平衡因子 < 0,称为“右重right-heavy”,即右子树高度更大;

  • 平衡因子 = 0,称作平衡,即左、右子树高度一样。

    比如,某个节点的平衡因子为2,则说明该节点:左子树高度-右子树高度=2。

如果一个二叉查找树中每个节点的平衡因子都在==-1,0,1之间,则把这个二叉查找树称为平衡树==。在平衡树操作过程中,有节点的平衡因子超出此范围,则需要重新进行平衡

二、AVL树的性能

我们来分析AVL树最差情形下的性能:即平衡因子为1或者-1。

下图列出平衡因子为1的“左重”AVL树,树的高度从1开始,来看看问题规模(总节点数N)和比对次数(树的高度h)之间的关系如何?

在这里插入图片描述

观察上图h=1~4时,总节点数N的变化:

  • h = 1, N = 1
  • h = 2, N = 2 = 1 + 1
  • h = 3, N = 4 = 1 + 1 + 2
  • h = 4, N = 7 = 1 + 2 + 4

通式: N h = 1 + N h − 1 + N h − 2 N_h=1+N_{h-1}+N_{h-2} Nh=1+Nh1+Nh2,观察这个通式,它很接近斐波那契数列!

  1. 定义斐波那契数列 F i F_i Fi,利用 F i F_i Fi重写 N h N_h Nh

    F 0 = 0 F_0=0 F0=0

    F 1 = 1 F_1=1 F1=1

    F i = F i − 1 + F i − 2 F_i=F_{i-1}+F_{i-2} Fi=Fi1+Fi2,对于所有的 i ≥ 2 i\geq 2 i2

    N h = 1 + N h − 1 + N h − 2 N_h=1+N_{h-1}+N_{h-2} Nh=1+Nh1+Nh2

    可得 N h = F h + 2 − 1 , h ≥ 1 N_h=F_{h+2}-1,h\geq 1 Nh=Fh+21,h1

  2. 斐波那契数列的性质: F i / F i − 1 F_i/F_{i-1} Fi/Fi1趋向于黄金分割 Φ Φ Φ

    Φ = 1 + 5 2 \Phi = \frac{1+\sqrt[]{5}}{2} Φ=21+5

    可得 F i F_i Fi的通式 F i = Φ i 5 F_i=\frac{\Phi^i}{\sqrt[]{5}} Fi=5 Φi

  3. 将通式 F i F_i Fi带入 N h N_h Nh,得到 N h N_h Nh的通式

    N h = Φ h + 2 5 − 1 N_h = \frac{\Phi^{h+2}}{\sqrt[]{5}}-1 Nh=5 Φh+21

  4. 上述通式只有 N N N h h h了,我们解出 h h h

    log ⁡ N h + 1 = ( H + 2 ) log ⁡ Φ − 1 2 log ⁡ 5 \log N_h+1=(H+2)\log \Phi - \frac{1}{2}\log 5 logNh+1=(H+2)logΦ21log5

    h = log ⁡ N h + 1 − 2 log ⁡ Φ + 1 2 log ⁡ 5 log ⁡ Φ h=\frac{\log N_h +1-2\log \Phi + \frac{1}{2}\log 5}{\log \Phi} h=logΦlogNh+12logΦ+21log5

    h = 1.44 log ⁡ H h h=1.44\log H_h h=1.44logHh

最多搜索次数h和规模N的关系, 可以说AVL树最差情况下的搜索时间复杂度为 O ( log ⁡ n ) O(\log n) O(logn)

三、AVL树的python实现

既然AVL平衡树确实能够改进BST树的性能, 避免退化情形,我们来看看向AVL树插入一个新key, 如何才能保持AVL树的平衡性质。

  1. 首先,作为BST,新key必定以叶节点形式插入到AVL树中;

  2. 叶节点的平衡因子是0,其本身无需重新平衡,但会影响其父节点的平衡因子:

    • 作为左子节点插入,则父节点平衡因子会增加1;
    • 作为右子节点插入,则父节点平衡因子会减少1。
  3. 这种影响可能随着其父节点到根节点的路径一直传递上去,直到:

    • 传递到根节点为止;

    • 或者某个父节点平衡因子被调整到0,不再影响上层节点的平衡因子为止。(无论是从1还是-1,只要是调整到0,都不会改变子树高度)

    在这里插入图片描述

1. put方法

相比之前二叉查找树的put方法,只需重新定义_put方法即可,多了两步更新平衡因子的步骤:

def _put(self,key,val,currentNode):
    # 如果key比当前节点小,那么放到到左子树
    if key < currentNode.key:
        # 如果有左子树,就递归进入左子树继续查找合适的空位置
        if currentNode.hasLeftChild():
                self._put(key,val,currentNode.leftChild)
        # 如果没有左子树,那么key就成为左子节点
        else:
                currentNode.leftChild = TreeNode(key,val,parent=currentNode)
                # 更新平衡因子
                self.updateBalance(currentNode.leftChild)
     # 如果key比当前节点大,那么放到到右子树
    else:
         # 如果有右子树,就递归进入右子树继续查找合适的空位置
        if currentNode.hasRightChild():
                self._put(key,val,currentNode.rightChild)
        # 如果没有右子树,那么key就成为右子节点
        else:
                currentNode.rightChild = TreeNode(key,val,parent=currentNode)
                # 更新平衡因子
                self.updateBalance(currentNode.rightChild)

2. UpdateBalance方法

def updateBalance(self,node):
    # 如果当前节点的平衡因子大于1或小于-1,就进行重新平衡
    if node.balanceFactor > 1 or node.balanceFactor < -1:
        self.rebalance(node)  # 重新平衡的方法后面再讲
        return
    # 如果平衡因子在正常范围内,且有父节点的话,就看父节点的平衡因子
    if node.parent != None:
        # 如果当前节点是其父节点的左子节点,就将父节点的平衡因子加1
        if node.isLeftChild():
                node.parent.balanceFactor += 1
        # 如果当前节点是其父节点的右子节点,就将父节点的平衡因子减1
        elif node.isRightChild():
                node.parent.balanceFactor -= 1

        # 如果当前节点的父节点平衡因子是否调整为0了,不为0就递归调用,传入父节点
        if node.parent.balanceFactor != 0:
                self.updateBalance(node.parent)
    # 如果没有父节点,说明“影响”已经传递到根了,不用做任何操作

3. 重新平衡的思路

主要手段:将不平衡的子树进行旋转(rotation)。

根据“左重”或者“右重”进行不同方向的旋转。同时更新相关父节点引用,更新旋转后被影响节点的平衡因子。

如图,是一个“右重”子树A的左旋转(并保持BST性质):将右子节点B提升为子树的根,将旧根节点A作为新根节点B的左子节点。

在这里插入图片描述

如果新根节点B原来有左子节点,则将此节点设置为A的右子节点(A的右子节点一定有空,因为A原来的右子节点是B)。

更复杂一些的情况:如图的“左重”子树右旋转:

  • 旋转后,新根节点C将旧根节点E作为右子节点,但是新根节点C原来已有右子节点D,需要将原有的右子节点D重新定位!

  • 原有的右子节点D改到旧根节点E的左子节点同样, E的左子节点在旋转后一定有空。

在这里插入图片描述

4. rotateLeft左旋(右旋的代码与之相似)

def rotateLeft(self,rotRoot):
    # 新根(有可能只是某个子树的根)是rotRoot的右子节点
    newRoot = rotRoot.rightChild
    # rotRoot的右子节点指向新根的左子节点
    rotRoot.rightChild = newRoot.leftChild
    # 如果新根的左子节点不为空,让新根的左子节点指向rotRoot
    if newRoot.leftChild is not None:
        newRoot.leftChild.parent = rotRoot
    newRoot.parent = rotRoot.parent
    # 如果rotRoot是根节点,那么就将新根确定为根
    if rotRoot.isRoot():
        self.root = newRoot
    # 如果rotRoot不是根节点,则根据rotRoot是其根节点的左或右子节点,确定新根的位置
    else:
        if rotRoot.isLeftChild():
                rotRoot.parent.leftChild = newRoot
        else:
            rotRoot.parent.rightChild = newRoot
    # 新根的左子节点指向rotRoot
    newRoot.leftChild = rotRoot
    # rotRoot的父节点指向新根
    rotRoot.parent = newRoot
    # 仅有两个Root结点需要调整因子,公式的讲解在下面
    rotRoot.balanceFactor = rotRoot.balanceFactor + 1 - min(newRoot.balanceFactor, 0)
    newRoot.balanceFactor = newRoot.balanceFactor + 1 + max(rotRoot.balanceFactor, 0)

左旋转对平衡因子的影响:保持了次序ABCDE,ACE的平衡因子不变。

在这里插入图片描述

主要看B、D的平衡因子的新旧关系:

新B= hA- hC,旧B= hA- 旧hD

而:

旧hD= 1+ max(hC, hE),所以:旧B= hA- (1+ max(hC, hE))

则:新B- 旧B= 1+ max(hC, hE)- hC

新B= 旧B+ 1+ max(hC, hE)- hC;把hC移进max函数里就有

新B= 旧B+ 1+ max(0, -旧D) <==> 新B= 旧B+ 1- min(0, 旧D)

5. 更复杂的情形

下图的“右重”子树, 单纯的左旋转无法实现平衡。左旋转后变成“左重”了,“左重”再右旋转,又回到“右重”。

在这里插入图片描述

  • 所以,在左旋转之前检查右子节点的因子:如果==右子节点“左重”==的话,先对它进行右旋转,再实施原来的左旋转;

  • 同样,在右旋转之前检查左子节点的因子:如果==左子节点“右重”==的话,先对它进行左旋转,再实施原来的右旋转。

在这里插入图片描述

6. rebalance方法:重新平衡

def rebalance(self,node):
  # 右重:需要左旋
  if node.balanceFactor < 0:
         # 右子节点左重:先右旋
         if node.rightChild.balanceFactor > 0:
            self.rotateRight(node.rightChild)
            self.rotateLeft(node)
         else:
            self.rotateLeft(node)
  # 左重:需要右旋
  elif node.balanceFactor > 0:
         # 左子节点右重:先左旋
         if node.leftChild.balanceFactor < 0:
            self.rotateLeft(node.leftChild)
            self.rotateRight(node)
         else:
            self.rotateRight(node)

经过复杂的put方法, AVL树始终维持平衡, get方法也始终保持 O ( log ⁡ n ) O(\log n) O(logn)高性能。

  • 需要插入的新节点是叶节点,更新其所有父节点和祖先节点的代价最多为 O ( log ⁡ n ) O(\log n) O(logn)
  • 如果插入的新节点引发了不平衡,重新平衡最多需要2次旋转,但旋转的代价与问题规模无关,是常数 O ( 1 ) O(1) O(1)
  • 所以整个put方法的时间复杂度还是 O ( log ⁡ n ) O(\log n) O(logn)

四、ADT Map的实现方法小结

我们采用了多种数据结构和算法来实现ADT Map,其时间复杂度数量级如下表
所示:

有序表散列表二叉查找树AVL树
put O ( n ) O(n) O(n) O ( 1 ) O(1) O(1) -> O ( n ) O(n) O(n) O ( log ⁡ 2 n ) O(\log _2n) O(log2n) -> O ( n ) O(n) O(n) O ( log ⁡ 2 n ) O(\log _2n) O(log2n)
get O ( log ⁡ 2 n ) O(\log_2n) O(log2n) O ( 1 ) O(1) O(1) -> O ( n ) O(n) O(n) O ( log ⁡ 2 n ) O(\log _2n) O(log2n) -> O ( n ) O(n) O(n) O ( log ⁡ 2 n ) O(\log _2n) O(log2n)
in O ( log ⁡ 2 n ) O(\log_2n) O(log2n) O ( 1 ) O(1) O(1) -> O ( n ) O(n) O(n) O ( log ⁡ 2 n ) O(\log _2n) O(log2n) -> O ( n ) O(n) O(n) O ( log ⁡ 2 n ) O(\log _2n) O(log2n)
del O ( n ) O(n) O(n) O ( 1 ) O(1) O(1) -> O ( n ) O(n) O(n) O ( log ⁡ 2 n ) O(\log _2n) O(log2n) -> O ( n ) O(n) O(n) O ( log ⁡ 2 n ) O(\log _2n) O(log2n)
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

花_城

你的鼓励就是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值