Fork me on GitHub

数据结构进阶篇-红黑树

红黑树是一种平衡二叉搜索树,它的自平衡机制简单高效,因此常被用在许多底层设计当中,他的发明者之一Robert Sedgewick正是经典算法书籍《Algorithms》的作者。

红黑树有哪些性质

首先红黑树是二叉搜索树,所以它满足二叉搜索树性质,除此之外红黑树还有下面5个性质:

  1. 节点是红色或黑色
  2. 根节点是黑色
  3. 每个叶子节点都是NIL,并且是黑色的
  4. 每个红色节点的两个子节点都是黑色
  5. 从任意一节点到它的每个叶子节点的所有简单路径上都包含相同数目的黑色节点

还可以推出下面这条性质:

  1. 假设红黑树的根节点到叶子节点的简单距离的最大值为MAX,最小值为MIN,则有MAX <= 2 * MIN(这点很容易理解,假设根节点的黑高度是BH,由性质5可知MIN等于BH,结合性质2、3、4可得出,根节点到叶子节点的红高度RH必定小于等于黑高度BH,所有MAX = BH + RH <= 2 * BH = 2 * MIN

从性质6可以知道,红黑树只是一种接近平衡的二叉搜索树,它不是严格的平衡二叉树。所以红黑树的查询要比AVL树稍慢一丢丢,但是由于红黑树维护平衡只需要旋转和着色这两种简单操作,所以它的插入、删除操作要比AVL树快,综合的统计效率要高于AVL树。

下面这幅图片展示了一颗红黑树,其中黑色的点表示叶子节点NIL。

旋转操作

旋转操作其实并不是红黑树特有的操作,早在AVL树中就开始使用旋转操作来维护树的平衡了。我觉得旋转操作的本质就是使树倾斜,对节点X做左旋操作,其实就是使以X为根节点的子树向左倾斜,右旋就是向右倾斜,这样的话,原本向右倾斜的树,经过左旋之后就会变得平衡了,右旋也是同样的道理。

这幅图是上面那颗红黑树的一部分,展示了先对节点30做左旋操作,然后对节点38做右旋操作的过程

红黑树的查询

红黑树的查询操作和二叉搜索树没什么区别,这里就不赘述了,直接上一段代码,关于红黑树的代码我推荐直接看TreeMap的代码,比较简单明了。

1
2
3
4
5
6
7
8
9
10
11
12
Comparable<? super K> k = (Comparable<? super K>) key;
Entry<K,V> p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;

红黑树的插入和删除

红黑树的插入和删除操作比较复杂,要考虑很多种情况,为了描述方便,我在这里首先做一些约定,假设要插入或删除的当前节点为C,其父节点为P,祖父节点为G,叔叔节点为U,兄弟节点为S,兄弟节点的左子节点为SL,兄弟节点的右子节点为SR

插入节点

我们约定插入的节点C是红色的,为什么这样呢?不妨思考一下,如果C是黑色的,必定会使树倾斜。
此时我们考虑两种情况,首先,如果P是黑色,直接插入节点就行了,不会违背红黑树的任何性质;如果P是红色,插入节点后会有两个连续的红色节点C和P,会违背性质4,所以我们需要做一些操作,重新平衡这棵树。
很好,我们一下就去除了一半的可能性,只需要考虑P是红色情况,一共有3个维度:

  1. P是G的左孩子或右孩子,这两种情况是对称的,考虑一种情况可以推出另一种情况。
  2. 在1的基础上,再考虑U是红色还是黑色。
  3. 在2的基础上,再考虑C是P的左孩子或右孩子。

这里我们只讨论P是G的左孩子的情况

1. 先考虑一种最基本的情况,P是G的左孩子,且U是黑色,且C是P的左孩子

这种情况下,只需要将P变为黑色,G变为红色,对G做右旋操作即可。
下图考虑了一种更一般的情况(其实真正插入的时候,x、y、z和U都是NIL节点),很容易可以看出子树x、y、z和U三者的黑高度是相等的,此时并不违背性质5,所以单靠旋转不能解决问题,需要重新着色,重新着色之后,P的左子树黑高度变大了,即向左倾斜了,所以再对P做右旋转操作,整棵树恢复平衡,并且满足了性质4

2. P是G的左孩子,且U是黑色,且C是P的右孩子

这种情况下,只需要对P做左旋操作,然后就变成了情况1

3. P是G的左孩子,且U是红色

这种情况下,由于U是红色的,所以我们不能像情况1那样直接把G变成红色(这也是U分成两种情况讨论的原因)。
此时需要将P和U变成黑色,并且将G变成红色,做完这个操作之后,对于以G为根节点的子树来说,已经是平衡的了,并且它的黑高度没有发生变化。不需要考虑C是P的左孩子还是右孩子,因为P变成了黑色,C是红色,不会违背红黑树的性质。
那么唯一会影响整体树性质的只有节点G了,因为G变成了红色,如果G的父节点也是红色,就违背了性质4。此时我们可以发现,G变成了和C刚插入时类似的情况(这也是我在情况1中直接讨论一般情况的原因),我们只需要以G作为当前节点再进行以上的操作即可,很明显这是一个递归的操作。

上述3的递归操作可能会把根节点设置为红色,所以在平衡完整棵树之后,将根节点设置为黑色即可。

下面是TreeMap在插入节点后的平衡操作的代码,我在核心的地方添加了注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private void fixAfterInsertion(Entry<K,V> x) {
// 插入节点是红色的
x.color = RED;
while (x != null && x != root && x.parent.color == RED) {
// P是G的左孩子
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
// y是x的叔叔节点
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
// 情况3:P是G的左孩子,且U是红色,且C是P的左孩子
setColor(parentOf(x), BLACK);
setColor(y, BLACK);
setColor(parentOf(parentOf(x)), RED);
// 将当前节点指向祖父节点G,向上循环
x = parentOf(parentOf(x));
} else {
// 情况2:P是G的左孩子,且U是黑色,且C是P的右孩子
if (x == rightOf(parentOf(x))) {
x = parentOf(x);
rotateLeft(x);
}
// 情况1:P是G的左孩子,且U是黑色,且C是P的左孩子
setColor(parentOf(x), BLACK);
setColor(parentOf(parentOf(x)), RED);
rotateRight(parentOf(parentOf(x)));
}
} else {
// P是G的右孩子和上面是类似的过程
// ......
}
}
// 最后面把根节点设置为黑色
root.color = BLACK;
}

删除节点

删除节点的操作比插入更加复杂,需要考虑更多种情况。
首先从删除节点的种类上分为以下三种情况:

  1. 删除的节点没有非NIL子节点
  2. 删除的节点有且只有一个非NIL子节点
  3. 删除的节点有两个非NIL孩子(严格的内部节点)

其中情况3可以使用直接后继法,转换为情况1或2,就是将删除节点C的直接后继X的值拷贝到C,然后删除X。这是二叉搜索树删除节点的通用做法。

下图是三种查询直接后继的路径图,这里只是为了说明直接后继,实际在本文中只会用到第一种情况。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {
if (t == null)
return null;
else if (t.right != null) {
// 图中的情况一
Entry<K,V> p = t.right;
while (p.left != null)
p = p.left;
return p;
} else {
Entry<K,V> p = t.parent;
Entry<K,V> ch = t;
// 如果未进入循环体就是情况二,否则就是情况三
while (p != null && ch == p.right) {
ch = p;
p = p.parent;
}
return p;
}
}

1. 先看最简单的一种情况,删除的节点有且只有一个非NIL子节点

此时删除的节点C必定是黑色的,且它的非NIL孩子一定是红色。否则以C为根节点的子树就违背了性质5。此时删除C很简单,直接将C的孩子设置为黑色,替换C即可。

2. 删除的节点没有非NIL子节点

此时如果C是红色,这种情况也很简单,直接删除C即可。
如果C是黑色,这是最复杂的一种情况,因为C删除之后,没有子节点来补充C原本的位置,会影响到整体的平衡,下面讨论一下这种情况下不同的处理方法。这里我们只考虑C是P的左孩子的情况,同理可以得出C是P右孩子的情况。

2.1 先考虑一个最简单的情况,C的兄弟S为黑色,且S的右孩子SR为红色

因为C要被删除,P的左边黑高度会减1,所以我们需要对P做左旋操作,然后S就到了P原来的位置,所以我们把S设置为P的颜色,将P设置为黑色,将SR设置为黑色。此时原来以P为根节点的树在C删除后依旧保持平衡。这里为啥没考虑S的左孩子SL呢?因为SL只能是红节点或者NIL节点,不会影响平衡。下面图中白色的节点表示可以为红色也可以为黑色的意思。

2.2 C的兄弟S为黑色,且S的右孩子SR为黑色(NIL),且SL为红色

这种情况可以将SL设置为黑色,S设置为红色,然后对S做右旋操作,就变成了2.1

2.3 C的兄弟S为黑色,且S的左右孩子都为黑色(NIL)

这种情况下,如果P是红色的,就比较简单,直接删除C,然后将S设置为红色,将P设置为黑色就行了。
如果P是黑色的,就稍微麻烦一些,如果删除了C,以P为根节点的子树的黑高度必然会降低1,此时只能向P的父辈节点寻求帮助,才能保持平衡了。
具体做法是,将C删除,将S设置为红色,然后将P作为当前节点,向上递归的考虑2.12.2就可以了。

2.4 C的兄弟S为红色

这种情况下,SL和SR必定是黑色的非NIL节点。此时可以先对P做左旋操作,因为P是黑色的,S是红色的,所以需要重新着色,将P设置为红色,S设置为黑色。然后我们再来观察节点C,发现C的兄弟变成了SL,而SL是黑色的,很显然我们已经将C的兄弟S为红色的情况转换成了S是黑色的情况,根据SL的子节点X、Y的不同,会转换成2.12.22.3三种情况,如下图所示:

下面看一下Java的TreeMap的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
private void deleteEntry(Entry<K,V> p) {
// ...
// 对于严格的内部节点,使用直接后继法替换成另外两种情况
if (p.left != null && p.right != null) {
Entry<K,V> s = successor(p);
p.key = s.key;
p.value = s.value;
p = s;
} // p has 2 children

// Start fixup at replacement node, if it exists.
Entry<K,V> replacement = (p.left != null ? p.left : p.right);

if (replacement != null) {
// 情况1:删除的节点有且只有一个非NIL子节点
replacement.parent = p.parent;
if (p.parent == null)
root = replacement;
else if (p == p.parent.left)
p.parent.left = replacement;
else
p.parent.right = replacement;

p.left = p.right = p.parent = null;

if (p.color == BLACK)
fixAfterDeletion(replacement); // 这里其实只会设置replacement为黑色
} else if (p.parent == null) { // return if we are the only node.
root = null;
} else { // No children. Use self as phantom replacement and unlink.
// 情况2: 删除的节点没有非NIL子节点
if (p.color == BLACK)
fixAfterDeletion(p);

if (p.parent != null) {
if (p == p.parent.left)
p.parent.left = null;
else if (p == p.parent.right)
p.parent.right = null;
p.parent = null;
}
}
}

下面是删除后的平衡操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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));

// 情况2.4:C的兄弟S为红色
if (colorOf(sib) == RED) {
setColor(sib, BLACK);
setColor(parentOf(x), RED);
rotateLeft(parentOf(x));
sib = rightOf(parentOf(x));
}

// 情况2.3:C的兄弟S为黑色,且S的左右孩子都为黑色
if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
setColor(sib, RED);
x = parentOf(x);
} else {
// 情况2.2:C的兄弟S为黑色,且S的右孩子SR为黑色,且SL为红色
if (colorOf(rightOf(sib)) == BLACK) {
setColor(leftOf(sib), BLACK);
setColor(sib, RED);
rotateRight(sib);
sib = rightOf(parentOf(x));
}
// 情况2.1:C的兄弟S为黑色,且S的右孩子SR为红色
setColor(sib, colorOf(parentOf(x)));
setColor(parentOf(x), BLACK);
setColor(rightOf(sib), BLACK);
rotateLeft(parentOf(x));
x = root;
}
} else { // symmetric
// ......
}
}

setColor(x, BLACK);
}

红黑树和2-3-4树的关系

2-3树、2-3-4树以及B类树都是常见的多路查找树,它们通过分裂和合并节点来维持绝对的平衡(这类树的根节点到所有叶子节点的简单距离都相等)。当然本文的重点不在此,这里只是提一下红黑树和2-3-4树的关系。
红黑树其实就是从2-3-4树演变过来的,我们将一般红黑树的黑色节点和其红色子节点合并为一个节点后,就能得到一颗标准的2-3-4树,类似的左倾红黑树和右倾红黑树都能转换为2-3树,这里就不展开讨论了。
下图就是本文最开始展示的那颗红黑树的2-3-4树形态。

本文标题:数据结构进阶篇-红黑树

文章作者:山坡杨

发布时间:2019年04月21日 - 10:15:28

最后更新:2019年04月24日 - 09:37:29

原始链接:http://www.yangxf.top/12/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

感觉本站内容不错,读后有收获?
0%