红黑树是一个比较复杂的数据结构,相信很多人也只知其名而不知其意,因为理解它的原理确实需要花费一定的功夫。之所以写这篇文章,也是为了更好的理解 Java 中 TreeMap 的源码。
写之前,搜了下网上的文章,说实话,看完有点懵,大部分一上来就给你它的五大性质,然后就是一顿插入、删除、旋转操作,就完事了,理解起来相当吃力。
本文将结合 2-3-4 树,循序渐进地介绍红黑树的由来和原理,相信看完之后,你对它会有更清晰的认识。此外,这里描述的是普通红黑树,而不是它的变体左倾红黑树(LLRB),这一点需要注意。
红黑树的引入
二叉查找树
红黑树的由来要从二叉查找树说起。二叉查找树是一颗二叉树,它每个结点的值都大于其左子树的任意结点而小于右子树的任意结点,它结合了链表插入的灵活性和有序数组查找的高效性(二分查找)。
对于使用二叉查找树的算法,它的运行时间取决于树的形状,而树的形状又取决于结点插入的先后顺序。如上图所示,最好情况下,N 个结点的树是完全平衡的,每条空链接到根结点的距离都为 ~lgN;而在最坏的情况下,搜索路径上可能有 N 个结点,退化成了链表。
所以,为了保证运行时间始终在对数级别,在动态构建二叉查找树时,希望保持其平衡性,也就是降低树的高度,使其尽可能为 ~lgN,这样就能保证所有的查找都能在 ~lgN 次比较内结束,就像二分查找那样,这样的树被称为平衡二叉查找树。
AVL 树
第一个自平衡二叉查找树就是AVL 树,它规定,每个结点的左右子树的高度之差不超过 1。在插入或删除结点,打破平衡后,就会通过一次或多次树旋转来重新平衡。
AVL 树是严格平衡的,适用于查找密集型应用程序,因为在频繁插入或删除结点的场景下,它花费在树旋转的代价太高。
而红黑树就是一种折中方案,它不追求完美平衡,只求部分达到平衡,从而降低在调整时树旋转次数。
2-3-4 树
说到红黑树,就不得不提 2-3-4 树,因为,红黑树可以说就是它的一种特殊实现,对它有所了解,非常有助于理解红黑树。
保持平衡,无非是为了降低树的高度,如果把二叉查找树一般化,允许一个结点保存多个值,变成多叉树,也可认为是降低了高度。
确切地说,标准二叉查找树中的结点称为2-结点(一个值两个子结点),现在引入3-结点(两个值三个子结点)和4-结点(三个值四个子结点),这样就能得到一颗 2-3-4 树(也称为 2-4 树)。
2-3-4 树是 4 阶 B 树,所有数据按排序顺序保存,所有叶子结点都在相同的深度。对于大多数编程语言,直接实现 2-3-4 树比较困难,而红黑树的实现相对要简单容易,这也是红黑树应用广泛的一部分原因。
红黑树是二叉树,所有的结点都是2-结点,所以为了能够表示3-结点和4-结点,为结点引入了颜色属性:
- 黑色,表示普通结点
- 红色,表示可与父结点合并看作多值结点
如上图所示,如果把红黑树的红色结点和其父结点放平,它的结构就和左边的 2-3-4 树一样。
红黑树
现在,来看下红黑树的性质:
- 每个结点都是红色或黑色的
- 根结点是黑色的(是红色最终也会转黑色)
- 所有叶子结点都是黑色的,这里的叶子结点指的是空结点,常用 NIL 表示
- 如果结点为红色,则其子结点均为黑色(红色表示可与父结点合并,子结点凑什么热闹)
- 从给定结点到其任何后代 NIL 结点的每条路径都包含相同数量的黑色节点(转成 2-4 树,所有叶子节点均在最底层)
这些性质不必去背,就算记住后也绝对会忘,应该结合着 2-3-4 树理解性记忆。
另外,红黑树中的旋转和颜色翻转,就相当于 2-3-4 树中的拆分和合并,并且 2-3-4 树结点的拆分和合并,理解起来相当简单。对比分析和理解红黑树的操作,绝对让你眼前一亮。
树旋转
在分析插入和删除之前,先了解下什么是树旋转。树旋转是二叉树中调整子树的一种操作,常用于调整树的局部平衡性,它包含两种方式,左旋转和右旋转。
其实旋转操作很容易理解:左旋转就是将用两个结点中的较小者作为根结点变为将较大者作为根结点,右旋转刚好于此相反,如上图所示:
- 右旋转,就是将较小者 L 作为根结点,然后调整 L 和 P 的子树
- 左旋转,就是将较大者 P 作为根结点,然后调整 P 和 L 的子树
红黑树的旋转其实就是为了确保和其结构相同的 2-3-4 树的一一对应关系,同时保证红黑树的有序性和平衡性。
插入
接下来,就结合 2-4 树分析结点的插入,首先 2-4 树的插入逻辑是这样的:
- 如果是 2-结点,直接插入变成 3-结点
- 如果是 3-结点,直接插入变成 4-结点
- 如果是 4-结点,首先进行分裂,变成 2-结点,再插入
2-4 树插入的都是叶子结点,红黑树插入的结点都是红色的,因为在 2-4 树中,待插入结点都认为可以插入到一个多值结点中。
这里假设待插入结点为 N,P 是 N 的父结点,G 是 N 的祖父结点,U 是 N 的叔叔结点(即父结点的兄弟结点),那么红黑树有以下几种插入情况:
- N 是根结点,即红黑树的第一个结点
- N 的父结点(P)为黑色
- P 是红色的(不是根结点),它的兄弟结点 U 也是红色的
- P 为红色,而 U 为黑色
情况 1,2,3
这三种情况比较简单,就放在一起说明了,它们都不涉及旋转,只涉及颜色翻转,换句话说就是只是结点合并没有拆分。
情况 1 和 2,不影响红黑树的性质,不会打破平衡,直接插入即可:
- 对于 2-4 树来说,空树插入就是一个 2-结点->3-结点->4-结点转换的过程
- 红黑树就是建立一个根结点为黑色的标准 2-结点
情况 3,P 为红色(不是根结点),U 也是红色,两个树插入情况如下:
- 在 2-4 树中,就意味着这是个 4-结点,它首先拆分成 2-结点,然后再进行插入
- 对于红黑树,它相当于已经拆分,直接变色即:P 和 U 变成黑色,G 变成红色,若 G 是根结点,直接变黑,否则递归向上检查是否造成不平衡
以 [7, 5, 9, 3] 输入序列为例,两个树构建过程如下:
情况 4
情况 4,P 为红色,而 U 为黑色,此时,在 2-4 树看来这个结点就是一个 3-结点,直接插入变成 4-结点;而对于红黑树,它为了和这个 2-4 树结构保持一致,会根据不同的情况做旋转,分别有以下四种可能:
- P 是 G 的左孩子,若 N 是 P 的左孩子,那么将祖父结点 G 右旋转 即可
- P 是 G 的左孩子,若 N 是 P 的右孩子,那么 P 先左旋转,然后再将祖父结点 G 右旋转
相反的:
- P 是 G 的右孩子,若 N 是 P 的右孩子,那么将祖父结点 G 左旋转 即可
- P 是 G 的右孩子,若 N 是 P 的左孩子,那么 P 先右旋转,然后再将祖父结点 G 左旋转
以 [7, 5, 9, 3, 4] 输入序列为例,也就是在上图的基础上,插入 4,演示 P 为左,N 为右,树的旋转过程:
其他情况,左右互换即可,可自行尝试分析。这里给出最开始提供的红黑树和 2-3-4 树它们的动态构建过程,输入序列为 [7, 5, 9, 3, 4, 8, 10, 11, 12, 13, 14, 15],首先是 2-3-4 树的构建:
红黑树构建时会有一次根结点调整,可注意一下:
删除
二叉查找树的结点无非是有两个子结点,有一个子结点和叶子结点三种,其中有两个子结点的 M 结点的删除逻辑是:
- 首先寻找 M 结点左子树最大或右子树最小的结点 X
- 然后把 X 结点的值复制到 M 结点
- 最后删除 X 结点,而这个结点要么是叶子结点,要么就只有一个孩子
所以,删除任一结点的问题就简化成了:删除一个最多只有一个孩子的结点的情况,并且所有的删除操作都在叶子结点完成,只不过删除的结点不再是一开始想删除的结点,但结点的值最终是删除了,而树结构的变化与简化问题相比,并不重要。
在分析红黑树的删除之前,简单来看下 2-3-4 树的删除情况。
2-3-4 树结点删除
它类似二叉查找树的删除,实际的删除操作也是在叶子结点完成,只不过在删除的过程中涉及到结点的合并,主要有 3 种不同的情况:
- 如果元素 K 是内部结点,并且在一个至少有 2 个 key 的多值叶子结点内部,则只需从结点中删除 K
- 如果元素 K 是内部结点,且有左孩子和右孩子,那么:
2.1 如果左孩子至少有 2 个 key,那么找一个最大值替换 K,然后删除这个最大值
2.2 如果右孩子至少有 2 个 key,那么找一个最小值替换 K,然后删除这个最小值
2.3 如果两个孩子都只有 1 个 key,那么将 K 下沉,与其子女合并,形成一个至少有 2 个 key 的结点,最后再删除 K - 如果元素 K 不是内部结点,所在结点只有它 1 个 key,那么根据以下情况,最终会转成情况1或情况2:
3.1 如果它的兄弟结点至少有 2 个 key,那么选择一个推到父节点中,再把旧的父节点下沉和 K 合并
3.2 如果它的兄弟结点也只有 1 个 key,那么将父结点下沉,与其子女合并,再删除 K,所以,此时需要父结点至少有 2 个 key,如果没有那么在父结点上递归按情况 3 处理
上面这些情况,有一个前提就是,在遍历查找待删除结点时,必须保证路过的结点都至少有 2 个 key,不是的话就需要合并结点。这点比较难理解,在插入时,会把遍历过程中遇到的4-结点 进行拆分,相对的,在删除时,就要保证遍历的结点至少有 2 个 key,也就相当于把之前拆分的进行了合并。
以下图示演示了上述的每种可能的删除情况:
简单来说,理解 2-3-4 树删除的重点就是:
- 如果删除的结点是多值结点,直接删除即可
- 否则从兄弟结点获取一个多余的结点填补空缺
- 再否则就从父结点获取一个结点填补空缺,如果父结点没有多余结点,将问题递归到父结点处理。
红黑树的删除
红黑树的删除也同样类似二叉查找树,不过要考虑平衡,也就是结点颜色问题,要麻烦一点。
首先声明一点,接下来说的红黑树叶子结点和二叉查找树叶子结点相同,如果要强调红黑树结点是空的叶子结点 NIL 会特殊说明,画图会使用黑色方框表示。
假设待删除结点为 M,如果有非叶子结点,称为 C,那么有两种比较简单的删除情况:
- M 为红色结点,那么它必是叶子结点,直接删除即可,因为如果它有一个黑色的非叶子结点,那么就违反了性质5,通过 M 向左或向右的路径黑色结点不等
- M 是黑色而 C 是红色,只需要让 C 替换到 M 的位置,并变成黑色即可,或者说交换 C 和 M 的值,并删除 C(就是第一个简单的情况)。
注意:M 有且仅有一个非叶子的左或右孩子结点,相当于 2-3-4 树删除的情况 1。
这两个情况,本质都是删除了一个红色结点,不影响整体平衡。以 [7, 5, 9, 3, 4] 输入序列构建的红黑树为例,演示以上两种比较简单的情况:
删除比较复杂的是 M 和 C 都是黑色的情况,此时 M 肯定是叶子节点,而 C 肯定是 NIL 结点,如果不是这样的情况将违反性质5。
一个黑色结点被删除会打破平衡,需要找一个结点填补这个空缺,假设待删除结点为 M,删除后它的位置上就变成了 NIL 结点,为了方便描述,这个结点记为 N,P 表示 N 的父结点,S 表示 N 兄弟结点,S 如果存在左右孩子,分别使用 SL 和 SR 表示,那么删除就有以下几种情况:
情况 1 - N 是根结点
删除后,N 变成了根结点,也就是说删除前只有 M 这一个结点,直接删除即可。
情况 2 - P 黑 S 红
S 是红色,那么它必有两个孩子结点,且都为黑色,而且 P 也肯定是黑色。此时,交换 P 和 S 的颜色,然后对 P 左旋转,如下:
现在,结点 N 的父结点变成了红色,兄弟结点变成了 SL,此时就可以按照情况 4、5、6继续处理。
情况 3 - P 黑 S 黑
P 是黑色,S 也是黑色,并且 S 也没有非空的孩子结点。此时,直接将 S 变成红色,那么经过 S 的路径也就少了一个黑色结点,整体上就导致经过 P 的路径比原来少了一个黑色结点,把不平衡状态从结点 N 转移到了结点 P,可以把 P 按 情况1 处理,直到遇到根结点,以此形成递归:
情况 4 - P 红 S 黑
P 是红色,S 是黑色,并且 S 也没有非空的孩子结点。此时,只要交换 P 和 S 的颜色,正好填补了少一个黑色结点的空缺,也就是恢复了平衡的状态:
情况 5 - P 任意 S 黑 SL 红
P 任意颜色,S 黑色,S 的左孩子红色,(S 有右孩子也是红色)。此时,对 S 右旋转,并交换 S 和 SL 的颜色:
其实就是把这种情况,转成了 情况 6 进行处理。
情况 6 - P 任意 S 黑,SR 红
P 任意颜色,S 黑色,S 的右孩子红色,(S 有左孩子也是红色)。此时,对 P 左旋转,交换 P 和 S 的颜色,并将 SR 变成黑色:
此时恢复平衡的状态,无论 P 之前是什么颜色,N 都比之前多了一个黑色父结点。假设 P 原先是红色的,现在变成了黑色;假设原先是黑色的,现在 P 又多了一个黑色的父结点 S,所以,无论怎样,经过结点 N 路径增加了一个黑色结点。
以上 6 种情况,结点 N 都是左孩子,如果是右孩子,只需把左右对调即可。类比 2-3-4 树的删除,理解黑色结点删除后的关键就是:
- 如果儿子结点中有红色的则从儿子结点中选一结点填补被删除后的空缺
- 否则,从兄弟结点中选择一个结点填补空缺
- 再否则,从父结点中选择一个结点填补空缺,将问题递归到父结点处理
最后来看下 2-3-4 树和红黑树动态删除的过程,输入序列为 [7, 5, 9, 3, 4, 8, 10, 11, 12, 13, 14, 15, 16],删除顺序是 [16, 13, 11, 7, 15, 14, 8, 4, 9, 10, 5, 3, 12],首先是 2-3-4 树的动态删除过程:
红黑树动态删除的过程:
小结
红黑树确实比较复杂,单纯的分析性质和旋转,意义不大,而 2-3-4 树就不一样了,它的插入和删除简单多了,而红黑树的旋转和变色最终也是为了和同构的 2-3-4 树保持一致,本文就是相互结合分析,互相印证,相信会相对容易理解一点。
动图来自网站:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
它支持单步调试,有兴趣可以试一下。