【数据结构之红黑树】深入原理与实现

简介: 意节点的左子树和右子树的层高差不大于1,为了维护树的平衡,我们介绍了树的左右旋转。但是,AVL树维护平衡的代价是比较大的。所以,我们又介绍了红黑树这种数据结构,这是因为红黑树插入的效率相对AVL树是比较高的,在统计意义上来讲红黑树在插入和查找综合上效率是比较高的,这也是为什么红黑树为什么广泛应用在计算机各个方面。

从这篇文章开始我们来介绍红黑树这种数据结构,由于红黑树有二分搜索树和AVL树的性质,对于红黑树的操作同样依赖于这些性质。所以,如果理解了二分搜索树和AVL树之后再来理解红黑树其实相对来讲还是比较简单的。

红黑树是由Robert Sedgewick发明的,你如果不知道Robert是谁,网上很多文章都推荐一本书叫《算法4》,这本书就是Robert写的。如果你不知道《算法4》这本书,那你一定知道 Donald Knuth(中文名叫唐纳德),如果你连唐纳德也不知道那就应该去补补课了。可以说,如果没有唐纳德我们就没有衡量数据结构算法性能的方法,相应的我们也就没有提升算法性能的依据了,这就相当于是现实世界没有了尺子和秤,对于计算机科学的重要性不言而喻。他写过一套书叫《The Art of Computer Programming》 中文叫《计算机编程的艺术》。没错,微软在最鼎盛时期,比尔盖茨说读了这本书,并且读懂了,你可以把简历直接发给比尔盖茨。

我第一次去认真学习红黑树是看《算法导论》但当时备受打击,我给你看一下算法导论对红黑树的说明你就知道了。
一棵红黑树是满足下面红黑性质的二叉搜索树

  1. 每个节点或是红色的,或是黑色的
  2. 根节点是黑色的
  3. 每个叶结点(NIL)是黑色的
  4. 如果一个节点是红色的,则它的两个子节点都是黑色的
  5. 对每个节点,从该节点到其所有子节点的简点路径上,均包含相同数量的黑色节点

如果第一次看到这5条性质,相信大部分人和我一样是有点蒙的,个人觉得对于红黑树的讲解比较好的还是《算法4》这本书。可能跟作者是红黑树的发明者有关系吧。

这篇文章我们最终就是要把红黑树的这几条性质搞明白。但如果直接上来就讲红黑树还是有些吃力。所以,我们从另一种树结构,2-3树作为切入点。

什么是2-3树
2-3树的特点是同一个节点可以保存一个元素,也可以保存两个元素,如下图:

对于一颗2-3树,节点可以有1个元素或者2个元素,如果节点只有1个元素,其最多只有两个子节点,左子节点比当前节点小,右子节点比当前节点大。如果一个节点有两个元素,左子节点比小的那个元素小,右子节点比大的那个元素大,中间的子节点介于两个元素之间。最后,对一颗2-3树一定是一颗绝对平衡树,也就是平衡因子为0,回忆一下我们讲AVL树的时候对平衡因子的说明,我们说对于任一节点,其平衡因子等于左子树和右子树的层高差。

C/C++Linux服务器开发高级架构师/C++后台开发架构师​免费学习地址
​ke.qq.com/course/417774?flowToken=1013189
【文章福利】另外还整理一些C++后台开发架构师 相关学习资料,面试题,教学视频,以及学习路线图,免费分享有需要的可以自行添加:Q群:720209036 点击加入~ 群文件共享

2-3树插入节点
2-3树节点的插入,主要还是在于维护树的绝对平衡性质,同时又不破坏2-3树的性质,下面我们通过画图的方式推演一遍2-3树插入的过程。

在一开始,2-3树只有一个节点42,我们插入一个节点37,最终结果37和42融合成为了一个三节点。\

插入一个节点12, 将12,37,42融合成一个4节点,但4节点明显不符合2-3树的性质。所以,我们将37抽出来融合成一个2节点,完成节点12的插入。

插入节点18, 将12和18融合成一个3节点,完成节点18的插入操作。

插入节点6,先将6,12,18临时融合成一个4节点。同样的,此时已经不符合2-3树的性质了,我们将12向上融合成一个2节点,但此时破坏了树的绝对平衡性质。所以将12继续向上和37融合成一个3节点,完成节点6的插入操作。

插入节点11,此时只需将11和6进行融合形成一个3节点,完成节点11的插入。

最后我们插入一个节点5, 先将5,6,11融合成一个4节点,此时破坏了2-3树的性质,将节点6继续向上和12,37融合成一个4节点,此时还是破坏了2-3树的性质,将节点12向上融合成一个2节点,完成节点5的插入操作。

2-3树和红黑树的关系
我们上面费了这么大力气讲了2-3树,那么2-3树和红黑树之间到底有什么关系呢?

我们先回顾一下前面提到的红黑树5条性质里的第一条:每个节点或者是红色,或者是黑色。那么对应到2-3树的2,3节点是什么样子的呢?我们来看一张图,如下:

2-3树里的2节点,我们可以对应红黑树里的黑色节点,我们用黑色表示,这里面有几层意思。首先,我们引申出前面提到了的红黑树的5条性质里的第二条,根节点是黑色的,那么对于节点a,是一颗只有一个节点的红黑树,那么它自然是黑色的。其次,红黑树的每个节点要么是黑色,要么是红色,对应红黑树5条性质里的第一条。

然后我们看3节点,对于一个三节点,就是包含了两个元素的节点,对应红黑树我们可以把左边的b抽出来当作c节点的一个左子节点。那么,我们如何表示b和c之间的关系呢?这里我们将b节点标记成了红色,表示b和c之间对应2-3树里的3节点。

好了,对于2-3树和红黑树之间的关系我们就讲完了,下面我们来看一个2-3树转成红黑树的例子,如下图:

左边是一颗2-3树,右边第一颗树我们以2-3树的样子排列成的一颗红黑树,然后将改造后的红黑树拉伸之后大家可以看到此时这就是一颗树二叉树,只不过此的二叉树满足红黑树的性质,你可以对照图思考一下。如果这张图看懂了,基本上就对红黑树有了概念。

对照上面的图,我们来看一下红黑树剩下的3三性质:

第3条,每一个叶子节点(最后空节点)是黑色的,这是什么意思呢?对于上面的这颗树,我们没有画出来的,其实都有一个对应为NULL的子节点,你可以理解为左子树或者右子树指向的是一个空指针,这些节点的颜色是黑色的。

第4条,如果一个节点是红色的,那么他的孩子节点都是黑色的,如果理解了2-3树的话这个也好理解,我们可以简单理解红色和黑色融合为一个3节点,如果红色节点相邻节点出现了红色节点,就说明可以融合成一个大于3节点的节点。所以,红色节点的子节点一定都是黑色。

第5条,从任意一个节点到叶子节点,经过的黑色节点是一样的,这个怎么理解呢?我们先看2-3树,左、右子树的层高一定是一样的,因为2-3树是一颗绝对平衡的二叉树,那么对于应到红黑树,我们在上面说我们可以简单理解红色节点和黑色节点融合成一个3节点,由此,我们可以推导出红黑树从凭单节点到叶子节点所经过的黑色节点一定是一样多的。对此,我们回忆一下AVL树,我们说保持AVL树的平衡其实是维护树中任一节点的平衡因子小于等于1。那么,对于红黑树而言,根据其性质在最坏的情况下,隔层会出现一个红色节点,那么极端情况下,左右层高差可能相差2倍,这也是红黑树和AVL树最大的区别之一,我们知道,树的层越高,查询的时间复杂度也会相应变高。所以,单纯的从随机读的性能来看,似乎红黑树还不如AVL树。那为什么说在实际工程中我们更多的使用的是红黑树而不是AVL树呢?这主要是因为红黑树的插入性能会比AVL树要好,从统计意义上来讲我们认为红黑树的性能要比AVL树更好,这里的统计意义涉及到一些数学知识,三两句话讲不明白,如果有兴趣可以自己查一些资料。

好了,通过将2-3树和红黑色进行对应,我们将红黑树的5条性质都讲明白了,下面我们要解决的问题是如何向红黑树中插入节点。

插入节点
我们假设,一开始创建出来的节点是红色,当插入完成再去调整颜色,如下图:

上面我们创建了一红黑树,第一个节点为42,一开始是红色,因为整颗树只有一个节点,42就是整颗树的根节点,所以,将节点42调整为黑色。紧接着,插入节点37,同样的,一开始节点37也是红色,由于37比42小所以我们插入到节点42的左子树。此时,整颗树已经是一颗合法的红黑树了,不用做其它操作。

左旋转
上面我们讲了一种情况,就是插入的节点在左侧,并且父亲节点是黑色,我们将节点直接插入到父亲节点的左侧就完成了。下面我们看另外一种情况,如下图:

我们看到第一个节点是37,然后我们插入42,这时,42比37要大,按照二分搜索树的性质,我们要将42放在37的右子树。但是,回忆我们前面讲到的2-3树和红黑树的对应关系,小的节点我们是放在左边的,那么,很明显,此时这颗红黑树已经不是一颗合法的红黑树了,这就是我们要讲的左旋转。

我们将这颗红黑换一种表现方式,如下图:

节点37是我们要待插入元素的父亲节点,我们用node表示,42是待插入节点我们用x表示,37和42的子节点我们分别用T1、T2、T3表示。然后我们将node节点做一次左旋转,如下图:

首先,我们将node的右子节点指向x的左子节点也就是T3,然后我们将x的左子节点指向node节点,完成左旋转。此时,虽然结构正确了,但还没有完,我们需要继续调整节点的颜色,如下图:

我们将x的颜色染成和node节点一样,然后将node节点的颜色染成红色,这里有一个小陷阱,就是如果node节点的颜色本身就是红色,那调整过后不就破坏了红黑树的性质吗?其实,这里只是维护红黑树过程中的一个子步骤,调整完之后我们还需要继续进入上一层进行同样的操作,在上一层一定会帮我们调整正确,这里可能需要结合递归的思想仔细思考一下。

下面,我们来看一下左旋转的核心代码:

public Node leftRotate(Node node) {
// 先将x记下来
Node x = node.right;
// 旋转操作
node.right = x.left;
x.left = node;​
// 调整颜色
x.color = node.color;
node.color = RED;​
// 最后将x返回
return x;
}
好了,到此为止我们左旋转就完成了。

颜色翻转
假如我们要插入的父亲节点已经有了一个红色的左子节点,此时我们要插入到其右边,我们应该怎么处理,下面我们通过一张图看一下这个过程,如图:

在原有的红黑树,里面有两个节点37和42,然后我们插入一个新的节点66,此时,按照二分搜索树的性质我们应该插入到节点42的右子树,同样的,一开始插入的节点66是红色的,那么对应到2-3树就相当于是融合了一个临时的4节点,但显然,此时是不符合2-3树的性质的,我们需要将42继续向上融合成一个2节点,那么对应到红黑树,我们就需要将节点42的颜色染成红色,将37、66节点的颜色染成黑色,这样我们就完成了节点的插入,如果对这个还比较疑惑,可以回过头去看一下2-3树插入元素的过程。

下面我们看一下颜色翻转的代码:

private void flipColor(Node node) {
node.color = RED;
node.left.color = BLACK;
node.right.color = BLACK;
}
颜色翻转代码还是比较简单的,可以对照图仔细再对一下。

右旋转
我们前面说红黑树的红色节点的最近的子节点一定是黑色,那么假如我们插入的节点的父亲节点是一个红色节点,我们应该怎么处处理呢?同样的,我们先通过一张图还看一下这个过程,如图:

我们看到节点10比37要小,此时节点37也是一个红色节点。其实也很简单,我们只需要对节点42做一次右旋转,如下图:

我们用node表示节点42,用x表示37,然后将node的左子节点指向T1,接下来我们再将x的右子节点指向node就完成了右旋转,和前面说的左旋转是一个对称的操作,如图:

和左旋转一样,我们也需要调整颜色,如下图:

可以看到,相对于左旋转,我们多了一步颜色翻转,你可以对照上面我们讲的颜色翻转那一节来看。

我们同样看一下右旋转的核心代码:

publicNode rightRotate(Node node) {
// 先记下x
Node x = node.left;​
// 旋转操作
node.left = x.right;
x.right = node;​
// 调整颜色
x.color = node.color;
node.color = RED;
return x;
}
可以看到,右旋转和左旋转非常像,其实右旋转就是左旋转的对称操作。好了,到此我们的右旋转也就完成了。

但其实还有一种情况,就是插入的节点在黑色节点的左子节点的右边,如果黑色节点的左子节点是红色,如下图:

对于这种情况,我们可以先对节点37做一次左旋转,得到一个和我们需要使用右旋转的结构,如图:

然后我们就可以进行右旋转的过程了,如下图:

好了,到这里红黑树插入节点的所有情况我们都讲完了,下面我们来总结一下红黑树插入节点的整个过程,如下图:

我们可以通过判断插入点的颜色,来决定是直接插入,还是左旋转又或者是右旋转,再或者是否要进行颜色反转,这几个过程其实都有可能,过程之间不是互斥的条件,等下看代码的时候就明白了。

下面我们将关键代码再理一遍。首先,我们定义了一个Node的内部类来表示红黑树的一个节点,代码如下:

publicstatic final boolean RED = true;
public static final boolean BLACK = false;​
private class Node {
private K key;
private V value;
private Node left,right;
private boolean color;​
public Node(K key, V value) {
this.key = key;
this.value = value;
this.left = null;
this.right = null;
this.color = RED;
}
}
然后我们再看一下添加节点的核心逻辑,代码如下:

private Node add(Node node, K key, V value) {
if (node == null) {
size++;
return new Node(key, value);
}
// 节点插入的主流程和二分搜索树是一样的
if (key.compareTo(node.key) < 0)
node.left = add(node.left, key, value);
else if (key.compareTo(node.key) > 0)
node.right = add(node.right, key, value);
else
node.value = value;
// 如果节点的右子节点是红色,右子节点是黑色,进行左旋转
// 这里可对照我们左旋转那一节的图来看
if (isRed(node.right) && !isRed(node.left))
node = leftRotate(node);
// 如果大子节点和左子节点的左子节点都是红色,进行右旋转
if (isRed(node.left) && isRed(node.left.left))
node = rightRotate(node);
// 如果左、右子节点都是红色,需要进行一次颜色的翻转
if (isRed(node.left) && isRed(node.right))
flipColor(node);​
return node;
}
前面我们说左旋转、右旋转、颜色翻转这几个步骤不是互斥的,通过上面的代码应该就能够有比较直观的理解了,我们并没有使用else if这类逻辑。

好了,红黑树的插入就讲完了。其实,总结下来,红黑树的插入就是左旋转、右旋转、颜色翻转这简单几步,但如果你不理解这背后的原理,不理解二分搜索树和AVL树的性质理解起来还是有些困难的。

对红黑树的介绍我们就只讲到这里了,红黑树的节点删除,相对是比较复杂的,要讲清楚其实是不容易的,我也不想罗列一堆规则在这里,没有太多意义,在平时的开发中,真正要从头到尾写一颗红黑这种场景是极少的,除非你是做一些特别底层的开发。

其实我们这里介绍的红黑树是一颗左倾的红黑树,如果你有印象我们在前面是假设在2-3树的3节点上,左边的元素是对应红黑树里的那个红色节点,而且对此我们是做了一些特殊处理的。红黑树的实现不止这一种方式,这一点需要你知道。

总结一下,我们回顾一下二分搜索树和AVL树,二分搜索树的性质是对于任意节点的左子节点一定比右子节点要小,但如果我们插入的数据是有序的时候,二分搜索树会退化成一个链表,相应的随机读的时间复杂度也退化成了O(n)。所以,我们需要去维护树的平衡性来阻止其退化成链表,这时我们介绍了AVL树,我们说AVL树对于任意节点的左子树和右子树的层高差不大于1,为了维护树的平衡,我们介绍了树的左右旋转。但是,AVL树维护平衡的代价是比较大的。所以,我们又介绍了红黑树这种数据结构,这是因为红黑树插入的效率相对AVL树是比较高的,在统计意义上来讲红黑树在插入和查找综合上效率是比较高的,这也是为什么红黑树为什么广泛应用在计算机各个方面。

参考资料

推荐一个零声教育C/C++后台开发的免费公开课程,个人觉得老师讲得不错,分享给大家:C/C++后台开发高级架构师,内容包括Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,立即学习

相关文章
|
3月前
|
存储 算法 C语言
"揭秘C语言中的王者之树——红黑树:一场数据结构与算法的华丽舞蹈,让你的程序效率飙升,直击性能巅峰!"
【8月更文挑战第20天】红黑树是自平衡二叉查找树,通过旋转和重着色保持平衡,确保高效执行插入、删除和查找操作,时间复杂度为O(log n)。本文介绍红黑树的基本属性、存储结构及其C语言实现。红黑树遵循五项基本规则以保持平衡状态。在C语言中,节点包含数据、颜色、父节点和子节点指针。文章提供了一个示例代码框架,用于创建节点、插入节点并执行必要的修复操作以维护红黑树的特性。
100 1
|
5月前
|
存储 NoSQL Redis
Redis系列学习文章分享---第十六篇(Redis原理1篇--Redis数据结构-动态字符串,insert,Dict,ZipList,QuickList,SkipList,RedisObject)
Redis系列学习文章分享---第十六篇(Redis原理1篇--Redis数据结构-动态字符串,insert,Dict,ZipList,QuickList,SkipList,RedisObject)
82 1
【数据结构】红黑树——领略天才的想法
【数据结构】红黑树——领略天才的想法
|
5月前
|
存储 消息中间件 缓存
Redis系列学习文章分享---第十七篇(Redis原理篇--数据结构,网络模型)
Redis系列学习文章分享---第十七篇(Redis原理篇--数据结构,网络模型)
96 0
|
1月前
|
消息中间件 存储 Java
数据结构之 - 深入探析队列数据结构: 助你理解其原理与应用
数据结构之 - 深入探析队列数据结构: 助你理解其原理与应用
29 4
|
2月前
|
设计模式 安全 Java
HashMap底层原理:数据结构+put()流程+2的n次方+死循环+数据覆盖问题
假如有T1、T2两个线程同时对某链表扩容,他们都标记头结点和第二个结点,此时T2阻塞,T1执行完扩容后链表结点顺序反过来,此时T2恢复运行再进行翻转就会产生环形链表,即B.next=A;采用2的指数进行扩容,是为了利用位运算,提高扩容运算的效率。JDK8中,HashMap采用尾插法,扩容时链表节点位置不会翻转,解决了扩容死循环问题,但是性能差了一点,因为要遍历链表再查到尾部。例如15(即2^4-1)的二进制为1111,31的二进制为11111,63的二进制为111111,127的二进制为1111111。
HashMap底层原理:数据结构+put()流程+2的n次方+死循环+数据覆盖问题
|
1月前
|
搜索推荐 索引
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(二)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
1月前
|
搜索推荐 C++
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(一)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
1月前
|
Java C++
【数据结构】探索红黑树的奥秘:自平衡原理图解及与二叉查找树的比较
本文深入解析红黑树的自平衡原理,介绍其五大原则,并通过图解和代码示例展示其内部机制。同时,对比红黑树与二叉查找树的性能差异,帮助读者更好地理解这两种数据结构的特点和应用场景。
25 0
|
1月前
|
人工智能 搜索推荐 算法
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(三)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理