红黑树的添加

228 阅读15分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第7天,点击查看活动详情

如何在红黑树上添加节点?

写在前面

文章摘要

  1. 红黑树的基本构造、节点以及辅助函数
  2. 红黑树添加的分析以及分类讨论
  3. 红黑树添加的代码实现

阅读准备

一、红黑树的实现前的铺垫

“工欲善其事,必先利其器”

  • 为了更好的学习红黑树,我们已经学习了:AVL树、B树
  • 下面,再铺垫几个方法,辅助我们学习红黑树

(1)几个名词

  • 父节点:parent(一直所说的父节点)
  • 兄弟节点:sibling(父亲相同的节点)
  • 叔父节点:uncle(父节点的兄弟节点)
  • 祖父节点:grandparent(父节点的父节点)
  • 在二叉搜索树的内部,我们本来就维护了父节点,不用处理,祖父节点也是同理
  • 为了方便起见,我们在二叉搜索树的Node节点中,可以添加获取兄弟节点的方法sibling()。有了此方法,叔父节点也就可以通过parent.sibling()拿到了

image-20221109160057127

  • 看完了这几个名词,我们来看看红黑树的基本构造

(2)红黑树的基本构造

  • 较为简单,先看代码
public class RBTree<E> extends BSTImpl<E> {

    private static final boolean RED = false; // 红色
    private static final boolean BLACK = true; // 黑色

    public RBTree() { this(null); }

    /**
     * 可以传入一个比较器的构造函数
     * @param comparator:比较器
     */
    public RBTree(Comparator<E> comparator) {
        super(comparator);
    }
    
    @Override
    protected void afterAdd(Node<E> node) {
        // 添加节点后的逻辑
    }

    @Override
    protected void afterRemove(Node<E> node) {
        // 删除节点后的逻辑
    }
    
    @Override
    protected Node<E> createNode(E element, Node<E> parent) {
        return new RBNode<>(element, parent); // 需要使用红黑树的节点
    }
}
  • 红黑树和AVL树一样,都是在二叉搜索树的基础上,添加了自平衡的功能
  • 那么,都需要继承自二叉搜索树BST
  • 回想一下,AVL树是通过维护平衡因子,使其失衡后恢复平衡
  • 那红黑树呢?其实上面谈性质的时候已经说了:通过维护节点的颜色(红、黑),使其失衡后恢复平衡
  • 所以我们内部定义了两个常量:RED、BLACK,用于方便标识节点的颜色。因为只有两种颜色,使用boolean类型即可
  • 还有:afterAdd()、afterRemove()两个方法,相信不用我多说了吧。如果不清楚,强烈推荐看看上篇文章《AVL树的实现》
  • 基本构造了解后,来看看红黑树内部的节点对象。外部使用的是元素,可内部操作的是节点。这一点应该很清楚了

(3)红黑树内部的节点

  • 二叉搜索树的节点,就维护了父亲、兄弟节点、存储的元素
  • AVL树在此基础上,添加了节点高度的属性
  • 那红黑树的节点应该长什么样呢?
    private static class RBNode<E> extends Node<E> {
        /**
         * 节点的颜色【默认为红色】
         */
        boolean color = RED;
        public RBNode(E element, Node<E> parent) {
            super(element, parent);
        }
    }
  • 在二叉搜索树节点的基础上,额外维护一个颜色即可
  • 并且让它的颜色默认为红色,因为能够更快的满足红黑树的性质

(4)一些辅助函数

① 节点上色

  • 既然牵扯到了颜色,之后肯定需要调整节点的颜色
  • 方便之后使用,我们提供给节点上色的方法
    /**
     * 将节点上色
     * @param node:待染色节点
     * @param color:待染的颜色
     * @return :被染色的节点
     */
    private Node<E> color(Node<E> node, boolean color) {
        if (node == null) return node;
        ((RBNode<E>)node).color = color; // 染色
        return node;
    }

    /**
     * 将节点染成红色
     */
    private Node<E> red(Node<E> node) {
        return color(node, RED);
    }

    /**
     * 将节点染成黑色
     */
    private Node<E> black(Node<E> node) {
        return color(node, BLACK);
    }

② 查看节点颜色

  • 因为要遵循红黑树的5条性质,之后肯定要经常获取节点的颜色信息
  • 方便之后使用,我们提供获取节点颜色的方法
    /**
     * 查看节点颜色
     * @param node:节点
     * @return :节点的颜色
     */
    private boolean colorOf(Node<E> node) {
        return node == null ? BLACK : ((RBNode<E>) node).color;
    }

    /**
     * 查看是否是红色的节点
     */
    private boolean isRed(Node<E> node) {
        return colorOf(node) == RED;
    }

    /**
     * 查看是否是黑色的节点
     */
    private boolean isBlack(Node<E> node) {
        return colorOf(node) == BLACK;
    }

二、添加

(1)分析

  • 如下,有一棵红黑树,先不管如何在它上面添加节点,先来做一个约定:
  • 在讨论红黑树时,心中应该立刻想到它等价的B树

image-20221109195059502

  • 先看看右边等价的B树,回想B树的性质:如果在B树中添加元素,新元素必然是添加到叶子节点中
  • 所以,添加的节点只能位于最下层的节点:(4、8、10)(16、18)(22、25)(36)
  • 而对应红黑树节点的颜色,对应B树的叶子节点就只有这四种情况:红黑红、黑红、红黑、黑
  • 又因为有一些节点已经有叉了,所以最终节点能添加的位置只有这些了:

image-20221109201014803

  • 也就是上图所示的:新1 ~ 新12,12种情况。其实...... 也不多是吧......
  • 又因为想要红黑树尽快的成长起来,添加的节点默认是红色
  • 我们一直在说:添加的节点默认是红色,能够尽快的满足红黑树的5条性质。为什么呢?
  • 回看性质:

image-20221109202206337

  • ①:肯定是毋庸置疑
  • ②:因为B树新添加的节点肯定在叶子节点中,在红黑树中,这点也毋庸置疑
  • ③:红黑树的叶子节点本身就是假想的null节点,本身就是黑色,这点没有问题
  • ⑤:如果添加的节点是红色,从任一节点到新添加的节点,其经过的黑色节点个数肯定不会改变(有没有加入黑色节点),这一点也没有问题
  • ④:乍眼一看,新节点如果是红色,①、②、③、⑤ 这几条性质都能满足。而第 ④ 条性质呢?
    • 如果添加的节点的父节点本身就是红色了,再添加一个红色的节点,肯定不会满足性质 ④
    • 则需要额外的操作,来使其重新满足性质 ④
  • 经过这简单的分析,你应该知道,我们为什么要将新添加的节点默认为红色了吧

(2)分类讨论

  • 添加的情况太多了,我们来分组讨论一下这 12 种情况:
    • 我们知道了为什么要默认节点为红色,那么我们也就可以根据添加后是否满足性质④,来分组了

① 父节点是黑色 —— 没有影响

image-20221109203935269

  • 因为父节点是黑色,添加了一个新的红色节点。能够满足红黑树的性质④
  • 并且也满足4阶B树的性质,也就是说不会产生上溢现象
  • 还满足将红黑树转换成4阶B树的情形(将红色子节点与父节点合并成一个超级节点)
  • 因此:父节点是黑色的情况,不需要做任何处理

② 父节点是红色 —— 需要特殊处理

image-20221109204412105

  • 这八种情况,肉眼可见,首先就不满足红黑树的性质④了
  • 也太多了,简直...... 还能不能再细分情况呢?
  • 确实很多,那我们回想下4阶B树的性质:1 ≤ 任一节点的元素个数 ≤ 3
  • 剩下的8种情况中,我们发现,添加新节点时,有些会超过3个节点(会出现上溢现象),有的不会出现上溢现象
  • 那我们就可以根据添加后,在其等价的4阶B树中,节点是否会上溢。来分类讨论
1、未上溢 —— 通过旋转解决

image-20221109211646537

  • 这几种情况,将添加后的新节点合并上去变成4阶B树的节点,也不会产生上溢现象。只需要通过 染色 + 旋转 即可解决
  • 说到旋转,我们可不陌生了,如果还陌生的话,再次强烈推荐看看:《透过AVL树的实现,学习树的旋转》
  • 若需要旋转,新添加的节点会出现LL、RR、LR、RL,这四种情况
  • 与之前AVL树的讨论一样,我们先来看看单旋即可解决的两种情况LL、RR

image-20221109213643037

  • 先不谈如何旋转,我们先站在将红黑树等价转换成B树的角度,来看问题

  • 如果需要将新添加的节点向上合并到父节点所在层,那么父节点肯定得是黑色,被向上合并的子节点都得是红色

  • 如果这一点明白了,那么就明白,为什么要先染色了(当然,先染色是因为还未旋转、节点的关系还没发生变化、更容易找到需要染色的节点)

  • 来看看思路:

    • 1、将父节点染成【黑色】,祖父节点染成【红色】
    • 2、将祖父节点进行单旋操作【LL就右旋、RR就左旋】
    • 3、将父节点变成祖父节点的子节点
  • 至于对旋转的分析,可以看看AVL树的文章,这里就不多赘述了,直接看图

image-20221109215454485

  • 我们再来看看需要双旋才能解决的两种情况RL、LR

image-20221110083107261

  • 与单旋的操作类似,染色 + 旋转,看看思路:

    • 1、将自己染成【黑色】,祖父节点染成【红色】
    • 2、进行双旋【LR:(先父节点左旋、后祖父节点右旋)RL:(先父节点右旋、后祖父节点左旋)】
    • 3、将自己变成祖父节点的子节点
  • 直接看图

image-20221110084227999

  • 虽然我没有把旋转的过程拆开,但还是希望你能够想一想,将其染色后,旋转的过程
  • 如果你了解了上面的四种情况。那恭喜你:在红黑树中添加新节点,有八种情况,你已经理解其思想了,我们来看看最后的四种
2、上溢 —— 通过合并解决

image-20221110091257180

  • 如图所示,这四个节点,添加任何一个,都超过了4阶B树节点所能容纳元素的最大值,也就是添加后,会变成上溢节点
  • 在B树中,若节点出现了上溢的现象,我们是通过向上合并来解决的
  • 那在红黑树中呢?其实也差不多,不信你跟着我的思路思考一下

image-20221110091350803

  • 如上图,我们添加了节点新1后,站在B树的角度来看

  • 出现了上溢节点:(新1、4、8、10),需要解决上溢【找出中间的元素,向上与父节点合并,再将剩下的元素拆分成左右子节点

  • 于是找到了元素:4 和 8,其实将谁向上合并都可以

  • 为了方便,我们选择了元素8,为什么呢?

  • 因为这是红黑树,元素8所在的红黑树节点的左右两边,本身就是拆分开的节点,而且找它也很容易

  • 那我们直接向上合并,看看还会出现什么问题

image-20221110093701177

  • ①可能会对B树的父节点产生副作用。如图(8、13、20、30),是再次导致上溢就再次出现了上溢节点

  • ②分裂的子节点,作为红黑树等价转换的B树节点,父节点必须是黑色。如图B树节点(新1、4)(10),是通过红黑树等价转换的,也即。4 和 10 必须是黑色节点

  • 如果解决了这两个问题,那这四种情况,也就能顺利的添加上了

  • 我们先解决第②个问题:子节点不是黑色

  • 如果我们先将祖父节点染红、父节点和叔父节点染黑,再向上合并祖父节点呢?

image-20221110095358826

  • 我们发现,只需要解决对B树而言,产生的副作用就行了。这里出现了新的上溢节点:(8、13、20、30)
  • 你会发现,我们在染色的时候,不仅将:父节点、叔父节点染成了【黑色】,也将祖父节点染成了【红色】,为什么呢?
  • 现在新节点的祖父节点8,是红色
  • 而在红黑树中新添加一个节点的颜色,默认也是红色
  • 那我们能否将祖父节点,看待成是一个新添加的节点呢?
  • 如图所示,将 8 添加到节点:(13、20、30)
  • 刚刚讨论的,红黑树中,8的父节点13是红色(添加到父节点也是红色的情况)。所以需要特殊处理
  • 又因为出现了上溢现象。需要染色 + 合并。即:

image-20221110102300156

  • 也是将祖父节点染成红色、父节点和叔父节点染成黑色后,再向上合并祖父节点
  • 经过这样处理过后,可以发现,已经上溢到了根节点。树都长高了
  • 这时只需要将根节点20,变成黑色节点即可

image-20221110102454323

  • 至此,这一种情况也解决完了。红黑树又恢复了最初的平静

  • 向上合并,为什么可能会产生副作用呢?我们是通过什么思路来解决上溢的呢?

  • 其实不难想通,我们后面的思路,是将其向上合并的节点,看成是一个新添加的节点。那么新添加的节点。总共有12种情况,有4种没有影响,剩下的8种都需要做额外的操作,才能满足红黑树的5条性质

  • 我们一路分类讨论下来,分了三种情况

  • 向上合并时,不过是按我们分类的三种情况,再讨论一次罢了,这个过程其实就是一个递归的过程

  • 至此,添加的思路就已经分析完成了。如果还有些迷,下面我们来总结一下红黑树的添加吧!

(3)添加的总结

  • 总共12种情况,我们利用图示,分了下面三种情况讨论
  • 如果用文字描述,就是:先看父节点,再看叔父节点

image-20221110141301732

① 父节点是黑色 —— 不需要处理

  • 应该可以不用解释了,因为都不需要额外处理嘛
  • 除了这四种情况,下面讨论的叔父节点的颜色,都是基于父节点是红色的情况讨论的

② 叔父节点是红色 —— 通过合并解决

  • 这种会出现上溢现象,需要:
    • 先染色,再向上合并
    • 最后将向上合并的父节点当做新添加的节点。递归调用添加的逻辑
    • 如果向上合并到了根节点,那么会使对应的B树高度 + 1,这时直接将根节点变成黑色即可

③ 叔父节点不是红色 —— 通过旋转解决

  • 1、叔父节点是黑色(递归解决上溢时可能会遇到)
  • 2、没有叔父节点的情况,又因为null节点(叶子节点)也是黑色
  • 这种情况需要:
    • 先染色,再单旋或双旋

(4)添加的实现

添加前的准备

  • 分析与思考结束了,在用代码实现之前,我们先来复习一下,二叉搜索树添加的逻辑:

image-20221117192129625

① 父节点是黑色 —— 不需要处理

image-20221110164521788

  • 就很单纯,取出父节点,看看父节点的颜色是否为黑色,是的话,不需要做任何处理
  • 但是有可能是添加了根节点,根节点必须是黑色,而新添加的节点,默认是红色,所以需要将其染成黑色

image-20221110135526990

② 叔父节点是红色 —— 通过合并解决

image-20221110165000882

  • 看完代码,你可能会有疑惑,不是说要合并解决吗?可是也没看到我去写合并的逻辑啊!
  • 是的,其实并不需要去合并,因为我们将其红黑树等价转换成4阶B树,就是方便我们理解
  • 而且转换的时候,是将黑色节点与它的红色子节点合并在一起,变成一个B树节点。那我们改变了节点的颜色,它自然就会重新等价转换了
  • 你看看这幅图,左边是等价转换的B树,用于方便理解
  • 而右边是红黑树,要保持它的性质,真正的操作

image-20221110143930246

③ 叔父节点不是红色 —— 通过旋转解决

  • 这种情况,需要通过旋转来解决。而旋转的逻辑,大部分应该都是和AVL树中的逻辑相同的
  • 所以在实现这种情况之前,我们先来改造一下继承关系:将中间再加入一层,平衡二叉搜索树

image-20221110153551512

  • 改变了继承关系之后,我们就能实现代码了

image-20221110170102676

  • 为了写清楚处理逻辑,上面的代码有些重复的地方(待会贴完整代码的时候再精简代码)
  • 思路其实很简单,先判断是哪种情况:LL、LR、RR、RL
  • 根据情形先将对应节点染色、然后再进行旋转【旋转的操作就不多赘述了】

④ 完整实现afterAdd(Node<E> node)

protected void afterAdd(Node<E> node) {
    Node<E> parent = node.parent;
    if (parent == null) { // 添加的节点是根节点
        black(node); // 将其染成黑色
        return;
    }

    if (isBlack(parent)) return; // 1、父节点是黑色 —— 不需要处理

    // 写完发现:2、3两种情况,都需要将祖父节点先染成红色,将其抽出来
    Node<E> grandparent = red(parent.parent);
    Node<E> uncle = parent.sibling(); // 取出叔父节点

    if (isRed(uncle)) { // 2、叔父节点是红色 —— 通过合并解决
        // 将父节点、叔父节点染成黑色
        black(parent);
        black(uncle);
        afterAdd(grandparent); // 再将祖父节点当做新添加的节点,去递归调用添加后的逻辑
        return;
    }

    // 来到这里说明:3、叔父节点不是红色 —— 通过旋转解决

    if (parent.isLeftChild()) { // L
        if (node.isLeftChild()) { // LL
            black(parent); // 父节点染成黑色
        } else { // LR
            black(node); // 将自己染成黑色
            // 先左旋、后右旋
            rotateLeft(parent);
        }
        // LL、LR最后都需要将祖父节点右旋
        rotateRight(grandparent);
    } else { // R
        if (node.isRightChild()) { // RR
            black(parent); // 父节点染成黑色
        } else { // RL
            black(node); // 将自己染成黑色
            // 先右旋、后左旋
            rotateRight(parent);
        }
        rotateLeft(grandparent);
    }
}

写在后面

  • 完整代码在下一篇《红黑树的删除》后会一起给出,可根据文章分析与代码尝试实现~

本篇收获

  • 红黑树的基本构造与辅助函数
  • 红黑树添加的思路分析与分类讨论
  • 熟练掌握树的旋转操作

读后思考

  • 能否根据本文分析红黑树添加操作的方法,尝试分析红黑树的删除呢?
  • 下一篇文章,我们将一起来分析以及实现红黑树的删除~