红黑树
在最坏情况下二叉查找树的性能十分糟糕,我们迫切需要一种能够所有操作都能在对数时间内完成的数据结构。接下来我们就来介绍一下一种非常常用的动态维护的平衡二叉树——红黑树。
在引入红黑树之前,我们需要了解一下2-3查找树。
2-3查找树
2-3查找树的介绍
上图是一个简单的2-3查找树。可以看出,比起普通的二叉查找树,2-3查找树多了一个3-节点。3-节点的性质也是类似于2-节点的。因此,2-3查找树的查找算法与普通二叉查找树的查找算法也是类似的(只是要对3-节点讨论)。
那么,2-3树的插入算法又要怎么实现呢?何时插入2-节点,又要何时插入3-节点呢?
发明者给出了答案:
2-3查找树的插入算法
根据插入元素的大小,查找到插入的位置:
- 如果插入的位置是一个2-节点,那么就与该节点节点形成3-节点,如下图:
- 如果插入的位置是一个3-节点,那么就与该节点形成一个4-节点,再从4-节点生长出一个新的2-节点
这个过程我们可以看出2-3查找树的生长过程:
- 向树的底部找到位置插入元素,如果插入的位置是2-节点。2-节点会与插入元素一起生长为3-节点。如果是3-节点,那么3-节点会与插入元素形成4-节点。
- 4-节点会向上生长,即中间的元素会与父节点结合。如果父节点是2-节点,就会形成3-节点。如果父节点是3-节点,那么就会形成4-节点继续生长。
2-3查找树的性质
- 局部变换:这是与AVL二叉平衡树最大的区别,2-3查找树只需要在局部完成2-3-4节点的变换即可。
- 全局有序,完美平衡:局部变换不会改变2-3树的有序性与平衡性。这是因为在插入的过程保证有序。唯一一个改变树高度的操作——4-节点的向上生长,也是左右子树同时高度+1。
在一个大小为N的2-3树中,查找和插入操作的访问的节点必然不超过lgN个。一颗含有N个节点的2-3树的高度在$[lgN,log_3N]$之间。
红黑二叉查找树
前言:替换3-节点
红黑二叉查找树的基本思想就是用标准的二叉查找树(完全由2-节点构成)和一些额外的信息(替换3-节点)来表示2-3树。由此我们将树中的链接分为两个种类:红链接将两个2-节点连接起来构成一个3-节点,黑链接则是2-3树中的普通链接。简单来说:我们将原来的3-节点表示为一条左斜的红色链接。
基于2-3树的性质以及红黑树的基本思想,我们可以给出红黑树的定义:
- 红链接均为左链接;
- 没有任何一个节点同时和两条红链接相连,即不存在4-节点;
- 该树是完美黑色平衡的,即任意空链接(即指向null的节点指针)到根节点的路径上的黑链接数量相同。
满足这些定义的红黑树和相应2-3树是一一对应的。如下图:
如上图:任意空链接到根节点的黑链接都是相同的——2条。
我们在Node类(节点)中添加color属性,代表与父节点指向它的链接的颜色:
private static final boolean RED = true;
private static final boolean BLACK = false;
private Node root;
private class Node{
Key key; //键
Value val; //相关联的值
Node left, right; //左右子树
int N; //子树中的节点总数
boolean color; //由其父节点指向它的链接的颜色
public Node(Key key, Value val, int n, boolean color) {
this.key = key;
this.val = val;
this.N = n;
this.color = color;
}
}
/**
* 判断是否是3-节点
*/
private boolean isRed(Node x){
if (x == null) {
return false;
}
return x.color == RED;
}
插入操作
因为红黑树是二叉树结构的2-3树,因此插入操作实际上是与2-3树一样的。当看到这里时,自然就想到了两个问题:
- 如前文所诉,2-3树的插入是伴随着临时的4-节点的产生的(在红黑树中表现为一个节点同时连接着两条红链接),这时我们应该如何处理?
- 在插入中,插入的元素一定与父节点成红链接(因为插入之后只有3-节点与4-节点两种情况),但是如果插入的元素比父节点的元素大,红链接就会在右边,这同样破坏了红黑树的结构,这时我们又该如何处理?
在说具体的插入操作之前,我们先介绍两种特殊的操作:旋转与颜色转换。
对于上面两个问题,我们可以总结出三种基本的特殊结构:
其他的特殊结构均可以分解为如上三种,可以拿笔画图试一下
因此我们给出三个方法用于解决这三个结构,如图:
Java实现:
/*------------------- 旋转 ------------------*/
/**
* 向左旋转
* 当h节点的右链接为红而左链接为黑,需要向左旋转
* 保证红黑树的有序性或者完美平衡性
* @param h
* @return
*/
Node rotateLeft(Node h){
Node x = h.right;
h.right = x.left;
x.left = h;
x.color = h.color;
h.color = RED;
x.N = h.N;
h.N = 1+size(h.left)+size(h.right);
return x;
}
/**
* 如果节点的左链接为红,且左子节点的左链接也为红。进行右旋
* @param h
* @return
*/
Node rotateRight(Node h){
Node x = h.left;
h.left = x.right;
x.right = h;
x.color = h.color;
h.color = RED;
x.N = h.N;
h.N = 1+size(h.left)+size(h.right);
return x;
}
/**
* 如果节点的左链接和右连接都为红,则进行颜色转换
*/
void flipColors(Node h){
h.color = RED;
h.left.color = BLACK;
h.right.color = BLACK;
}
插入算法的完整实现:
/* ------------------- 插入操作 ---------------- */
/**
* 插入键值
* @param key
* @param value
*/
public void put(Key key, Value value){
root = put(root, key, value);
root.color = BLACK;
}
private Node put(Node h, Key key, Value value) {
// 当发现合适的位置,插入元素。将2-节点转化为3-节点。3-节点转化为4-节点
if (h == null) {
return new Node(key, value, 1, RED);
}
// 通过递归找到
int cmp = key.compareTo(h.key);
if (cmp < 0) {
h.left = put(h.left, key, value);
} else if (cmp > 0) {
h.right = put(h.right, key, value);
} else {
h.val = value;
}
//通过局部的左旋右旋与颜色转化,维系红黑树的完美平衡性
//左旋:当h节点的右链接为红而左链接为黑,需要向左旋转
//右旋:如果节点的左链接为红,且左子节点的左链接也为红。进行右旋
//颜色转换:如果节点的左链接和右连接都为红,则进行颜色转换
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.N = size(h.left)+size(h.right)+1;
return h;
}
删除操作
我们首先模拟一下对底部元素的删除操作,容易发现3-节点的删除是非常简单的。但是2-节点会出现问题,无论哪个2-节点删除,红黑树都不会是完美黑色平衡。
这时我们回到原来的2-3查找树,除根节点以外所有的2-节点都是由4-节点生长而来。如果删除掉的是4-节点中的元素,也只是让4-节点退化到3-节点,而不影响红黑树的平衡性。
现在我们的问题就变成了怎么将2-节点和其他兄弟节点合并成4-节点,且不改变红黑树的结构.
回顾插入操作,4-节点是由下向上生长的.如果向让2-节点重新变为4-节点,就相当于树的“退化”,也就是从上到下将查找到的2-节点变为4-节点.同插入操作,退化成4-节点是左右子树的高度同时减一,不需要担心树的平衡性的改变.
直接考虑任意节点的删除还是很麻烦的,我们这里先对最小键的删除进行讨论:
最小键的删除
删除最小键,我们只需要保证树底没有2-节点存在,即可直接删除.
在沿着左链接向下的过程中,会有以下三种情况:
- 当前节点的左子节点不是2-节点,继续遍历
- 当前节点的左子节点是2-节点而它的亲兄弟节点不是2-节点,将左子节点的兄弟节点的一个节点移动到左子节点中
- 当前节点的左子节点和它的亲兄弟节点都是2-节点,将左子节点,父节点中的最小键和左子节点最近的兄弟节点合并成一个4-节点.
最后在树底得到一个3-节点或4-节点.
Java实现操作:
/**
* 对2-节点的左子节点进行变换
* @param h
* @return
*/
private Node moveRedLeft(Node h){
//将左子节点变为3-节点,即上图2的第一步变换(J和M之间的连接变为红链接)
flipColors(h);
//如果左节点的兄弟节点是3-节点,则通过旋转操作将兄弟节点的借一个元素过来
if (isRed(h.right.left)){
h.right = rotateRight(h.right);
h = rotateLeft(h);
}
return h;
}
/**
* 在回溯过程中,维护红黑树。保持结构的完整
* @param h
* @return
*/
private Node balance(Node h)
{
if (isRed(h.right)) {
h = rotateLeft(h);
}
if (isRed(h.left) && isRed(h.left.left)) {
h = rotateRight(h);
}
if (isRed(h.left) && isRed(h.right)) {
flipColors(h);
}
return h;
}
最小键删除的实现:
public void deleteMin() {
root = deleteMin(root);
//将根节点的颜色重新变为黑色
if (isEmpty()) {
root.color = BLACK;
}
}
private Node deleteMin(Node h) {
if (h.left==null) {
return null;
}
//如果左子节点不是3-节点,则进行变换
if (!isRed(h.left) && !isRed(h.left.left)){
h = moveRedLeft(h);
}
h.left = deleteMin(h.left);
return balance(h);
}
private boolean isEmpty() {
return root != null;
}
最大键删除
最大键的删除原理与最小键相似,只是查找与旋转的方向不同
private Node moveRedRight(Node h){
flipColors(h);
if (isRed(h.left.left)){
h = rotateRight(h);
}
return h;
}
public void deleteMax() {
if (!isRed(root.left) && !isRed(root.right)) {
root.color = RED;
}
root = deleteMax(root);
if (isEmpty()) {
root.color = BLACK;
}
}
private Node deleteMax(Node h) {
if (h.left==null) {
h = rotateRight(h);
}
if (h.right == null) {
return null;
}
if (!isRed(h.right) && !isRed(h.right.left)){
h = moveRedRight(h);
}
deleteMax(h.right);
return balance(h);
}
任意节点的删除
首先同最小键的删除一样,在查找路径上进行2-3-4树的变换,保证当前节点不是2-节点。如果查找到的键在数的底部,则可以直接删除。如果不在,我们需要将它与后继节点(在整个二叉树中正好比他大的,即右子树的最小键)交换,类似于二叉查找树。
因为当前节点必定不是2-节点,问题已经转化为在一颗根节点不是2-节点的子树中删除最小的键,我们可以在这颗子树中使用前文所述的删除最小键算法。
private Node delete(Node h, Key key) {
//如果删除节点小于当前节点,查找并维护左子树
if (key.compareTo(h.key) < 0){
if (!isRed(h.left) && !isRed(h.left.left)) {
h = moveRedLeft(h);
}
h.left = delete(h.left, key);
} else {
if (isRed(h.left)) {
h = rotateRight(h);
}
//找到节点位置,如果在树的底部,直接删除即可
if (key.compareTo(h.key)==0 && h.right==null) {
return null;
}
//如果当前节点是2-节点,向左边的兄弟节点借元素,使节点变为3-节点或4-节点
if (!isRed(h.right) && !isRed(h.right.left)) {
h = moveRedRight(h);
}
//找到节点位置,遍历得到右子树的最小值
if (key.compareTo(h.key)==0) {
h.val = get(h.right, min(h.right).key);
h.key = min(h.right).key;
h.right = deleteMin(h.right);
} else {
h.right = delete(h.right, key);
}
}
return balance(h);
}
红黑树的性质
所有基于红黑树的符号表的实现都能保证操作的运行时间为对数级别(范围查找除外,它所需的额外时间和返回的键的数量成正比)
红黑树的实现仅仅只有插入与删除较复杂。而其他方法因为不涉及节点颜色,与二叉查找树基本相同,因此使用非常广泛。
各种符号表的性能总结
数据结构 | 查找(最坏情况) | 插入(最坏情况) | 查找(平均情况) | 插入(平均情况) | 是否支持有序性相关的操作 |
---|---|---|---|---|---|
顺序查找(无序链表) | N | N | N/2 | N | 否 |
二分查找(有序链表) | lgN | N | lgN | N/2 | 是 |
二叉树查找(BST) | N | N | 1.39lgN | 1.39lgN | 是 |
2-3树查找(红黑树) | 2lgN | 2lgN | lgN | lgN | 是 |
更多详细的信息可以查看http://www.cs.princeton.edu/~... 或是《算法》
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。