Algorithm——红黑树
《算法导论》介绍了红黑树这种重要的数据结构。红黑树是一颗二叉搜索树,它在每个节点上增加了一个存储位来表示节点的颜色(RED or BLACK)。通过对任何一条从根到叶子节点的简单路径上各个节点的颜色进行约束,红黑树确保没有一条路径会比其他路径长出2倍,因而是近似平衡的。
根据《导论》中的介绍,红黑树中的节点对象相比之前二叉搜索树中的节点对象多了一个表示颜色属性的color成员。如果一个节点没有子节点或父节点,则该节点相应的指针属性值都为NIL。在红黑树中,我们可以把这些NIL视为指向二叉搜索树的叶节点(外部节点)的指针,把带关键字的节点视为树的内部指针。此处引用的NIL,我们可以将它看做是一个红黑树中的普通节点,但它的各属性值较为特殊:NIL节点的color属性必为BLACK,而其他属性(诸如父节点p,左右孩子,key)的取值并不重要,可以设为任意值。我们不会把NIL节点当做是红黑树中的有意义的节点。红黑树中有意义的节点只会是内部节点,NIL节点只用来确定红黑树的边界范围。
如图所示,它是一株红黑树:
红黑树是满足如下红黑性质的二叉搜索树:
- 每个节点或是红色的,或是黑色的
- 根节点是黑色的
- 每个叶节点(NIL)是黑色的(该节点的作用可以看做是为了填充;并且为了简化,后续的图示都不会标出该节点)
- 如果一个节点是红色的,则它的两个子节点都是黑色的
- 对每个节点,从该节点到其所有后代叶节点的简单路径上,均包含相同数目的黑色节点
在使用了NIL节点之后,我们就可以将节点x的NIL孩子视为一个普通节点,其父节点为x。在一株红黑树中,从某个节点x出发(不含该节点)到达一个叶节点的任意一条简单路径上的黑色节点个数称为该节点的black-height(黑高)属性。根据性质5,黑高的概念是明确定义的,因为从该节点出发的所有下降到其叶节点的简单路径个黑节点个数相同。于是定义红黑树的黑高为其根节点的黑高。在实际实现代码时,我们会定义一个NIL节点,以保持实现与《导论》中的伪代码尽量一致,便于理解。
《算法导论》得出了一个关于红黑树的引理:
- 一颗有n个内部节点的红黑树的高度至多为2lg(n+1)
红黑树作为一种效率较高的数据结构,它主要涉及的操作有:
- 节点插入
- 节点删除
- 因为节点的插入与删除操作会改变红黑树的结构,为了保持新树不违反红黑树的5个特性,引入了节点的旋转操作
下面就分别介绍红黑树中节点的旋转、插入和删除操作。
一、节点的旋转
因为节点的插入和删除操作都可能会导致新树违反红黑树的5个特性,所以为了保证这些想性质,必须改变树中某些节点的颜色以及指针结构。节点的旋转操作能保持二叉搜索树的性质,它并不局限于红黑树中。节点的旋转可以分为左旋转和右旋转:
- 当在某个节点x上做左旋转时,假设它的右孩子为y而不是NIL节点。左旋以x到y的链为轴进行。它使y成为该子树新的根节点,x成为y的左孩子,y的左孩子成为x的右孩子
- 类似的,当在某个节点y上做右旋转时,它的左孩子为x而不是NIL节点;右旋以y到x的链为轴进行。它使x成为该子树新的根节点,y成为x的右孩子,x的右孩子成为y的左孩子
具体图示如下所示:
二叉搜索树上的旋转操作。图中字母α、β和γ代表任意的子树。旋转操作保持了二叉搜索树的性质:
α的关键字在x.key之前,x.key在β的关键字之前,β的关键字在y.key之前,y.key在γ的关键字之前
我们首先先定义实现红黑树所需的两个对象类型:
/*
* RedBlackTree Node color flag
*/
enum COLOR {
RED, BLACK
}
/*
* Node class in RedBlackTree
*/
class Node {
public Node() {
p = left = right = NIL;
key = Integer.MIN_VALUE;
color = COLOR.BLACK;
}
public Node(int key) {
p = left = right = NIL;
this.key = key;
color = COLOR.BLACK;
}
Node p;
Node left;
Node right;
int key;
COLOR color;
@Override
public String toString() {
// TODO Auto-generated method stub
return "key:" + key + " color:" + (color == COLOR.BLACK ? "black" : "red");
}
}
public final Node NIL = new Node();// 红黑树的叶子节点,key/p/left/right等属性为任意值;color为BALCK
public Node root = NIL;// 红黑树的根节点
综合上述的介绍,我们可以写出节点的左旋代码了:
/*
* 在x节点上做左旋转
*/
public void leftRoate(Node x) {
Node y = x.right;// y是x的右孩子
x.right = y.left;// 根据左旋的概念,y的左孩子将是x的右孩子
if (y.left != NIL)// 如果y的左孩子存在,则它的双亲节点将是x
y.left.p = x;
y.p = x.p;// y将成为新子树的根节点
if (x.p == NIL)// 如果x的双亲节点为null,则它为根节点;重置root为y
root = y;
else if (x == x.p.left)// 否则,如果原先x是它的双亲节点的左子树,则更新y替换x
x.p.left = y;
else
x.p.right = y;// 同理
y.left = x;// 左旋,x将成为y的左孩子
x.p = y;// x的双亲节点将是y
}
同理,右旋代码实现为:
/*
* 在y节点上做右旋转
*/
public void rightRoate(Node y) {
Node x = y.left;// x是y的左孩子
y.left = x.right;// 根据右旋的概念,x的右孩子将是y的左孩子
if (x.right != NIL)// 如果x的右孩子存在,那么它的双亲节点将是y
x.right.p = y;
x.p = y.p;// x将成为新子树的根节点
if (y.p == NIL)// 如果y的双亲节点为null,则它是根节点;重置root为x
root = x;
else if (y == y.p.left)// 如果原先y是其双亲节点的左子树,则更新x替换y
y.p.left = x;
else
y.p.right = x;// 同理
x.right = y;// 右旋,y将成为x的右孩子
y.p = x;// y的双亲节点将是x
}
二、节点的插入
由于红黑树本身就是一株二叉查找树,所以它的节点插入操作与二叉查找树很类似;最大的区别就是需要做保持新树的红黑性质,因为节点插入是会改变树的结构的。根据《算法导论》中的伪代码,我们可以得出节点插入的实现:
/*
* 红黑树节点的插入操作
*/
public void rbNodeInsert(Node z) {
Node y = NIL;
Node x = root;
while (x != NIL) {// 因为红黑树是一种二叉搜索树,插入节点的操作与二叉搜索树类似,先找到z需要插入的位置的双亲节点y;
y = x;
if (z.key < x.key)
x = x.left;
else
x = x.right;
}
z.p = y;// 此时y即将是节点z的双亲
if (y == NIL)// 只有根节点的双亲才能是NIL
root = z;// 重置根节点为z
else if (z.key < y.key)
y.left = z;
else
y.right = z;
// z是新插入的节点,根据红黑树的性质,将z的左右孩子都置为NIL
z.left = NIL;
z.right = NIL;
z.color = COLOR.RED;// 新插入节点的color默认置为RED
// 上一步默认将z.color置为RED,可能会违反红黑树性质(2)或者(4);调用rbInsertFixUp()保持整棵树的红黑性质
rbInsertFixUp(z);
}
/*
* 保持红黑树的红黑性质
*/
private void rbInsertFixUp(Node z) {
while (z.p.color == COLOR.RED) {
if (z.p == z.p.p.left) {
Node y = z.p.p.right;// y是z的双亲的兄弟节点,即z的叔叔节点
if (y.color == COLOR.RED) {// 叔叔节点是红色
z.p.color = COLOR.BLACK; // case 1
y.color = COLOR.BLACK; // case 1
z.p.p.color = COLOR.RED; // case 1
z = z.p.p; // case 1
} else {
if (z == z.p.right) {// 叔叔节点是黑色,且z是右孩子
z = z.p; // case 2
leftRoate(z); // case 2
}
// 叔叔节点是黑色,且z是左孩子
z.p.color = COLOR.BLACK; // case 3
z.p.p.color = COLOR.RED; // case 3
rightRoate(z.p.p); // case 3
}
} else {// z.p == z.p.p.right
Node y = z.p.p.left;
if (y.color == COLOR.RED) {
z.p.color = COLOR.BLACK; // case 1
y.color = COLOR.BLACK; // case 1
z.p.p.color = COLOR.RED; // case 1
z = z.p.p; // case 1
} else {
if (z == z.p.left) {
z = z.p; // case 2
rightRoate(z); // case 2
}
z.p.color = COLOR.BLACK; // case 3
z.p.p.color = COLOR.RED; // case 3
leftRoate(z.p.p); // case 3
}
}
}
root.color = COLOR.BLACK;
}
为了理解上述代码的执行过程,我们以书中的图示为例说明:
z是当前新插入的节点。代码中,我们是假定了z的颜色是RED的。为什么呢,这样假设的主要原因是为了减少插入节点z后的新树所违反的红黑性质的个数;违反的红黑性质越少,代码实现就越明了。因为NIL节点一定是BLACK,所以此时可能会违反的只会是性质(2)和性质4。对于违反性质(2),原因是因为z是根节点且是红节点;对于违反性质(4),原因是z是红色且z.p也是红色。
现在来看rbInsertFixUp(Node z)函数的实现过程:
- while循环的状态初始化:rbNodeInsert(Node z)是从一颗正常的红黑树开始,并新增一个红节点z的。如果z.p是根节点,那么在调用FixUp()函数前,z.p是黑节点,并会保持不变。注意到在调用FixUp()时,新的红黑树只会改变性质2或4。如果此时违反的是性质2,那新增节点z就是当前的红色root节点,它是树中唯一的内部节点。如果此时违反的是性质4,考虑NIL,则违反必然是因为z和z.p都是红色。
- while循环的终止:while循环如果终止,则z.p是黑色的。如果z是根节点,考虑NIL,没有违反红黑性质。考虑while的循环条件可知唯一可能不成立的是性质2。所以,while循环结束之后强制恢复根节点状态,以保持红黑性质。
- while循环的保持:while循环的处理主要分了两个部分,之间的区分依据是z的父节点z.p是z的祖父节点z.p.p的左孩子还是右孩子。外if部分给出的是z.p是左孩子时的代码,这部分逻辑的处理刚好对应上图的示例讲解。当z.p是右孩子时的代码(对应外if的else部分)的示例图解,看下图即可;它的处理与z.p是左孩子时的处理刚好对称。
从FixUp()的代码处理可知,整个情况的处理分了三种情况:
- case 1:z的叔叔节点y是红色的
- case 2:z的叔叔节点y是黑色的,且z是一个右孩子
- case 3:z的叔叔节点y是黑色的,且z是一个左孩子
具体的分析过程通过上面的图示,具体分析即可。
三、节点删除
与插入操作相比,节点的删除要复杂些。节点删除的代码实现:
/**
* 中序遍历
*
* @param x
* 节点x是要开始遍历的子树的根节点
*/
public void inorderTreeWalk(Node x) {
if (x != NIL) {
inorderTreeWalk(x.left);
System.out.println(x);
inorderTreeWalk(x.right);
}
}
/*
* 根据二叉查找树的性质,key最小的节点一定在左子树中
*/
public Node rbTreeMinimum(Node x) {
while (x.left != NIL)
x = x.left;
return x;
}
/*
* 根据二叉查找树的性质,key最大的节点一定在右子树中
*/
public Node rbTreeMaximum(Node x) {
while (x.right != NIL)
x = x.right;
return x;
}
/*
* 子树的转换,v将替换u成为u.p的孩子
*/
private void rbTransplant(Node u, Node v) {
if (u.p == NIL)
root = v;
else if (u == u.p.left)
u.p.left = v;
else
u.p.right = v;
v.p = u.p;
}
/*
* 红黑树的删除
*/
public void rbDelete(Node z) {
Node y = z;// y记录要被删除的节点或者即将移至树内的节点
COLOR yOrigColor = y.color;// 记住被删除节点的颜色
Node x;// x保存y的唯一子节点,或者指向NIL
// 因为红黑树本是一颗二叉查找树,先按照二叉查找树的方式删除节点z
if (z.left == NIL) {// 节点z的孩子节点树少于2时,直接将它存在的孩子节点与z进行交换即可
x = z.right;
rbTransplant(z, z.right);
} else if (z.right == NIL) {// 节点z的孩子节点树少于2时,直接将它存在的孩子节点与z进行交换即可
x = z.left;
rbTransplant(z, z.left);
} else {// 如果z有两个孩子节点,那么代替z的应是它的后继节点y
y = rbTreeMinimum(z.right);
yOrigColor = y.color;
x = y.right;
if (y.p == z)
x.p = y;
else {
rbTransplant(y, y.right);
y.right = z.right;
y.right.p = y;
}
rbTransplant(z, y);
y.left = z.left;
y.left.p = y;
y.color = z.color;
}
// 节点删除可能会改变树的红黑性质
if (yOrigColor == COLOR.BLACK)
rbDeldetFixUp(x);// 保持红黑树的性质
}
/*
* 删除操作后,保持红黑树的性质
*/
private void rbDeldetFixUp(Node x) {
while (x != root && x.color == COLOR.BLACK) {
if (x == x.p.left) {
Node w = x.p.right;
if (w.color == COLOR.RED) {//case 1
w.color = COLOR.BLACK;
x.p.color = COLOR.RED;
leftRoate(x.p);
w = x.p.right;
}
if (w.left.color == COLOR.BLACK && w.right.color == COLOR.BLACK) {//case 2
w.color = COLOR.RED;
x = x.p;
} else {
if (w.right.color == COLOR.BLACK) {// case 3
w.left.color = COLOR.BLACK;
w.color = COLOR.RED;
rightRoate(w);
w = x.p.right;
}
w.color = x.p.color;//case 4
x.p.color = COLOR.BLACK;//case 4
w.right.color = COLOR.BLACK;//case 4
leftRoate(x.p);//case 4
x = root;//case 4
}
} else { // x == x.p.right
Node w = x.p.left;
if (w.color == COLOR.RED) {
w.color = COLOR.BLACK;
x.p.color = COLOR.RED;
rightRoate(x.p);
w = x.p.left;
}
if (w.right.color == COLOR.BLACK && w.left.color == COLOR.BLACK) {
w.color = COLOR.RED;
x = x.p;
} else {
if (w.left.color == COLOR.BLACK) {
w.right.color = COLOR.BLACK;
w.color = COLOR.RED;
leftRoate(w);
w = x.p.left;
}
w.color = x.p.color;
x.p.color = COLOR.BLACK;
w.left.color = COLOR.BLACK;
rightRoate(x.p);
x = root;
}
}
}
x.color = COLOR.BLACK;
}
/*
* 返回根节点
*/
public Node getRoot() {
return root;
}
与实现对应,rbDelete()函数的伪代码如下:
红黑树的节点删除操作与之前描述的二叉搜索树的节点删除操作有相同的基本结构,不同点主要如下:
与实现对应,rbDeleteFixup()函数的伪代码实现如下:
rbDeleteFixup()函数的整个while循环是为了将额外的黑色节点沿树上移,直到:
- x指向红黑节点,此时函数最后一步将x设置成黑色
- x指向根节点,此时可以简单地“移除”额外的黑色
- 执行适当的旋转和重新着色,退出循环
while循环中,x总是指向一个具有双重黑色的非根节点。首先,我们要判断x是其父节点x.p的左孩子还是右孩子。保持指针w指向x的兄弟。由于节点x是双重黑色的,故w不可能是NIL,因为否则,从x.p至(单黑色)叶子w的简单路径上的黑节点个数就会小于从x.p到x的简单路径上的黑节点数。
下图给出了代码中的4中情况:
红黑树的删除确实挺复杂的,看的也是有点懵逼;此处做些记录。更详细的分析,还是参考《算法导论》中的介绍理解~