红黑树
一、红黑树的定义
R-B Tree,全称是Red-Black Tree,又称为“红黑树”,它一种特殊的二叉查找树。红黑树的每个节点上都有存储位表示节点的颜色,可以是红(Red)或黑(Black)。
红黑树的特性
- 每个节点或者是黑色,或者是红色。
- 根节点是黑色。
- 每个叶子节点(NIL)是黑色。
- 如果一个节点是红色的,则它的子节点必须是黑色的。
- 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点(黑高相等特性)。
注意:
- 特性3中的叶子节点,是指为空(NIL或null)的节点。
- 特性5,确保没有一条路径会比其他路径长出俩倍。因而,红黑树是相对是接近平衡的二叉树。
红黑树的示例:
二、红黑树的修正
2.1 左旋
对某个节点(或以某个节点为支点)左旋,即“把该节点变成其右孩子的左孩子”。
2.2 右旋
对某个节点(或以某个节点为支点)右旋,即“把该节点变成其左孩子的右孩子”。
2.3 改变颜色
即改变节点的颜色。本质上,改变颜色并不能使得树平衡,它只是辅助判断何时进行何种旋转,而旋转才能使树平衡。
三、红黑树的操作
在说明红黑树的相关操作前,先定义红黑树的节点类和红黑树类,如下代码所示。
public class RBNode<T extends Comparable<T>> { public static final boolean RED = true; boolean color; T key; RBNode<T> left; RBNode<T> right; RBNode<T> parent; public RBNode(boolean color, T key, RBNode<T> left, RBNode<T> right, RBNode<T> parent) { super(); this.color = color; this.key = key; this.left = left; this.right = right; this.parent = parent; } public T getKey() { return this.key; } public String toString() { return "" + key + (this.color == RED ? "R":"B"); } }
红黑树类只包含一个数据属性,即根节点。代码如下:
public class RBTree<T extends Comparable<T>> { RBNode<T> root; //以下为诸如左旋、右旋、遍历、查找等操作的代码 }
红黑树的操作包括左旋、右旋、插入、删除、查找、遍历等,这里只对比较重要且难理解的左(右)旋、插入和删除进行描述。
3.1 左旋
private void leftRotate(RBNode<T> x) { RBNode<T> rightChild = x.right; // 将x节点的右节点(即rightChild节点)的左孩子赋给x的右孩子 x.right = rightChild.left; if (rightChild.left != null) { // 更新rightChild节点原左节点的父节点 rightChild.left.parent = x; } // 将x赋给rightChild的左节点 rightChild.left = x; // 将x的父节点赋给rightChild的父节点 rightChild.parent = x.parent; if (x.parent == null) { // 如果x的父节点为空,则说明x原先是根节点,则设置rightChild为新的根节点 this.root = rightChild; } else { // 以下判断x是其父节点左或右孩子,进而更新其父节点孩子节点为rightChild if (x == x.parent.left) { x.parent.left = rightChild; } else { x.parent.right = rightChild; } } // 更新x的父节点为rightChild x.parent = rightChild; }
3.2 右旋
private void rightRotate(RBNode<T> x) { RBNode<T> leftChild = x.left; // 将x节点的左节点(即leftChild节点)的右孩子赋给x的左孩子 x.left = leftChild.right; if (leftChild.right != null) { // 更新leftChild节点原右节点的父节点 leftChild.right.parent = x; } // 将x赋给leftChild的右节点 leftChild.right = x; // 将x的父节点赋给leftChild的父节点 leftChild.parent = x.parent; if (x.parent == null) { // 如果x的父节点为空,则说明x原先是根节点,则设置leftChild为新的根节点 this.root = leftChild; } else { // 以下判断x是其父节点左或右孩子,进而更新其父节点孩子节点为leftChild if (x == x.parent.left) { x.parent.left = leftChild; } else { x.parent.right = leftChild; } } // 更新x的父节点为leftChild x.parent = leftChild; }
3.3 插入
和二叉树的插入操作一样,都是得先找到插入的位置,然后再将节点插入。
public void insert(T key) { if(key == null) { return; } RBNode<T> node = new RBNode<T>(RBNode.RED, key, null, null, null); insert(node); } public void insert(RBNode<T> node) { if(node == null) { return; } // 表示最后node的父节点 RBNode<T> parent = null; RBNode<T> current = this.root; // 寻找插入点 while(current!=null) { parent = current; int cmp = node.key.compareTo(current.key); if(cmp < 0) { current = current.left; }else { current = current.right; } } // 找到插入节点的位置,将parent节点作为node的父节点 node.parent = parent; // 判断node是左节点还是右节点 if(parent == null) { this.root = node; } else { int cmp = node.key.compareTo(parent.key); if(cmp < 0) { parent.left = node; } else { parent.right = node; } } // 利用旋转操作修正树为红黑树 insertFixUp(node); }
插入代码的难点在于最后一步的 insertFixUp() 操作。因为插入可能导致树的不平衡,所以在 insertFixUp() 方法里需要分情况讨论何时变色、左旋、右旋。下面先从思路上进行分析,最后再实现 insertFixUp() 方法。
如果是第一次插入,由于原树为空,所以只会违反红黑树的特性2,所以只要把根节点涂黑即可。如果插入节点的父节点是黑色的,则不会违背红黑树的规则,不需要进行任何调整。但如遇到以下三种情形,则需要进行变色和旋转:
- 插入节点的父节点和其叔叔节点(即父节点的兄弟节点)均为红色;
- 插入节点的父节点是红色的,叔叔节点是黑色的,且插入节点是其父节点的右子节点;
- 插入节点的父节点是红色的,叔叔节点是黑色的,且插入节点是其父节点的左子节点。
下面将对这三种情况逐一分析,在分析中,使用N、P、G、U表示关联的节点。N(now)表示当前节点,P(parent)表示N的父节点,U(uncle)表示N的叔叔节点,G(grandfather)表示N的祖父节点(即P和U的父节点)。
对于情况1:插入节点的父节点和其叔叔节点(祖父节点的另一个子节点)均为红色。此时,肯定存在祖父节点,但是不知道父节点是其左子节点还是右子节点,但是由于对称性,我们只要讨论出一边的情况,另一种情况自然也与之对应。这里考虑父节点是其祖父节点的左子节点的情况,如下图所示:
对于这种情况,我们要做的操作有:将当前节点(4)的父节点(5) 和叔叔节点(8)涂黑,将祖父节点(7)涂红,变成了下图所示的情况;再将当前节点指向其祖父节点,再次从新的当前节点开始算法(具体看下面的步骤)。这样情形1就变成情况2(如下图)了。
对于情况2:插入节点的父节点是红色的,叔叔节点是黑色的,且插入节点是其父节点的右子节点(如上图)。我们要做的操作有:将当前节点(7)的父节点(2)作为新的节点,以新的当前节点为支点做左旋操作。完成后如下图所示。这样情形2就变成情况3了。
对于情况3:插入节点的父节点是红色,叔叔节点是黑色,且插入节点是其父节点的左子节点。我们要做的操作有:将当前节点的父节点(7)涂黑,将祖父节点(11)涂红,在祖父节点为支点做右旋操作。最后把根节点涂黑,整个红-黑树重新恢复了平衡,如下图所示。至此,插入操作完成!
我们可以看出,如果是从情况1开始发生的,必然会走完情况2和3,也就是说这是一整个流程,当然在实际中可能不一定会从情况1发生。如果从情况2开始发生,那再走个情况3即可完成调整。如果直接只要调整情况3,那么前两种情况均不需要调整了。故变色和旋转之间的先后关系可以表示为:变色->左旋->右旋。
以上即为插入过程的分析,下面是insertFixUp的具体实现。
private void insertFixUp(RBNode<T> node) { if (node == null) { return; } RBNode<T> p = null, u = null, g = null; while ((p = parentOf(node)) != null && isRed(p)) { g = parentOf(p); boolean isParentLeft = p == g.left; u = isParentLeft ? g.right : g.left; if (isRed(u)) { // 情形1:父节点和叔叔节点均为红色 setBlack(p); setBlack(u); setRed(g); node = g; continue; } if (!isParentLeft) { // 情形2:父节点为红色,叔叔节点为黑色,父节点为祖父节点右孩子 leftRotate(p); RBNode<T> tmp = p; p = node; node = tmp; } // 情形3:父节点为红色,叔叔节点为黑色,父节点为祖父节点左孩子 setBlack(p); setRed(g); rightRotate(g); // 经过情形3之后,树达到红黑平衡,退出循环 } setBlack(root); }
3.4 删除
红-黑树的删除和二叉排序树的删除是一样的,只不过删除后多了个平衡的修复而已。对于一棵普通的二叉排序树来说,删除的节点情况可以分为3种:
- 叶子节点;
- 只有左子树或只有右子树的节点;
- 既有左子树又有右子树的节点。
而对于情形3,首先需要找到待删除节点的(中序遍历时的)直接后继节点(注:对于二叉排序树,其中序遍历即为从小到大排序结果,此时其直接后继节点即为该节点右子树中最左边的那个叶子节点;当然,也可使用中序遍历时的直接前驱节点,即该节点的左子树中最右边的那个叶子节点),然后用这个后继节点替换待删除的节点,然后按照情形1或2删除后继节点即可。
所以,对于二叉排序树或红黑树,删除的实际情况分为如下两种:
- 叶子节点;
- 只有左或右子树的节点。
对于情形2,即待删除的节点只有左或右子树的情况,很多组合在红黑树的特性约束下是不可能出现的,这些不存在的组合如下(下文中以D表示待删除节点,DL和DR分别表示其左子树和右子树):
上图这四种情况违背了红黑树特性5(从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点)。
上图的两种情况违背了特性4(如果一个节点是红色的,则它的子节点必须是黑色的)。
如果从待删除节点的颜色来分,可以分为删除红色节点和删除黑色节点两大类,其中删除红色节点的算法较为简单而删除黑色节点则需要考虑的情况较多,下面分别讨论。
3.4.1 删除红色节点
情形1:删除红色叶节点
如上图,P表示待删除节点D的父节点(PD之间的竖直直线表示D既可以是左子也可以是右子)。对于这种情况,直接删除D即可。即如果待删除的节点是红色的叶节点则直接删除即可。
情形2:删除红色非叶节点
即待删除的红色节点具有左或右孩子(同时具有左右孩子节点的删除操作可以先转化为删除只有左或右孩子节点的操作),通过上面的分析可知,红黑树中不存在此种形态。
3.4.2 删除黑色节点
删除黑色节点的操作较为复杂,我们先分析较为简单的删除非叶的黑色节点,最后分析删除黑色叶节点。
(1)删除只有左或右孩子的黑色节点
去除前面分析过的不存在的情形,最后可能的情况只能为如下图所示的两种(未涂色的P节点表示其颜色可黑可红):
这两种情况的处理方式是一样的,即用D的孩子(左或右)替换D,并将D孩子的颜色改成黑色即可(因为路径上少了一个黑节点,所已将红节点变成黑节点以保持红黑树的性质)。
(2)删除黑色叶节点
情况1:待删除节点D的兄弟节点S是红色
情况1-1: D是左节点
调整做法是:将父亲节点和兄弟节点的颜色互换,也就是P变成红色,S变成黑色,然后以P为支点进行左旋,结果如下图:
此时D的兄弟节点变成了黑色(即节点SL),这样就成为了后面将要讨论的情况。
情况1-2:D是右节点
调整做法是:将父亲节点和兄弟节点的颜色互换,也就是P变成红色,S变成黑色,然后以P为支点进行右旋,结果如下图:
此时D的兄弟节点变成了黑色(即节点SL),这样就成为了后面将要讨论的情况。
情况2:待删除节点D的兄弟节点S是黑色,且其远侄节点为红色。
情况2-1:D为左节点,此时D的远侄节点为S的右孩子。
没有上色的节点表示黑色红色均可,注意如果SL为黑色,则SL必为NULL节点。
这个时候,如果我们删除D,这样经过D的子节点(NULL节点)的路径的黑色节点个数就会减1,但是我们看到S的孩子中有红色的节点,如果我们能把这个红色的节点移动到左侧,并把它改成黑色,那么就满足要求了,这也是为什么P的颜色无关,因为调整过程只在P整棵子树的内部进行。
调整过程为:将P和S的颜色对调,然后以P为支点做左旋,最后把SR节点变成黑色,并删除D即可。
情况2-2:D为右节点,此时远侄节点为S的左孩子。
与情况2-1类似,调整过程为:将P和S的颜色对调,然后以P为支点做右旋,最后把SL节点变成黑色,并删除D即可。
情况3:待删除节点D的兄弟节点是黑色,其远侄节点为黑色,且其近侄节点为红色。
根据红黑树特性5(黑高相等)的特性可知,此时的远侄节点必为NIL节点。调整思路是将这种情况转换为情况2。
情况3-1:D为左节点,此时D的近侄节点为S的左孩子。
调整过程为:以S为支点右旋并将S和SL颜色互换,这样就变成了情况2-1(兄弟节点为黑色,远侄节点为红色),如下图:
情况3-2:D为右节点,此时D的近侄节点为S的右孩子。
与情况3-1类似,调整过程为:以S为支点左旋并将S和SR颜色互换,这样就变成了情况2-2(兄弟节点为黑色,远侄节点为红色),如下图:
情况4:父亲节P为红色,兄弟节点和兄弟节点的两个孩子(只能是NULL节点)都为黑色的情况。
如果删除D,那经过P到D的子节点NULL的路径上黑色就少了一个,这个时候我们可以把P变成黑色,这样删除D后经过D子节点(NULL节点)路径上的黑色节点就和原来一样了。但是这样会导致经过S的子节点(NULL节点)的路径上的黑色节点数增加一个,所以这个时候可以再将S节点变成红色,这样路径上的黑色节点数就和原来一致。
所以做法是,将父亲节点P改成黑色,将兄弟节点S改成红色,然后删除D即可。如下图:
情况5:父亲节点P,兄弟节点S和兄弟节点的两个孩子(只能为NULL节点)都为黑色。
方法是:将兄弟节点S的颜色改成红色,这样删除D后P的左右两支的黑节点数就相等了,但是经过P的路径上的黑色节点数会少1,这个时候,我们再以P为起始点,继续根据情况进行平衡操作,一直向上直到新的起始点为根节点。结果如下图:
至此,删除黑色叶节点的所有情况都讨论完了,总结成一句话:
判断类型的时候,先看待删除的节点的颜色,再看兄弟节点的颜色,再看侄子节点的颜色(侄子节点先看远侄子再看近侄子),最后看父亲节点的颜色。
对应的流程图如下(忽略了处理过程):
四、红黑树的效率
红黑树的查找、插入和删除时间复杂度都为O(log2N),额外的开销是每个节点的存储空间都稍微增加了一点,因为一个存储红黑树节点的颜色变量。
插入和删除的时间要增加一个常数因子,因为要进行旋转,平均一次插入大约需要一次旋转,因此插入的时间复杂度还是O(log2N),(时间复杂度的计算要省略常数),但实际上比普通的二叉树是要慢的。
大多数应用中,查找的次数比插入和删除的次数多,所以应用红黑树取代普通的二叉搜索树总体上不会有太多的时间开销。而且红黑树的优点是对于有序数据的操作不会慢到O(N)的时间复杂度。
参考文档