红黑树(不必细抠实现,理解思路应该够了)
什么是“平衡二叉查找树”?
平衡二叉树的严格定义是这样的:二叉树中任意一个节点的左右子树的高度相差不能大于1。
完全二叉树、满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。
但是很多平衡二叉查找树其实并没有严格符合上面的定义(树中任意一个节点的左右子树的高度相差不能大于
1),比如我们下面要讲的红黑树,它从根节点到各个叶子节点的最长路径,有可能会比最短路径大一倍。
发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。
如何定义一棵“红黑树”?
平衡二叉查找树其实有很多,比如,
Splay Tree
(伸展树)、
Treap(树堆)等,但是我们提到平衡二叉查找树,听到的基本都是红黑树。它的出镜率甚至要高于
“
平衡二叉查找树
”
这几个字,有时候,我们甚至默认平衡二叉查找树就是红黑树,那我们现在就来看看这个
“
明星树
”
。
红黑树的英文是
“Red-Black Tree”
,简称
R-B Tree
。它是一种不严格的平衡二叉查找树.
顾名思义,红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:
- 根节点是黑色的;
- 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
- 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
- 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;
这里的第二点要求
“
叶子节点都是黑色的空节点
”
,主要是为了简化红黑树的代码实现而设置的.
![](https://img-blog.csdnimg.cn/20200205113714501.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2c1MzQ0NDE5MjE=,size_16,color_FFFFFF,t_70)
为什么说红黑树是“近似平衡”的?
平衡二叉查找树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题。所以,
“
平衡
”
的意思可以等价为性能不退化。
“近似平衡
”
就等价为性能不会退化的太严重。
二叉查找树很多操作的性能都跟树的高度成正比。一棵极其平衡的二叉树(满二叉树或完全二叉树)的高度大约是
log
2
n,所以如果要证明红黑树是近似平衡的,我们只需要分析,红黑树的高度是否比较稳定地趋近
log
2
n
就好了。
我们来看,如果我们将红色节点从红黑树中去掉,那单纯包含黑色节点的红黑树的高度是多少呢?
红色节点删除之后,有些节点就没有父节点了,它们会直接拿这些节点的祖父节点(父节点的父节点)作为父节点。所以,之前的二叉树就变成了四叉树。
![](https://img-blog.csdnimg.cn/20200205114527219.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2c1MzQ0NDE5MjE=,size_16,color_FFFFFF,t_70)
前面红黑树的定义里有这么一条:从任意节点到可达的叶子节点的每个路径包含相同数目的黑色节点。我们从四叉树中取出某些节点,放到叶节点位置,四叉树就变成了完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度还要小。
完全二叉树的高度近似
log
2
n
,这里的四叉
“
黑树
”
的高度要低于完全二叉树,所以去掉红色节点的
“
黑树
”
的高度也不会超过
log
2
n
。
我们现在知道只包含黑色节点的
“
黑树
”
的高度,那我们现在把红色节点加回去,高度会变成多少呢?
从上面我画的红黑树的例子和定义看,在红黑树中,红色节点不能相邻,也就是说,有一个红色节点就要至少有一个黑色节点,将它跟其他红色节点隔开。红黑树中包含最多黑色节点的路径不会超过
log
2
n
,所以加入红色节点之后,最长路径不会超过
2log2n
,也就是说,红黑树的高度近似
2log
2
n
。
所以,红黑树的高度只比高度平衡的
AVL
树的高度(
log
2
n)仅仅大了一倍,在性能上,下降得并不多。这样推导出来的结果不够精确,实际上红黑树的性能更好。
为什么在工程中大家都喜欢用红黑树这种平衡二叉查找树?
树堆(
Treap)
、伸展树(
Splay Tree)
,绝大部分情况下,它们操作的效率都很高,但是也无法避免极端情况下时间复杂度的退化。尽管这种情况出现的概率不大,但是对于单次操作时间非常敏感的场景来说,它们并不适用。
AVL
树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,
AVL树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用
AVL
树的代价就有点高了。
红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比
AVL
树要低。
所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。
实现红黑树的基本思想
- 根节点是黑色的;
- 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
- 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
- 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点。
在插入、删除节点的过程中,第三、第四点要求可能会被破坏,而
“
平衡调整
”
,实际上就是要把被破坏的第三、第四点恢复过来。
两个非常重要的操作,左旋(
rotate left
)、右旋(
rotate right)。左旋全称其实是叫围绕某个节点的左旋,那右旋则是围绕某个节点的右旋。
插入操作的平衡调整
红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上。所以,关于插入操作的平衡调整,有这样两种特殊情况:
- 如果插入节点的父节点是黑色的,那我们什么都不用做,它仍然满足红黑树的定义。
- 如果插入的节点是根节点,那我们直接改变它的颜色,把它变成黑色就可以了。
除此之外,其他情况都会违背红黑树的定义,于是我们就需要进行调整,调整的过程包含两种基础的操作:左右旋转和改变颜色。
红黑树的平衡调整过程是一个迭代的过程。我们把正在处理的节点叫作关注节点。关注节点会随着不停地迭代处理,而不断发生变化。最开始的关注节点就是新插入的节点。
新节点插入之后,如果红黑树的平衡被打破,那一般会有下面三种情况。我们只需要根据每种情况的特点,不停地调整,就可以让红黑树继续符合定义,也就是继续保持平衡。
我们下面依次来看每种情况的调整过程。为了简化描述,我把父节点的兄弟节点叫作叔叔节点,父节点的父节点叫作祖父节点。
CASE 1
:如果关注节点是
a
,它的叔叔节点
d
是红色,我们就依次执行下面的操作:
- 将关注节点a的父节点b、叔叔节点d的颜色都设置成黑色;
- 将关注节点a的祖父节点c的颜色设置成红色;
- 关注节点变成a的祖父节点c;
- 跳到CASE 2或者CASE 3。
CASE 2
:如果关注节点是
a
,它的叔叔节点
d
是黑色,关注节点
a
是其父节点
b
的右子节点,我们就依次执行下面的操作:
- 关注节点变成节点a的父节点b;
- 围绕新的关注节点b左旋;
- 跳到CASE 3。
CASE 3
:如果关注节点是
a
,它的叔叔节点
d
是黑色,关注节点
a
是其父节点
b
的左子节点,我们就依次执行下面的操作:
- 围绕关注节点a的祖父节点c右旋;
- 将关注节点a的父节点b、兄弟节点c的颜色互换。
- 调整结束。
删除操作的平衡调整
红黑树插入操作的平衡调整还不是很难,但是它的删除操作的平衡调整相对就要难多了。不过原理都是类似的,我们依旧只需要根据关注节点与周围节点的排布特点,按照一定的规则去调整就行了。
删除操作的平衡调整分为两步:
第一步是针对删除节点初步调整。初步调整只是保证整棵红黑树在一个节点删除之后,仍然满足最后一条定义的要求,也就是说,每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;
第二步是针对关注节点进行二次调整,让它满足红黑树的第三条定义,即不存在相邻的两个红色节点。
1.针对删除节点初步调整
这里需要注意一下,红黑树的定义中
“
只包含红色节点和黑色节点
”,经过初步调整之后,为了保证满足红黑树定义的最后一条要求,有些节点会被标记成两种颜色,
“
红
-
黑
”
或者
“
黑
-
黑
”
。如果一个节点被标记为了
“
黑
-
黑
”
,那在计算黑色节点个数的时候,要算成两个黑色节点。
在下面的讲解中,如果一个节点既可以是红色,也可以是黑色,在画图的时候,我会用一半红色一半黑色来表示。如果一个节点是
“
红
-
黑
”
或者
“
黑
-
黑
”,我会用左上角的一个小黑点来表示额外的黑色。
CASE 1
:如果要删除的节点是
a
,它只有一个子节点
b
,那我们就依次进行下面的操作:
- 删除节点a,并且把节点b替换到节点a的位置,这一部分操作跟普通的二叉查找树的删除操作一样;
- 节点a只能是黑色,节点b也只能是红色,其他情况均不符合红黑树的定义。这种情况下,我们把节点b改为黑色;
- 调整结束,不需要进行二次调整。
![](https://img-blog.csdnimg.cn/20200205164708500.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2c1MzQ0NDE5MjE=,size_16,color_FFFFFF,t_70)
CASE 2
:如果要删除的节点
a
有两个非空子节点,并且它的后继节点就是节点
a
的右子节点
c
。我们就依次进行下面的操作:
- 如果节点a的后继节点就是右子节点c,那右子节点c肯定没有左子树。我们把节点a删除,并且将节点c替换到节点a的位置。这一部分操作跟普通的二叉查找树的删除操作无异;
- 然后把节点c的颜色设置为跟节点a相同的颜色;
- 如果节点c是黑色,为了不违反红黑树的最后一条定义,我们给节点c的右子节点d多加一个黑色,这个时候节点d就成了“红-黑”或者“黑-黑”;
- 这个时候,关注节点变成了节点d,第二步的调整操作就会针对关注节点来做。
CASE 3
:如果要删除的是节点
a
,它有两个非空子节点,并且节点
a
的后继节点不是右子节点,我们就依次进行下面的操作:
- 找到后继节点d,并将它删除,删除后继节点d的过程参照CASE 1;
- 将节点a替换成后继节点d;
- 把节点d的颜色设置为跟节点a相同的颜色;
- 如果节点d是黑色,为了不违反红黑树的最后一条定义,我们给节点d的右子节点c多加一个黑色,这个时候节点c就成了“红-黑”或者“黑-黑”;
- 这个时候,关注节点变成了节点c,第二步的调整操作就会针对关注节点来做。
2.针对关注节点进行二次调整
CASE 1
:如果关注节点是
a
,它的兄弟节点
c
是红色的,我们就依次进行下面的操作:
- 围绕关注节点a的父节点b左旋;
- 关注节点a的父节点b和祖父节点c交换颜色;
- 关注节点不变;
- 继续从四种情况中选择适合的规则来调整。
CASE 2
:如果关注节点是
a
,它的兄弟节点
c
是黑色的,并且节点
c
的左右子节点
d
、
e
都是黑色的,我们就依次进行下面的操作:
- 将关注节点a的兄弟节点c的颜色变成红色;
- 从关注节点a中去掉一个黑色,这个时候节点a就是单纯的红色或者黑色;
- 给关注节点a的父节点b添加一个黑色,这个时候节点b就变成了“红-黑”或者“黑-黑”;
- 关注节点从a变成其父节点b;
- 继续从四种情况中选择符合的规则来调整。
CASE 3
:如果关注节点是
a
,它的兄弟节点
c
是黑色,
c
的左子节点
d
是红色,
c
的右子节点
e
是黑色,我们就依次进行下面的操作:
- 围绕关注节点a的兄弟节点c右旋;
- 节点c和节点d交换颜色;
- 关注节点不变;
- 跳转到CASE 4,继续调整。
CASE 4
:如果关注节点
a
的兄弟节点
c
是黑色的,并且
c
的右子节点是红色的,我们就依次进行下面的操作:
- 围绕关注节点a的父节点b左旋;
- 将关注节点a的兄弟节点c的颜色,跟关注节点a的父节点b设置成相同的颜色;
- 将关注节点a的父节点b的颜色设置为黑色;
- 从关注节点a中去掉一个黑色,节点a就变成了单纯的红色或者黑色;
- 将关注节点a的叔叔节点e设置为黑色;
- 调整结束。
讨论
一:我说的
case3
的情况是表示老师的画的那个图,
case3
图的例子根节点到左边叶子节点只经过
2
个黑色节点,到右边叶子节点却经过了
3
个黑色节点。
二:我这里就大概说下吧(一家之言,自己的一点经验,也希望别的同学来一起讨论):
1.
左旋右旋这个,个人还是认为要画图,不画图我自己也写不出那个代码
……
哈哈。
2.
说到插入删除的算法,我说用到了递推,就比如插入的
CASE1
的情况,
CASE1的处理之后,关注节点从本身变成了他的祖父节点(红色节点),这就是往根节点递推。不过我认为
CASE1
处理过一次之后,不一定会进入
case2
或者
case3
,是有可能还在
case1
的。
换句话说,就是可以在
case1
的情况下,一直往根节点走,因为当前节点永远是红色,所以在最后要把根节点涂黑。同时,只要进入到
case2,case3的情况,就是变成平衡二叉树的单旋和双旋的情况,双旋的处理逻辑就是把双旋变成单旋(比如先右后左旋就是把树变成
“
左撇子
”)。这个就变成了单左旋能一步到位处理的平衡了,这个就是归纳。把未知情况转化为已知,如果我没有记错的话,数学归纳法的核心思想就是递推和归纳。
3.其实我们只要记住,除了关注的节点所在的子树,其他的子树本身都是一颗红黑树,他们是满足红黑树的所有特征的。当关注节点往根节点递推时,这个时候关注节点的子树也已经满足了红黑树的定义,我们就不用再去特别关注子树的特征。只要注意关注节点往上的部分。这样就能把问题简化,思考的时候思路会清晰一些。
4.
再说到删除算法,我看到很多同学没理解为什么要红
-
黑,黑
-
黑节点的出现。这里我的看法是,红黑树最不好控制的其实是最后一个的性质
4(每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点),因为你永远不知道别的子树到底有多少个黑色节点。这里加入红
-
黑,黑
-黑节点就可以控制红黑树满足性质
4
,到时候要恢复颜色,只要去掉多余的黑色即可。
接下来的处理思路就是要满足:
1.
每个节点不是红的就是黑的,
2.
相邻节点不能是红的。这个思路计时变复杂为简单。
删除的
case1
情况,并没有真正处理,而且为了进入接下来的
case2,case3,case4
,这里又是之前说到的归纳思想。
case2的情况又是一个递推思路,关注节点往根节点递推,让其左右子树都满足红黑树的定义。因为往上推,右子树多了一个黑色节点,就把关注节点的兄弟节点变红,使其满足性质
4.
删除的
case3
是为了进入
case4
,提前变色的原因和
case2
是一样的,都是为了满足性质
4
。同样是归纳推理的思路。都要记住一点,各种
case下的其他子树节点都满足红黑树的定义,需要分类讨论的,都在这几种
case
情况中了。
4.最后的建议,其实说了这么多,很多的表达都不太清楚,但是个人感觉,数学基础好的同学,理解红黑树会好一些,学习的时候多画画图,人对图形的敏感肯定比文字高,另外的就是大家可以去看看源码,本人是做
java
开发的,
jdk1.8
的
treemap就是用红黑树实现的,跟着源码多看看,跟着老师的说明或者百度上的教程思考,动笔画画图,都能理解的。我自己看
jdk源码的也是看了将近两个月才大概明白(因为也在上班,只有晚上有一些时间来看看代码)。学习
的过程中要耐心,学习红黑树本身也不是为了
“
默写
”
,而是去学习思想,锻炼思维,复杂问题简单化,新问题转化为已解决过的问题等等。其实说到最后,
都是用到了数学的思维,这些思维都会在潜移默化中影响到自己。