红黑树
我们知道对于二叉搜索树而言,无法保证树的平衡性,从而使得进行操作的时候时间复杂度在O(logn)与O(n)之间。这样是不稳定的。而2-3树则借助于3-结点和临时的4-结点,通过分解,解决了平衡问题。例如对于插入,在最终是3-结点的情况下,临时构成4-结点,然后再分解成3个2-结点,这样令树高度+1,但是整体平衡性不变。而红黑树就是为了实现自平衡这个功能而对2-3树进行了扩展。而红黑树仍然属于一棵二叉查找树,只是多了红结点与黑结点,来模仿2-结点和3-结点。所以对于二叉搜索树的方法,很多都能在红黑树上使用。而红黑树的关于自平衡的很多理论,来源于2-3树。下面是一棵简单的红黑树:
其实按照标准定义,叶子节点仍然有左右两个引用,也就是说,任何一个结点都一定有2个子树,就算这个子树是空的。而这些结点分为红色与黑色。注意看上面的箭头,也有红黑之分。或者说,我们虽然在定义Node结点的时候,认为某个节点的颜色有红黑之分,但是实际上,这个红黑是指链接是红色还是黑色。
也就是说,如果某结点的颜色是红色,是说连接到该结点的链接是红色,而根的颜色永远为黑色。
这里提一嘴,红黑树非常依赖图像演示,推荐找视频或者找各种动图来学习红黑树,能够非常快理解旋转之类的操作。
红黑树与2-3树
为什么一定要对链接区分红黑?如果就上面的图进行变换,我们不管黑色,将红色的连接平着画,可以得到这个图:
可以看到,这样来表示,树的高度是一致的,如果我们仔细看,是不是可以认为,红色链接连接的2个结点可以组合起来作为一个2-3树中的3-结点。这样,就把一棵红黑树转换成了2-3树。而我们认为2-3树是完美平衡的,那么是否说明了,红黑树也是完美平衡。
红链接将2个2-结点连接起来构成一个3-结点,黑链接则是2-3树中的普通链接。那么对于红黑树而言,仍然可以使用二叉查找树中的搜索之类的,与颜色无关的方法。而例如插入和删除,我们可以认为在处理过程中,使用2-结点、3-结点、4-结点之间的组合与删除,来完成自平衡。
《算法》书中认为:这样用红黑链接表示的2-3树,是红黑二叉查找树。
红黑树的性质
对红黑树有这样的定义:
1.红链接均为左链接。
2.两个红链接不能相连在一起。
3.红黑树是完美黑色平衡,即任意空结点到根节点所经过的黑链接完全一致。
4.空结点的链接颜色是黑色。
第一条有点疑问,因为有的资料说可以将红链接作为右链接,这个文章统一认为是只能为左。因为上面的性质,我们需要额外确定一些性质:
1.根节点的链接颜色必定为黑。这里其实无所谓,因为根没有父链接,但是结点中有color,所以就默认为黑了。
2.我们认为红黑树“有数据”的结点都不是叶子结点。叶子结点只能为null或者NIL。
既然我们认为红黑树就是2-3树的一种,那么就可以理解为什么不能两个红链接连在一起了,这就像一个临时的4-结点,我们允许其存在,但是为了保证平衡,需要分解。
红黑树的颜色
其实上面写了一点,虽然我们将颜色定义到结点中,但是只是因为我们无法对链接声明数据结构罢了,应该认为是指向该结点的链接的颜色。
在实际的代码中,可以用常量来表示红黑,调用一个方法可以判定是黑是红即可。例如:
private boolean isRed(Node node){
if(node == null) return false;
return node.color == RED;
}
写一个就行了,没必要写个isBlack。具体的实现可以看下面对红黑树的定义以及结点的定义。这里并不是写进Node类,而是RBT类中的方法,因为这样会使得后面的算法更加灵活。
红黑树的声明以及Node结点的实现
我们可以像容器一样,声明一个“红黑树”,而将Node结点作为其内部类。这样可以保证比较广泛地应用。当然也需要使用泛型,因为不可能固定数据类型。红黑树在Java中是TreeMap和TreeSet的底层,对于Map这种数据结构,我们需要使用Key-Value的结构,那么在结点中,声明两种元素就可以了,为了保证顺序,可以令Key实现comparator接口,从而保证按照Key顺序排序,同时完成Map映射关系。这样,我们可以得到这样的数据结构:
class RBT<Key extends Comparable<Key>, Value>{
private static final boolean BLACK = false;
private static final boolean RED = true;
private Node root;
private class Node{
private Key key;
private Value value;
private Node left;
private Node right;
private boolean color;
public Node(Key key,Value value){
this.key = key;
this.value = value;
this.color = RED;
}
}
}
在Node结点中,我是定义了boolean作为红黑判定,这个可以任意改变,例如int,都可以。而这两个常量是在RBT类里面而不是Node,因为我们需要在其他算法里也使用这些常量。
红黑树自平衡的操作-旋转
会AVL树的话,可能就非常简单能够理解旋转操作。旋转的作用是在进行例如插入、删除操作的时候,根据不同的情况(主要是因为平衡被破坏了),从而进行不同的旋转操作使得以及破坏平衡的树重新回归平衡。这也是我们说“自平衡”的原因。
红黑树的话,旋转其实有点类似2-3树中的结点分解与临时合并。这个等后面再分析。旋转其实就是左旋和右旋。接收一个结点,转就完事了。而不同情况下如何调用旋转来保持平衡,这是插入、删除等方法中的算法,与单纯的左右旋无关。所以下面先分析对一个结点进行左旋和右旋,怎么调用,调用几次,等到用了再分析。
可能会担心,父结点怎么办,或者旋转操作是在根上怎么办。其实无所谓,我们只用在旋转操作后返回一个旋转后的根就行了,具体怎么链接怎么操作,都与单纯的旋转算法无关。
左旋
对于一个结点左旋,其实就像是将这个结点“降级”了,让这个结点在树上变得更低,而将另一个原来比较低的结点给提上去。不管样得看图,看懂图就能理解这个算法。
这个是对结点h进行了左旋。所以左旋是必须要求有右子树,不一定有左子树。这里注意,画的是红色链接,h和x的链接是一定是红色的,因为红黑树的旋转一定是因为颜色。而连接h的链接,可能是左链接也可能是右链接,这个不是旋转的算法,我们只需要返回“拉上去”的结点x,让上层来处理。这个链接也可能为红或者为黑,那么令x.color = h.color就完美解决问题了,算法如下:
public Node rotateLeft(Node h){
Node x = h.right;
h.right = x.left;
x.left = h;
x.color = h.color;
h.color = RED;
return x;
}
右旋
与左旋类似,还是看图:
右旋是下降,其他基本和左旋一致,那么右旋的代码呼之欲出:
public Node rotateRight(Node h){
Node x = h.left;
h.left = x.right;
x.right = h;
x.color = h.color;
h.color = RED;
return x;
}
旋转后对父结点进行重置
上面的旋转,最后是返回一个结点的。主要就是因为无法确定父结点到该结点的具体链接是左是右,以及是否需要重置根。并且可能造成两条连续的红色结点——这里可以理解为2-3树的临时4-结点。需要将其分解。但是这样可以通过对旋转的多次调用解决这个问题。
旋转可以保持红黑树的有序性和完美平衡。
红黑树的插入
红黑树的插入分为多种情况,需要逐条分析,但是需要确定红黑树在插入的时候需要遵循怎样的条件。更新这个就不说了,如果在插入过程中,发现key已存在,那就更新value,这个可以忽略,主要讨论插入的问题。我们认为红黑树是黑色完美平衡,并且在看一棵红黑树的时候,将红链接放平,看成2-3树。那么假设我们在最底部插入元素,肯定是想要插入一个红色结点,这样,树的高度实际上没有任何变化,而插入黑结点使得高度+1。所以插入的链接必定是红色。
插入之前仍然是需要搜索到具体的插入位置。如果不存在树,则new出来根。最终需要找到插入点。所以先写一部分插入代码,跟正常的二叉查找树很类似,就是搜索:
public void put(Key key,Value value){
root = put(root,key,value);
root.color = BLACK;
}
private Node put(Node root,Key key,Value value){
if(root == null) return new Node(key,value);
int cmp = key.compareTo(root.key);
if(cmp == 0){
root.value = value;
}else{
if(cmp > 0){
root.right = put(root.right,key,value);
}else{
root.left = put(root.left,key,value);
}
}
}
为什么一定要令root的颜色为黑?这是为了符合性质。根节点一定是黑色(主要是后面算法,可能会导致根变红)。
插入的过程其实是类比2-3树的,可能会导致出现临时4-结点等情况,所以需要通过旋转来保持平衡,整体上的插入分为以下几种情况。
红黑树的插入-向单个黑结点插入
单个黑结点之所以单独拿出来说,是因为比较特殊——只存在根的情况。空树不考虑,这个算是特殊情况了。如上面所描述,我们认为新增结点一定是红结点,这样树高度暂时保持不变。那么对于只存在根结点的情况下,只有2种情况:
1.新结点小于根结点。
2.新结点大于根结点。
忽略相等,这样是更新。而小于根结点,那么左链接是红色完全符合红黑树性质,也就是不需要任何处理。而如果是大于,则是右链接为红色。这样其实是不符合红黑树性质的——只存在左链接为红链接。那么我们可以看一下上面两种旋转的图片:左旋会导致右红链接变成左红链接。
那么对于这种情况下,解决方法就确定了:相等,则更新根结点value。小于则new出来左子树根结点。大于则new出来右子树根结点,然后再对根结点进行左旋。
红黑树的插入-向一个黑结点插入
这个包含了上面的情况,即只存在一个根节点。但是先不考虑这个,也就是有很多结点的时候插入。如果是向一个黑结点,或者说2-结点插入,最终插入的时候仍然是必定插入红链接,但是有可能引发一系列问题。这里分析一下:
1.新结点小于父结点。
2.新结点大于父结点。
仍然忽略相等的情况。那么如果是小于,则是左链接,因为父结点是黑色,所以不会引发红黑树的不平衡。如果是右链接,那么就是出现了红色右链接,左旋即可解决。实际上跟在仅仅一个根上插入是一样的,只是多了很多其他结点。但是实际上,跟其他结点是无关的,旋转的操作仅仅影响2个结点而已,其他的全部没有变化。
但是可以认为,对一个黑结点进行插入,必定形成一个红链接。
红黑树的插入-向一个红结点插入
这个其实可以分解为3种情况,看图理解会更好:
如果将红链接想象成3-结点,那么1情况就是,插入数据小于两个值,2就是在两个值中间,3就是大于两个值。仍然是考虑新加的链接是红色的,那么3种插入情况都有会出现问题——2-3树此时产生了一个4-结点。
而2-3树解决方案是分解4-结点,红黑树也一样。允许出现临时的4-结点,但是会根据情况来进行旋转,使其变成正常的红黑树。同时向上传递红色链接直到碰见黑链接或者是根。这个过程在2-3树中是分解4-结点并向上传递。先看图,展示一下3种情况下的旋转方法,再文字解析:
这3种情况,看图也基本能理解了:
1.左右连接均为红色。两链接变为黑色,而父结点颜色变红。
2.连续两个左链接均为红色。父结点右旋,然后变成1的情况。
3.出现红色右链接。父结点左旋。然后变成2的情况。
实际上,这是一个向上传递的过程——所以代码写成了递归,改成非递归算法很难理解,而递归就能直接理解。最简单的,如果发生了1,那么有可能出现这样的情况:
等于说,我们需要回溯,因为上面的结点可能出现其他需要旋转的情况。这个就是在回溯的时候解决的。
既然有3种情况,考虑到回溯,我们需要对3种情况的出现排个顺序,就能方便写出代码:3之后可能出现2或者1,2之后可能出现1,1之后可能出现2、3。但是有一点,2和3都是正常的旋转,但是1却使得父结点之上出现红色结点。1是向上传递的,这就引发了树的向上分解的过程。很多其他的写法,会单独将这个过程和顺序写进一个方法或者函数,认为这个是维持红黑树平衡的核心:
if(isRed(root.right) && !isRed(root.left)) root = rotateLeft(root);
if(isRed(root.left) && isRed(root.left.left)) root = rotateRight(root);
if(isRed(root.left) && isRed(root.right)) flipColors(root);
return root;
3种情况,1情况必须放在最后。这样的顺序可以保证解决当前结点下所有不平衡的行为,最终通过情况1向上传递红结点。那么完善的put方法为:
public void put(Key key,Value value){
root = put(root,key,value);
root.color = BLACK;
}
private Node put(Node root,Key key,Value value){
if(root == null) return new Node(key,value);
int cmp = key.compareTo(root.key);
if(cmp == 0){
root.value = value;
}else{
if(cmp > 0){
root.right = put(root.right,key,value);
}else{
root.left = put(root.left,key,value);
}
}
if(isRed(root.right) && !isRed(root.left)) root = rotateLeft(root);
if(isRed(root.left) && isRed(root.left.left)) root = rotateRight(root);
if(isRed(root.left) && isRed(root.right)) flipColors(root);
return root;
}
那么树在增高的时候,就是红结点一直传递到根的时候。而根颜色是什么无所谓的,只需要知道:如果根结点为红,就是树增高了一层。
删除
有点难了,最难的就是这个= =。书上直接简单说一下思路就不管了,代码也不给,认为这个是扩展内容。确实,删除并维持平衡要比插入考虑的多得多,主要在于删除后如何保持平衡。
网上关于红黑树的删除有很多不同的描述方法,例如分析可能出现的情况,比较经典的有X图详解删除操作等等。先解释删除最小值和最大值,然后再解释常规的删除。
删除最小值
《算法》书中认为,最小值如果是3-结点,那么删除后无所谓,但是不能够直接删除2-结点。扩展到红黑树的话,就是说不能删除黑结点,但是可以删除红结点。这是为了满足红黑树黑树完美平衡的情况,或者就跟上面写的一样,将红链接平画——那么删除红色结点,树的高度是不变的,但是删除黑结点,就无法满足黑色完美平衡性质:任何叶子结点到根节点所经过的黑链接数量相同。认为红黑树的平衡被破坏了。
以此出发,书中认为,我们需要从根节点开始,跟正常的二叉树找最小值一样,一直左子树遍历,但在遍历过程中,通过算法,保证最终找到的最小值一定是红结点。这个看起来似乎是有点扯了,大体思路如此。在遍历的时候,不关心是否满足红黑树的具体情况,就是通过一定的算法,保证最终是红结点,而最后在回溯的时候,再将其不合理的结点通过一定的算法给变合理。这样,保证最终树是平衡的,又删除了最小值。
首先分析搜索阶段的“特殊算法”。根节点比较特殊,如果根节点左右子树全是黑结点,在2-3树中就是一棵3个2-结点组成的树。此时我们需要操作——在2-3树中是将3个2-结点合成1个临时的4-结点,然后删除最小值,保存这个3-结点。而扩展称为红黑树的画,看下面的图:
这个是比较特殊的,仅限有3个结点的情况,而其他情况下实际上是这样:
左子树是黑结点,右子树一定是黑结点,但是左子树的左子树可能我们要删除的——我们需要保证,X结点的左子树的左子树是一个3-结点。也就是说,每次我们向下遍历一次,都需要保证出现这样的情况:
H结点一定是3-结点。而实际上,我们只用保证1的情况出现就可以了,在此基础上,2情况不可能出现。但是在分析的时候,只要我们能够满足每一步都实现上述两种情况,那么最终删除的时候,一定是在删除一个红结点。
那么,为了保证这样的情况出现,我们需要向兄弟结点“借结点”。这个过程是这样的:假设左子结点的兄弟结点是红结点,那么将兄弟结点的一个给移动过来,如果不是,则选择最上面的情况,即父结点+左结点+右结点,合并成4-结点。由此出现了一个辅助方法:
private Node moveRedLeft(Node root){
flipColors(root);
if(isRed(root.right.left)){
root.right = rotateRight(root.right);
root = rotateLeft(root);
flipColors(root);
}
return root;
}
假设我们发现需要将2-结点给转换了,首先进行反转flipColors。这也是当出现特殊情况——只有3个2-结点的时候的操作,合成一个临时的4-结点。而如果发现兄弟结点由红色结点,最后再调用一次flipColors,那么临时的4-结点就取消了。if语句里面的内容,就是将右边兄弟结点的一个给移动过来。为什么是两次旋转?先右转后左转,给个图理解一下:
这就是转换过程,认为此时给了一个结点。这里可能有疑问,但是注意:我们开始就对根结点调用了flipColors,那么我们代入红色结点再来看这个图:
为什么最后一张图少了2个红链接?因为最后又调用了一次flipColors,给恢复了。那么跟最开始的图比起来,是不是左子树多了一个结点。
那么,整体代码可以看这样:
public void deleteMin(){
if(!isRed(root.left) && !isRed(root.right)) root.color = RED;
root = deleteMin(root);
if(root != null) root.color = BLACK;
}
private Node deleteMin(Node root){
if(root.left == null) return null;
if(!isRed(root.left) && !isRed(root.left.left)) root = moveRedLeft(root);
root.left = deleteMin(root.left);
return balance(root);
}
根结点置红,但是最后又给变黑,这个是为了满足中间条件,根操作的时候不会出错。而第二个重载方法就是核心了。而当左结点是null的时候,直接return null,因为这个就是要删除的结点。但是可能存在的右子树怎么办?我们之前的转换就解决这个情况了。最终,如果该结点满足上面讨论的——连续两个左结点是黑,那么进行递归过程中的转换,从兄弟中拿过来结点,或者生成临时的4-结点。而没有问题,就递归继续向右前进。
最终,递归的结局就是删除最小值。此时因为可能也生成了临时的4-结点,或者其他不满足的条件,应该向上回溯,分解4-结点并解决其他平衡问题,这个balance方法其实就是put方法最后几行的代码:
private Node balance(Node root){
if(isRed(root.right)) root = rotateLeft(root);
if(isRed(root.left) && isRed(root.left.left)) root = rotateRight(root);
if(isRed(root.left) && isRed(root.right)) flipColors(root);
return root;
}
不解释了,在put中就已经解释了。
删除最大值
删除最大值跟删除最小值有不同,虽然说是直接右子树递归,但是按照书中给的红黑树定义:不存在红色右链接。(这个其实网上有的写法是允许红色右链接的)。所以算法有不同,但是整体思路一样——生成临时4-结点或者从兄弟节点中借过来。
private Node moveRedRight(Node root){
flipColors(root);
if(isRed(root.left.left)){
root = rotateRight(root);
flipColors(root);
}
return root;
}
public void deleteMax(){
if(!isRed(root.left) && !isRed(root.right)) root.color = RED;
root = deleteMax(root);
if(root != null) root.color = BLACK;
}
private Node deleteMax(Node root){
if(isRed(root.left)) root = rotateRight(root);
if(root.right == null) return null;
if(!isRed(root.right) && !isRed(root.right.left)) root = moveRedRight(root);
root.right = deleteMax(root.right);
return balance(root);
}
基本跟删除最小值一样,从上到下,慢慢分析不同点。
首先是moveRedRight,if判断是判断的左子树的左子树,满足条件的话只用一次右旋即可,此时已经送过来一个右子节点了。因为必定会送过来红链接,所以只用旋转一次。而在deleteMax的重载方法中,并不是先判定是否找到最大值,而是先进行一次旋转。因为红黑树的性质,下面图中左边的树一定不存在,右边的树可能存在,这个是红黑树性质问题:
那么,找最大值的时候,可能直接就找到根了,但是此时没有处理直接return null的话,左子树就丢失了。所以要先进行一次旋转,再进行删除。后面的就一样了。
完整的删除
删除,其实就是删除最大值和删除最小值的代码的结合。因为我们肯定是在不断利用compareTo方法进行比较,确定递归进入左子树还是右子树。可以简单理解为,进入左子树,就用删除最小值的特殊方法moveRedLeft来处理结点,反之则是删除最大值的情况。这样分别调用moveRedLeft和moveRedRright,最后还是调用balance回溯的时候保证平衡。
因为解释比较碎,代码还长,所以写进注释了:
public void delete(Key key){
if(!isRed(root.left) && !isRed(root.right)) root.color = RED;
root = delete(root,key);
if(root != null) root.color = BLACK;
}
private Node delete(Node root,Key key){
int cmp = key.compareTo(root.key);
if(cmp < 0){
// 左递归,跟删除最小值的一样
if(!isRed(root.left) && !isRed(root.left.left)) root = moveRedLeft(root);
root.left = delete(root.left,key);
}else{
// 右递归,先判定了是否需要删除最小值的第一次旋转保证不会丢失左子树
if(isRed(root.left)) root = rotateRight(root);
// 找到了,该删了,但是需要确保不会丢失右子树
if(cmp == 0 && (root.right == null)) return null;
// 这个是删除最大值中的算法
if(!isRed(root.right) && !isRed(root.right.left)) root = moveRedRight(root);
// 此时,完美解决所有丢失情况,可以正常删除了,这个过程实际上就是二叉搜索树树的删除
if(cmp == 0){
Node x = min(root.right);
root.key = x.key;
root.value = x.value;
root.right = deleteMin(root.right);
}else{
root.right = delete(root.right,key);
}
}
return balance(root);
}
而真正删除一个结点的时候,其实就是二叉搜索树的删除算法——找到右子树的最小值来补充删除位。这里不多说了,不理解这个的可以先看一下二叉搜索树的删除。
关于和红黑链接无关的算法
例如搜索。在遍历的时候,我们是不需要考虑红色链接或者黑色链接,它们的作用就是保证红黑树的平衡,而查找等不改变红黑树结构的算法根本用不到考虑红黑链接。所以这些算法,其实跟二叉搜索树中的算法完全一致,直接拿过来用就行了,例如get方法或者search方法,都跟二叉搜索树一样。所以说推荐先学会二叉搜索树再考虑学习红黑树。
总结
我觉得还是不太会= =。好鸡儿难啊,但是红黑树是真的厉害,解决了自平衡的问题,当然因为AVL树差不多也没啥印象了,应该是比AVL树好的吧,具体怎么好了不知道了,印象中AVL树在一定情况下不是完美平衡,会有一定的高度差,在数据非常大的时候还是不算特别好,但是红黑树是非常完美的黑色平衡。保证了树高一定是小于2logN。等于说非常的稳定和高效。所以在Java中Map的底层之一就是红黑树。
还是好难啊,说真的删除这一部分其实现在也是勉强理解,不过插入倒是感觉还行。然而自己写肯定gg。并且红黑树的算法是递归了,应该是改成非递归会好一点,但是也懒得再思考了= =,菜鸡还是别花太大时间搞这个了。
给一个完整的连接,附带一棵写好的红黑树:
https://github.com/iwts/data-structure/blob/master/red-black-tree.java