前言
在作为铺垫的第一篇与第二篇中我们分别介绍了 BST 和 2-3 查找树,本篇我们就要来介绍我们的主角红黑树了,如果你能够理解前两篇所说的内容,那么在这里你只需要在多花一点点的时间就能理解红黑树了,那么就让我们开始把。
文中所涉及的代码均可在 Github 找到,笔者水平有限,如有纰漏请不吝指出。
2-3 查找树与红黑树
定义红黑树
与网络上所查阅的大部分资料不同 (至少是我查阅的),本文中的红与黑指的并不是节点的颜色,而是链接的颜色,具体来说我们在红黑树中有两类链接:红链接将两个 2-结点连起来形成一个 3-结点,特别的是这里所有的红链接都是左斜的 (这一点需要特别关注),而黑链接就是一个普通的链接。引入两种链接并加上以下的定义我们就可以将一个 BST 等价为我们的 2-3 查找树:
- 红链接均是左链接
- 没有两个节点同时和两条红链接相连
- 该树是完美黑平衡的,及任意空结点到根节点路径上的黑链接数量相同
理解对于红与黑定义在链接上很重要这样可以更方便我们将 2-3 查找树和红黑树进行一个统一比较,但实际实现中我们的属性都是定义在 Node 中的,因此我们可以变更一下将颜色定义到节点上,由于一个结点只会有一个链接指向它因此我们可以将指向它的链接的颜色看做结点的颜色,由此我们的红结点即是被一个红链接指向的节点,而黑结点同样如此,在定义了结点的颜色后我们可以进一步给出两个性质:
- 根结点是黑色的
- 所有的空结点是黑色的
红黑树与 2-3 查找树的关联
从上文所述可以发现与我们上一篇所说的 2-3 查找树本质是相同的甚至通过将红链接拉平的方法我们可以让红黑树看起来与 2-3 查找树一模一样 (见下图),我们将红链接 (图中颜色已经并入结点) 拉平从而就可以得到一颗完美平衡的 2-3 查找树。
同样的,在 2-3 查找树插入新结点时保存平衡的那些机制在红黑树中也可以有完全等价的实现,我们马上就会看到它们。
红黑树实现
结点定义
根据上文的叙述,红黑树的结点比起 BST 而言只不过是多了一个 color
属性而已因此我们可以很方便的将之前的代码搬过来,为了表示颜色已经查询颜色我们额外定义两个变量以及一个简单的方法这些都非常好理解。
public class RBTree<Key extends Comparable<Key>, Value> {
private static final boolean RED = true;
private static final boolean BLACK = false;
/**
* 红黑节点,被红连接指向的节点为红节点
*/
private class Node{
Node left, right;
int N;
Key key;
Value val;
boolean color;
Node(Key key, Value val, int n, boolean color) {
N = n;
this.key = key;
this.val = val;
this.color = color;
}
}
private boolean isRed(Node node){
if( node == null ){
return false;
}
return node.color == RED;
}
}
get() 方法
由于红黑树只是一颗多了一些特别机制的 BST 所以 get() 方法可以完全照搬 BST 的,这里也就不多说什么了。
// 递归实现
public Node getNode(Key key){
return get(root, key);
}
private Value get(Node node, Key key){
if( node == null ){
return null;
}
int cmp = key.compareTo(node.key);
return cmp < 0 ? get(node.left, key) :(cmp == 0 ? node.val : get(node.right, key));
}
// 非递归实现
public Node getNode(Key key){
Node node = root;
while (node != null){
int cmp = key.compareTo(node.key);
if( cmp == 0 ){
return node;
}else if( cmp < 0 ){
node = node.left;
}else if( cmp > 0 ){
node = node.right;
}
}
return null;
}
put() 方法
和 2-3 查找树一样 put() 方法是保持平衡的关键,在插入的过程中我们难以避免的会破坏树的平衡性,但是红黑树通过其机制可以来消除不平衡让我们的回到完美的状态,当然消除不平衡的状态前我们首先要甄别一下那些状态会导致不平衡。问题的答案很简单打破我们红黑树定义的状态就会导致不平衡,还记得我们在概述中定义的前两条吗,1,红链接均是左链接;2,任意结点不会与两条红链接相连,我们只要始终让我们的结点保存这两条定义的状态我们就可以保存红黑树的完美黑平衡。
修复红色右链接
我们先来看会破坏我们定义 1 的情况,出现红色的右链接,由于我们插入的结点都是红色的 (这很好理解,如果插入的结点都是黑色的那么就变成一颗 BST 了)因此当我们插入的 key 大于当前的 key 时就会插入其右子树,这时就出现了红色右链接,就如下图状态1所示,这种情况下我们只需要通过左旋操作就可以将其修复,具体来说就是将当前结点 (图中的 5) 变为其右结点 (图中的 4) 的左子树并将此节点的颜色赋予右结点然后将该节点变红,这样我们就完成了一次最简单情况的左旋。
当然不会只有插入的时候会出现红色右链接,就像我们在 2-3 查找树中所讨论的,在插入的过程中分解过程会向上传递,因此当然这里的 4 可能会出现由黑变红的情况因此我们需要一种更普遍的左旋即可以带子树的,这种扩展也非常简单,我们只需要在执行上述的操作之前将右结点(图中的 4) 的左子树 (图中的蓝色三角) 链接到该结点(图中的5)的右链接上即可,具体见下图:
代码实现其实也非常简单,但是我们需要清楚的理解这一过程private Node rotateLeft(Node node){
Node right = node.right;
node.right = right.left;
right.left = node;
right.color = node.color;
node.color = RED;
right.N = node.N;
node.N = 1 + size(node.left) + size(node.right);
return right;
}
对于右旋操作只需要将 left
和 right
对应交换即可:
private Node rotateLeft(Node node){
Node right = node.right;
node.right = right.left;
right.left = node;
right.color = node.color;
node.color = RED;
right.N = node.N;
node.N = 1 + size(node.left) + size(node.right);
return right;
}
修复两个红链接
上一节我们用左旋操作修复了右边链接,而且幸运的是对于出现红色右链接的情况我们只需要一个左旋就够了,接下来我们再来看看第二种破坏我们定义的情况一个结点出现两个红链接,这种情形下会出现两种情况,1,某个结点的左右子树都是红结点,2,某个结点的左孩子以及左孩子的左孩子都是红结点。
我们先来看第一种情况:某个结点的左右结点都是红结点,这种情况我们可以直接的等价为 2-3 查找树的临时 4- 结点情况,还记得我们是怎么做的吗,我们将 4-结点中间的结点取出来成为新的根结点然后将左右两个结点分别变为其子结点,那么在这里我们已经完成了分解因此我们转换以下结点的颜色就可以了。
上图很直观的展现了 2-3 查找树的分解和红黑树变色的对照,可以发现两者是极为相似的,特别需要注意的是我们需要将新的根节点变为红色,这样才能像 2-3 查找树的分解过程那样向上传递,实现的代码非常简单:
private void flipColor(Node node){
// 我们不用显示的将 color 变为 BLACK 或 RED (当然那样也对)
// 我们只需要取反就行,可以在看完文章后思考一下为什么
node.color = !node.color;
node.left.color = !node.left.color;
node.right.color = !node.right.color;
}
说完了第一种我们来看下第二种情况:某个结点的左孩子以及左孩子的左孩子都是红结点,这种情况下我们需要右旋与变色两个操作结合来修复,我们首先使用右旋将这条红色链转变成上图中右上角的情况,然后再使用变色就可以将其修复,具体可以看下图:
至此我们已经了解了关于修复结点的三个操作的全部内容,然后我们只需要在每次插入结点后以此执行以下三个步骤就可以始终保持平衡:
- 如果该结点的右结点是红结点则左旋
- 如果该结点的左孩子以及左孩子的左孩子都是红结点则右旋
- 如果该结点的左右孩子都是红结点则变色
OK,至此我们的 put() 方法以及呼之欲出了,只需要在 BST 的 put() 加上这三个操作就可以完成平衡具体看代码:
private Node put(Node node, Key key, Value value){
if( node == null ){
// 如果查找不到则创建一个新节点
return new Node(key, value ,1, RED);
}
// 如果能查找到则更新节点的 value 值
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.val = value;
}
return balance(node);
}
// 之后也会用到这三个操作因此我们将其封装为 balance() 方法
private Node balance(Node node){
// if (isRed(node.right)) node = rotateLeft(node);
// 红色的右节点
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) ) {
flipColor(node);
}
node.N = size(node.left) + size(node.right) + 1;
return node;
}
接下来给出一个完整展示三个插入过程的图来帮助理解,读者也可以自己模拟一些场景然后在插入新结点之后进行上述三个操作。
OK,至此我们的 put() 操作就结束啦,不过可能有读者会想问红黑树的插入过程不是需要讨论很多种情况的吗?确实是如此我之前查阅资料也发现是这样,但是我们实际上并不需要了解哪些情况到底是怎么回事,我们只需要每次插入结点后以此执行三个操作就一定可以得到平衡的结果,因为这三个操作涵盖了所有的情况,如果想要知道插入中我们会遭遇的各种情况可以自行阅读《算法》中相关内容,其中提供了非常详细且易于理解的叙述。
delete()
红黑树的 delete() 方法非常复杂,但我们依然可以利用 2-3 查找树来帮助我们立即其工作原理,这也是我没有在上一篇讲删除的原因,在这里一起比较能更帮助理解。
deleteMin()
与 BST 一样我们从简单一些的删除最小键入手,要在 2-3 查找树中删除一个键会分为两种情况:1,要删除的键在 3-结中,这样我们可以直接将其删除而不会影响树的平衡;2,要删除的键在 2-结点中,这种情况下我们就不能直接删除因为哪样会影响树的平衡因此我们必须确保待删除的键不是一个 2-结点,现在我们从根结点出发来探讨一下如何做才能保证待删除的键处于 3-结点中。
在根结点处我们会遭遇以下这些情况:
-
根结点的左子节点不是 2-结点,这种情况我们可以直接将其删除,对比到我们的红黑树就相当于删除了一条红链接
-
根结点是 2-结点且左右子结点也都是 2-结点,这种情况下我们可以直接将三个结点合并为一个临时的 4-结点然后将最小键删除从而得到平衡的三结点,在我们的红黑树中我们可以通过变色
flipColor
来模拟合并的过程然后通过左旋来修复红色的右链接, -
根结点与其左子结点都是 2-结点而其右子结点不是 2-结点,这种情况下我们可以从右子结点中拿出一个结点来最小的结点来替换根结点然后将根结点并入左子结点从而形成一个 3-结点,这样我们就可以顺利的将其删除了,在我们的红黑树中,我们首先要对根结点的右子结点进行一次右旋操作从而将较小的那个键变为根结点的右子结点,然后再对根结点进行一次左旋就可以得到我们的 3-结点的子左子结点然后我们就可以删除啦
-
根结点不是 2-结点而子结点都是 2-结点,对于这种情况我们可以将左子结点、离左子结点最近的亲兄弟结点以及根结点中的最小键合并为一个 3-结点或 4-结点然后进行删除具体的这里就不画图红黑树的图了读者可以自己看看如何对应到红黑树上面去
OK,了解上面的四种情况接下来我们再补充两点:
- 树是一种递归结构因此我们上面讨论的根结点情况可以扩展至任意结点都是正确的,且这些变化都是局部的不会影响整棵树的平衡
- 上述的所有情况中 3-结点存在的地方都可以替换为 4-结点,更进一步的说我们需要利用临时的 4-结点来讲上面结点变化的过程不断的向下传递
可以结合代码与上述的所有内容 一同来理解:
// moveRedLeft 实现了沿路向下的过程中保证了结点的正确性
private Node moveRedLeft(Node node){
// 假设节点 node 为红色, node.left 和 node.left.left 都是黑色
// 将 node.left 或者 node.left 的子节点之一变红
// 被删除的节点必须存在于一个 3-节点或者 4-节点中
flipColor(node);
if (isRed(node.right.left)){
node.right = rotateRight(node.right);
node = rotateLeft(node);
// flipColor(node);
}
return node;
}
private Node deleteMin(Node node){
if (node.left == null ){
return null;
}
// 在当前结点进入下层递归前都需要执行一次保证结点正确性的操作
if( !isRed(node.left) && !isRed(node.left.left) ){
node = moveRedLeft(node);
}
node.left = deleteMin(node.left);
// 在递归返回时修复所有结点
return balance(node);
}
public void deleteMin(){
if ( !isRed(root.left) && !isRed(root.right) ){
root.color = RED;
}
root = deleteMin(root);
if (!isEmpty()){
root.color = BLACK;
}
}
对于 deleteMax() 而言只需要讲 left 和 right 相应的对换就可以完成但需要注意的是在 moveRedRight
中我们需要去掉 node.right = rotateRight(node.right);
这一行代码,这是由于红结点的定义导致的。
delete()
了解了删除最小和删除最大的方法后我们再结合之前 BST 删除方法中用后继结点替代当前结点的方法我们可以很简单的得到红黑树的 delete() 方法,具体见代码,结合注释很容易理解当我们要去到左子树前需要保证左子树中的结点是正确的因此需要左一次我们在 deleteMin() 中类似的操作,右子树也一样,如果我们找到了目标 key 那么就可以使用 BST 中删除方法来进行删除啦。
private Node delete(Node node, Key key){
// int cmp = key.compareTo(node.key); 不可以这样,因为后面有旋转操作,可能改变了树的结构,
if( key.compareTo(node.key) < 0){
// 在左子树中 按照类似于 deleteMin 方法将结点合并向下传递
if( !isRed(node.left) && !isRed(node.left.left) ){
// 要删除的 key 必须处在一个 3-节点 或 4-节点中,即其必须是一个红节点
node = moveRedLeft(node);
}
// 改变节点结构后当前节点的左节点在当前递归中为最小节点
node.left = delete(node.left, key);
}else{
if( isRed(node.left) ){
node = rotateRight(node);
}
if( key.compareTo(node.key) == 0 && node.right == null ){
return null;
}
if( key.compareTo(node.key) == 0 ){
// 用后继节点(右子树中的最小节点)代替当前节点
// BST 的删除方法
// 右子树中最小的节点
Node minNode = min(node.right);
node.val = minNode.val;
node.key = minNode.key;
node.right = deleteMin(node.right);
}else{
// 在右子树中 按照类似 deleteMax 方法将结点合并向下传递
if( !isRed(node.right) && !isRed(node.right.left) ){
node = moveRedRight(node);
}
node.right = delete(node.right, key);
}
}
// 在递归返回时修复所有结点
return balance(node);
}
结语
OK,红黑树系列到这里就完结啦,事实来说在实际的应用中我们并不需要知道红黑树那么多的细节只需要知道它是一种能够提供高效查询的数据结构就行了,但是在探究的过程中能够体会到思考的快乐与前人的智慧,感谢你读到这里,完。
参考
《算法》第四版本