换个角度彻底理解红黑树
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,只有一个节点,简单:
插入12、插入2
插入2,由于树不平衡,所以进行融合3、插入3
插入3这里说明下,2-3树在插入过程中会临时形成4-node(也就是有3个key,4个子树的node),而这时,4-node要马上分解成3个2-node。
4、插入4
插入45、插入5
插入5-1 插入5-2 插入5-3插入5的时候比较复杂,临时的4-node会分解成3个2-nodes,这时会导致树不平衡,右边子树高度+1,那么需要将3个2-node的中间节点向上融合(调平的重要步骤)。
6、插入6
插入67、插入7
插入7-1 插入7-2 插入7-3 插入7-4可以看出插入7的结果会不断将临时4-node分裂成3个2-node,并向上传递,最后会使树的高度增加1,但是整个树是完美平衡的。
8、插入8
插入89、插入9
插入9-1 插入9-2 插入9-310、插入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-node3.3.3 一步步构建红黑树
还是通过依次插入1-10,用《算法》中讲述的红黑线试图来解析。
1、插入1
很简单……
2、插入2
rule1:插入的新节点跟父节点用red线连接
插入23、插入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-26、插入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
插入89、插入9
插入9-1 插入9-210、插入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、工程上好实现,编码简单。