【爬树合集】难啃的骨头——红黑树
写在前面
红黑树应用:
1.红黑树在Linux非实时任务调度中的应用
2.红黑树在Linux虚拟内存中的应用
3.红黑树在检测树的平衡性上的应用
4.epoll在内核中的实现,用红黑树管理事件块
5.nginx中,用红黑树管理timer等
6.Java的TreeMap实现
7.广泛用在C++的STL中。map和set都是用红黑树实现的。
在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。在jdk1.8版本后,java对HashMap做了改进,在链表长度大于8的时候,将后面的数据存在红黑树中,以加快检索速度。
HashMap的扩容因子为什么是0.75:
假设hash函数是理想的,数据会通过hash函数均匀的映射到数组上。一个数据映射到每一个桶(bucket)的概率是相等的。**那么在任意的数组容量下,put一个数据发生碰撞的概率=**数组中元素的个数数组容量数组容量数组中元素的个数。而数组的扩容门槛threshold = capacity * loadFactorloadFactor。也就是说扩容因子就是HashMap在扩容门槛的状态下,put操作发生碰撞的概率。
那么,扩容因子等于0.75还是0.95的区别就很明显了。扩容因子=0.75。当使用量接近数组容量的75%的时候,数组中还有25%的剩余空间。平均来看,就是每4个桶(bucket)中还有一个是空的,当我们向map中put数据的时候,发生碰撞的概率是75%。因为这25%的空闲空间的存在,发生hash碰撞的概率还处在一个可以接受的范围内。
而当扩容因子=0.95的时候,平均来看,就是每20个桶(bucket)中才有一个是空的,此时数组中几乎没有空闲的桶(bucket),当我们put数据的时候,碰撞的概率是95%,几乎可以认为会发生碰撞。
除此之外,碰撞的概率越大,put的元素就越多,平均到每个桶中的元素的数量也越多。一旦发生碰撞,需要付出更大的代价。所以,如果扩容因子越大,碰撞的概率也就越大,发生碰撞后的代价也更大,结果导致效率大打折扣。
因此扩容因子=0.75也是一个空间换时间的考虑,0.75这个数值应该是经过充分的考虑决定的。
红黑树可以保证 最好 最坏 情况的所有操作(插入/删除/查找等)时间复杂度都是对数级别 O(logN)
B+树插入操作的平均时间复杂度为O(logn),最坏时间复杂度为O(logn)
哈希表插入的时间复杂度与冲突次数有关,O(冲突次数/n),最好的情况冲突次数为0,直接插入,时间复杂度为O(1)。最坏情况是所有值对应同一个键值,这是冲突次数最多,为0+1+2+3+4+…+(n-1)=n*(n-1)/2,平均比较次数为(n-1)/2**,时间复杂度为O(n)**
排序链表插入操作的平均时间复杂度为O(n),最坏时间复杂度为O(n)
深入解读:
红黑树是一种自平衡二叉查找树
红黑树的特性:
- 每个结点是黑色或者红色。
- 根结点是黑色。
- 每个叶子结点(NIL)是黑色。
- 如果一个结点是红色的,则它的子结点必须是黑色的。
- 每个当代结点到叶子结点NIL所经过的黑色结点的个数一样的。
上图介绍了一颗正常的红黑树
通过旋转与变色保证自平衡
左旋
可以很简单的发现他们位置的变换,通俗的说是:
V做P的父节点,V的左结点R做P的右结点
伪代码;
LEFT-ROTATE(T, x)
y ← right[x] // 前提:这里假设x的右孩子为y。下面开始正式操作
right[x] ← left[y] // 将 “y的左孩子” 设为 “x的右孩子”
p[left[y]] ← x // 将 “x” 设为 “y的左孩子的父亲”
p[y] ← p[x] // 将 “x的父亲” 设为 “y的父亲”
if p[x] = nil[T]
then root[T] ← y // 情况1:如果 “x的父亲” 是空节点,则将y设为根节点
else if x = left[p[x]]
then left[p[x]] ← y // 情况2:如果 x是它父节点的左孩子,则将y设为“x的父节点的左孩子”
else right[p[x]] ← y // 情况3:(x是它父节点的右孩子) 将y设为“x的父节点的右孩子”
left[y] ← x // 将 “x” 设为 “y的左孩子”
p[x] ← y // 将 “x的父节点” 设为 “y”
右旋
RIGHT-ROTATE(T, y)
x ← left[y] // 前提:这里假设y的左孩子为x。下面开始正式操作
left[y] ← right[x] // 将 “x的右孩子” 设为 “y的左孩子”
p[right[x]] ← y // 将 “y” 设为 “x的右孩子的父亲”
p[x] ← p[y] // 将 “y的父亲” 设为 “x的父亲”
if p[y] = nil[T]
then root[T] ← x // 情况1:如果 “y的父亲” 是空结点,则将x设为根结点
else if y = right[p[y]]
then right[p[y]] ← x // 情况2:如果 y是它父结点的右孩子,则将x设为“y的父结点的左孩子”
else left[p[y]] ← x // 情况3:(y是它父结点的左孩子) 将x设为“y的父结点的左孩子”
right[x] ← y // 将 “y” 设为 “x的右孩子”
p[y] ← x // 将 “y的父结点” 设为 “x”
插入:
插入算法伪代码:
寻找并插入
网络上的伪代码
RB-INSERT(T, z)
y ← nil[T] // 新建结点“y”,将y设为空结点。
x ← root[T] // 设“红黑树T”的根结点为“x”
while x ≠ nil[T] // 找出要插入的结点“z”在二叉树T中的位置(父结点),即“y”结点要存放的位置
do y ← x
if key[z] < key[x]
then x ← left[x]
else x ← right[x]
p[z] ← y // 设置 “z的父亲” 为 “y”
if y = nil[T]
then root[T] ← z // 情景1:若y是空结点,则将z设为根结点
else if key[z] < key[y]
then left[y] ← z // 情景2:若“z的key值” < “y的key值”,则将z设为“y的左孩子”
else right[y] ← z // 情景2:若“z的key值” >= “y的key值”,则将z设为“y的右孩子”
left[z] ← nil[T] // z的左孩子设为空
right[z] ← nil[T] // z的右孩子设为空。至此,已经完成将“结点z插入到二叉树”中了。
color[z] ← RED // 将z着色为“红色”
RB-INSERT-FIXUP(T, z) // 通过RB-INSERT-FIXUP对红黑树的结点进行颜色修改以及旋转,让树T仍然是一颗红黑树
变色处理,多情景
RB-INSERT-FIXUP(T, z)
while color[p[z]] = RED // 若“当前结点(z)的父结点是红色”,则进行以下处理。
do if p[z] = left[p[p[z]]] // 若“z的父结点”是“z的祖父结点的左孩子”,则进行以下处理。
then y ← right[p[p[z]]] // 将y设置为“z的叔叔结点(z的祖父结点的右孩子)”
if color[y] = RED // 4.1情景:叔叔是红色
then color[p[z]] ← BLACK // (01) 将“父结点”设为黑色。
color[y] ← BLACK // (02) 将“叔叔结点”设为黑色。
color[p[p[z]]] ← RED // (03) 将“祖父结点”设为“红色”。
z ← p[p[z]] // (04) 将“祖父结点”设为“当前结点”(红色结点)
else if z = right[p[z]] // 4.3.1情景:叔叔是黑色,且当前结点是右孩子
then z ← p[z] // (01) 将“父结点”作为“新的当前结点”。
LEFT-ROTATE(T, z) // (02) 以“新的当前结点”为支点进行左旋。
color[p[z]] ← BLACK // 4.2.1情景:叔叔是黑色,且当前结点是左孩子。(01) 将“父结点”设为“黑色”。
color[p[p[z]]] ← RED // (02) 将“祖父结点”设为“红色”。
RIGHT-ROTATE(T, p[p[z]]) // (03) 以“祖父结点”为支点进行右旋。
else (same as then clause with "right" and "left" exchanged) // 若“z的父结点”是“z的祖父结点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。
color[root[T]] ← BLACK // 情景1:若y是空结点,则将z设为根结点
情景1:
红黑树为空树
最简单的一种情景,直接把插入结点作为根结点就行,但注意,根据红黑树性质2:根结点是黑色。所以还需要把插入结点设为黑色。
处理: 把插入结点作为根结点,并把结点设置为黑色。
情景2:
插入结点的Key已存在
插入结点的Key已存在,因为红黑树总保持平衡,在插入前红黑树已经是平衡的,那么把插入结点设置为将要替代的结点颜色,再把结点的值更新就完成插入了。
处理:
把z设为当前结点的颜色。
更新当前结点的值为插入结点的值。
情景3:
插入结点的父结点为黑结点
由于插入的结点是红色的,并不会影响红黑树的平衡,直接插入即可,无需做自平衡。
处理:
直接插入。
情景4:
插入结点的父结点为红结点
再次回想下红黑树的性质2:根结点是黑色。如果插入的父结点为红结点,那么该父结点不可能为根结点,所以插入结点总是存在祖父结点。这点很重要,因为后续的旋转操作需要祖父结点的参与。
情景4.1:
叔叔结点存在并且为红结点
从红黑树性质4可以确定,祖父结点为黑结点,因为不可以同时存在两个相连的红结点。那么此时该插入子树的红黑层数的情况是:黑红红。显然最简单的处理方式是把其改为:红黑红。
将P和S设置为黑色(当前插入结点I)
将PP设置为红色
把PP设置为当前插入结点
情景4.2:
叔叔结点不存在或为黑结点,并且插入结点的父亲结点是祖父结点的左子结点
单纯从插入前来看,也即不算情景4.1自底向上处理时的情况,叔叔结点非红即为叶子结点(Nil)。因为如果叔叔结点为黑结点,而父结点为红结点,那么叔叔结点所在的子树的黑色结点就比父结点所在子树的多了,这不满足红黑树的性质5。后续情景同样如此,不再多做说明了。
情景4.2.1:
插入结点是其父结点的左子结点
同样的根据《算法》书中讲解的就是把PP设为红色,I和P设为黑色。但把PP设为红色,显然又会出现情景4.1的情况,需要自底向上处理,做多了无谓的操作~
情景4.2.2:
插入结点是其父结点的右子结点
这种情景显然可以转换为情景4.2.1,如图12所示,不做过多说明了。
对P进行左旋
把P设置为插入结点,得到情景4.2.1
进行情景4.2.1的处理
情景4.3:
叔叔结点不存在或为黑结点,并且插入结点的父亲结点是祖父结点的右子结点
该情景对应情景4.2,只是方向反转,不做过多说明了
情景4.3.1:
插入结点是其父结点的右子结点
处理:
将P设为黑色
将PP设为红色
对PP进行左旋
情景4.3.2:
插入结点是其父结点的右子结点
对P进行右旋
把P设置为插入结点,得到情景4.3.1
进行情景4.3.1的处理
实际插入演示
前文参考《知音小助手》还有不明白的地方,可以去搜索一下。接下来我找了个实践的插入操作演示:
相信在仔细阅读了前文以后,这一套插入操作应该能够看明白了,当你仔细阅读看到了这里,肯定已经明白了,不然早就草草划走了,上面两张图是我找到很明了的图了,大家有不明白的可以下方评论,顺带关注一下哟!
删除
查找目标结点显然可以复用查找操作,当不存在目标结点时,忽略本次操作;当存在目标结点时,删除后需要做自平衡处理。删除结点后我们需要找结点来替代删除结点的位置,不然子树跟父辈结点断开了,除非删除结点刚好没子结点,那么就不需要替代。
查找目标结点。
删除结点后自平衡。
情景1:若删除结点无子结点,直接删除。
情景2:若删除结点只有一个子结点,用子结点替换删除结点。
情景3:若删除结点有两个子结点,用后继结点(大于删除结点的最小结点)替换删除结点。
前继和后继
这里的前继和后继,根据数据结构中学的是指:离该节点大小最近的结点,比如上图P的前继和后继是指M,Q这点很基础也很重要,而不是K,R
删除算法
RB-DELETE(T, z)
if left[z] = nil[T] or right[z] = nil[T]
then y ← z // 若“z的左孩子” 或 “z的右孩子”为空,则将“z”赋值给 “y”;
else y ← TREE-SUCCESSOR(z) // 否则,将“z的后继节点”赋值给 “y”。
if left[y] ≠ nil[T]
then x ← left[y] // 若“y的左孩子” 不为空,则将“y的左孩子” 赋值给 “x”;
else x ← right[y] // 否则,“y的右孩子” 赋值给 “x”。
p[x] ← p[y] // 将“y的父节点” 设置为 “x的父节点”
if p[y] = nil[T]
then root[T] ← x // 若“y的父节点” 为空,则设置“x” 为 “根节点”。
else if y = left[p[y]]
then left[p[y]] ← x // 若“y是它父节点的左孩子”,则设置“x” 为 “y的父节点的左孩子”
else right[p[y]] ← x // 若“y是它父节点的右孩子”,则设置“x” 为 “y的父节点的右孩子”
if y ≠ z
then key[z] ← key[y] // 若“y的值” 赋值给 “z”。注意:这里只拷贝z的值给y,而没有拷贝z的颜色!!!
copy y's satellite data into z
if color[y] = BLACK
then RB-DELETE-FIXUP(T, x) // 若“y为黑节点”,即替换结点是黑色则调用删除修正算法
return y
删除修复算法
删除修正算法:
RB-DELETE-FIXUP(T, x)
while x ≠ root[T] and color[x] = BLACK
do if x = left[p[x]]
then w ← right[p[x]] // 若 “x”是“它父节点的左孩子”,则设置 “w”为“x的兄弟”(即x为它父节点的右孩子)
if color[w] = RED // 情景2.1.1: x是“黑+黑”节点,x的兄弟节点是红色。(此时x的父节点和x的兄弟节点的子节点都是黑节点)。
then color[w] ← BLACK // (01) 将x的兄弟节点设为“黑色”。
color[p[x]] ← RED // (02) 将x的父节点设为“红色”。
LEFT-ROTATE(T, p[x]) // (03) 对x的父节点进行左旋。
w ← right[p[x]] // (04) 左旋后,重新设置x的兄弟节点。
if color[left[w]] = BLACK and color[right[w]] = BLACK // 情景2.1.2.3: x是“黑+黑”节点,x的兄弟节点是黑色,x的兄弟节点的两个孩子都是黑色。
then color[w] ← RED // (01) 将x的兄弟节点设为“红色”。
x ← p[x] // (02) 设置“x的父节点”为“新的x节点”。
else if color[right[w]] = BLACK // 情景2.1.2.2: x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的左孩子是红色,右孩子是黑色的。
then color[left[w]] ← BLACK // (01) 将x兄弟节点的左孩子设为“黑色”。
color[w] ← RED // (02) 将x兄弟节点设为“红色”。
RIGHT-ROTATE(T, w) // (03) 对x的兄弟节点进行右旋。
w ← right[p[x]] // (04) 右旋后,重新设置x的兄弟节点。
color[w] ← color[p[x]] // 情景2.1.2.1: x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的右孩子是红色的。(01) 将x父节点颜色 赋值给 x的兄弟节点。
color[p[x]] ← BLACK // (02) 将x父节点设为“黑色”。
color[right[w]] ← BLACK // (03) 将x兄弟节点的右子节设为“黑色”。
LEFT-ROTATE(T, p[x]) // (04) 对x的父节点进行左旋。
x ← root[T] // (05) 设置“x”为“根节点”。
else (same as then clause with "right" and "left" exchanged) // 若 “x”是“它父节点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。
color[x] ← BLACK
接下来我们使用找个例子:删除某个值时,用R结点代替的情况
图中字母并不代表结点Key的大小。R表示替代结点,P表示替代结点的父结点,S表示替代结点的兄弟结点,SL表示兄弟结点的左子结点,SR表示兄弟结点的右子结点。灰色结点表示它可以是红色也可以是黑色。
值得特别提醒的是,R是即将被替换到删除结点的位置的替代结点,在删除前,它还在原来所在位置参与树的子平衡,平衡后再替换到删除结点的位置,才算删除完成。
删除情况可以总结为下面这些:
情景1:
替换结点是红色结点
我们把替换结点换到了删除结点的位置时,由于替换结点是红色,删除也了不会影响红黑树的平衡,只要把替换结点的颜色设为删除的结点的颜色即可重新平衡。
处理:
颜色变为删除结点的颜色
情景2:
替换结点是黑结点
当替换结点是黑色时,我们就不得不进行自平衡处理了。我们必须还得考虑替换结点是其父结点的左子结点还是右子结点,来做不同的旋转操作,使树重新平衡。
情景2.1:
替换结点是其父结点的左子结点
情景2.1.1:
替换结点的兄弟结点是红结点
若兄弟结点是红结点,那么根据性质4,兄弟结点的父结点和子结点肯定为黑色,不会有其他子情景,我们按图15处理,得到情景2.1.2.3(后续讲解,这里先记住,此时R仍然是替代结点,它的新的兄弟结点SL和兄弟结点的子结点都是黑色)。
处理:
将S设为黑色
将P设为红色
对P进行左旋,得到情景2.1.2.3
进行情景2.1.2.3的处理
情景2.1.2:
替换结点的兄弟结点是黑结点
当兄弟结点为黑时,其父结点和子结点的具体颜色也无法确定(如果也不考虑自底向上的情况,子结点非红即为叶子结点Nil,Nil结点为黑结点),此时又得考虑多种子情景。
情景2.1.2.1:
替换结点的兄弟结点的右子结点是红结点,左子结点任意颜色
即将删除的左子树的一个黑色结点,显然左子树的黑色结点少1了,然而右子树又有红色结点,那么我们直接向右子树“借”个红结点来补充黑结点就好啦,此时肯定需要用旋转处理了。
处理:
将S的颜色设为P的颜色
将P设为黑色
将SR设为黑色
对P进行左旋
平衡后的图怎么不满足红黑树的性质?前文提醒过,R是即将替换的,它还参与树的自平衡,平衡后再替换到删除结点的位置,所以R最终可以看作是删除的。另外图15是考虑到第一次替换和自底向上处理的情况,如果只考虑第一次替换的情况,根据红黑树性质,SL肯定是红色或为Nil,所以最终结果树是平衡的。如果是自底向上处理的情况,同样,每棵子树都保持平衡状态,最终整棵树肯定是平衡的。后续的情景同理,不做过多说明了。
情景2.1.2.2:
替换结点的兄弟结点的右子结点为黑结点,左子结点为红结点
兄弟结点所在的子树有红结点,我们总是可以向兄弟子树借个红结点过来,显然该情景可以转换为情景2.1.2.1。
将S设为红色
将SL设为黑色
对S进行右旋,得到情景2.1.2.1
进行情景2.1.2.1的处理
图17
删除情景2.1.2.3:专业啃老!
替换结点的兄弟结点的子结点都为黑结点
好了,此次兄弟子树都没红结点“借”了,兄弟帮忙不了,找父母呗,这种情景我们把兄弟结点设为红色,再把父结点当作替代结点,自底向上处理,去找父结点的兄弟结点去“借”。但为什么需要把兄弟结点设为红色呢?显然是为了在P所在的子树中保证平衡(R即将删除,少了一个黑色结点,子树也需要少一个),后续的平衡工作交给父辈们考虑了,还是那句,当每棵子树都保持平衡时,最终整棵总是平衡的。
处理:
将S设为红色
把P作为新的替换结点
重新进行删除结点情景处理
删除情景2.2:
替换结点是其父结点的右子结点
好啦,右边的操作也是方向相反,不做过多说明了,相信理解了删除情景2.1后,肯定可以理解2.2。
删除情景2.2.1:
替换结点的兄弟结点是红结点
将S设为黑色
将P设为红色
对P进行右旋,得到情景2.2.2.3
进行情景2.2.2.3的处理
删除情景2.2.2:
替换结点的兄弟结点是黑结点
删除情景2.2.2.1:
替换结点的兄弟结点的左子结点是红结点,右子结点任意颜色
处理:
将S的颜色设为P的颜色
将P设为黑色
将SL设为黑色
对P进行右旋
删除情景2.2.2.2:
替换结点的兄弟结点的左子结点为黑结点,右子结点为红结点
处理:
将S设为红色
将SR设为黑色
对S进行左旋,得到情景2.2.2.1
进行情景2.2.2.1的处理
删除情景2.2.2.3:
替换结点的兄弟结点的子结点都为黑结点
将S设为红色
把P作为新的替换结点
重新进行删除结点情景处理
综上,红黑树删除后自平衡的处理可以总结为:
自己能搞定的自消化(情景1)
自己不能搞定的叫兄弟帮忙(除了情景1、情景2.1.2.3和情景2.2.2.3)
兄弟都帮忙不了的,通过父母,找远方亲戚(情景2.1.2.3和情景2.2.2.3)
上文来源
最后再附上一个实践:
现在我们有一颗红黑树:
最后,我相信能看完这篇文章的朋友不多,虽然自己写的不是很好,但还是会一直努力,朋友点个关注,谢谢啦!看到这里的朋友,可以回头去看看最前面的一些问题了~
全文结束,除去自己复制的文章,自己也写了几千字,花了很多时间熟悉红黑树,谢谢老哥看到最后,感谢,码字不易,插图不易,望一个关注,认真学习,认真对待每一件事!纵使被虐千百遍,也不负自己!
学不死的小强!
上海第二工业大学智能科学与技术大二 周小夏(CV调包侠)