红黑树
看了网上关于红黑树的大量教程,发现一个共性,给出定义,适用情况,然后大量篇幅开始讨论它如何旋转,这就一发不可收拾了,各种情况的讨论,插入删除,插入删除,看的云里雾里,好不容易搞清楚,过段时间就给忘了。本文还是着重描述红黑树的诞生过程,尽量理清它背后的设计哲学。
思考:
- 红黑树是如何动态平衡的?
- 红黑树和23树之间有什么关系?
如果这两个问题已经了然于胸,那可以直接略过此文了。当然,如果还不理解23树的动态平衡性或者说对它的概念还很模糊,请先参考博文【算法原理系列:2-3查找树】。
结构缘由
红黑树的命名很有趣,据说当时Robert Sedgewick在施乐做访问学着的时候,施乐正好发明出激光彩色打印机,然后觉得这个打印机打出的红色很好看,就用了红色来区分结点的不同,于是就叫红黑树。
它是Robert创建红黑树的小插曲,但我们从中能发现一些有趣的东西,原本树的每个结点状态是唯一的,而红黑树在设计之处就考虑到了多个状态【红和黑】,这是为什么?
来看看这张图
红黑树,从上图来看已经很明显了,一个结点为红,一个结点为黑,用来表示两种状态,既然有了红黑两个标识,那算法一定是从这两个状态上下功夫,不急,咱们此时回顾下23树中是如何实现动态平衡的。
在23树中,我们定义了2结点和3结点,而真正使它平衡的原因在于3结点,它多存储了一个结点信息,使得新结点的插入能够停留,而此时,23树有了重新分配结点,动态改造树结构的能力。所以说,多一个状态对树的平衡起了决定性的作用。现在来看看红黑树,是否就能理解它为什么多了一个红色状态呢?或许还有些困难,我们继续分析。
这是《算法》对于红黑树的定义,和网上流传的版本不太一样,但我个人觉得它更切中红黑树的要害,红色加粗现表示了什么?它的意思就是说,结点a和结点b【看上去】是不可分割的一个整体。可以对比下23树,所以说,【a+红线+b】等于23树中的【3结点】,当我看到这里时,我顿时恍然大悟,原来如此,于是我当时就想,后续的插入操作是否就和23树的插入是一样的呢?的确如此,所以理解了23树的【插入】操作,红黑树的【旋转】操作还有什么难的,本质上是一个东西。但是,凡是还是有个但是比较符合上下文,请思考:
- 为什么 这种【a+红线+b】的结构就是【3结点】了呢,有没有约束?
- 为什么是【看上去】,看上去给我们带来了什么好处?
- 为什么它是左斜的,右斜可不可以呢?
红黑树 vs 23树
要回答第一个问题,就需要了解红黑树插入结点的规则,在23树中,我们定义了3结点,它有如下特性:
- 3结点中的两个元素有顺序关系,即左侧元素a小于右侧元素b。
- 两个元素对应有三条链,分别表示为左链,中链,右链,符合如下关系。
- 左链下的所有元素均小于左侧元素a。
- 中链下的所有元素位于两元素a,b之间。
- 右链下的所有元素均大于右侧元素b。
此时,对比下红黑树的结点图,你会发现在a和b之间做了一个标记【红色】,这模拟了23树中3结点的特性,因为我们知道,单纯的树在做插入时,如果对key没有分配额外的空间,即不是Key[] keys= new Key[2];
的形式,那么新插入的结点无法停留,唯一的办法就是把它分配在它应有的位置,即普通二叉搜索树的做法。可如何表示就近相邻的两个结点看上去是一个【3结点】呢?好吧,最直观的就是在链接上做操作,标一个状态即可,然后对它做些约束就好了。这有两个好处,第一,它还是二叉树的形式,即之前的get()
查找操作是兼容这种新结构的。其次,【结点的停留】可以用一个状态来表示,这就自然能够使得红黑树有权力去动态平衡。
而大部分教材,是用红黑结点,来表示结点的状态,但本质上是一样的,【红+黑】的结点表示3结点,可以理解为由黑指向红结点的链接都是红链接,没什么区别。
现在,我们来看看《算法》书上的红色链接的定义:
- 红链接均为左链接。
- 没有任何一个结点同时和两条红链接相连。
- 该树是完美黑色平衡,即任意空链接到根结点的路径上的黑链接数量相同。
每一条都值得细细品味,切中要害。第一条,红链接均为左链接,意思是红黑树在构建生成过程当中,不允许出现右链。第一点我是不理解的,从3结点的定义来看,只有符合左侧元素小于右侧元素即可,而出现右链,也完全符合23树所定义的规则,我觉得一个原因在于,如果让红黑树中同时出现左链右链,它需要维护的情况就增多了,从代码的清晰和简洁程度上来看,是不利于理解的。
第二条是肯定的,联想23树中不能出现4结点的约束,如果一个结点左右链都是红色的,那就意味着它们整体构成了4结点,与23树模型是违背的。
第三条完美黑色平衡,如果去除颜色标记,它并不能算平衡树,而书中画了一张图,把红色链接铺平来看,黑色链接是完美平衡的,对比下23树,很容易理解。
旋转操作
上述定义都是为了让红黑树符合23树的初始定义,而接下来才是红黑树动态平衡的真正核心,插入旋转操作,完全可以类比23树的向上分裂操作。我们现在来模拟红黑树的创建过程。
注意,学习旋转操作没有必要分情况,把每一个操作搞懂之后再来看整体红黑树应该怎么构建,而是直接从脑海中开始尝试构建树,只要它的构建过程符合23树的向上分裂性质即可。很神奇的是,只要思路正确,基本算法细节了然于胸,你自己一步步写出来的代码,思路可以完全和书中旋转操作一样,当你完成整个过程再回过头来看这些操作时,你会有一种顿悟。插入操作的连续性相当重要,割裂来看只会让你更加糊涂。
红黑树构建 step.1
先前我们已经完成了二叉树的代码,我们先看看它不带操作的初始定义:
public class RedBlackBST<Key extends Comparable<Key>,Value> {
private Node root;
private class Node{
Key key;
Value value;
Node left, right;
int N;
Node(Key key, Value value, int N){
this.key = key;
this.value = value;
this.N = N;
}
}
}
很简单的定义,Key是唯一状态的,现在我们考虑如何把【红链】加入代码中,这里必须有红黑状态的区分,一个简单的想法是在Node中加入Node leftRed
和Node leftBlack
,由此我们可以判断当前的结点左链是否是红色,有如下代码
private boolean isLeftRed(Node x){
if (x == null) return false;
if (x.leftRed != null) return true;
else return false;
}
Ok,基本的代码已经完成了,我们开始模拟插入,第一次插入“b”,好,生成一个新结点,作为root。第二次插入“c”,嗯,c>b
,所以插入在b的右侧。发现问题没,你没有状态可以去表示右链的颜色,因为根据23树的定义,初始插入两个结点的情况,它应该是3结点,而因为没有右链颜色的定义,自然这种构建已经出现问题了。所以,我们应该是要定义右链的颜色的!于是有Node rightRed; Node rightBlack
,嗯,继续
private boolean isRightRed(Node x){
if (x == null) return false;
if (x.rightRed != null) return true;
else return false;
}
嗯,此时再插入第三个结点“a”,应该位于左侧,所以用红链链接,此时变成了左右链均为红链的情况,所以自然而然的要开始分裂操作了。这一切变得顺理成章。
构建过程如上图所示,很容易理解,但你发现一个问题没,对于链接的状态定义是否冗余?真正让它们状态相异的其实是结点本身,不管是leftRed
还是rightRed
最终都是指向结点的,所以我们完全没有必要用不同的链来区分,这显然增加了代码的复杂度,而是直接对Node
本身做标记。所以有了书中红黑树的初始定义,代码如下。
public class RedBlackBST<Key extends Comparable<Key>,Value> {
private enum Color{
RED,
BLACK
}
private Node root;
private class Node{
Key key;
Value value;
Node left, right;
int N;
Color color;
Node(Key key, Value value, int N, Color color){
this.key = key;
this.value = value;
this.N = N;
this.color = color;
}
public String toString() {
return "{key: "+key+", Val: "+value+"}" + " {Color: "+ (color == Color.RED ? "RED" : "BLACK")+"}";
}
}
private boolean isRed(Node x){
if (x == null) return false;
if (x.color == Color.RED) return true;
else return false;
}
}
它的好处是明显的,对结点着色,代码精简了很多。好了,该继续我们的构建了。
public void put(Key key, Value value){
//咋做咧
}
public Value get(Key key){
//忽略结点状态,和二叉搜索树代码完全一样。
}
get()
操作太好做了,忽略结点的颜色,红黑树就是个二叉搜索树,代码如下。
public Value get(Key key){
return get(root,key);
}
private Value get(Node root, Key key) {
if (root == null)
return null;
int cmp = key.compareTo(root.key);
if (cmp < 0) {
return get(root.left, key);
} else if (cmp > 0) {
return get(root.right, key);
} else {
return root.val;
}
}
红黑树构建 step.2
刚才,在对Color进行定义时,我们举了一个例子,插入结点“b”,那真正实际操作应该如何,很简单。
public void put(Key key, Value value){
root = put(root, key, value);
//注意根节点颜色的区分
root.color = Color.BLACK;
}
public Node put(Node node, Key key, Value value){
//根节点初始化是black
if(node == null) return new Node(key,value,1,Color.RED);
}
在此处,为了让新插入的元素都成为待宰的小羊羔,每当插入一个新结点时,就给它标记为热腾腾的【红色】。这也就是说,每个红色结点在插入时,都是需要待分配状态,也就是必须先停留,只有当红黑树做完分裂操作后,它才能表示为【已分配】状态,变成黑色。所以可以看到new
操作颜色传入为Color.RED
。
好了,根节点初始完毕,我们尝试插入第二个元素,插入“c”,标记为红色,并被链接到右链,代码如下。
public Node put(Node node, Key key, Value value){
//根节点初始化是black
if(node == null) return new Node(key,value,1,Color.RED);
//有新元素时
int cmp = key.compareTo(node.key);
//每插入一个节点,就对它进行着色
if(cmp < 0){ //插入到左子树
node.left = put(node.left,key,value);
}else if (cmp > 0){
node.right = put(node.right,key,value);
}
else{
node.value = value; //这种情况是不需要做任何变换的
}
//插入结点结束,做约束操作位置
}
这是一个递归操作,我们可以看到,当插入”c”时,node.right = new Node ("c",value,Color.RED)
,它是一个红色右链!由刚才的定义已知,这种状态是不允许的,所以做完插入操作,我们一定要把它的状态调一调。该怎么办呢?右链转成左链就好了,3结点元素之间的关系没有发生变化,所以这是可以的,来看图。
只需要对每个结点的链接做重定向就好了,注意,此时还是红链接。
private Node rotateLeft(Node h){
Node node = h.right;
//颜色变换
node.color = h.color; //这里的实现有点区别,h节点可黑可红,所以更好的应该是保留h的颜色
h.color = Color.RED;
h.right = node.left;
node.left = h;
return node;
}
这里要注意一个细节,图中的E结点之前的颜色需要保留,所以有node.color = h.color
,具体原因看到下面的操作你就明白了。
我们在插入操作之后,要加入判断,如果出现右链为红色的情况,则进行旋转,于是有
public Node put(Node node, Key key, Value value){
//根节点初始化是black
if(node == null) return new Node(key,value,1,Color.RED);
//有新元素时
int cmp = key.compareTo(node.key);
//每插入一个节点,就对它进行着色
if(cmp < 0){ //插入到左子树
node.left = put(node.left,key,value);
}else if (cmp > 0){
node.right = put(node.right,key,value);
}
else{
node.value = value; //这种情况是不需要做任何变换的
}
//插入结点结束,做约束操作位置
//情况1 : 刚开始构建时,出现右链为红色的情况,进行旋转
if(isRed(node.right) && !isRed(node.left)) node = rotateLeft(node);
}
所以,现在不管什么顺序的两个结点被插入后,都会被旋转成左链为红色的情况,后续的操作都是在此基础上完成,它可以分为三种情况,插入的第三键最小,位于中间,最大。
插入新建最大
想想它应该是什么样的结构,是不是就是一个最开始的平衡二叉树?看图
很明显,该状态是个非法状态,不允许左右链均为红色(4结点状态),应该和23树一样,向上分裂,而很巧,红黑树在此处的分裂就是把链接变为黑色,所以定义一个着色方法。
代码如下:
private void flipColors(Node h){
h.color = Color.RED; //向上传递
h.left.color = Color.BLACK;
h.right.color = Color.BLACK;
}
此处也有一个细节,就是结点E的颜色应该是什么,红色?为什么?所以,我们接着就应该判断左右链为红色时,进行着色。代码如下:
public Node put(Node node, Key key, Value value){
//根节点初始化是black
if(node == null) return new Node(key,value,1,Color.RED);
//有新元素时
int cmp = key.compareTo(node.key);
//每插入一个节点,就对它进行着色
if(cmp < 0){ //插入到左子树
node.left = put(node.left,key,value);
}else if (cmp > 0){
node.right = put(node.right,key,value);
}
else{
node.value = value; //这种情况是不需要做任何变换的
}
//插入结点结束,做约束操作位置
//情况1 : 刚开始构建时,出现右链为红色的情况,进行旋转
if(isRed(node.right) && !isRed(node.left)) node = rotateLeft(node);
//情况2:遇到4结点状态,对它着色,即分裂操作
if(isRed(node.left) && isRed(node.right)) flipColors(node);
}
插入新建最小
直接上图
从图中已经能看出来,它的旋转操作了,把b和c进行旋转,也就是left的你操作,所以如下图所示:
代码和左旋转完全对称:
private Node rotateRight(Node h){
Node node = h.left;
node.color = h.color;
h.color = Color.RED;
h.left = node.right;
node.right = h;
return node;
}
对应的,遇到新键最小时,先进行右旋转,然后再着色。
public Node put(Node node, Key key, Value value){
//根节点初始化是black
if(node == null) return new Node(key,value,1,Color.RED);
//有新元素时
int cmp = key.compareTo(node.key);
//每插入一个节点,就对它进行着色
if(cmp < 0){ //插入到左子树
node.left = put(node.left,key,value);
}else if (cmp > 0){
node.right = put(node.right,key,value);
}
else{
node.value = value; //这种情况是不需要做任何变换的
}
//插入结点结束,做约束操作位置
//情况1 : 刚开始构建时,出现右链为红色的情况,进行旋转
if(isRed(node.right) && !isRed(node.left)) node = rotateLeft(node);
//情况2:遇到4结点状态,对它着色,即分裂操作
if(isRed(node.left) && isRed(node.right)) flipColors(node);
//情况3:遇到新键最小,先右旋转,再着色
if(isRed(node.left) && isRed(node.left.left)) {
node = rotateRight(node);
flipColors(node);
}
return node;
}
插入新键介于两者之间
这种情况是不需要再更新代码的,在插入a和b之后,它会自动的左旋,然后就回到了新键最小的情况,所以初始的三个结点已经全部构建完成了,并且它能实现分裂!那么你会问了,当插入第四个键呢?这就要谈到递归的好处了,我们可以完全假想对刚才的旋转操作,是从根结点出发,对左子树和右子树的操作。
而红黑树是天然的自底向上分裂的,元素在向下沉到底部时,最后的3个结点自然能符合上述所有的情况,做完所有旋转操作后,随着子递归函数的结束,就不断向上传递了,而刚才强调了一些着色和旋转的细节处理,对每个操作完的结点,都需要保留之前的颜色,尤其是在flipcolor
操作时,需要把“根节点”的颜色标红,这是为了子递归结束后,把上一轮的影响向上传播到它的父结点,对比23树的向上分裂过程,还是很容易理解的。
此处代码还可以做下优化,代码如下
if(isRed(node.right) && !isRed(node.left)) node = rotateLeft(node);
if(isRed(node.left) && isRed(node.left.left)) node = rotateRight(node);
if(isRed(node.left) && isRed(node.right)) flipColors(node);
从结构上来说,它们的效果是一样的,但代码更精简。到这里,所有的操作都已经介绍完了,且跟书上的代码完全一致。回过头来,再去看那些所谓的左旋,右旋,着色,不就好理解了么。
删除操作
删除操作又是一个不逊于插入操作的复杂过程,这里就简单讲述一下思想。
删除最小键思想
因为最小键一定是在根结点的左侧的左侧的左侧…所以,我们只要判断两种情况如果,最左侧的结点是3结点,那么可以直接删除,不影响树的平衡性,而如果最左侧的结点是2结点,那么事情就麻烦了,你可能会想了,直接在它的右子树中找寻最小键代替不就好了么?但这个问题就又转变为了删除右子树的最小键,问题又回到了起点,那可能你又想了,直到找到一个不含有右子树的最小键删除不就好了么。但我们知道,如果遇到这种情况,首先它是3结点,还好,问题完美解决了,而如果不幸它是2结点,那么直接删除导致的一个最坏结构就是原先辛苦维护的红黑树黑色链的平衡性遭到了破坏,这是绝对不允许的。
它的一个思想就是,如果删除的最小键是2结点,那就从它的亲兄弟那里借一个过来,合并成3结点,那如果亲兄弟也是2结点呢?没办法了,智能从父亲结点那里坑一个过来,但坑一个过来的结果是,我必须把近亲兄弟的唯一结点也得拿过来,这就变成了一个4结点,我靠问题复杂了。但没关系,它是临时4结点,此时我们可以删除那个想要的键了,一删不就回归到了3结点么,完美解决。看图
删除操作
有了删除最小键,删除操作就已经解决了,在查找路径上进行和删除最小键相同的变换同样可以保证在查找过程中任意当前结点均不是2结点。如果被查找的键在树的地步,我们可以直接删除它。如果不在,我们需要将它和它的后继结点交换,就和二叉查找树一样,因为当前结点必然不是2结点,问题已经转化为在一棵根结点不是2结点的子树中删除最小的键,我们可以在这棵子树中使用前文所述的算法。和以前一样,删除之后我们需要向上回溯并分解余下的4结点。
性能分析
平衡和有序导致了插入和查找操作均为对数级别,且最坏情况依然如此,这部分和二叉搜索树的实现分析一致,不再赘述,直接给出书中测试数据和性能对比。
性能对比
参考文献
- Robert Sedgewick. 算法 第四版[M]. 北京:人民邮电出版社,2012.10
- Cormen. 算法导论[M].北京:机械工业出版社,2013
- 算法原理系列:查找
- 算法原理系列:2-3查找树