红黑树是自平衡的二叉查找树,又称二叉B树。它可以在O(logN)时间复杂度内完成查找、增加、删除操作。红黑树是在二叉查找树基础上增加了着色和左右旋转使得红黑树相对平衡, 与AVL树相比,红黑树并不追求所有子树的高度差不超过1,而是保证从根节点到叶子节点的最长路径不超过最短路径的2倍。红黑树通过如下性质实现了自平衡:
1. 节点必须是黑色或红色;
2.根节点必须是黑色;
3.所有NIL节点都是黑色的; NIL即叶子节点下挂的两个虚节点(逻辑意义,不真实存在)
4.一条路径上不能出现相邻的两个红色节点;
5.在任意递归子树内,根节点到叶子节点的所有路径上包含相同数目的黑色节点。
总结一下,即“有红必有黑,红红不相连”。 上述性质保证了红黑树新增、删除、查找最坏时间复杂度是O(logN), 红黑树的任何旋转都可在3次内完成, 红黑树每次向上回溯的步长是2(即父亲、祖父节点)。
条件4、5保证了任意节点到叶节点的最长路径不超过最短路径的2倍。 原因如下:当某条路径最短时,必然都由黑色节点组成(条件4)。 当某条路径最长时,必然由红色和黑色节点组成; 性质5要求黑色节点总数相同,最长路径比最短路径多的就是红色节点,而性质4保证了出现红色节点后(父亲和孩子节点必须是黑色),所以红色数量等于黑色节点数量时是理论上的最长路径。 即最长路径和最短路径的区别是红色节点数量的差别。
从上图根节点到所有叶子节点的路径是56-55、 56-59-58、 56-59-83-90, 最短路径长度是2,最长路径长度是4 ,正好是2倍, 所以理论上红黑树根节点高度h<=2*log(N+1)。 常规BST操作比如查找、插入、删除的时间复杂度是O(h), 即取决于树的高度h。当我们保证树的高度始终保持在O(logN)时,所有操作的时间复杂度也能保持在O(logN)以内。
下面看红黑树的左旋、右旋操作步骤,其实挺简单的。 左旋就用右子树替换自己的位置,自己变为原右子节点的左子树,原右节点的左子树变为自己的右子树。右旋就是用左节点替换自己的位置,自己变为原左子节点的右子树,原左节点的右子树变为自己的左子树。 以左旋代码为例说明,摘自TreeMap.java
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
Entry<K,V> r = p.right; //r是当前节点p的右子树
p.right = r.left; //当前节点的右子树变为右子节点的左子树
if (r.left != null)
r.left.parent = p; //右子节点的左子树parent变为当前节点
r.parent = p.parent; //将p的父亲设置为r的父亲
if (p.parent == null)
root = r; //如果p是root节点,那么r是新的root节点
else if (p.parent.left == p)
p.parent.left = r; //p是父亲的左节点,那么r是p原父亲新的左节点
else
p.parent.right = r; //p是父亲的右节点,那么r是p原父亲新的右节点
r.left = p; //当前节点p变为右子树的左节点
p.parent = r; //当前节点的parent变为右子树
}
}
原理说起来有点绕, 看看图就明白了。 节点1、2、3表示子树, 从图中看出左旋、右旋就是把右、左节点替换自己的位置, 自己逆时针、顺时针下沉一层。
节点定义:
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left; //左子树
Entry<K,V> right; //右子树
Entry<K,V> parent; //父亲节点
boolean color = BLACK; //默认为黑色
...
}
红黑树节点定义里比普通二叉树多了个parent,因为旋转时需要向上回溯,所以要保存父亲节点的引用。
TreeMap通过put、deleteEntry实现新增、删除节点操作,下面以插入流程为例进行说明。在插入新节点前必须满足3个前提条件:
1、需要调整的新节点总是红色的;
2、如果插入新节点的父节点是黑色的,无须调整。因为依然符合红黑树的5个约束条件;
3、如果插入新节点的父节点是红色的,因为红黑树规定不能出现相邻的两个红色节点,所有进入循环判断,或重新着色,或左右旋转,最终达到红黑树的5个约束条件;退出条件如下:
while(x != null && x != root && x.parent.color==RED) {…} r
如果是根节点则退出,设置x为黑色即可;如果不是根节点且父节点是红色,则一直进行调整,直到退出循环。 TreeMap的插入操作是按Key的对比向下遍历,大于比较节点值的向右走,小于比较节点值的向左走,即按照二叉查找树的特性进行操作,并不关心节点的颜色和树的平衡,插入节点后会重新着色和旋转。 PS:TreeMap的key是唯一的,相同key会覆盖value。
此处省略TreeMap插入操作的二叉查找过程, 重点分析旋转和着色的过程。 着色原则是父亲变为黑色,爷爷变为红色。
private void fixAfterInsertion(Entry<K,V> x) {
x.color = RED; //新节点赋值为红色
//新节点是根节点或父节点是黑色,则插入红色节点不破坏红黑树性质,无须调整退出循环
//如果父节点是红色则不断向上遍历,直到父节点是黑色或到达根节点
while (x != null && x != root && x.parent.color == RED) {
//x的父节点是爷爷的左子
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
//y是x的叔叔节点,是x爷爷节点的右子节点 (第1种情况)
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
//如果右叔是红色,则调整父亲、叔叔节点为黑色,爷爷节点为红色,并将爷爷节点赋值为x继续循环(即向上回溯)
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK); //父亲节点设为黑色
setColor(y, BLACK); //叔叔节点设为黑色
setColor(parentOf(parentOf(x)), RED); //爷爷节点设为红色
x = parentOf(parentOf(x)); //使用爷爷节点继续循环
} else {
//右叔为黑色,父亲为红色
//x是父亲的右子节点
if (x == rightOf(parentOf(x))) {
//对红色的父亲节点做左旋,将父亲赋值给x。从而原x的父亲会成为x的左子
x = parentOf(x);
rotateLeft(x);
}
setColor(parentOf(x), BLACK); //父亲设为黑色
setColor(parentOf(parentOf(x)), RED); //爷爷设为红色
rotateRight(parentOf(parentOf(x))); //对爷爷进行右旋(原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 {
//x是父亲的左子
if (x == leftOf(parentOf(x))) {
//对x的父亲进行右旋,并将父亲赋值给x
x = parentOf(x);
rotateRight(x); //旋转后原x的父亲变成跟原x的同级,即旋转后x的爷爷仍然是旋转前原x的爷爷
}
setColor(parentOf(x), BLACK); //父节点设为黑色
setColor(parentOf(parentOf(x)), RED); //爷爷节点设为红色
rotateLeft(parentOf(parentOf(x))); //爷爷节点左旋(原x的爷爷)
}
}
}
root.color = BLACK; //根节点设置为黑色
}
上述代码中第一句是将新插入节点设为红色, 最后一句是将根节点设为黑色。通过代码可以看出着色、旋转有5种情况:
1、叔叔是红色, 将父亲、叔叔设置为黑色,爷爷设置为红色后继续循环;
2、叔叔是黑色,父亲是爷爷右子,自己是父亲左子, 则父亲右旋(自己转到原父亲的位置)后对爷爷左旋;
3、叔叔是黑色,父亲是爷爷右子,自己是父亲右子,则对爷爷左旋;
4、叔叔是黑色,父亲是爷爷左子,自己是父亲右子,则父亲左旋后爷爷右旋;
5、叔叔是黑色,父亲是爷爷左子,自己是父亲左子,则对爷爷右旋;
看起来有点难以理解,左旋右旋记不住。 其实就是将自己、父亲、爷爷节点旋转成三角形。简单的记法:1、 爷爷、父亲、自己在一条直线上把爷爷转成自己的兄弟节点,父亲仍然是父亲,父亲的位置变到原来爷爷的位置; 2、爷爷、父亲、自己不在一条直线上,旋转父亲节点使得爷爷、父亲、自己在一条直线上,然后旋转爷爷使原来的爷爷节点变成自己的兄弟节点。
第一种情况
爷爷、父亲、自己在一条直线上, 则直接对爷爷进行旋转, 目标是得到三角形。
第4/5中情况同3/4, 只是方向反了而已。 一句话概括旋转规则: 爷爷、父亲、自己形成三角形(按照二叉查找树的性质,左子最小、右子最大)。 爷爷、父亲、自己在一条直线上旋转1次, 爷爷、父亲、自己不在一条直线上旋转2次。
参考: 《码出高效》
画图工具:OmniGraffle