红黑二叉查找树是一种数据结构,即为实现2-3树而存在。对于之前的2-3树的插入算法并不难理解,下面将介绍红黑二叉查找树的简单数据结构来表达并实现它。实现红黑二叉查找树的意义在于能够二叉查找树中简洁高效的查找方法和2-3树中中高效的平衡插入算法结合起来。
1、替换3-结点
红黑二叉查找树背后的基本思想使用标准的二叉查找树(完全由2-结点构成)和一些额外的信息(替换3-结点)来表示2-3树。我们将树的链接分为两种类型:红链接将两个2-结点连接起来构成一个3-结点,黑链接则是2-3树中的普通链接。确切的说,我们将3-结点表示为由一条左斜的红色链接相连的两个2-结点。
这种表示法的一个优点是,我们无需修改就可以直接使用标准二叉查找树的get()方法。
对于任意的2-3树,只要对结点进行转换,我们都可以立即派生出一棵对应的二叉查找树,我们用这种方式表示2-3树的二叉查找树成为红黑二叉查找树(简称红黑树)。
2、一种等价的定义
红黑树的另一种定义是含有红黑链接并满足下列条件的二叉查找树:
- 红链接均为左链接。
- 没有任何一个结点同时和两条红链接相连。
- 该树是完美黑色平衡的,即任意空链接到根节点的路径上的黑链接数量相同。
满足这样定义的红黑树和相应的2-3树是一一对应的。
3、一一对应
如果我们将一棵红黑树中的红链接画平,那么所有的空链接到根节点的距离都是相同的。如果我们将由红链接相连的两个2-结点合并,得到的就是一棵2-3树。相反,如果将一棵2-3树中的3-结点画作由红色左链接相连的两个2-结点,那么不会存在能够和两条红链接相连的结点,且树必然是完美黑色平衡的,因为黑链接即为2-3树中的普通链接。
4、颜色表示
方便起见,因为每个结点都只会有一条指向自己的链接(从它的父节点指向它),我们将链接的颜色保存在表示结点的Node数据类型的布尔变量color中。如果指向它的链接是红色的,那么该变量为true,黑色则为false。如如下代码所示:
private static final boolean RED = true;
private static final boolean BLACK = false;
private class Node {
private Key key; // 值
private Value val; // 相关联的值
private Node left, right; // 左右子树
private boolean color; // 由其父节点指向它链接的颜色
private int size; // 这棵子树中结点总数
public Node(Key key, Value val, boolean color, int size) {
this.key = key;
this.val = val;
this.color = color;
this.size = size;
}
}
private boolean isRed(Node x) {
if (x == null) return false;
return x.color == RED;
}
5、旋转(保持平衡的核心)
在我们实现的某些操作中可能会出现红色右链接或者连续的红链接,但在操作完成前这些情况都会小心地旋转并修复。旋转操作会改变红链接的指向。
假设,我们有一条红色的右链接需要被转换为左链接。这个操作叫做左旋转,它对应的方法接受一条指向红黑树中的某个结点的链接作为参数。假设被指向的结点的右链接是红色的,这个方法会对树进行必要的调整并返回一个指向包含同一组键的子树且其左链接为红色的根节点的链接。具体步骤,可以参考下图。
左旋转
右旋转
6、在旋转后重置父节点的链接
无论左旋还是右旋,旋转操作都会返回一条链接,我们总是可以用rotateRighr()和rotateLeft()的返回值重置父节点(或是根节点)中相应的链接。通过转换,可能会出现其他情况:比如产生两条连续的红链接。这不用担心,我们的算法会继续用旋转修正这种情况。
7、向单个2-结点中插入新键
一棵只含一个键的红黑树只含由一个2-结点,插入另一个键之后,我们马上就需要将它们旋转。
如果新键小于老键,我们只需要增加一个红色结点即可,新的红黑树和单个3-结点完全等价。
如果新键大于老键,那么新增的红色结点将会产生一个红色的右链接。我们需要使用 root=rotateLeft(root);来将其旋转为红色左链接并修正根节点链接,插入操作才算完成。
8、向树底部的2-结点插入新键
用和二叉查找树相同的方式向一棵红黑树中插入一个新键会在树的底部新增一个结点,但总是用红链接将新节点与它的父节点相连。
如果它的父节点是一个2-结点,那么上面讨论的两种方式仍然有效。
如果指向新结点的是父节点的左链接,那么父节点就直接成为一个3-结点;
如果指向新结点的是父节点的右链接,这就是一个错误的3-结点,但一次左旋就可以修正它。
9、向一棵双键树(即一个3-结点)中插入新键
这种情况下有可分为三种子情况:新键小于树中两键,在两者之间,大于树中两键。
- 最简单的情况就是新键大于原树中的两个键,因此它被连接到3-结点的右链接。此时树是平衡的,根结点为中间大小的键,它有两条红链接分别和较小和较大的结点相连。如果我们将两条链接的颜色都由红变黑,那么我们得到一棵由三个结点组成,高度为2的平衡树。
- 如果新键小于原树中的两个键,它会被链接到最左边的空链接,这就产生了两条连续的红链接,我们只需将上层的红链接右旋即可得到第一种情况。
- 如果新键介于原树中的两个键之间,这又会产生两条连续的红链接,一条红色左链接接一条红色右链接,我们只需下层的红链接左旋即可得到第二种情况。
10、颜色转换
我们专门用一个方法flipColor()来转换一个结点的两个红色子节点的颜色。除了将子节点的颜色由红变黑之外,我们同时还需要将父结点的颜色由黑变红。这项操作的重要性质在于它和旋转操作一样是局部变换,不会影响整棵树的黑色平衡性。
11、根节点总是黑色的
颜色转换会是根节点变为红色。这也可能出现在很大的红黑树中。严格来说,红色的根节点说明根节点是一个3-结点的一部分,但实际情况并不是这样。因此我们在每次插入后都会将根节点颜色设为黑色。
//红黑树的插入算法
public void put(Key key, Value val) {
if (key == null) throw new IllegalArgumentException("first argument to put() is null");
if (val == null) {
delete(key);
return;
}
root = put(root, key, val);
root.color = BLACK;
// assert check();
}
private Node put(Node h, Key key, Value val) {
if (h == null) return new Node(key, val, RED, 1);
int cmp = key.compareTo(h.key);
if (cmp < 0) h.left = put(h.left, key, val);
else if (cmp > 0) h.right = put(h.right, key, val);
else h.val = val;
// fix-up any right-leaning links
if (isRed(h.right) && !isRed(h.left)) h = rotateLeft(h);
if (isRed(h.left) && isRed(h.left.left)) h = rotateRight(h);
if (isRed(h.left) && isRed(h.right)) flipColors(h);
h.size = size(h.left) + size(h.right) + 1;
return h;
}
删除操作:
在了解删除前,我们需要先了解一下2-3-4树的插入算法,2-3-4树中允许出现之前未出现过的4-结点。它的插入算法沿查找路径向下进行变换是为了保证当前结点不是4-结点(这样树底才有空间来插入新的键),沿着路径向上进行变换是为了将之前创建的4-结点配平。
当删除一个3-结点时,我们可以直接删除。
而删除2-结点时则不然。当从2-结点删除一个键时会留下一个空结点。 如果我们将它替换为一个空链接,这样的话会破坏树的完美平衡性。
为了保证我们不会删除一个2-结点,我们沿着左链接向下变换,确保当前结点不是2-结点。首先根节点可能有两种情况。
如果根是2-结点且它的两个子节点都是2-结点,我们可以直接将这三个结点变成一个4-结点;否则我们需要保证根节点的左子结点不是2-结点,如果有必要可以从它右侧的兄弟结点“借”一个键来。通过如此,我们可以得到一个3-结点或者4-结点,然后我们就可以直接将其删除。
性质总结