开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第7天,点击查看活动详情
如何在红黑树上添加节点?
写在前面
文章摘要
- 红黑树的基本构造、节点以及辅助函数
- 红黑树添加的分析以及分类讨论
- 红黑树添加的代码实现
阅读准备
- 建议阅读时间:10 ~ 15 分钟
- 本文提到的二叉搜索树、AVL树、B树、红黑树,推荐阅读:
- 了解二叉搜索树 ->《二叉搜索树的实现与分析》
- 了解树的旋转 ->《透过AVL树的实现,学习树的旋转》
- 了解上溢现象 ->《你心里有B树吗?》
- 红黑树与B树的等价转换 ->《初识红黑树》♨️♨️♨️
- 上一篇文章,我们基本了解了红黑树。本篇文章,是继上一篇文章,来学习红黑树的添加操作的,当然,如果你只是想看看如何分析红黑树的添加操作的,可以略过,直接看第二小节~
一、红黑树的实现前的铺垫
“工欲善其事,必先利其器”
- 为了更好的学习红黑树,我们已经学习了:
AVL树、B树 - 下面,再铺垫几个方法,辅助我们学习红黑树
(1)几个名词
- 父节点:
parent(一直所说的父节点) - 兄弟节点:
sibling(父亲相同的节点) - 叔父节点:
uncle(父节点的兄弟节点) - 祖父节点:
grandparent(父节点的父节点) - 在二叉搜索树的内部,我们本来就维护了父节点,不用处理,祖父节点也是同理
- 为了方便起见,我们在二叉搜索树的
Node节点中,可以添加获取兄弟节点的方法sibling()。有了此方法,叔父节点也就可以通过parent.sibling()拿到了
- 看完了这几个名词,我们来看看红黑树的基本构造
(2)红黑树的基本构造
- 较为简单,先看代码
public class RBTree<E> extends BSTImpl<E> {
private static final boolean RED = false; // 红色
private static final boolean BLACK = true; // 黑色
public RBTree() { this(null); }
/**
* 可以传入一个比较器的构造函数
* @param comparator:比较器
*/
public RBTree(Comparator<E> comparator) {
super(comparator);
}
@Override
protected void afterAdd(Node<E> node) {
// 添加节点后的逻辑
}
@Override
protected void afterRemove(Node<E> node) {
// 删除节点后的逻辑
}
@Override
protected Node<E> createNode(E element, Node<E> parent) {
return new RBNode<>(element, parent); // 需要使用红黑树的节点
}
}
- 红黑树和
AVL树一样,都是在二叉搜索树的基础上,添加了自平衡的功能 - 那么,都需要继承自二叉搜索树
BST - 回想一下,
AVL树是通过维护平衡因子,使其失衡后恢复平衡 - 那红黑树呢?其实上面谈性质的时候已经说了:通过维护节点的颜色(红、黑),使其失衡后恢复平衡
- 所以我们内部定义了两个常量:
RED、BLACK,用于方便标识节点的颜色。因为只有两种颜色,使用boolean类型即可 - 还有:
afterAdd()、afterRemove()两个方法,相信不用我多说了吧。如果不清楚,强烈推荐看看上篇文章《AVL树的实现》 - 基本构造了解后,来看看红黑树内部的节点对象。外部使用的是元素,可内部操作的是节点。这一点应该很清楚了
(3)红黑树内部的节点
- 二叉搜索树的节点,就维护了父亲、兄弟节点、存储的元素
- 而
AVL树在此基础上,添加了节点高度的属性 - 那红黑树的节点应该长什么样呢?
private static class RBNode<E> extends Node<E> {
/**
* 节点的颜色【默认为红色】
*/
boolean color = RED;
public RBNode(E element, Node<E> parent) {
super(element, parent);
}
}
- 在二叉搜索树节点的基础上,额外维护一个颜色即可
- 并且让它的颜色默认为红色,因为能够更快的满足红黑树的性质
(4)一些辅助函数
① 节点上色
- 既然牵扯到了颜色,之后肯定需要调整节点的颜色
- 方便之后使用,我们提供给节点上色的方法
/**
* 将节点上色
* @param node:待染色节点
* @param color:待染的颜色
* @return :被染色的节点
*/
private Node<E> color(Node<E> node, boolean color) {
if (node == null) return node;
((RBNode<E>)node).color = color; // 染色
return node;
}
/**
* 将节点染成红色
*/
private Node<E> red(Node<E> node) {
return color(node, RED);
}
/**
* 将节点染成黑色
*/
private Node<E> black(Node<E> node) {
return color(node, BLACK);
}
② 查看节点颜色
- 因为要遵循红黑树的5条性质,之后肯定要经常获取节点的颜色信息
- 方便之后使用,我们提供获取节点颜色的方法
/**
* 查看节点颜色
* @param node:节点
* @return :节点的颜色
*/
private boolean colorOf(Node<E> node) {
return node == null ? BLACK : ((RBNode<E>) node).color;
}
/**
* 查看是否是红色的节点
*/
private boolean isRed(Node<E> node) {
return colorOf(node) == RED;
}
/**
* 查看是否是黑色的节点
*/
private boolean isBlack(Node<E> node) {
return colorOf(node) == BLACK;
}
二、添加
(1)分析
- 如下,有一棵红黑树,先不管如何在它上面添加节点,先来做一个约定:
- 在讨论红黑树时,心中应该立刻想到它等价的B树
- 先看看右边等价的B树,回想B树的性质:如果在B树中添加元素,新元素必然是添加到叶子节点中
- 所以,添加的节点只能位于最下层的节点:(4、8、10)(16、18)(22、25)(36)
- 而对应红黑树节点的颜色,对应B树的叶子节点就只有这四种情况:
红黑红、黑红、红黑、黑 - 又因为有一些节点已经有叉了,所以最终节点能添加的位置只有这些了:
- 也就是上图所示的:
新1 ~ 新12,12种情况。其实...... 也不多是吧...... - 又因为想要红黑树尽快的成长起来,添加的节点默认是红色
- 我们一直在说:添加的节点默认是红色,能够尽快的满足红黑树的5条性质。为什么呢?
- 回看性质:
- ①:肯定是毋庸置疑的
- ②:因为B树新添加的节点肯定在叶子节点中,在红黑树中,这点也毋庸置疑
- ③:红黑树的叶子节点本身就是假想的
null节点,本身就是黑色,这点没有问题 - ⑤:如果添加的节点是红色,从任一节点到新添加的节点,其经过的黑色节点个数肯定不会改变(有没有加入黑色节点),这一点也没有问题
- ④:乍眼一看,新节点如果是红色,①、②、③、⑤ 这几条性质都能满足。而第 ④ 条性质呢?
- 如果添加的节点的父节点本身就是红色了,再添加一个红色的节点,肯定不会满足性质 ④
- 则需要额外的操作,来使其重新满足性质 ④
- 经过这简单的分析,你应该知道,我们为什么要将新添加的节点默认为红色了吧
(2)分类讨论
- 添加的情况太多了,我们来分组讨论一下这 12 种情况:
- 我们知道了为什么要默认节点为红色,那么我们也就可以根据添加后是否满足性质④,来分组了
① 父节点是黑色 —— 没有影响
- 因为父节点是黑色,添加了一个新的红色节点。能够满足红黑树的性质④
- 并且也满足4阶B树的性质,也就是说不会产生上溢现象
- 还满足将红黑树转换成4阶B树的情形(将红色子节点与父节点合并成一个超级节点)
- 因此:父节点是黑色的情况,不需要做任何处理
② 父节点是红色 —— 需要特殊处理
- 这八种情况,肉眼可见,首先就不满足红黑树的性质④了
- 也太多了,简直...... 还能不能再细分情况呢?
- 确实很多,那我们回想下
4阶B树的性质:1 ≤ 任一节点的元素个数 ≤ 3 - 剩下的8种情况中,我们发现,添加新节点时,有些会超过3个节点(会出现上溢现象),有的不会出现上溢现象
- 那我们就可以根据添加后,在其等价的
4阶B树中,节点是否会上溢。来分类讨论
1、未上溢 —— 通过旋转解决
- 这几种情况,将添加后的新节点合并上去变成
4阶B树的节点,也不会产生上溢现象。只需要通过 染色 + 旋转 即可解决 - 说到旋转,我们可不陌生了,如果还陌生的话,再次强烈推荐看看:《透过AVL树的实现,学习树的旋转》
- 若需要旋转,新添加的节点会出现
LL、RR、LR、RL,这四种情况 - 与之前
AVL树的讨论一样,我们先来看看单旋即可解决的两种情况:LL、RR
-
先不谈如何旋转,我们先站在将红黑树等价转换成B树的角度,来看问题
-
如果需要将新添加的节点向上合并到父节点所在层,那么父节点肯定得是黑色,被向上合并的子节点都得是红色
-
如果这一点明白了,那么就明白,为什么要先染色了
(当然,先染色是因为还未旋转、节点的关系还没发生变化、更容易找到需要染色的节点) -
来看看思路:
- 1、将父节点染成【黑色】,祖父节点染成【红色】
- 2、将祖父节点进行单旋操作【LL就右旋、RR就左旋】
- 3、将父节点变成祖父节点的子节点
-
至于对旋转的分析,可以看看
AVL树的文章,这里就不多赘述了,直接看图
- 我们再来看看需要双旋才能解决的两种情况:
RL、LR
-
与单旋的操作类似,染色 + 旋转,看看思路:
- 1、将自己染成【黑色】,祖父节点染成【红色】
- 2、进行双旋【LR:(先父节点左旋、后祖父节点右旋)RL:(先父节点右旋、后祖父节点左旋)】
- 3、将自己变成祖父节点的子节点
-
直接看图
- 虽然我没有把旋转的过程拆开,但还是希望你能够想一想,将其染色后,旋转的过程
- 如果你了解了上面的四种情况。那恭喜你:在红黑树中添加新节点,有八种情况,你已经理解其思想了,我们来看看最后的四种
2、上溢 —— 通过合并解决
- 如图所示,这四个节点,添加任何一个,都超过了4阶B树节点所能容纳元素的最大值,也就是添加后,会变成上溢节点
- 在B树中,若节点出现了上溢的现象,我们是通过向上合并来解决的
- 那在红黑树中呢?其实也差不多,不信你跟着我的思路思考一下
-
如上图,我们添加了节点
新1后,站在B树的角度来看 -
出现了上溢节点:
(新1、4、8、10),需要解决上溢【找出中间的元素,向上与父节点合并,再将剩下的元素拆分成左右子节点】 -
于是找到了元素:
4 和 8,其实将谁向上合并都可以 -
为了方便,我们选择了元素
8,为什么呢? -
因为这是红黑树,元素
8所在的红黑树节点的左右两边,本身就是拆分开的节点,而且找它也很容易 -
那我们直接向上合并,看看还会出现什么问题
-
①可能会对B树的父节点产生副作用。如图
(8、13、20、30),是再次导致上溢就再次出现了上溢节点 -
②分裂的子节点,作为红黑树等价转换的B树节点,父节点必须是黑色。如图B树节点
(新1、4)和(10),是通过红黑树等价转换的,也即。4 和 10 必须是黑色节点 -
如果解决了这两个问题,那这四种情况,也就能顺利的添加上了
-
我们先解决第②个问题:子节点不是黑色
-
如果我们先将祖父节点染红、父节点和叔父节点染黑,再向上合并祖父节点呢?
- 我们发现,只需要解决对B树而言,产生的副作用就行了。这里出现了新的上溢节点:
(8、13、20、30) - 你会发现,我们在染色的时候,不仅将:
父节点、叔父节点染成了【黑色】,也将祖父节点染成了【红色】,为什么呢? - 现在新节点的
祖父节点8,是红色 - 而在红黑树中新添加一个节点的颜色,默认也是红色
- 那我们能否将
祖父节点,看待成是一个新添加的节点呢? - 如图所示,将 8 添加到节点:
(13、20、30)中 - 刚刚讨论的,红黑树中,
8的父节点13是红色(添加到父节点也是红色的情况)。所以需要特殊处理 - 又因为出现了上溢现象。需要染色 + 合并。即:
- 也是将祖父节点染成红色、父节点和叔父节点染成黑色后,再向上合并祖父节点
- 经过这样处理过后,可以发现,已经上溢到了根节点。树都长高了
- 这时只需要将根节点
20,变成黑色节点即可
-
至此,这一种情况也解决完了。红黑树又恢复了最初的平静
-
向上合并,为什么可能会产生副作用呢?我们是通过什么思路来解决上溢的呢?
-
其实不难想通,我们后面的思路,是将其向上合并的节点,看成是一个新添加的节点。那么新添加的节点。总共有12种情况,有4种没有影响,剩下的8种都需要做额外的操作,才能满足红黑树的5条性质
-
我们一路分类讨论下来,分了三种情况
-
向上合并时,不过是按我们分类的三种情况,再讨论一次罢了,这个过程其实就是一个递归的过程
-
至此,添加的思路就已经分析完成了。如果还有些迷,下面我们来总结一下红黑树的添加吧!
(3)添加的总结
- 总共12种情况,我们利用图示,分了下面三种情况讨论
- 如果用文字描述,就是:先看父节点,再看叔父节点
① 父节点是黑色 —— 不需要处理
- 应该可以不用解释了,因为都不需要额外处理嘛
- 除了这四种情况,下面讨论的叔父节点的颜色,都是基于父节点是红色的情况讨论的
② 叔父节点是红色 —— 通过合并解决
- 这种会出现上溢现象,需要:
- 先染色,再向上合并
- 最后将向上合并的父节点当做新添加的节点。递归调用添加的逻辑
- 如果向上合并到了根节点,那么会使对应的B树高度 + 1,这时直接将根节点变成黑色即可
③ 叔父节点不是红色 —— 通过旋转解决
- 1、叔父节点是黑色(递归解决上溢时可能会遇到)
- 2、没有叔父节点的情况,又因为
null节点(叶子节点)也是黑色 - 这种情况需要:
- 先染色,再单旋或双旋
(4)添加的实现
添加前的准备
- 分析与思考结束了,在用代码实现之前,我们先来复习一下,二叉搜索树添加的逻辑:
① 父节点是黑色 —— 不需要处理
- 就很单纯,取出父节点,看看父节点的颜色是否为黑色,是的话,不需要做任何处理
- 但是有可能是添加了根节点,根节点必须是黑色,而新添加的节点,默认是红色,所以需要将其染成黑色
② 叔父节点是红色 —— 通过合并解决
- 看完代码,你可能会有疑惑,不是说要合并解决吗?可是也没看到我去写合并的逻辑啊!
- 是的,其实并不需要去合并,因为我们将其红黑树等价转换成4阶B树,就是方便我们理解
- 而且转换的时候,是将黑色节点与它的红色子节点合并在一起,变成一个B树节点。那我们改变了节点的颜色,它自然就会重新等价转换了
- 你看看这幅图,左边是等价转换的B树,用于方便理解
- 而右边是红黑树,要保持它的性质,真正的操作
③ 叔父节点不是红色 —— 通过旋转解决
- 这种情况,需要通过旋转来解决。而旋转的逻辑,大部分应该都是和
AVL树中的逻辑相同的 - 所以在实现这种情况之前,我们先来改造一下继承关系:将中间再加入一层,平衡二叉搜索树
- 改变了继承关系之后,我们就能实现代码了
- 为了写清楚处理逻辑,上面的代码有些重复的地方(待会贴完整代码的时候再精简代码)
- 思路其实很简单,先判断是哪种情况:
LL、LR、RR、RL - 根据情形先将对应节点染色、然后再进行旋转【旋转的操作就不多赘述了】
④ 完整实现afterAdd(Node<E> node)
protected void afterAdd(Node<E> node) {
Node<E> parent = node.parent;
if (parent == null) { // 添加的节点是根节点
black(node); // 将其染成黑色
return;
}
if (isBlack(parent)) return; // 1、父节点是黑色 —— 不需要处理
// 写完发现:2、3两种情况,都需要将祖父节点先染成红色,将其抽出来
Node<E> grandparent = red(parent.parent);
Node<E> uncle = parent.sibling(); // 取出叔父节点
if (isRed(uncle)) { // 2、叔父节点是红色 —— 通过合并解决
// 将父节点、叔父节点染成黑色
black(parent);
black(uncle);
afterAdd(grandparent); // 再将祖父节点当做新添加的节点,去递归调用添加后的逻辑
return;
}
// 来到这里说明:3、叔父节点不是红色 —— 通过旋转解决
if (parent.isLeftChild()) { // L
if (node.isLeftChild()) { // LL
black(parent); // 父节点染成黑色
} else { // LR
black(node); // 将自己染成黑色
// 先左旋、后右旋
rotateLeft(parent);
}
// LL、LR最后都需要将祖父节点右旋
rotateRight(grandparent);
} else { // R
if (node.isRightChild()) { // RR
black(parent); // 父节点染成黑色
} else { // RL
black(node); // 将自己染成黑色
// 先右旋、后左旋
rotateRight(parent);
}
rotateLeft(grandparent);
}
}
写在后面
- 完整代码在下一篇《红黑树的删除》后会一起给出,可根据文章分析与代码尝试实现~
本篇收获
- 红黑树的基本构造与辅助函数
- 红黑树添加的思路分析与分类讨论
- 熟练掌握树的旋转操作
读后思考
- 能否根据本文分析红黑树添加操作的方法,尝试分析红黑树的删除呢?
- 下一篇文章,我们将一起来分析以及实现红黑树的删除~