【数据结构】平衡二叉树介绍 + 手写简单的BalancedBinaryTree

平衡二叉树

什么是平衡二叉树

平衡二叉树就是平衡之后的二分搜索树:

主要有以下几种:

  1. AVL树
  2. B树
  3. 红黑树
  4. 平衡因子树

这里我们介绍的是AVL树 ,用于解决二叉排序树搜索效率可能不高的情况(因为我们添加数据顺序不当的话可能会出现搜索二叉树退化为链表)

AVL树就是将二分搜索树转化为平衡的二叉树。(平衡条件: 左右子树的高度差小于等于1)

AVL树的特点

  • 左子树和右子树的高度之差的绝对值小于等于1
  • 左子树和右子树也是平衡二叉树
  • 每个节点需要维护左右节点的引用和父节点的引用
  • 每个节点需要有一个高度属性,用于计算平衡因子
  • 搜索时间复杂度为O(logn), 删除和查找的时间复杂度为O(logn)到O(logn)平方,因为可能需要一到两次旋转来恢复平衡。

我们需要保证每个节点的左右子树高度差的绝对值不会超过1。

上面图就是平衡状态。上面的数字表示平衡因子,计算公式为左结点的高度减去右节点的高度

那不平衡的时候该怎么办呢?

我们可以进行一些操作来使不平衡的搜索二叉树转化为平衡的二叉树

恢复平衡的操作

首先我们得知道什么时候会不平衡,以下是不平衡的四种情况:

不同的平衡状态有不同的恢复平衡的办法:

这操作之后: 我们仍然需要保持搜索二叉树的性质: 左边节点小于根,右边节点大于根

  1. LL型: 根据字面意义,两个节点都在左边,此时再在左边添加节点会导致左边不平衡,这个时候变换形式,可以发现B变为根,c为左结点,A为右节点,变换之后仍然是满足搜索二叉树的性质。 使用右旋来恢复。
  2. LR型: 节点以左左摆放,然后右下边添加节点。使用先左旋后右旋来恢复。
  3. RL型: 节点先右右摆放,然后左下边添加节点。使用先右旋后左旋来恢复。
  4. RR型: 节点以右右摆放,然后右下边添加节点。使用左旋来恢复。

手写AVL树

AVL树就是在搜索二叉树的基础上添加了一个功能: 自平衡。

因此,它的代码结构会更复杂,在插入或删除的时候需要判断平衡情况并作出相应的调整。

完成之后的整体结构

从上到下依次是:

  • 根节点root
  • 比较器cmp
  • 静态节点Node
  • 构造方法BalancedBinaryTree
  • 计算当前节点的高度的方法
  • 计算节点的平衡因子的方法
  • 左旋操作
  • 右旋操作
  • 插入操作
  • 中序输出方法

节点代码

kotlin 复制代码
static class Node<T> {
    public T data;
    public int height;
    public Node<T> parent;
    public Node<T> left;
    public Node<T> right;
    public Node(T data) {
        this.data = data;
        this.height = 1;
    }
}

这里有一个关键属性height, 这个是用于进行平衡度计算。每个节点的高度是确定的,因此每个节点计算出来的平衡因子值也是固定的。

构造方法

ini 复制代码
BalancedBinaryTree(Comparator<T> comparator) {
    cmp = comparator;
}

传入比较器,用于节点之间的排序操作。

计算节点的高度

arduino 复制代码
private int calcHeight(Node<T> root) {
    if (root.left == null && root.right == null) return 1;
    else if (root.right == null) return root.left.height + 1;
    else if (root.left == null) return root.right.height + 1;
    else return root.left.height > root.right.height ? root.left.height + 1 : root.right.height + 1;
}

计算高度:

  1. 如果左右节点为空,则高度为1;
  2. 如果右节点为空,则高度为左节点的高度加一
  3. 如果左结点为空,则高度为右节点的高度加一
  4. 如果左右节点都不为空,则高度为大的height加一

计算平衡值

kotlin 复制代码
private int calcBF(Node<T> root) {
    if (root == null) return 0;
    else if (root.left == null && root.right == null) return 0;
    else if (root.right == null) return root.left.height;
    else if (root.left == null) return -root.right.height;
    else return root.left.height - root.right.height;
}

计算平衡值:

  1. 如果节点为null,肯定为0;
  2. 如果节点的左右节点为null,那么平衡因子也为0;
  3. 如果节点的左结点为null,返回的平衡因子就是右节点的高度
  4. 如果节点的右节点为null,返回的平衡因子就是左节点的高度
  5. 如果节点的左右节点不为空,那么返回的平衡因子就是左节点的高度-右节点的高度

遍历节点的值(中序遍历)

scss 复制代码
public void inOrder() {//中序遍历
    if (root == null) return;
    inOrder(root);
}
public void inOrder(Node<T> node) {
    if (node == null) return;
    inOrder(node.left);
    System.out.println(node.data);
    inOrder(node.right);
}

这里是使用中序遍历来遍历根节点:

使用递归的方法,先遍历左结点,然后是根节点,然后是右节点。

RR型使用左旋代码

上面标记的数字表示节点的平衡值,只能为-1,0,1。出现其他值表示现在结构不平衡。

ini 复制代码
public Node<T> leftRotate(Node<T> root) {
    Node<T> newRoot = root.right;
    Node<T> parent = root.parent;
    //1.newRoot 替换 root 位置
    if (parent != null) {
        // 判断该oldRoot是在左边还是右边
        int compare = cmp.compare(parent.data, root.data);
        // 在parent的左边
        if (compare > 0) parent.left = newRoot;
        else parent.right = newRoot;
    }
    newRoot.parent = parent;

    //2.将 newRoot 的左子树 给 oldRoot 的右子树
    root.right = newRoot.left;

    if (newRoot.left != null) {
        newRoot.left.parent = root;
    }

    //3. oldRoot 为 newRoot 的左子树
    newRoot.left = root;
    root.parent = newRoot;
    //更新高度

    root.height = calcHeight(root);
    newRoot.height = calcHeight(newRoot);
    return newRoot;
}

下面说说代码的逻辑:

  1. 首先保存根的右节点作为新节点,因为我们这个是RR型,所以肯定是右节点,然后保存父亲节点的引用。
  2. 将newRoot与root进行代码位置更换,让父亲节点指向新节点,但是不能确定是父亲节点的哪个节点(可能是左或是右), 因此需要使用比较器进行比较来判断在哪边。
  3. 将老的根的右子树指向新节点的左结点,毕竟新节点还是可能会有左结点的。记得进行新节点的左结点的null判断,来判断是否需要使用它的parent指针(毕竟为null时就没有parent指针了)
  4. 新root与旧root的位置关系调整,让旧root的parent指向新root,新root的左结点指向旧root
  5. 更新高度情况: 这里只用更新新旧root的高度,因为其它节点的相对位置并没有改变,height是保持不变的,刚开始我也不理解,举一些例子就行了。

LL型使用右旋代码

下面是一个演示图:

ini 复制代码
public Node<T> rightRotate(Node<T> root) {
    Node<T> newRoot = root.left;
    Node<T> parent = root.parent;
    //1.newRoot 替换 root 位置
    if (parent != null) {
        // 判断该oldRoot是在左边还是右边
        int compare = cmp.compare(parent.data, root.data);
        // 在parent的左边
        if (compare > 0) parent.left = newRoot;
        else parent.right = newRoot;
    }
    newRoot.parent = parent;

    // 2.将newRoot的右节点给旧root的左结点
    root.left = newRoot.right;
    if (newRoot.right != null) {
        newRoot.right.parent = root;
    }

    // 3.旧root与newRoot的关系
    newRoot.right = root;
    root.parent = newRoot;

    // 只用刷新 新旧节点高度
    root.height = calcHeight(root);
    newRoot.height = calcHeight(newRoot);

    return newRoot;
}

LL型使用右旋操作:

  1. 首先保存根的左节点作为新节点,因为我们这个是LL型,所以肯定是左节点,然后保存父亲节点的引用(可能为null)。
  2. 将newRoot与root进行代码位置更换,让父亲节点指向新节点,但是不能确定是父亲节点的哪个节点(可能是左或是右), 因此同样需要使用比较器进行比较来判断在哪边。(这里与左旋操作一致)
  3. 将老根的左子树指向新节点的右节点,毕竟新节点还是可能会有右结点的(看我上面画的图)。记得进行新节点的右节点的null判断,来判断是否需要使用它的parent指针(毕竟为null时就没有parent指针了)
  4. 新root与旧root的位置关系调整,让旧root的parent指向新root,新root的右结点指向旧root
  5. 更新高度情况: 这里只用更新新旧root的高度,因为其它节点的相对位置并没有改变,height是保持不变的。

添加节点

ini 复制代码
public void insert(T data) {
    if (this.root == null) {
        this.root = new Node<>(data);
        return;
    }
    this.root = insert(this.root, data);
}
public Node<T> insert(Node<T> root, T data) {
    int compare = cmp.compare(root.data, data);
    //插入左子树
    if (compare > 0) {
        if (root.left == null) {
            root.left = new Node<>(data);
            root.left.parent = root;
        } else insert(root.left, data);
    }
    //插入右子树
    else if (compare < 0) {
        if (root.right == null) {
            root.right = new Node<>(data);
            root.right.parent = root;
        } else insert(root.right, data);
    }
    //更新高度
    root.height = calcHeight(root);
    //旋转
    //1. LL 型 右旋转
    if (calcBF(root) == 2) {
        //2. LR 型 先左旋转
        if (calcBF(root.left) == -1) {
            root.left = leftRotate(root.left);
        }
        root = rightRotate(root);
    }
    //3. RR型 左旋转
    if (calcBF(root) == -2) {
        //4. RL 型 先右旋转
        if (calcBF(root.right) == 1) {
            root.right = rightRotate(root.right);
        }
        root = leftRotate(root);
    }
    return root;
}

上面是添加节点的方法,是一个重载方法,便于重载。下面是逻辑:

  1. 如果根节点为null,直接将添加节点作为根节点,返回
  2. 根节点不为null,进行重载操作
  3. 第一步肯定是比较当前节点与添加的值,如果当前节点更大,则把添加的值添加到当前节点的左边,因此,需要进行递归操作,传入当前节点的左节点与要添加的值。如果当前节点更小,则把添加的值添加到当前节点的右边,因此传入当前节点的右节点与要添加的值进行递归调用。
  4. 记得每次添加都需要更新根的高度,因为添加了一个数据,后面的高度会因为递归依次更新
  5. 一层层的调用之后呢,我们就可以成功把新的值加入到平衡二叉树中,这时候就可能会出现不平衡的情况:
    1. 平衡因子为2,表示肯定是LL型或者LR型:

      1. 判断新root的平衡因子,如果为-1,表示此时为LR型,下面是示意图:

      这个情况呢: 先将旧根的左结点进行左旋操作,再进行右旋操作,下面是画的示意图: 本质上是将LR型转化为LL型,然后进行右旋操作。 2. 如果是LL型,直接进行右旋操作。

    2. 平衡因子为-2,表示肯定是RR型或者RL型:

      1. 判断新root的平衡因子,如果为1,表示此时为RL型: 这个情况呢: 先将旧根的右节点进行右旋操作,再进行左旋操作。 本质上是将RL型转化为RR型,然后进行左旋操作。 这款里就不放图了,与上面的操作类似。
      2. 如果是RR型,直接进行左旋操作

测试操作

ini 复制代码
public static void main(String[] args) {
    BalancedBinaryTree<Integer> tree = new BalancedBinaryTree<>(Integer::compareTo);
    tree.insert(1);
    tree.insert(9);
    tree.insert(15);
    tree.insert(19);
    tree.insert(47);
    tree.insert(68);
    tree.insert(91);
    tree.inOrder();
    System.out.println("树的高度:"+ tree.root.height);
}

我们进行以上操作:

输出: 1

9

15

19

47

68

91

树的高度:3

我们使用极端的添加数据的情况,但是呢,最后出来的高度是3;而如果是搜索二叉树的话,结果应该是7,退化为链表。

证明我们的平衡二叉树添加成功,结构正确。

总结

  1. 平衡二叉树就是左右子树的高度差不超过1的搜索二叉树
  2. 平衡二叉树就是用于解决搜索二叉树退化为链表的问题
  3. 平衡二叉树搜索时间复杂度是O(logn)。适用于经常查询的数据。
  4. 删除和插入的时间复杂度为O(logn)到O(logn)平方。
  5. 平衡二叉树的失衡有四种:LL,RR,RL,LR型,每种都有不同的解决思路。
  6. 手写平衡二叉树需要注意使用递归,因为每一层的平衡因子都会随上一层的变化而变化, 因此需要实时更新节点的高度值。
相关推荐
蔚一1 小时前
Java面向对象——内部类(成员内部类、静态内部类、局部内部类、匿名内部类,完整详解附有代码+案例)
java·开发语言·数据结构·分类
pcplayer1 小时前
WEB 编程:使用富文本编辑器 Quill 配合 WebBroker 后端
前端·后端·delphi·web开发·webbroker
TT哇3 小时前
【数据结构】经典题
数据结构
xuchengxi-java4 小时前
SpringBoot Kafka发送消息与接收消息实例
spring boot·后端·kafka
城南vision4 小时前
算法题总结(三)——滑动窗口
数据结构·算法
严文文-Chris4 小时前
【算法-基数排序】
数据结构·算法·排序算法
橙意满满的西瓜大侠4 小时前
二叉树(一)高度与深度
数据结构
咕咕吖4 小时前
插入排序详解
数据结构·c++·算法
郭小儒4 小时前
代码随想录算法训练营第3天|链表理论基础、203. 移除链表元素、 707.设计链表、 206.反转链表
数据结构·算法·链表