算法算法和数据结构数据结构

换个角度彻底理解红黑树

2019-05-17  本文已影响474人  小龙的城堡

0 、前言

红黑树是软件工程中非常重要的数据结构,在很多的工程领域都有它的身影,比如java的treemap、linkedhashmap,linux内核、linux的高并发多路复用利器epoll的核心数据结构就是红黑树;然而这个数据结构却不是那么容易理解,特别是网络上缺少对红黑树本质的分析,一般都是自底向上的来讲述,非常tricky,往往看了一段就不知所云,最后放弃了。但是,红黑树真的没这么复杂,本文从自顶向下的方式来解构红黑树,争取写一篇能持续看下去,最后达到能够手写(手画)一棵红黑树出来的目的。

本文没有代码,全是图解,请放心阅读

1、什么是红黑树

红黑树是查找表(符号表)数据结构群中的一员,这群数据结构主要的功能是维护一组key-value键值对,并通过增删查改等操作对其进行操作。比如:hash表、二分查找表、BST、2-3树,跳表、红黑树都是这些数据结构的一员。

2、红黑树有啥用?

在众多查找表中,可以说红黑树是最稳定(增删查改都是O(logn)的复杂度),动态增删性能最好(Ologn),编码最容易的一种实现(奇怪吧,hashtable不是都是O(1)么?)。

hash表不是查找,插入都是O(1)么,怎么红黑树会是综合性能最好的?这就要说到工程特性了。一个技术从科学研究到工程实践可行是有一个鸿沟的,而hash表就是这个悖论的典型例子。虽然容易理解,但是hashtable并不容易实现,而且它的效率并不稳定,原因有以下两个:

1、恼人的最差性能,其实hashtable最差的效率是O(n),就是在冲突不断增加的时候,性能会急剧下降;这时需要做非常tricky的补救措施;

2、这个补救措施就是rehashing,简单来讲就是将这个数组重新搬到大一倍的空间中,然后重新算hash映射,除此之外没别的办法。这个过程耗时耗力,耗尽了hashtable的好印象。在这个过程中还会出线程同步问题,而且编码难度极大?比如,如果在多线程环境下,对线程不安全版本的hashtable做频繁的新增操作会死锁(亲测这个死锁几乎7 80%会出现),具体的原因,主要是rehashing的时候开链发对链表新增节点时的同步问题;

3、而且在java中,hashmap其实在链表的数量大于8时,会将链表转成红黑树来加快查询。

红黑树的稳定性与动态维护超大数据量kv表的能力使它成为互联网应用的重要数据结构,对于一个10亿规模的数据量,最多也只要查30多次就能找到你想要的key-value pair。而且对于很多linux内核的代码中也是大量使用红黑树来保持插入与查询的稳定性。

3、解构红黑树

既然,红黑树这么重要,对于有追求的码农应该有必要了解它的原理。

但是遗憾的是,网络上对红黑树的解读大都比较难懂,大部分的解释都是来自一本神书《算法导论》,这也是万恶之源:

1、这本书并不是面向程序员的,别被导论这两个字迷惑,这个导论可能是对于计算机科学家来说的吧~

2、这个书中所描述的红黑树定义是结论型的,也就是假设你对BST树、2-3树都很熟悉的情况下得出的结论,比如《导论》中对红黑树的定义是:

    1.节点是红色或黑色。

    2.根节点是黑色。

    3.每个叶子节点都是黑色的空节点(NIL节点)。

    4 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)

    5.从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。

很多人,包括我也疑惑很多年,这是啥?好好的数据结构,咋还要染色?为啥是红黑,黑白可以吗?为啥会制定这么复杂的规则?这么复杂的规则是怎么得出来的?

后来看了《算法》这本面向程序员的算法书后才霍然开朗,这里我试着将这本书对红黑树的阐述总结一下。

3.1 还是得先讲讲BST

BST(Binary Search Tree)是二叉查找树的简称,其实很简单。大概的意思如下图:

BST树

1、树左边的都比根小;

2、右边都比根大;

3、将规则递归到所有节点,就得到一颗BST。

细心的你会发现BST有个”bug“,在插入的时候,如果是按照升序插入,那么就是一个链表了那么这时的查询也从O(logn)退化到了O(n).

退化后的BST

所以可以推断,一颗好的查找树一定是平衡的,而且最好是完美平衡的(任何节点的左右子树深度都相等),因为这样它的查询操作就会稳定到O(logn)——也就是树的高度了。毕竟,在工程领域最重要的准则就是——稳定压倒一切

3.2、2-3 tree

那么主角2-3 tree就登场了,为啥2-3树是主角?剧透一下,因为红黑树本质就是2-3tree,而且”红“就是2-3树中的3节点,”黑“就是2节点。下面详细讲解。

2-3 tree的定义其实很简单:

1、2-3tree是BST的一种,继承所有BST的特点;

2、整棵树由2-node与3-node组成;

3、2-node就是包含一个key节点,两个左右子树链接(对应BST中的普通节点),3-node就是包含2个key,同时包含3个子树链接的节点,就是3叉树节点;

4、2-3树是完美平衡的。

有图更好理解:

2-3 tree

可以看出来,2-node就是二叉树普通节点,3-node就是三叉树的普通节点。那么2-3 tree是由2-node与3-node组成的完美平衡的搜索树,通俗的讲就是,这棵树中根节点的左右子树中2-3 node高度始终都一样高。

怎么保持树的完美平衡呢?

我们通过例子看看插入的规则,我们以顺序插入1-10来作为例子,先不讲规则,我们看看例子,然后总结出规则,我们以依次向2-3 tree中插入1-10个数字的例子来演示。

1、插入1,只有一个节点,简单:

插入1

2、插入2

插入2,由于树不平衡,所以进行融合

3、插入3

插入3

这里说明下,2-3树在插入过程中会临时形成4-node(也就是有3个key,4个子树的node),而这时,4-node要马上分解成3个2-node。

4、插入4

插入4

5、插入5

插入5-1 插入5-2 插入5-3

插入5的时候比较复杂,临时的4-node会分解成3个2-nodes,这时会导致树不平衡,右边子树高度+1,那么需要将3个2-node的中间节点向上融合(调平的重要步骤)。

6、插入6

插入6

7、插入7

插入7-1 插入7-2 插入7-3 插入7-4

可以看出插入7的结果会不断将临时4-node分裂成3个2-node,并向上传递,最后会使树的高度增加1,但是整个树是完美平衡的。

8、插入8

插入8

9、插入9

插入9-1 插入9-2 插入9-3

10、插入10

插入10

好了,画完了,树中2-3节点完美平衡。找到规律了么?没有的话,我总结下:

1、在2-node中插入新节点,形成3-node,不破坏树的平衡,done;

2、在3-node中插入新节点分为3步:

    2.1、形成新的临时4-node;

    2.2、将4-node分解成3个2-node;

    2.3、如果树不平衡,就将中间节点向上融合,重新递归1、2、两个步骤,直到树平衡。

看懂了么?是不是很简单,如果没有,就再看看上面的图解吧~

3.3 重新发明红黑树

您可能会问,2-3 tree已经很完美了,不管怎么插入数据,只要根据"两个"简单的规则就能维持一个树的完美平衡,为啥还要发明红黑树呢?

原因很简单,工程实现难度!又是工程的实现难度!说白了就是容易出bug……

跟hashmap一样,实现一个工程可用的2-3tree是比较困难的(观点来自于《算法》没有亲测):

1、树中间包含3种节点,相比BST来说意味着更多的判断,比如,从4-node,分裂成3个2-node,从2-node合并成3-node等等;情况多,编码复杂;

2、各种node的节点在互相转变的过程中需要拷贝的信息也比较多,比如3-node可以有3个子树,2-node有2个,那么融合的时候情况较多,不能直接向BST一样统一处理,算法实现要处理的情况太多,容易出bug;

3、代码不能重用(BST的代码不能重用)。

但是我觉得最重要的是2-3 tree原理完美,但是结构不完美。一个完美的数据结构一定是原理与结构都是统一的、朴素简单的。2-3 tree更像一个通向最终答案的中间解,只要把中间的3-node与4-node这些tricky的node想办法变换成2-node就结构也统一完美了;那么BST的很多代码就可以稍微修改一下就可以重用了(特别是删除节点的代码~)。

怎么统一呢?

方法其实也很简单、直接,我们只要将2-3 tree在结构上对齐BST,在保持平衡的原理上维持现状不就行了?也就是西瓜芝麻都一起抓——去掉2-3 tree中的3-node。

3.3.1 去掉3-node

怎么去掉3-node?很简单,真的很简单,还是看图说话:

3-node到2个2-node

就是把3-node拉直就行了!

更一般化的形式为

《算法》节选

但是,左子树的高度加了1,那么结构的算法特性有没有发生改变呢?

其实没有,因为你依然可以把”4-7“这个红色标记的链接看做是一个3-node,那么它的搜索特性就没有变化。本质来讲,当我们在2-3 tree里面做搜索的时候,当我们碰到3-node的时候,其实就相当于在节点内部要比2-node多做一次比较,拉直了相当于把这个多出来的比较形式化成了一条链接,计算量是没有变化的。

所以红黑树跟2-3 tree是完全等价的!或者说通过把2-3 tree中的3-node拉直而形成的BST树就是——红黑树!

书中的例子,更加清楚:

截图自《算法》

3.3.2 定义的解析

3.3.2.1 一些前提与说明

之前我们只是把边涂成红色,但是BST是没有边的定义的,所以这个颜色只能记录在节点的数据结构中,所以我们把从父节点到子节点的红边会存储在子节点中——将子节点涂成红色构成红色节点,其他的自然都是黑色节点。

对于网上比较常见的一个红黑树例子:

网上常用的例子

细心的读者会发现,这个根本不是一个2-3 tree,但是确实是红黑树,这是怎么回事?

因为红黑树定义有不同,相同节点的红黑树会根据定义不同产生不同的红黑节点数量,如果直接从2-3tree转的红黑树可能跟以上5条定义获得的红黑树稍有不同,但是他们都是红黑树

再来看看经典红黑树的定义《导论》中的定义(针对的是节点颜色):

 1.节点是红色或黑色。(貌似是废话,难道对于红黑树还有第三种颜色?)

    2.根节点是黑色。(没啥用)

    3.每个叶子节点都是黑色的空节点(NIL节点)。

    4 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)

    5.从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。

再看看《算法》中的定义(针对的是线的颜色):

 1、红色的线永远是左侧链接;(强行的规定)

  2、没有一个节点同时链了两条红色的线;(也就是没有3-node)

  3、任何从根到叶子节点的路径上有相同颜色的黑线。

可以看出定义是完全不同的(一个针对节点颜色,一个针对线的颜色——而线的颜色存在node中,所以还是红黑树),但是都能构成一棵红黑树。

而我们的解析是采用《算法》中的,因为它跟2-3 tree一一对应,很好理解。而《导论》中的定义是经过2-3 tree,2-3-4 tree的抽象总结出来的非常通用抽象的解析,其经过很多思维迭代后的结果,所以很抽象,但是本质来说他们是一回事,是可以通过有限次推导(reduction)互相转换的。

那么下面我们对网上的例子做些转换,将它化简成一个2-3 tree的原型:

有两个红色子节点的都可以做传递转换

转换得到的依然是红黑树。

然后可以将红色节点跟父节点融合成一个3-node

3.3.3 一步步构建红黑树

还是通过依次插入1-10,用《算法》中讲述的红黑线试图来解析。

1、插入1

很简单……

2、插入2

rule1:插入的新节点跟父节点用red线连接

插入2

3、插入3

rule2:一个节点不能有两个红线相连(不是父子),否则就要通过旋转将中间节点移动成父节点。

rule3:如果一个节点父子都是红线相连,则需要翻转颜色,并把父节点与祖父节点的链接变成红色(也就是2-3 tree中分解4-node的时候,3个2-node中间的那个向上传递的过程)。

插入3-1 插入3-2

所以,可见红黑树中的翻转其本质就是将4-node分解成3个2-node,并将中间节点与父节点融合的过程。

4、插入4

插入4

插入4,很简单,红线相连即可,因为每条路径黑线数目一样。

5、插入5

插入5-1 插入5-2

6、插入6

插入6

此时可以跟2-3 tree插入到6的图比较下,其实可以找到规律:->

2-3 tree插入6

是不是红色线跟3-node一一对应?

7、插入7

插入7-1 插入7-2 插入7-3 插入7-4

可以发现2-3 tree插入7也是4步,跟红黑树的旋转变色的过程一一对应。

8、插入8

插入8

9、插入9

插入9-1 插入9-2

10、插入10

插入10

完毕,这就是一个以边为视角的插入过程,只需要将边的颜色信息存储到节点中就可以得到一棵红黑树:

插入10后的红黑树

总结下2-3tree与红黑树的操作对应:

1、2-3 tree中的4-node分裂成3个2-node,并让中间节点上移;等价于红黑树中的左右旋转;

2、2-3tree中间节点上移与父节点融合过程;等价于红黑树中的反色操作。

是不是很好理解了?

3.4 后续的操作

红黑树的插入操作我们解构完了,我们还剩下一个比较复杂的删除操作。这个操作其实是使用BST的标准删除操作,一个比较标准的算法是,将要删除节点的右子树中的最小值(或者左子树中最大值)替换掉当前删除的节点,当然针对不同的情况需要做些判断。而且在工程领域,也会引入随机的删除左右子树中的最大最小交替的方式,来完成删除,以至于不会使得树的稀疏度差异太大,从而降低搜索效率那是另一个范畴的事情了。

4、总结

红黑树是一个非常重要的,而且在工程中常用的数据结构。比如jvm的hashmap,linux中还为红黑树单独建立了数据结构对象(linux内核源码的lib/rbtree.c文件)因为在linux内核中的进程调度,虚拟内存管理等操作频繁而且性能critical的地方都会使用红黑树;另外在linux的epoll多路复用的模型中,核心数据结构就是红黑树。那么为什么红黑树这么好,一句话:

1、动态插入、删除、查找都是O(logn),而且只要内存足够,性能稳定在这个数量级上;

2、工程上好实现,编码简单。

上一篇下一篇

猜你喜欢

热点阅读