【数据结构之AVL树】平衡的艺术

1,085 阅读6分钟

终于到AVL树了,我们在堆那一篇文章使用数组实现了一个二叉堆,其实堆也是一种AVL树,至于为什么,相信看完这篇文章再回过头去回顾一下你就理解了。

这篇文章会依赖二分搜索树的知识,如果对二分搜索树还不是很了解,可以去看前面关于二分搜索树的那篇文章。

那么,什么是AVL树呢?AVL树又叫平衡二叉树,所谓平衡就是尽量保持左右子树的高度是一致的,前面我们讲二分搜索树的时候提到,如果我们往树里插入节点是按照数据的大小顺序插入,那么二分搜索树会退化成链表,显然当二分搜索树退化成链表之后相应的时间复杂度也退化成了O(n)。而AVL树就是为了解决这个问题的,在添加节点的时候我们要去维护树的平衡,为了说明这个问题,我们先看一个图:

图片

左边是一颗AVL树,右边是退化为链表的二分搜索树,我们可以看到,AVL树同样具备二分搜索树性质,对于任一节点,左子节点永远小于右子节点。

什么样的树可以称为一颗AVL树呢?对于任一节点,其左子树和右子树的层高差不能超过1,下面给出了两颗合法的AVL树:

图片

层高表示任一节点到叶子节点的最大高度,这里的最大高度取的是左、右子树最高的那一个。上图左边的树,左子树层高为3,右子树层高为2,高度差不大于1,所以是合法的AVL树。右边的树,左子树层高为3,右子树层高也为2,所以也是合法的AVL树。

弄明白了什么是AVL树之后,我们来看该如何维护AVL树平衡的性质呢?在开始之前我们先看一个概念,平衡因子。

平衡因子

平衡因子其实就是左子树的层高减去右子树的层高,即 balnaceFactor = left(height) - right(height)。为了说明什么是平衡因子我们先看一张图,如下:

图片

图中,黑色字体表示节点当前的层高,蓝色字体表示平衡因子。比如,节点2由于其左右子树都为NULL,所以平衡因子为0。节点4因为左子树的层高为1,右子树为NULL对应的层高为0,所以平衡因子为1,对于其它节点的计算方法也是一样的。最后我们看节点8,节点8的左子树的层高为3,右子树的层高为1,所以平衡因子为2。此时,由于平衡因子已经大于1了,所以破坏了AVL树的平衡。

右旋转

我们假设添加一个节点是在左子树上,如下图:

图片

我们插入节点2,此时节点8的平衡因子已经是2了,所以要维护AVL树的平衡。具体怎么做呢?

我们将节点5、4、2用y、x、z表示成如下形式:

图片

些时y代表节点5,x代表节点4,z代表节点2。实际上对于y、x、z也有左右子节点,只不过对于没有子节点的节点的左子树或者右子树都可以认为有一个值为NULL的节点,我们将节点是NULL的子节点用T表示,如下:

图片

根据二分搜索树的性质有:T1 < z < T 2 < x < T3 < y < T4。

接下来我们就可以进行右旋转了。首先,我们将x的的右子树指向y,如图:

图片

接下来我们将y的左子树指向T3, 如下图:

图片

至此,我们就已经完成了右旋转的整个过程了。根据二分搜索树的性质,此时:T1 < z  < T2 < x < T3 < y < T4,结果和我们一开始是一样的。所以,右旋转之后同样保持了二分搜索树的性质,同时又解决了树的平衡问题。

我们再来看一下右旋转的核心代码:

private Node rightRotate(Node y) {    
    Node x = y.left;    
    // 将T3先记下    
    Node T3 = x.right;   
    
    // 进行右旋转    
    x.right = y;    
    y.left = T3;
    
    // 维护层高    
    y.height = Math.max(getHeight(y.right), getHeight(y.left)) + 1;     
    x.height = Math.max(getHeight(x.right), getHeight(x.left)) + 1;
    
    return x;
}

左旋转

左旋转指的是我们将元素插入到了右子树。注意,这里的右子树指的就是右子树的右子树,而不应该是右子树的任何一颗左子树,有点绕,但仔细思考一下应该就能明白了。为了说明这个问题呢,我们还是通过画图来说明,同样我们将右子树抽象成y、x、z的形式,如图:

图片

同样,对于y的左子树,x的左子树,z的左右子树用T来表示,如图:

图片

根据二分搜索树的性质可以有:T4 < y < T 3 < x < T1 < z  < T 2

然后,我们就可以对y进行左旋转了。首先我们将x的左子树指向y,如下图:

图片

然后,我们将y的右子树指向T3,如下图:

图片

至此,我们就完成了左旋转,根据二分搜索树的性质,我们可以得出结论:T4 < y < T3 < x < T1 < z < T2 和旋转之前是一样的。

下面是左旋转的核心代码:

private Node leftRotate(Node y) {    
    Node x = y.right;    
    // 先将T2记下    
    Node T2 = x.left;
    
    // 左旋转操作    
    x.left = y;    
    y.right = T2;
    
    // 维护层高        
    y.height = Math.max(getHeight(y.left), getHeight(y.right)) + 1;    
    x.height = Math.max(getHeight(x.left), getHeight(x.right)) + 1;    
    return x;
}

插入到左子树的右子树(LR)

上面我们详细解释了右旋转和左旋转,如果细心点就会发现,我们其实漏掉了两种情况,第一种就是插入左子树的右子树。如图:

图片

此时,z被插入到了y的左子树x的右子树,那么这种情况我们要怎么维护AVL树的平衡呢?其实也很简单,我们可以把这种情况变形成我们上面讲到的右旋转的形式,如图:

图片

我们来验证一下,变形前有:T1 < x < T2 < z < T3 < y < T4 ,变形后有:T1< x < T2 < z < T3 < y < T4, 可以看到变形之后同样还是满足二分搜索树的性质。接下来就很简单了,将y进行一次右旋转,如下图:

图片

这步操作和右旋转是一模一样的,这里就不重复了。其实到这一步我们就已经维护完了AVL树的平衡。当然在实际代码中,其实是一个递归,我们这里的操作只是处理当前这一层的平衡,处理完之后返回到上一层,又会去计算平衡因子看是否打破了平衡,然后再进行上面的旋转操作,最终完成平衡性质的维护。

插入到右子树的左子树(RL)

其实这个和我们上面讲的LR的操作非常像,我们来看一下这个过程。首先,我们还是用y、x、z来表示,如下图:

图片

然后我们对x进行一次左旋转变形,如下图:

图片

然后对y进行一次左旋转,得到最终结果,如下图:

图片

好了,到这里我们插入节点操作的几种情况就全部讲完了,对于AVL树维护平衡主要是在插入节点的时候。所以,对于维护AVL树的平衡就全部介绍完了,但AVL树除了插入节点还有删除节点,下面我们就来看删除节点。

删除节点

对于AVL树节点的删除,其实是结合了二分搜索树节点的删除和AVL树维护平衡的操作,下面我主要通过代码的方式来说明,AVL树删除节点的核心代码如下:

private Node add(Node node, K key, V value) {    
    if (node == null) {        
        size++;        
        return new Node(key, value);    
    }
    
    if (key.compareTo(node.key) < 0)         
        node.left = add(node.left, key, value);    
    else if (key.compareTo(node.key) > 0)         
        node.right = add(node.right, key, value);    
    else         
        node.value = value;
    
    // 重新计算高度    
    node.height = 1 + Math.max(getHeight(node.left), getHeight(node.right));
    
    // 计算高度因子    
    int factor = getAVLFactor(node);    
    // LL    
    // 如果节点的高度因子(左高度-右高度)大于1     
    // 并且,左子树的的高度因子>=0(也就是至少有一个元素)     
    // 则进行右旋转    
    if (factor > 1 && getAVLFactor(node.left)>=0)         
        return rightRotate(node);
    
    // RR    
    // 如果节点的高度因子 (左高度-右高度)小于-1     
    // 并且,右子树的高度因子<=0 (也就是至少有一个元素)    
    // 则进行左旋转    
    if (factor < -1 && getAVLFactor(node.right) <= 0)        
        return leftRotate(node);
    
    // LR    
    // 如果节点不平衡是因为左子节点的右子节点高度太高,则先将左子节点做一次左旋转,然后进行右旋转    
    if (factor > 1 && getAVLFactor(node.left) < 0) {        
        node.left = leftRotate(node.left);        
        return rightRotate(node);    
    }    
    // RL    
    // 如果节点不平衡是因为右子节点的左子节点高度太高,则先将右节点做一次右旋转,然后进行左旋转    
    if (factor < -1 && getAVLFactor(node.right) > 0) {        
        node.right = rightRotate(node.right);        
        return leftRotate(node);    
    }    
    return node;
}

重要的代码我都加了注释,可以对照我们前面讲的左、右旋转来看。主要步骤是这样的,首先,执行二分搜索树删除节点的逻辑,找到要删除的节点,然后计算节点的平衡因子,如果不平衡则进行我们的左右旋转,最终节点删除完成之后整颗树就是一棵平衡二叉树了。

完整的代码可以去我的github上查看,分类都比较清晰:github.com/seepre/data…

总结一下,AVL树主要是解决一些有序度比如高的数据,防止退化成链表,从而将树的操作稳定在O(logn)的时间复杂度。实际上AVL树已经是一个比较实用的性能非常好且可用于实际工程中的数据结构。但其性能其实还有优化空间,在下一篇要讲的红黑树,就是一种在维护写比较频繁的场景中性能更优的一种树形结构。