数据结构之AVL树

平衡树和AVL

我们先来回忆一下二分搜索树所存在的一个问题:当我们按顺序往二分搜索树添加元素时,那么二分搜索树可能就会退化成链表。例如,现在有这样一颗二分搜索树:

image.png

接下来我们依次插入如下五个节点:7、6、5、4、3。按照二分搜索树的特性,这棵树就会变成如下这样:


image.png

可见在极端的情况下,如果往一棵二分搜索树添加元素时,完全是按照顺序添加的,那么此时二分搜索树就会退化成链表,O(logn) 时间复杂度退化到 O(n)

这是因为二分搜索树不具有自平衡的特性,为了让二分搜索树不退化成链表,我们就得设计一种机制,即便是在按顺序添加元素时,也能让二分搜索树维持平衡。而具有自平衡特性的二叉树或 m 叉树,就称之为平衡树。

而这个“平衡”其实有几种情况,有绝对平衡:任意节点的左右子树高度相等(2-3树);高度平衡:任意节点的左右子树高度相差不超过 1(AVL树);近似平衡:任意节点的左右子树高度相差不超过 2,或者说从根节点到叶子节点的最长路径不大于最短路径的 2 倍(红黑树)。

基本上只要一棵树的高度和节点数量之间的关系始终是 O(logn),也就是不会发生退化情况的,就能称之为平衡树。如果敢说一棵树是”平衡“的,就意味着它的高度是 logn 级别的。也就意味着对这棵树的基本操作(增删改查)是 logn 级别的。

其中 AVL 树是最早被发明出来的平衡树,AVL 这个名称来自于它的两位发明者 G.M. Adelson-VelskyE.M. Landis 的首字母,AVL 树在他们1962年的论文中首次提出。所以,可以认为 AVL 树是最早的自平衡二分搜索树结构。AVL 树遵循的是高度平衡,任意节点的左右子树高度相差不超过 1。


计算节点的高度和平衡因子

经过以上的介绍,现在我们已经知道了AVL树是一种平衡的二分搜索树。那么为了维持AVL树的平衡,我们就得做一些额外的工作。首先,我们得知道AVL树的平衡状态,可以通过一些依据判断AVL树是否已经失衡了。如果处于失衡状态,就需要对AVL树做出一系列的调整使得它维持平衡。

判断AVL树是否平衡的主要依据是节点的平衡因子,而平衡因子则通过节点的高度计算得出。下图中,用黑色字体标记的是节点的高度,蓝色字体标记的是节点的平衡因子:


image.png

上图中的二叉树不是一棵合格的AVL树,因为只有当一棵二叉树所有节点的平衡因子都是 -1、0、1这 三个值时,这棵二叉树才能算是一棵合格的AVL树。如下图所示:


image.png
  • 其中节点 4 的左子树高度是 1,右子树不存在,所以该节点的平衡因子是 1-0=1
  • 节点7的左子树不存在,右子树高度是1,所以平衡因子是 0-1=-1
  • 所有的叶子节点,不存在左右子树,所以平衡因子都是 0

为了计算节点的平衡因子,我们需要在每个节点中新增加一个字段,存储节点的高度。而平衡因子的计算也很简单,用左子节点的高度减去右子节点的高度就可以了。也就是说,平衡因子就是左右子树高度的差值。

接下来,我们先实现AVL树的基础代码:

package tree.avl;

import java.util.ArrayList;

/**
 * AVL树
 *
 * @author 01
 * @date 2021-01-29
 **/
public class AVLTree<K extends Comparable<K>, V> {

    private class Node {
        public K key;
        public V value;
        public Node left, right;
        // 标识节点的高度
        public int height;

        public Node(K key, V value) {
            this.key = key;
            this.value = value;
            left = null;
            right = null;
            // 新节点的默认高度
            height = 1;
        }
    }

    private Node root;
    private int size;

    public AVLTree() {
        root = null;
        size = 0;
    }

    public int getSize() {
        return size;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    /**
     * 获得节点node的高度
     */
    private int getHeight(Node node) {
        return node == null ? 0 : node.height;
    }

    /**
     * 获得节点node的平衡因子
     */
    private int getBalanceFactor(Node node) {
        if (node == null) {
            return 0;
        }

        return getHeight(node.left) - getHeight(node.right);
    }
}

检查二分搜索树性质和平衡性

有了判断平衡状态的依据后,我们就可以判断AVL树的平衡性了。除此之外,由于AVL树本质上是一棵平衡版的二分搜索树,所以我们还需要检查AVL树的二分搜索树性质。因为,调整AVL树的过程中可能会破坏二分搜索树的性质,此时就需要将其“矫正”过来。

判断AVL树的平衡性很简单,就是看各个节点的平衡因子是否大于1即可。因为平衡因子本质上只是左右子树高度的差值,而AVL树的定义是这个差值不能大于1。检查二分搜索树的性质也不难,通过中序遍历就可以做到。因为一棵树满足二分搜索树的性质,那么中序遍历必然是有序的,如果得到的结果是无序的就证明不满足二分搜索树的性质。

具体的实现代码如下:

/**
 * 检查当前的AVL树是否满足二分搜索树的性质
 */
public boolean isBST() {
    ArrayList<K> keys = new ArrayList<>();
    inOrder(root, keys);
    for (int i = 1; i < keys.size(); i++) {
        // 中序遍历一棵二分搜索树所得到的key理应是有序的
        // 如果是无序的,就证明不满足二分搜索树的性质
        if (keys.get(i - 1).compareTo(keys.get(i)) > 0) {
            return false;
        }
    }

    return true;
}

/**
 * 中序遍历以node为根的二叉树,并将每个节点的key放到keys中
 */
private void inOrder(Node node, ArrayList<K> keys) {
    if (node == null) {
        return;
    }

    inOrder(node.left, keys);
    keys.add(node.key);
    inOrder(node.right, keys);
}

/**
 * 检查当前AVL树的平衡性
 */
public boolean isBalanced() {
    return isBalanced(root);
}

/**
 * 判断以Node为根的二叉树是否是一棵平衡二叉树,递归实现
 */
private boolean isBalanced(Node node) {
    if (node == null) {
        return true;
    }

    int balanceFactor = getBalanceFactor(node);
    // AVL对平衡的定义是:左右子树高度相差不能大于1
    if (Math.abs(balanceFactor) > 1) {
        return false;
    }

    return isBalanced(node.left) && isBalanced(node.right);
}

旋转操作的基本原理

经过前面的铺垫,现在我们已经完成了AVL树维持平衡时所需的辅助功能。接下来,我们看看AVL树是怎么维持平衡的。首先,我们得知道AVL树什么时候会发生平衡性被打破的情况。

与其他树形结构一样,当AVL树添加或删除节点时,其平衡性就有可能会被打破。如下图所示:


image.png

那么AVL树是怎么维持平衡的呢?之前在红黑树的文章中提到过,红黑树是通过变色、左旋及右旋转这三种操作来维持平衡的。

因为AVL树中的节点没有颜色的概念,所以不存在变色的问题,只有左旋转、右旋转这两种维持平衡的操作。并且AVL树中的左旋转和右旋转,和之前红黑树中所介绍的是一样的。

左旋转:逆时针旋转红黑树的两个节点,使得父节点被自己的右子节点取代,而自己成为自己的左子节点。如下图:

image.png

  • 在上图中,身为右子节点的Y取代了X的位置,而X变成了自己的左子节点,因此为左旋转

右旋转:顺时针旋转红黑树的两个节点,使得父节点被自己的左子节点取代,而自己成为自己的右子节点。如下图:

image.png

  • 在上图中,身为左子节点的Y取代了X的位置,而X变成了自己的右子节点,因此为右旋转

那么AVL树什么时候需要进行左旋转,什么时候需要进行右旋转呢?这得看树的倾斜情况,因为不同的倾斜情况,需要采取不同的旋转方式。主要分为四种情况,对应着四种旋转方式。这里将其称为:

  • 左左情况(LL),单次右旋转
  • 右右情况(RR),单次左旋转
  • 左右情况(LR),先左旋转,后右旋转
  • 右左情况(RL),先右旋转,后左旋转

如果你有学习过如何还原魔方的话,就会发现AVL树的平衡过程跟魔方的还原非常相似。魔方的还原是有固定公式的:根据色块在一个面上的不同排列情况,都有相应的旋转步骤。只要跟着这个还原步骤,最终就能将魔方还原。

而AVL树的平衡大致过程就是:遇到什么样的节点排布,我们就对应怎么去旋转调整。只要按照这些固定的旋转规则来操作,就能将一个非平衡的AVL树调整成平衡的。这里不同的节点排布就对应着上述所说的四种情况,接下来我们就看看这四种情况及其解法。

1、左左情况(LL),简单来说就是整体左倾的情况,倾斜发生在节点左子树中的最左子节点。如下图:

image.png

  • 图中的三角形表示各个节点的子树

在这种情况下,我们需要从下往上找到发生倾斜的子树的根节点,即该子树中平衡因子大于 1 的那个节点。在此例中就是 y 节点,此时我们以 y 节点为轴,进行一次右旋转,从而矫正这棵树:


image.png

2、右右情况(RR)是整体右倾的情况,倾斜发生在节点右子树中的最右子节点。如下图:

image.png

在这种情况下,同样从下往上找到相应的根节点,然后以根节点 y 为轴,进行一次左旋转:


image.png

3、左右情况(LR),倾斜发生在节点左子树中的最右子节点。如下图:

image.png

在这种情况下,我们就需要分两步走了,先以 x 节点为轴,进行左旋转:


image.png

可以看到此时就转换成了左左情况(LL),那么就只需要按照左左情况的方式,以 y 节点为轴,进行右旋转即可:


image.png

4、右左情况(RL),倾斜发生在节点右子树中的最左子节点。如下图:

image.png

同样,在这种情况下,我们也需要分两步走,先以 x 节点为轴,进行右旋转:


image.png

转换成了右右情况(RR)后,按照这种情况的方式,以 y 节点为轴,进行左旋转:


image.png

以上就是AVL树需要调整平衡的四种情况,以及四种对应的调整方式。现在让我们来看本小节最开始的那个例子,在该例子中,以节点4 为根的左子树出现了不平衡的情况。现在来看,该子树正好符合 “左左情况”。于是,我们以节点 4 为轴,进行右旋操作,就让AVL树重新恢复了高度平衡:


image.png

左旋转和右旋转的实现

在上一小节中,我们介绍了AVL树为了维持平衡所使用的旋转操作,以及不同情况所对应的不同旋转方式。在本小节中,就让我们用代码来实现AVL树的左旋转和右旋转操作。代码如下:

// 对节点y进行向右旋转操作,返回旋转后新的根节点x
//        y                              x
//       / \                           /   \
//      x   T4     向右旋转 (y)        z     y
//     / \       - - - - - - - ->    / \   / \
//    z   T3                       T1  T2 T3 T4
//   / \
// T1   T2
private Node rightRotate(Node y) {
    Node x = y.left;
    Node T3 = x.right;

    // 向右旋转过程
    x.right = y;
    y.left = T3;

    // 更新height
    y.height = Math.max(getHeight(y.left), getHeight(y.right)) + 1;
    x.height = Math.max(getHeight(x.left), getHeight(x.right)) + 1;

    return x;
}

// 对节点y进行向左旋转操作,返回旋转后新的根节点x
//    y                             x
//  /  \                          /   \
// T1   x      向左旋转 (y)       y     z
//     / \   - - - - - - - ->   / \   / \
//   T2  z                     T1 T2 T3 T4
//      / \
//     T3 T4
private Node leftRotate(Node y) {
    Node x = y.right;
    Node T2 = x.left;

    // 向左旋转过程
    x.left = y;
    y.right = T2;

    // 更新height
    y.height = Math.max(getHeight(y.left), getHeight(y.right)) + 1;
    x.height = Math.max(getHeight(x.left), getHeight(x.right)) + 1;

    return x;
}

向AVL树中添加元素

到目前为止,我们就已经了解了AVL树中维持平衡所需的内容。在理论和代码上我们都学习到了如何维持一棵AVL树的平衡性,也已经实现了相应的辅助功能。

那么也就知道在添加和删除元素时,如何解决可能破坏AVL树平衡性的问题。所以,接下来我们就实现向AVL树中添加元素的功能。具体代码如下:

/**
 * 向AVL树中添加新的元素(key, value)
 */
public void add(K key, V value) {
    root = add(root, key, value);
}

/**
 * 向以node为根的AVL中插入元素(key, value),递归实现
 * 返回插入新节点后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;
    }

    // 更新height
    node.height = 1 + Math.max(getHeight(node.left), getHeight(node.right));

    // 计算平衡因子
    int balanceFactor = getBalanceFactor(node);

    // --- 维护平衡 start ---

    // LL
    if (balanceFactor > 1 && getBalanceFactor(node.left) >= 0) {
        return rightRotate(node);
    }

    // RR
    if (balanceFactor < -1 && getBalanceFactor(node.right) <= 0) {
        return leftRotate(node);
    }

    // LR
    if (balanceFactor > 1 && getBalanceFactor(node.left) < 0) {
        node.left = leftRotate(node.left);
        return rightRotate(node);
    }

    // RL
    if (balanceFactor < -1 && getBalanceFactor(node.right) > 0) {
        node.right = rightRotate(node.right);
        return leftRotate(node);
    }

    // --- 维护平衡 end ---

    return node;
}

从AVL树中删除元素

从AVL树中删除元素也会打破AVL树的平衡性,那么在删除元素时如何维持AVL树的平衡呢?如果在删除元素时,打破了AVL树的平衡,其维持平衡的调整方式与之前提到的一样,还是根据那四种情况进行四种旋转操作即可。

因此,有了前面的基础,并且对二分搜索树的删除操作有一定的了解的话,那么对AVL树的删除操作理解起来就比较容易了。无非就是在二分搜索树的删除操作的基础上增加了维护平衡的操作,而这个操作与添加元素时是完全一样的。

我们来看个例子:


image.png

如上图所示,我们在AVL树中删除了节点 1,导致父节点 2 的平衡因子变为了 -2,打破了AVL树的平衡。此时,以节点 2 为根的子树正好形成了“右左情况(RL)”,于是我们首先以节点 4 为轴进行右旋转:


image.png

然后再以节点 2 为轴进行左旋转:


image.png

经过如上步骤后,最终AVL树重新恢复了高度平衡。

AVL树删除操作的具体实现代码如下:

/**
 * 返回以node为根的AVL的最小值所在的节点
 */
private Node minimum(Node node) {
    if (node.left == null) {
        return node;
    }

    return minimum(node.left);
}

/**
 * 从AVL中删除键为key的节点
 */
public V remove(K key) {
    Node node = getNode(root, key);
    if (node != null) {
        root = remove(root, key);
        return node.value;
    }

    return null;
}

/**
 * 删除以node为根的AVL中键为key的节点,递归实现
 * 返回删除节点后新的AVL的根
 */
private Node remove(Node node, K key) {
    if (node == null) {
        return null;
    }

    // 存放被删除的节点
    Node retNode;
    if (key.compareTo(node.key) < 0) {
        // 待删除节点在左子树中
        node.left = remove(node.left, key);
        retNode = node;
    } else if (key.compareTo(node.key) > 0) {
        // 待删除节点在右子树中
        node.right = remove(node.right, key);
        retNode = node;
    } else {
        // 待删除节点左子树为空的情况
        if (node.left == null) {
            Node rightNode = node.right;
            node.right = null;
            size--;
            retNode = rightNode;
        }

        // 待删除节点右子树为空的情况
        else if (node.right == null) {
            Node leftNode = node.left;
            node.left = null;
            size--;
            // return leftNode;
            retNode = leftNode;
        }

        // 待删除节点左右子树均不为空的情况
        else {
            // 找到比待删除节点大的最小节点, 即待删除节点右子树的最小节点
            // 用这个节点顶替待删除节点的位置
            Node successor = minimum(node.right);
            successor.right = remove(node.right, successor.key);
            successor.left = node.left;
            node.left = node.right = null;
            retNode = successor;
        }
    }

    if (retNode == null) {
        return null;
    }

    // 更新height
    retNode.height = 1 + Math.max(getHeight(retNode.left), getHeight(retNode.right));

    // 计算平衡因子
    int balanceFactor = getBalanceFactor(retNode);

    // --- 维护平衡 start ---

    // LL
    if (balanceFactor > 1 && getBalanceFactor(retNode.left) >= 0) {
        return rightRotate(retNode);
    }

    // RR
    if (balanceFactor < -1 && getBalanceFactor(retNode.right) <= 0) {
        return leftRotate(retNode);
    }

    // LR
    if (balanceFactor > 1 && getBalanceFactor(retNode.left) < 0) {
        retNode.left = leftRotate(retNode.left);
        return rightRotate(retNode);
    }

    // RL
    if (balanceFactor < -1 && getBalanceFactor(retNode.right) > 0) {
        retNode.right = rightRotate(retNode.right);
        return leftRotate(retNode);
    }

    // --- 维护平衡 end ---

    return retNode;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容