重温数据结构:深入理解红黑树

时间:2022-03-24 10:33:31

读完本文你将了解到:

上篇文章 《重温数据结构:二叉排序树的查找、插入、删除》 我们提到,二叉排序树的性能取决于二叉树的层数:

  • 最好的情况是 O(logn),存在于完全二叉排序树情况下,其访问性能近似于折半查找;
  • 最差时候会是 O(n),比如插入的元素是有序的,生成的二叉排序树就是一个链表,这种情况下,需要遍历全部元素才行(见下图 b)。

重温数据结构:深入理解红黑树

为了改变排序二叉树存在的不足,Rudolf Bayer 在 1972 年发明了另一种改进后的排序二叉树:红黑树,他将这种排序二叉树称为“对称二叉 B 树”,而红黑树这个名字则由 Leo J. Guibas 和 Robert Sedgewick 于 1978 年首次提出。

本文介绍了红黑树的基本性质和基本操作。

什么是红黑树

红黑树本质上是一种二叉查找树,但它在二叉查找树的基础上额外添加了一个标记(颜色),同时具有一定的规则。这些规则使红黑树保证了一种平衡,插入、删除、查找的最坏时间复杂度都为 O(logn)。

它的统计性能要好于平衡二叉树(AVL树),因此,红黑树在很多地方都有应用。比如在 Java 集合框架中,很多部分(HashMap, TreeMap, TreeSet 等)都有红黑树的应用,这些集合均提供了很好的性能。

由于 TreeMap 就是由红黑树实现的,因此本文将使用 TreeMap 的相关操作的代码进行分析、论证。

黑色高度

从根节点到叶节点的路径上黑色节点的个数,叫做树的黑色高度。

红黑树的 5 个特性

重温数据结构:深入理解红黑树

红黑树在原有的二叉查找树基础上增加了如下几个要求:

  1. Every node is either red or black.
  2. The root is black.
  3. Every leaf (NIL) is black.
  4. If a node is red, then both its children are black.
  5. For each node, all simple paths from the node to descendant leaves contain the same number of black nodes.

中文意思是:

  1. 每个节点要么是红色,要么是黑色;
  2. 根节点永远是黑色的;
  3. 所有的叶节点都是是黑色的(注意这里说叶子节点其实是上图中的 NIL 节点);
  4. 每个红色节点的两个子节点一定都是黑色;
  5. 从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点;

注意:
性质 3 中指定红黑树的每个叶子节点都是空节点,而且并叶子节点都是黑色。但 Java 实现的红黑树将使用 null 来代表空节点,因此遍历红黑树时将看不到黑色的叶子节点,反而看到每个叶子节点都是红色的。

性质 4 的意思是:从每个根到节点的路径上不会有两个连续的红色节点,但黑色节点是可以连续的。
因此若给定黑色节点的个数 N,最短路径的情况是连续的 N 个黑色,树的高度为 N - 1;最长路径的情况为节点红黑相间,树的高度为 2(N - 1) 。

性质 5 是成为红黑树最主要的条件,后序的插入、删除操作都是为了遵守这个规定。

红黑树并不是标准平衡二叉树,它以性质 5 作为一种平衡方法,使自己的性能得到了提升。

红黑树的左旋右旋

重温数据结构:深入理解红黑树

红黑树的左右旋是比较重要的操作,左右旋的目的是调整红黑节点结构,转移黑色节点位置,使其在进行插入、删除后仍能保持红黑树的 5 条性质。

比如 X 左旋(右图转成左图)的结果,是让在 Y 左子树的黑色节点跑到 X 右子树去。

我们以 Java 集合框架中的 TreeMap 中的代码来看下左右旋的具体操作方法:

指定节点 x 的左旋 (右图转成左图):

 //这里 p 代表 x
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
Entry<K,V> r = p.right; // p 是上图中的 x,r 就是 y
p.right = r.left; // 左旋后,x 的右子树变成了 y 的左子树 β
if (r.left != null)
r.left.parent = p; //β 确认父亲为 x
r.parent = p.parent; //y 取代 x 的第一步:认 x 的父亲为爹
if (p.parent == null) //要是 x 没有父亲,那 y 就是最老的根节点
root = r;
else if (p.parent.left == p) //如果 x 有父亲并且是它父亲的左孩子,x 的父亲现在认 y 为左孩子,不要 x 了
p.parent.left = r;
else //如果 x 是父亲的右孩子,父亲就认 y 为右孩子,抛弃 x
p.parent.right = r;
r.left = p; //y 逆袭成功,以前的爸爸 x 现在成了它的左孩子
p.parent = r;
}
}

可以看到,x 节点的左旋就是把 x 变成 右孩子 y 的左孩子,同时把 y 的左孩子送给 x 当右子树。

简单点记就是:左旋把右子树里的一个节点(上图 β)移动到了左子树。

指定节点 y 的右旋(左图转成右图):

private void rotateRight(Entry<K,V> p) {
if (p != null) {
Entry<K,V> l = p.left;
p.left = l.right;
if (l.right != null) l.right.parent = p;
l.parent = p.parent;
if (p.parent == null)
root = l;
else if (p.parent.right == p)
p.parent.right = l;
else p.parent.left = l;
l.right = p;
p.parent = l;
}
}

同理,y 节点的右旋就是把 y 变成 左孩子 x 的右孩子,同时把 x 的右孩子送给 x 当左子树。

简单点记就是:右旋把左子树里的一个节点(上图 β)移动到了右子树。

了解左旋、右旋的方法及意义后,就可以了解红黑树的主要操作:插入、删除。

红黑树的平衡插入

红黑树的插入主要分两步:

  • 首先和二叉查找树的插入一样,查找、插入
  • 然后调整结构,保证满足红黑树状态
    • 对结点进行重新着色
    • 以及对树进行相关的旋转操作

红黑树的插入在二叉查找树插入的基础上,为了重新恢复平衡,继续做了插入修复操作。

二叉查找树的插入

上篇文章 介绍过,二叉查找树的就是一个二分查找,找到合适的位置就放进去。

插入后调整红黑树结构

红黑树的第 5 条特征规定,任一节点到它子树的每个叶子节点的路径中都包含同样数量的黑节点。也就是说当我们往红黑树中插入一个黑色节点时,会违背这条特征。

同时第 4 条特征规定红色节点的左右孩子一定都是黑色节点,当我们给一个红色节点下插入一个红色节点时,会违背这条特征。

因此我们需要在插入黑色节点后进行结构调整,保证红黑树始终满足这 5 条特征。

调整思想

前面说了,插入一个节点后要担心违反特征 4 和 5,数学里最常用的一个解题技巧就是把多个未知数化解成一个未知数。我们这里采用同样的技巧,把插入的节点直接染成红色,这样就不会影响特征 5,只要专心调整满足特征 4 就好了。这样比同时满足 4、5 要简单一些。

染成红色后,我们只要关心父节点是否为红,如果是红的,就要把父节点进行变化,让父节点变成黑色,或者换一个黑色节点当父亲,这些操作的同时不能影响 不同路径上的黑色节点数一致的规则。

注:插入后我们主要关注插入节点的父亲节点的位置,而父亲节点位于左子树或者右子树的操作是相对称的,这里我们只介绍一种,即插入位置的父亲节点为左子树。

【插入、染红后的调整有 2 种情况:】

情况1.父亲节点和叔叔节点都是红色:

重温数据结构:深入理解红黑树

假设插入的是节点 N,这时父亲节点 P 和叔叔节点 U 都是红色,爷爷节点 G 一定是黑色。

红色节点的孩子不能是红色,这时不管 N 是 P 的左孩子还是右孩子,只要同时把 P 和 U 染成黑色,G 染成红色即可。这样这个子树左右两边黑色个数一致,也满足特征 4。

但是这样改变后 G 染成红色,G 的父亲如果是红色岂不是又违反特征 4 了?
这个问题和我们插入、染红后一致,因此需要以 爷爷节点 G 为新的调整节点,再次进行调整操作,以此循环,直到父亲节点不是红的,就没有问题了。

情况2.父亲节点为红色,叔叔节点为黑色:

重温数据结构:深入理解红黑树

假设插入的是节点 N,这时父亲节点 P 是红色,叔叔节点 U 是黑色,爷爷节点 G 一定是黑色。

红色节点的孩子不能是红色,但是直接把父亲节点 P 涂成黑色也不行,这条路径多了个黑色节点。怎么办呢?

既然改变不了你,那我们就此别过吧,我换一个更适合我的!

我们怎么把 P 弄走呢?看来看去,还是右旋最合适,通过把 爷爷节点 G 右旋,P 变成了这个子树的根节点,G 变成了 P 的右子树。

右旋后 G 跑到了右子树上,这时把 P 变成黑的,多了一个黑节点,再把 G 变成红的,就平衡了!

上面讲的是插入节点 N 在父亲节点 P 的左孩子位置,如果 N 是 P 的右孩子,就需要多进行一次左旋,把情况化解成上述情况。

重温数据结构:深入理解红黑树

N 位于 P 的右孩子位置,将 P 左旋,就化解成上述情况了。

根据 TreeMap 的代码来验证这个过程:

下面是 TreeMap 在插入后进行调整的代码,可以看出来跟我们分析的一致。

private void fixAfterInsertion(Entry<K,V> x) {
x.color = RED; //直接染成红色,少点麻烦

//这里分析的都是父亲节点为红色的情况,不是红色就不用调整了
while (x != null && x != root && x.parent.color == RED) {
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) { // 插入节点 x 的父亲节点位于左孩子
Entry<K,V> y = rightOf(parentOf(parentOf(x))); // y 是 x 的叔叔节点
if (colorOf(y) == RED) { //如果 y 也是红色,只要把父亲节点和 y 都变成黑色,爷爷节点变成红的,就 Ok 了
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else { //如果叔叔节点 y 不是红色,就需要右旋,让父亲节点变成根节点,爷爷节点去右子树去,然后把父亲节点变成黑色、爷爷节点变成红色
//特殊情况:x 是父亲节点的右孩子,需要对父亲节点进行左旋,把 x 移动到左子树
if (x == rightOf(parentOf(x))) {
x = parentOf(x);
rotateLeft(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
}
} else { //和上面对称的操作
Entry<K,V> y = leftOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
x = parentOf(parentOf(x));
} else {
if (x == leftOf(parentOf(x))) {
x = parentOf(x);
rotateRight(x);
}
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateLeft(parentOf(parentOf(x)));
}
}
}
root.color = BLACK;
}

红黑树的平衡删除

红黑树的插入平衡需要好好理解下,如果前面没有理解,删除后的调整平衡更加难懂,前方高能,请注意!

红黑树的删除也是分两步:

  1. 二叉查找树的删除
  2. 结构调整

二叉查找树的删除

上篇文章 介绍了,二叉查找树的删除分三种情况:

  1. 要删除的节点正好是叶子节点,直接删除就 OK 了;
  2. 只有左孩子或者右孩子,直接把这个孩子上移放到要删除的位置就好了;
  3. 有两个孩子,就需要选一个合适的孩子节点作为新的根节点,该节点称为 继承节点。

三种情况的图片示意
(图来自:http://shmilyaw-hotmail-com.iteye.com/blog/1836431):

1.要删除的节点正好是叶子节点,直接删除就 OK 了(右图有错误,应该是 z 不是 r)

重温数据结构:深入理解红黑树

2.有左孩子或者右孩子,直接把这个孩子上移放到要删除的位置就好了

重温数据结构:深入理解红黑树

3.有两个孩子,就需要选一个合适的孩子节点作为新的根节点,该节点称为 继承节点

重温数据结构:深入理解红黑树

删除后的结构调整

根据红黑树的第 5 个特性:

如果当前待删除节点是红色的,它被删除之后对当前树的特性不会造成任何破坏影响。
而如果被删除的节点是黑色的,这就需要进行进一步的调整来保证后续的树结构满足要求。

这里研究的是删除黑色节点的情况。

调整思想

为了保证删除节点父亲节点左右两边黑色节点数一致,需要重点关注父亲节点没删除的那一边节点是不是黑色。如果删除后父亲节点另一边比删除的一边黑色节点多,就要想办法搞到平衡,具体的平衡方法有如下几种方法:

  1. 把父亲节点另一边(即删除节点的兄弟树)其中一个节点弄成红色,也少一个黑色
  2. 或者把另一边多的黑色节点转过来一个

删除节点在父亲节点的左子树还是右子树,调整方式都是对称的,这里以当前节点为父节点的左孩子为例进行分析。

【删除后的调整主要分三步】:

第一步:

  • 兄弟如果是红的,说明孩子都是黑的 【旋转的情况 1 】
    • 把兄弟搞成黑的
    • 父亲搞成红的
    • 左旋转父亲(嘿嘿,兄弟给我分一个黑孩子)
    • 接下来对比旋转后的兄弟

第一步解释:

这一步的目的是将兄弟节点变成黑的,转变成第二步两种情形中的某一种情况。

在做后续变化前,这棵树还是保持着原来的平衡。

第二步,有两种情况:

情况1 :兄弟节点的孩子都是黑色

  • 把兄弟搞成红的
  • continue 下一波(这个子树搞完了,研究父亲节点,去搞上一级树,进入第三步)

第二步情况 1 解释:

这里将兄弟节点变成红色后,从它的父节点到下面的所有路径就都统一少了 1 个,同时也不影响别的特征,但是把兄弟节点变红后,如果有父亲节点也是红的,就可能违反红黑树的特征 4,因此需要到更高一级树进行鉴别、调整。

重温数据结构:深入理解红黑树

情况2 :兄弟节点的孩子至多有一个是黑的

  • 把不是黑的那个孩子搞黑 【旋转的情况 2 】
    • 兄弟搞红
    • 兄弟右旋转
    • 以后对比旋转后的兄弟
  • 把兄弟涂成跟父亲一样的颜色 【旋转的情况 3 】
  • 然后把父亲搞黑
  • 把兄弟的右孩子搞黑
  • 父亲节点左旋
  • 研究根节点,进入第三步

第二步情况 2 解释:

旋转的情况 2 是将兄弟节点的左右孩子都移动到右边,方便后续操作,如下图所示:

重温数据结构:深入理解红黑树

旋转的情况 3 将兄弟的孩子移到左边来,同时黑色的父亲变到了左边(总之就是让左边多些黑色节点),如下图所示:

重温数据结构:深入理解红黑树

第三步:

  • 如果研究的不是根节点并且是黑的,重新进入第一步,研究上一级树;
  • 如果研究的是根节点或者这个节点不是黑的,就退出
    • 把研究的这个节点涂成黑的。

第三步解释:

第三步中选择根节点为结束标志,是因为在第二步中,有可能出现我们正好给删除黑色节点的子树补上了一个黑色节点,同时不影响其他子树,这时我们的调整已经完成,可以直接设置调整节点 x = root,等于宣告调整结束。

因为我们当前调整的可能只是一棵树中间的子树,这里头的节点可能还有父节点,这么一直往上到根节点。当前子树少了一个黑色节点,要保证整体合格还是不够的。

这里需要在代码里有一个保证。假设这里 B 已经是红色的了。那么调整结束,最后对 B 节点,也就是调整目标 x 所指向的这个节点涂成黑色。这样保证前面亏的那一个黑色节点就补回来了。

前面讨论的这4种情况是在当前节点是父节点的左子节点的条件下进行的。如果当前节点是父节点的右子节点,则可以对应的做对称的操作处理,过程也是一样的。

其中具体旋转方向根据调整节点在父节点的左/右位置决定。

根据 TreeMap 的代码来验证这个过程:

private void fixAfterDeletion(Entry<K,V> x) {
while (x != root && colorOf(x) == BLACK) {
if (x == leftOf(parentOf(x))) {
Entry<K,V> sib = rightOf(parentOf(x));

//左旋,把黑色节点移到左边一个
if (colorOf(sib) == RED) {
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateLeft(parentOf(x));
sib = rightOf(parentOf(x));
}

if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
setColor(sib, RED);
x = parentOf(x);
} else {
if (colorOf(rightOf(sib)) == BLACK) {
setColor(leftOf(sib), BLACK);
setColor(sib, RED);
rotateRight(sib);
sib = rightOf(parentOf(x));
}
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(rightOf(sib), BLACK);
rotateLeft(parentOf(x));
x = root;
}
} else { //处理的节点在 右边,相同逻辑,只不过旋转的方向相反
Entry<K,V> sib = leftOf(parentOf(x));

if (colorOf(sib) == RED) {
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateRight(parentOf(x));
sib = leftOf(parentOf(x));
}

if (colorOf(rightOf(sib)) == BLACK &&
colorOf(leftOf(sib)) == BLACK) {
setColor(sib, RED);
x = parentOf(x);
} else {
if (colorOf(leftOf(sib)) == BLACK) {
setColor(rightOf(sib), BLACK);
setColor(sib, RED);
rotateLeft(sib);
sib = leftOf(parentOf(x));
}
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(leftOf(sib), BLACK);
rotateRight(parentOf(x));
x = root;
}
}
}

setColor(x, BLACK);
}

当调整的节点属于父亲节点的左子树时,调整方法对应的流程图如下:

重温数据结构:深入理解红黑树

当调整的节点属于父亲节点的右子树时,调整方法也类似,旋转的方向相对称。

这里列出删除后调整的全部逻辑流程图(右键新窗口打开图片更清晰):

重温数据结构:深入理解红黑树

总结

红黑树并不是真正的平衡二叉树,但在实际应用中,红黑树的统计性能要高于平衡二叉树,但极端性能略差。

红黑树的插入、删除调整逻辑比较复杂,但最终目的是满足红黑树的 5 个特性,尤其是 4 和 5。

在插入调整时为了简化操作我们直接把插入的节点涂成红色,这样只要保证插入节点的父节点不是红色就可以了。

而在删除后的调整中,针对删除黑色节点,所在子树缺少一个节点,需要进行弥补或者对别人造成一个黑色节点的伤害。具体调整方法取决于兄弟节点所在子树的情况。

红黑树的插入、删除在树形数据结构中算比较复杂的,理解起来比较难,但只要记住,红黑树有其特殊的平衡规则,而我们为了维持平衡,根据邻树的状况进行旋转或者涂色。

红黑树这么难理解,必定有其过人之处。它的有序、快速特性在很多场景下都有用到,比如 Java 集合框架的 TreeMap, TreeSet 等。

Thanks

@ coolingxyz 前辈写过数据结构相关的课件,flash 动态演示数据结构算法,可以去看看:
http://xu-laoshi.cn/shujujiegou/flash.html

一个不错的在线演示添加、删除红黑树:
http://sandbox.runjs.cn/show/2nngvn8w

《算法导论》

http://en.wikipedia.org/wiki/Red–black_tree

http://www.cnblogs.com/skywang12345/p/3245399.html

http://shmilyaw-hotmail-com.iteye.com/blog/1836431

http://blog.csdn.net/speedme/article/details/18743445

http://blog.csdn.net/eson_15/article/details/51144079

http://blog.csdn.net/v_july_v/article/details/6105630

http://dongxicheng.org/structure/red-black-tree/