介绍红黑树之前,我们先来简单了解下二叉查找树(BST)。
首先我们看下二叉查找树的特性:
1.若任意节点的左子树不空,则左子树上所有结点的值均小于或等于它的根结点的值。
2.若任意节点的右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值。
3.左、右子树也分别为二叉排序树。
如上图,这是一颗二叉树,如果我们要查询7,那么只要先跟8比,然后再跟3比,再跟6比,然后找到7,我们可以发现,这正是二分查找的思想,查询二叉树中的一个数据,最大的查询次数就是这颗树的高度。同理,当插入数据的时候,也是从根节点开始依次比较找到位置,再插入。但是二叉树也存在着缺陷,那就是当出现跛脚树的时候,二叉树就会变成了线性查找。举个例子。我们有一个根节点8,如果依次插入7,6,5,4,那么这棵二叉树就会变成下面这个模样:
我去,这还是我认识的树嘛。。。我们发现,树的高度就是元素的个数,查询效率变成了O(n)。为了解决二叉树的平衡问题,下面我们就来引出主角--红黑树(Red–black tree)。
红黑树(英语:Red–black tree)是一种自平衡二叉查找树,是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色。在二叉查找树强制一般要求以外,对于任何有效的红黑树我们增加了如下的额外要求:
- 节点是红色或黑色。
- 根是黑色。
- 所有叶子都是黑色(叶子是NIL节点)。
- 每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)
- 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。
下图是一颗红黑树
这5个特性,确保了二叉树的大致平衡:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。(最长路径为黑和红相间隔,最短路径为全黑节点)
当有插入和删除操作的时候,红黑树的平衡最容易被打破,接下来我们详细分析下各种场景下的破坏,红黑树是如何保持平衡的。
插入
这里先明确一个点,我们把新插入的节点标记为红色(为什么呢?如果设为黑色,就会导致根到叶子的路径上有一条路上,多一个额外的黑节点,这个是很难调整的。但是设为红色节点后,可能会导致出现两个连续红色节点的冲突,那么可以通过1⃣️变色和2⃣️树旋转来调整。)
为了便于理解,现在开头和大家普及几个概念。(祖父节点,叔节点)
class Node { Object value; int color; Node left, right, parent;}//祖父节点Node grandparent(Node n) { return n.parent.parent;}//叔叔节点Node uncle(Node n) { if(n.parent == grandparent(n).left) return grandparent(n).right; else grandparent(n).left;}复制代码
插入场景1(变色):这个场景比较简单,首次插入,根节点
void insert_case1(Node n){ if(n.parent == NULL) //根节点直接把颜色变黑就行了(变色) n.color = BLACK; return; else //否则,进入场景2 insert_case2 (n);}复制代码
插入场景2:父节点为黑色,直接插入红色节点就行了(新插入的节点是在原先的叶子节点,不会有两个红色相连的问题)
void insert_case2(Node n){ if(n.parent.color == BLACK) return; //父节点为黑色,直接插入红色节点就行了(不用操作) else //否则 insert_case3 (n);}复制代码
插入场景3(变色):此时父节点存在且为红色,那么祖父的节点肯定是黑色。那么要看uncle节点的情况。假设uncle节点的颜色是红色的
void insert_case3(Node n){ //如果uncle节点的颜色是红色的 if(uncle(n) != NULL && uncle(n).color == RED) { n.parent.color = BLACK; //把父节点变黑 uncle(n).color = BLACK; //uncle节点变黑 grandparent(n).color = RED; //祖父节点变红 //此时祖父节点以下的都已经平衡了,剩下的就是要把祖父节点当作新插入的节点,去递归调用一下场景1开始的流程 insert_case1(grandparent(n)); } else //如果uncle节点是黑色的,那么看下场景4 insert_case4 (n);}复制代码
场景3操作图如下:
插入场景4(左旋或者右旋):此时父节点为红色,祖父节点是黑色,uncle节点只能为NIL节点(因为uncle为黑的话,就违反了特性5)
void insert_case4(Node n){ //如果n为右节点,n的父节点p为祖父节点g的左节点,如下图 if(n == n.parent.right && n.parent == grandparent(n).left) { rotate_left(n.parent); //n的父节点执行--左旋操作 //旋转完成后,n指向自己的左节点(这样就变成了场景5的一种情形:自己是左节点,且父节点是祖父节点的左节点) n = n.left; } else if(n == n.parent.left && n.parent == grandparent(n).right) { //如果自己是左节点,且父节点为右节点 rotate_right(n.parent); //n的父节点执行--右旋操作 //旋转完成后,n指向自己的右节点(这样就变成了场景5的另一种情形:自己是右节点,且父节点是祖父节点的右节点) n = n.right; } //场景4的操作结果,或者直接满足场景5的,都将进入场景5做最后的操作 insert_case5 (n);}复制代码
场景4操作图如下:
插入场景5:执行变色,以及祖父节点的旋转
void insert_case5(Node n){ //首先执行变色操作,把n的父节点变黑,n的祖父节点变红 n.parent.color = BLACK; grandparent(n).color = RED; //如果n为左节点,n的父节点p为n祖父节点G的左节点(如图5-1) if(n == n.parent.left && n.parent == grandparent(n).left) { //n的祖父节点G右旋转 rotate_right(grandparent(n)); } else { //n为右节点,n的父节点p为n的祖父节点G的右节点(如图5-2) //n的祖父节点G左旋转 rotate_left(grandparent(n)); } }复制代码
如5-1和5-2步骤图所示,执行完毕后,从这棵子树的根节点P到叶子节点的所有黑色节点数是2,与N插入前一样(满足特性5),又因为P为黑色,不会违反红黑树的其他特性(特性1~4),所以,我们插入的步骤就全部完成了。
删除
如果需要删除的节点有两个儿子,那么问题可以被转化成删除另一个只有一个儿子的节点的问题,对于二叉查找树,在删除带有两个非叶子儿子的节点的时候,我们要么找到它左子树中的最大元素、要么找到它右子树中的最小元素,并把它的值转移到要删除的节点中。剩下的就是变色和旋转的操作了,篇幅有限,这里就不往下说了。