专栏名称: 吴师兄学算法
和程序员小吴一起从初学者的角度学习算法,以动画的形式呈现解题的思路。每周四篇原创文章,期待你的鉴赏!
目录
相关文章推荐
大厂日爆  ·  腾讯组织架构调整,IEG迎来新变化 ·  昨天  
大厂日爆  ·  腾讯组织架构调整,IEG迎来新变化 ·  昨天  
财联社AI daily  ·  AI会玩宝可梦了!Claude打赢道馆馆主 ·  2 天前  
财联社AI daily  ·  AI会玩宝可梦了!Claude打赢道馆馆主 ·  2 天前  
51好读  ›  专栏  ›  吴师兄学算法

动画 | 什么是红黑树?

吴师兄学算法  · 公众号  ·  · 2019-12-31 12:15

正文

来源: 掘金(已获得作者授权,禁止二次转载)

作者: JasonGaoH

之前在公司组内分享了红黑树的工作原理,今天把它整理下发出来,希望能对大家有所帮助,对自己也算是一个知识点的总结。

这篇文章算是我写博客写公众号以来画图最多的一篇文章了,没有之一,我希望尽可能多地用图片来形象地描述红黑树的各种操作的前后变换原理,帮助大家来理解红黑树的工作原理,下面,多图预警开始了。

在讲红黑树之前,我们首先来了解下下面几个概念: 二叉树,排序二叉树以及平衡二叉树

一、二叉树

二叉树指的是每个节点最多只能有两个字数的有序树。通常左边的子树称为 左子树 ,右边的子树称为 右子树 。这里说的有序树强调的是二叉树的左子树和右子树的次序不能随意颠倒。

二叉树简单的示意图如下:

代码定义:

class Node {
    T data;
    Node left;
    Node right;
}

二、排序二叉树

所谓排序二叉树,顾名思义,排序二叉树是有顺序的,它是一种特殊结构的二叉树,我们可以对树中所有节点进行排序和检索。

性质

  • 若它的左子树不空,则左子树上所有节点的值均小于它的根节点的值;

  • 若她的右子树不空,则右子树上所有节点的值均大于它的根节点的值;

  • 具有递归性,排序二叉树的左子树、右子树也是排序二叉树。

排序二叉树简单示意图:

排序二叉树

三、排序二叉树退化成链表

排序二叉树的左子树上所有节点的值小于根节点的值,右子树上所有节点的值大于根节点的值,当我们插入一组元素正好是有序的时候,这时会让排序二叉树退化成链表。

正常情况下,排序二叉树是如下图这样的:

但是,当插入的一组元素正好是有序的时候,排序二叉树就变成了下边这样了,就变成了普通的链表结构,如下图所示:

正常情况下的排序二叉树检索效率类似于二分查找,二分查找的时间复杂度为 O(log n),但是如果排序二叉树退化成链表结构,那么检索效率就变成了线性的 O(n) 的,这样相对于 O(log n) 来说,检索效率肯定是要差不少的。

思考,二分查找和正常的排序二叉树的时间复杂度都是 O(log n),那么为什么是O(log n) ?

为了解决排序二叉树在特殊情况下会退化成链表的问题(链表的检索效率是 O(n) 相对正常二叉树来说要差不少),所以有人发明了 平衡二叉树 红黑树 类似的平衡树。

四、平衡二叉树

平衡二叉数又被称为 AVL 树,AVL 树的名字来源于它的发明作者 G.M. Adelson-Velsky 和 E.M. Landis,取自两人名字的首字母。

官方定义:它或者是一颗空树,或者具有以下性质的排序二叉树:它的左子树和右子树的深度之差(平衡因子)的绝对值不超过1,且它的左子树和右子树都是一颗平衡二叉树。

两个条件:

  • 平衡二叉树必须是排序二叉树,也就是说平衡二叉树他的左子树所有节点的值必须小于根节点的值,它的右子树上所有节点的值必须大于它的根节点的值。

  • 左子树和右子树的深度之差的绝对值不超过1。

五、红黑树

讲了这么多概念,接下来主角红黑树终于要上场了。

为什么有红黑树?

其实红黑树和上面的平衡二叉树类似,本质上都是为了解决排序二叉树在极端情况下退化成链表导致检索效率大大降低的问题,红黑树最早是由 Rudolf Bayer 于 1972 年发明的。

红黑树首先肯定是一个排序二叉树,它在每个节点上增加了一个存储位来表示节点的颜色,可以是 RED 或 BLACK 。

Java 中实现红黑树大概结构图如下所示:

1、红黑树的特性

  • 性质1:每个节点要么是红色,要么是黑色。

  • 性质2:根节点永远是黑色的。

  • 性质3:所有的叶子节点都是空节点(即null),并且是黑色的。

  • 性质4:每个红色节点的两个子节点都是黑色。(从每个叶子到根的路径上不会有两个连续的红色节点。)

  • 性质5:从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点。

针对上面的 5 种性质,我们简单理解下,对于性质 1 和性质 2 ,相当于是对红黑树每个节点的约束,根节点是黑色,其他的节点要么是红色,要么是黑色。

对于性质 3 中指定红黑树的每个叶子节点都是空节点,而且叶子节点都是黑色,但 Java 实现的红黑树会使用 null 来代表空节点,因此我们在遍历 Java里的红黑树的时候会看不到叶子节点,而看到的是每个叶子节点都是红色的,这一点需要注意。

对于性质 5,这里我们需要注意的是,这里的描述是从任一节点,从任一节点到它的子树的每个叶子节点黑色节点的数量都是相同的,这个数量被称为这个节点的黑高。

如果我们从根节点出发到每个叶子节点的路径都包含相同数量的黑色节点,这个黑色节点的数量被称为树的黑色高度。树的黑色高度和节点的黑色高度是不一样的,这里要注意区分。

其实到这里有人可能会问了,红黑树的性质说了一大堆,那是不是说只要保证红黑树的节点是红黑交替就能保证树是平衡的呢?

其实不是这样的,我们可以看来看下面这张图:

左边的子树都是黑色节点,但是这个红黑树依然是平衡的,5 条性质它都满足。

这个树的黑色高度为 3,从根节点到叶子节点的最短路径长度是 2,该路径上全是黑色节点,包括叶子节点,从根节点到叶子节点最长路径为 4,每个黑色节点之间会插入红色节点。

通过上面的性质 4 和性质 5,其实上保证了没有任何一条路径会比其他路径长出两倍,所以这样的红黑树是平衡的。

其实这算是一个推论,红黑树在最差情况下,最长的路径都不会比最短的路径长出两倍。其实红黑树并不是真正的平衡二叉树,它只能保证大致是平衡的,因为红黑树的高度不会无限增高,在实际应用用,红黑树的统计性能要高于平衡二叉树,但极端性能略差。

2、红黑树的插入

想要彻底理解红黑树,除了上面说到的理解红黑树的性质以外,就是理解红黑树的插入操作了。

红黑树的插入和普通排序二叉树的插入基本一致,排序二叉树的要求是左子树上的所有节点都要比根节点小,右子树上的所有节点都要比跟节点大,当插入一个新的节点的时候,首先要找到当前要插入的节点适合放在排序二叉树哪个位置,然后插入当前节点即可。红黑树和排序二叉树不同的是,红黑树需要在插入节点调整树的结构来让树保持平衡。

一般情况下,红黑树中新插入的节点都是红色的,那么,为什么说新加入到红黑树中的节点要是红色的呢?

这个问题可以这样理解,我们从性质5中知道,当前红黑树中从根节点到每个叶子节点的黑色节点数量是一样的,此时假如新的黑色节点的话,必然破坏规则,但加入红色节点却不一定,除非其父节点就是红色节点,因此加入红色节点,破坏规则的可能性小一些。

接下来我们重点来讲红黑树插入新节点后是如何保持平衡的。

给定下面这样一颗红黑树:

当我们插入值为66的节点的时候,示意图如下:

很明显,这个时候结构依然遵循着上述5大特性,无需启动自动平衡机制调整节点平衡状态。

如果再向里面插入值为51的节点呢,这个时候红黑树变成了这样。

这样的结构实际上是不满足性质4的,红色两个子节点必须是黑色的,而这里49这个红色节点现在有个51的红色节点与其相连。

这个时候我们需要调整这个树的结构来保证红黑树的平衡。

首先尝试将49这个节点设置为黑色,如下示意图。

这个时候我们发现黑高是不对的,其中 60-56-45-49-51-null 这条路径有 4 个黑节点,其他路径的黑色节点是 3 个。

接着调整红黑树,我们再次尝试把45这个节点设置为红色的,如下图所示:

这个时候我们发现问题又来了,56-45-43 都是红色节点的,出现了红色节点相连的问题。

于是我们需要再把 56 和 43 设置为黑色的,如下图所示。

于是我们把 68 这个红色节点设置为黑色的。

对于这种红黑树插入节点的情况下,我们可以只需要通过变色就可以保持树的平衡了。但是并不是每次都是这么幸运的,当变色行不通的时候,我们需要考虑另一个手段就是旋转了。

例如下面这种情况,同样还是拿这颗红黑树举例。

现在这颗红黑树,我们现在插入节点65。

我们尝试把 66 这个节点设置为黑色,如下图所示。

这样操作之后黑高又出现不一致的情况了,60-68-64-null 有 3 个黑色节点,而60-68-64-66-null 这条路径有 4 个黑色节点,这样的结构是不平衡的。

或者我们把 68 设置为黑色,把 64 设置为红色,如下图所示:

但是,同样的问题,上面这颗红黑树的黑色高度还是不一致,60-68-64-null 和 60-68-64-66-null 这两条路径黑色高度还是不一致。

这种情况如果只通过变色的情况是不能保持红黑树的平衡的。

3、红黑树的旋转

接下来我们讲讲红黑树的旋转,旋转分为左旋和右旋。

(1)、左旋

文字描述:逆时针旋转两个节点,让一个节点被其右子节点取代,而该节点成为右子节点的左子节点。

文字描述太抽象,接下来看下图片展示。

首先断开节点PL与右子节点G的关系,同时将其右子节点的引用指向节点C2;然后断开节点G与左子节点C2的关系,同时将G的左子节点的应用指向节点PL。

接下来再放下 gif 图,希望能帮助大家更好地理解左旋,图片来自网络。

(2)右旋

文字描述:顺时针旋转两个节点,让一个节点被其左子节点取代,而该节点成为左子节点的右子节点。

右旋的图片展示:

首先断开节点G与左子节点PL的关系,同时将其左子节点的引用指向节点C2;然后断开节点PL与右子节点C2的关系,同时将PL的右子节点的应用指向节点G。

右旋的gif展示(图片来自网络):

介绍完了左旋和右旋基本操作,我们来详细介绍下红黑树的几种旋转场景。

左左节点旋转(插入节点的父节点是左节点,插入节点也是左节点)

如下图所示的红黑树,我们插入节点是65。

操作步骤如下可以围绕祖父节点 69 右旋,再结合变色,步骤如下所示:

左右节点旋转(插入节点的父节点是左节点,插入节点是右节点)

还是上面这颗红黑树,我们再插入节点 67。

这种情况我们可以这样操作,先围绕父节点 66 左旋,然后再围绕祖父节点 69 右旋,最后再将 67 设置为黑色,把 69 设置为红色,如下图所示。

右左节点旋转(插入节点的父节点是右节点,插入节点左节点)

如下图这种情况,我们要插入节点68。

这种情况,我们可以先围绕父节点 69 右旋,接着再围绕祖父节点 66 左旋,最后把 68 节点设置为黑色,把 66 设置为红色,我们的具体操作步骤如下所示。

右右节点旋转(插入节点的父节点是右节点,插入节点也是右节点)

还是来上面的图来举例,我们在这颗红黑树上插入节点 70 。

我们可以这样操作围绕祖父节点 66 左旋,再把旋转后的根节点 69 设置为黑色,把 66 这个节点设置为红色。具体可以参看下图:

六、红黑树的代码实现以及代码详解(Java)

Java 中的红黑树实现类是 TreeMap ,接下来我们尝试从源码角度来逐行解释 TreeMap 这一套机制是如何运作的。

// TreeMap中使用Entry来描述每个节点
 static final  class Entry<K,Vimplements Map.Entry<K,V{
        K key;
        V value;
        Entry left;
        Entry right;
        Entry parent;
        boolean color = BLACK;
        ...
 }

TreeMap 的put方法。

 public V put(K key, V value) {
        //先以t保存链表的root节点
        Entry t = root;
        //如果t=null,表明是一个空链表,即该TreeMap里没有任何Entry作为root
        if (t == null) {
            compare(key, key); // type (and possibly null) check
            //将新的key-value创建一个Entry,并将该Entry作为root
            root = new Entry<>(key, value, null);
            size = 1;
            //记录修改次数加1
            modCount++;
            return null;
        }
        int cmp;
        Entry parent;
        // split comparator and comparable paths
        Comparator super K> cpr = comparator;
        //如果比较器cpr不为null,即表明采用定制排序
        if (cpr != null) {
            do {
                //使用parent上次循环后的t所引用的Entry
                parent = t;
                 //将新插入的key和t的key进行比较
                cmp = cpr.compare(key, t.key);
                //如果新插入的key小于t的key,t等于t的左边节点
                if (cmp 0)
                    t = t.left;
                //如果新插入的key大于t的key,t等于t的右边节点    
                else if (cmp > 0)
                    t = t.right;
                else
                //如果两个key相等,新value覆盖原有的value,并返回原有的value
                    return t.setValue(value);
            } while (t != null);
        }
        else {
            if (key == null)
                throw new NullPointerException();
            @SuppressWarnings("unchecked")
                Comparable super K> k = (Comparable super K>) key;
            do {
                parent = t;
                cmp = k.compareTo(t.key);
                if (cmp 0)
                    t = t.left;
                else if (cmp > 0)
                    t = t.right;
                else
                    return t.setValue(value);
            } while (t != null);
        }
        //将新插入的节点作为parent节点的子节点
        Entry e = new Entry<>(key, value, parent);
        //如果新插入key小于parent的key,则e作为parent的左子节点
        if (cmp 0)
            parent.left = e;
        //如果新插入key小于parent的key,则e作为parent的右子节点
        else
            parent.right = e;
        //修复红黑树
        fixAfterInsertion(e);
        size++;
        modCount++;
        return null;
    }
//插入节点后修复红黑树
private void fixAfterInsertion(Entry x{
    x.color = RED;

    //直到x节点的父节点不是根,且x的父节点是红色
    while (x != null && x != root && x.parent.color == RED) {
        //如果x的父节点是其父节点的左子节点
        if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
            //获取x的父节点的兄弟节点
            Entry y = rightOf(parentOf(parentOf(x)));
            //如果x的父节点的兄弟节点是红色
            if (colorOf(y) == RED) {     
                //将x的父节点设置为黑色
                setColor(parentOf(x), BLACK);
                //将x的父节点的兄弟节点设置为黑色
                setColor(y, BLACK);
                //将x的父节点的父节点设为红色
                setColor(parentOf(parentOf(x)), RED);
                x = parentOf(parentOf(x));
            }
            //如果x的父节点的兄弟节点是黑色
            else {   
                //TODO 对应情况第二种,左右节点旋转
                //如果x是其父节点的右子节点
                if (x == rightOf(parentOf(x))) {
                    //将x的父节点设为x
                    x = parentOf(x);
                    //右旋转
                    rotateLeft(x);
                }
                //把x的父节点设置为黑色
                setColor(parentOf(x), BLACK);
                //把x的父节点父节点设为红色
                setColor(parentOf(parentOf(x)), RED);
                rotateRight(parentOf(parentOf(x)));
            }
        }
        //如果x的父节点是其父节点的右子节点
        else {
            //获取x的父节点的兄弟节点
            Entry y = leftOf(parentOf(parentOf(x)));
            //只着色的情况对应的是最开始例子,没有旋转操作,但是要对应多次变换
            //如果x的父节点的兄弟节点是红色  
            if (colorOf(y) == RED) {
                //将x的父节点设置为黑色
                setColor(parentOf(x), BLACK);
                //将x的父节点的兄弟节点设为黑色
                setColor(y, BLACK);
                //将X的父节点的父节点(G)设置红色
                setColor(parentOf(parentOf(x)), RED);
                //将x设为x的父节点的节点
                x = parentOf(parentOf(x));
            }
            //如果x的父节点的兄弟节点是黑色
            else {
                //如果x是其父节点的左子节点
                if (x == leftOf(parentOf(x))) {
                    //将x的父节点设为x
                    x = parentOf(x);
                    //右旋转
                    rotateRight(x);
                }
                //将x的父节点设为黑色
                setColor(parentOf(x), BLACK);
                //把x的父节点的父节点设为红色
                setColor(parentOf(parentOf(x)), RED);
                rotateLeft(parentOf(parentOf(x)));
            }
        }
    }
    //将根节点强制设置为黑色
    root.color = BLACK;
}

TreeMap的插入节点和普通的排序二叉树没啥区别,唯一不同的是,在TreeMap 插入节点后会调用方法fixAfterInsertion(e)来重新调整红黑树的结构来让红黑树保持平衡。

我们重点关注下红黑树的fixAfterInsertion(e)方法,接下来我们来分别介绍两种场景来演示fixAfterInsertion(e)方法的执行流程。

1、第一种场景:只需变色即可平衡

同样是拿这颗红黑树举例,现在我们插入节点 51。

当我们需要插入节点51的时候,这个时候TreeMap 的 put 方法执行后会得到下面这张图。

接着调用fixAfterInsertion(e)方法,如下代码流程所示。

当第一次进入循环后,执行后会得到下面的红黑树结构。

在把 x 重新赋值后,重新进入 while 循环,此时的 x 节点为 45 。

执行上述流程后,得到下面所示的红黑树结构。







请到「今天看啥」查看全文