一、前言
首先初生牛犊不怕虎,我想反驳一下许多许多大佬对红黑树中颜色属性的看法。如有冒犯,请忍回去。
大佬的看法:
红黑树是对2-3-4阶树(多路平衡查找树)概念的实现。红色节点是为了和黑色父节点结合形成2-3-4阶树中的节点。
红黑树中红色左孩子节点结合黑色父节点,形成2-3-4阶树中的3阶节点。
红黑树中红色右孩子节点结合黑色父节点,形成2-3-4阶树中的3阶节点。
红黑树中红色左孩子节点和红色右孩子节点结合黑色父节点,形成2-3-4阶树中的4阶节点。
红黑树中没有红色子节点的黑色节点,对应2-3-4阶树中的2阶节点。
我的看法:
红黑树是对平衡二叉树概念的妥协实现,通过红黑树的性质可以判断出当前树是否平衡,如果不平衡,则进行旋转以保持平衡
为什么如此大胆竟敢反驳大佬,让我鼓起勇气的原因如下:
首先红黑树无论从结构还是特点来看,都是更贴近平衡二叉树的,两者都是自平衡的二叉排序树。
其次红黑树的颜色应该是为了旋转而服务的,而非为了对应2-3-4阶树
- 即使通过颜色节点将红黑树与2-3-4阶树对应起来了,又能怎么样呢?so what?
- 将颜色属性与旋转联系在一起而形成红黑树,应该更符合常理。
二、正文
红黑树应该是大家公认的很难的一种数据结构,尤其是对于初级甚至中级开发者来说。“我连二叉树都还没整明白呢搞什么红黑树呢?听着都复杂又是红又是黑的莫非搁这川剧变脸呢?“。确实,红黑树的前置知识点有点多,要想把红黑树给弄明白需要先把二叉树、二叉排序树、线索二叉树以及平衡二叉树搞明白。但你先别急着走,本文将用最简单的话先把这三个树讲明白是怎么回事,红黑树只是在他们的基础上添加了颜色属性而已。
另外:本文默认大家都已经明白链表了,再把这玩意儿扯进来那就太扯了。
1. 二叉树
先把二叉树的样子贴出来,看一下二叉树长什么样。
从上图可以看出,二叉树中每个节点都有数据、左孩子、以及右孩子两个节点,那我们以链表的数据结构为参考,可以对二叉树的基本结构做出以下定义:
public class Node {
// 数据
String data;
// 指向左孩子
Node left;
// 指向右孩子
Node right;
}
这就是二叉树。下面在二叉树的基础上介绍二叉排序树。
2. 二叉排序树
二叉排序树,就是利用二叉树的结构,实现排序的功能。有人可能会疑惑,排序难道不应该是把一些数据拍成一个有序的序列嘛?起码得是线性的啊,这二叉树什么档次也可以排序?
二叉排序树利用二叉树的结构,把小于当前节点的节点放在当前节点左边,作为当前节点的左孩子,把大于当前节点的节点放在当前节点右边,作为当前节点的右孩子。按照左孩子 → 当前节点 → 右孩子的顺序来遍历二叉排序树,得到的结果不就是从小到大的递增序列吗?如下图所示。
当我们需要在二叉排序树中查找指定的数据时,可以将所指定的数据和当前节点的数据进行比较,如果小于当前节点,那就和当前节点的左孩子节点比较,如果大于当前节点,那就和当前节点的右孩子节点比较。再结合上图,是不是很容易联想到二分查找算法。只是实现方式不同罢了,但原理是一样的。
二叉排序树的意义是什么?
- 如果使用数组排序,同样可以实现二分查找,但是数组的痛点大家都明白,①地址连续容易产生内存碎片,②在插入、删除操作中产生大量的数据位移。
- 如果使用链表排序,虽然解决了上面数组排序的痛点,但是由于链表不支持随机访问,而无法实现二分查找。
- 而二叉排序树整合了数组排序和链表排序的优点,且支持二分查找,从而使查找、插入、删除三者的效率显著提高。
二叉排序树的缺点
二叉排序树看似完美,但有一个致命bug,如果我们插入的数据是递增或递减的话,得到的结果便不是树,而是链表。如下图所示。
3. 线索二叉树
线索二叉树在二叉排序树的基础上,引入了前驱和后继两个概念,那他们代表什么意思呢?比如下图所示的二叉排序树
此二叉排序树所表示的序列为【1,2,3,4,5,6,7,8】
,该序列中每个节点都有前后直接相邻的两个节点,但是由于二叉排序树是一个树形结构,仅能表达出一种父子关系,因此在二叉树节点的属性定义中,添加两个属性:前驱节点和后继节点
- 前驱结点:指定节点A在序列中前面的节点在二叉排序树中的位置。比如上图中节点3的前驱节点是节点2;
- 后继结点:指定节点A在序列中后面的节点在二叉排序树中的位置。比如上图中节点6的后继节点是节点7;
public class Node {
// 数据
String data;
// 指向左孩子
Node left;
// 指向右孩子
Node right;
// 指向前驱结点
Node preNode;
// 指向后继结点
Node sucNode;
}
插入
插入操作非常简单。和二叉排序树是一样的。因此不多介绍。
删除
如果该节点是叶子结点,则直接删除。如果是非叶子结点,则是通过将该节点的前驱结点A或后继节点B替换掉该节点,然后删除前驱结点A或后继节点B
叶子节点
非叶子节点
三、平衡二叉树
为了解决二叉排序树的致命bug,平衡二叉树横空出世。它在二叉排序树的基础上添加了一个关键点:平衡。先搞清楚何为不平衡:当树中某个节点的左子树的高度与其右子树的高度不一致时,就是不平衡。但是我们无法避免一个节点只有左孩子或只有右孩子这种情况,因此决定:当一个节点的左子树高度与右子树高度相差不超过1,即为平衡。如下图所示
因此当我们向一棵二叉排序树中插入节点后,需要判断插入节点的父节点的左右子树高度差,当高度差大于1时,需要对以插入节点的父节点为根节点的子树进行调整,使其重新平衡。而调整的方式我们使用左旋转和右旋转来实现。
1. 左旋转
当我们向节点A的右孩子的右子树插入一个节点后,导致以节点A为根的子树左右不平衡,此时需要将该子树进行一次左旋转实现该子树的平衡,如下图所示
简单情况,即节点A的右孩子只有右子树,没有左子树
复杂情况,节点A的右孩子既有右子树,也有左子树,这种情况下,需要在旋转后,将原先右孩子的左子树,变为旋转后节点A的右子树
其实只要理解上面的简单情况,这个复杂情况自然也就理解了。
2. 右旋转
当我们向节点A的左孩子的左子树插入一个节点后,导致以节点A为根的子树左右不平衡,此时需要将该子树进行一次右旋转实现该子树的平衡,如下图所示
- 简单情况,即节点A的左孩子只有左子树,没有右子树
复杂情况,节点A的左孩子既有左子树,也有右子树,这种情况下,需要在旋转后,将原先左孩子的右子树,变为旋转后节点A的左子树
这里就不贴图了,希望读者根据左旋转能举一反三。
3. 左右旋转(左旋转+右旋转)
当我们向节点A的左孩子的右子树插入一个节点,导致以节点A为根的子树左右不平衡,此时需要将该子树进行一次 左旋转 + 右旋转 实现该子树的平衡。
- 先将节点A的左孩子为根的左子树进行左旋转,旋转完成后,就得到了类似向节点A的左子树插入一个左孩子节点,参考上图中右旋转的情况
- 再将节点A为根的子树进行右旋转即可实现平衡
4. 右左旋转(右旋转+左旋转)
当我们向节点A的右子树插入一个左孩子节点,导致以节点A为根的子树左右不平衡,此时需要将该子树进行一次 右旋转 + 左旋转 实现该子树的平衡。
- 先将节点A的右孩子为根的右子树进行右旋转,旋转完成后,就得到了类似向节点A的右子树插入一个右孩子节点,参考上图中右旋转的情况
- 再将节点A为根的子树进行左旋转即可实现平衡
到这里,二叉树、二叉排序树、线索二叉树、平衡二叉树我们就都已经掌握了。
恭喜你,红黑树你已经掌握一半了。
四、红黑树
红黑树和平衡二叉树大致相同,区别为:
红黑树在平衡二叉树的基础上,引入了红色和黑色的概念
public class Node { // 数据 String data; // 指向左孩子 Node left; // 指向右孩子 Node right; // 颜色,通过boolean类型属性来表示当前节点是红色还是黑色 boolean black; }
以下为红黑树的示例图,本图中展示的都是非叶子结点,因为红黑树中的叶子结点均为空节点,因此忽略叶子结点。
1. 性质
- 结点要么是红色,要么是黑色
- 根结点是黑色
- 所有叶子都是黑色
- 每个红色结点的两个子结点都是黑色,即不能出现连续的两个红色节点
- 从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点,
2. 颜色的意义
为什么要引入颜色的概念呢?既然已经有平衡二叉树了,而且它也能保证绝对平衡,为什么还要使用平衡性较低的红黑树呢?红黑树中颜色的意义是什么?
回顾一下在讲平衡二叉树时,我们是如何判断节点的左右子树是否平衡的?是通过左右子树的高度差。当然了我们肉眼直接就可以看出一棵树中各个子树的高度,很容易得出高度差。
因此在程序中我们可以为平衡二叉树的每个节点添加一个属性表示以当前节点为根的子树的高度。但问题来了,每当我们新增或删除一个节点,都需要对从该节点到根节点的路径上的所有节点的高度属性进行自增或自减运算,在旋转的过程中该运算会更加复杂,因此给节点添加高度属性来标记当前子树的高度并不是一个好办法。
红黑树中的颜色属性就是为了解决这个问题,通过子节点与父节点的颜色比较,就可以知道当前子树是否平衡。进而进行旋转以达到平衡。
五、插入
在红黑树的插入操作中,与上面二叉排序树的插入操作相同,但是由于红黑树中的节点有颜色属性,那么如何决定新插入节点的颜色?,因为新节点的颜色导致红黑树的五个性质无法满足该怎么办?,这些都是新节点插入后的调整操作。
1. 调整
插入后的调整包含子树的左右旋转与颜色的调整,而左旋转和右旋转在平衡二叉树中我们已经详细介绍过了。
首先,我们将新插入的节点的颜色设置为红色。
调整时先考虑两种特殊情况:
情况一:红黑树为空树
直接将新插入节点的颜色由红色改为黑色即可。
情况二:新插入节点的父节点为黑色
新插入节点依然为红色,保持不变
这两种特殊情况都不会产生连续的红色节点,因此并不复杂。
再来考虑四种一般情况:
情况三:当我们向节点A的左孩子的左子树插入一个节点,节点A的左右孩子都是红色,如图所示
这种情况下,插入新节点后,产生了连续的红色节点,不满足性质四,需要将新节点的父节点变为黑色。变为黑色后,以节点A到其所有子节点所经历的黑色节点数量不同,因此不满足性质五,需要将节点A的另一个节点9也变为黑色。此时根据性质四,红色结点的两个子结点都是黑色,因此将节点A变为红色。
情况四:当我们向节点A的左孩子的左子树插入一个节点,节点A的左孩子是红色,右孩子为黑色,如图所示
在这种情况下,插入新节点后,产生了连续的红色节点,不满足性质四,需要将新节点的父节点变为黑色。变为黑色后,就得到了情况三的调整中第三步了,想必大家知道该怎么做了
情况五:当我们向节点A的左孩子的左子树插入一个节点,节点A的左孩子是红色,右孩子为空,如图所示
在这种情况下,
- 插入新节点后,产生了连续的红色节点,不满足性质四,需要将新节点的父节点变为黑色。
- 此时不满足性质五,需要再将节点A变为红色,避免从根节点到叶子结点所经历的黑色节点数量不同。
- 因为以节点A为根的子树的左右子树不平衡,因此需要以节点A为准,进行左旋转,旋转的过程与平衡二叉树一致。
情况六:当我们向节点A的左孩子的右子树插入一个节点,节点A的左孩子是红色,右孩子为空,如图所示
我们只需要先对新节点的父节点进行左旋转,就可以得到情况五了,剩下的步骤和情况五相同
其实还有一些情况是新节点的父节点为节点A的右孩子节点,但相信大家完全可以根据上面的例子去推演它插入后如何调整了。
六、删除
在红黑树中,删除一个节点的操作和线索二叉树的删除操作是一样的,只是删除节点后,由于节点位置的改变而导致节点颜色的变化,使得红黑树的性质无法被满足,因此,需要我们在将节点删除后对颜色进行调整。
另外,在使用前驱或后继节点替换待删除节点时。
我们的做法是:将待删除节点的值替换成前驱或后继节点的值,而非替换整个节点。然后以前驱或后继节点为当前节点,对其进行删除以及颜色调整。于是就将对指定节点的删除,转变为对该节点的后继节点的删除。
这样做的原因是:如果直接使用前驱或后继节点去替换待删除节点,那么待删除节点这个位置上的颜色就可能会发生变化;将前驱或后继节点删除后,由于节点之间的连接断开,会再一次发生颜色的变化而需要调整,这样会带来两次调整操作。
1. 调整
我们要先想明白一件事,删除红色节点是不需要调整的,什么样的节点需要进行调整?
删除的节点为叶子结点
该节点为黑色节点
需要调整,删除黑色叶子结点后,会导致父节点左右子树不平衡。
该节点为红色节点
无需调整,不会破坏五个性质中的任何一个。以上图为例。
删除的节点只有一个子节点
该节点为黑色节点
无需调整,从下图可以明显看到,只需要将子树中的数据复制到待删除节点中,再将子节点删除即可
该节点为红色节点
无需调整,不会破坏五个性质中的任何一个。例如上图中删除节点H。
删除的节点有左右两棵子树
该情况是否需要依赖于删除节点的后继节点,因为我们对删除节点的操作仅限于将后继节点的值复制到删除节点,删除节点的颜色没有发生变化。因此我们需要将注意力放在后继节点,因为后继节点才是真正要删除的节点,而后继节点往往只有右子树或没有子树,于是就回到上面两种情况了。
综上所述:
删除节点后需要对其进行调整的情况只有一种,删除的节点为叶子结点且该节点为黑色。
如何调整?
依然以此图为例,删除黑色叶子结点后,该节点的红父节点左右不平衡,因此需要对其进行调整。
我们对其先进行旋转,将其恢复平衡后,再将其颜色进行调整。
以上只是我为大家举的一个例子,当然还会有其他情况,但无非也就是我讲的是左旋转+变色,其它的是右旋转+变色,希望大家举一反三。
七、总结
整篇文章下来,大家可以发现,有几乎一半的篇幅并不是红黑树,只是基于二叉树实现的其他数据结构,但它们又是理解红黑树必不可少的知识点。
- 二叉排序树为红黑树提供了排序思想
- 线索二叉树提供了删除节点时利用前驱后继节点的思想
- 平衡二叉树提供了插入节点时左右旋转的思想。
在这三种树的基础上,我们只需要知道红黑树的颜色是做什么用的就行了,其实红黑树本身真的并不复杂。
因此难也不难,很好理解。
纸上得来终觉浅,绝知此事要躬行。
————————————————————我是万万岁,我们下期再见————————————————————