红黑树—红黑交响曲
Posted 二进制先生
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了红黑树—红黑交响曲相关的知识,希望对你有一定的参考价值。
从这篇文章开始我们来介绍红黑树这种数据结构,由于红黑树有二分搜索树和AVL树的性质,对于红黑树的操作同样依赖于这些性质。所以,如果理解了二分搜索树和AVL树之后再来理解红黑树其实相对来讲还是比较简单的。
红黑树是由Robert Sedgewick发明的,你如果不知道Robert是谁,网上很多文章都推荐一本书叫《算法4》,这本书就是Robert写的。如果你不知道《算法4》这本书,那你一定知道 Donald Knuth(中文名叫唐纳德),如果你连唐纳德也不知道那就应该去补补课了。可以说,如果没有唐纳德我们就没有衡量数据结构算法性能的方法,相应的我们也就没有提升算法性能的依据了,这就相当于是现实世界没有了尺子和秤,对于计算机科学的重要性不言而喻。他写过一套书叫《The Art of Computer Programming》 中文叫《计算机编程的艺术》。没错,微软在最鼎盛时期,比尔盖茨说读了这本书,并且读懂了,你可以把简历直接发给比尔盖茨。
我第一次去认真学习红黑树是看《算法导论》但当时备受打击,我给你看一下算法导论对红黑树的说明你就知道了。
一棵红黑树是满足下面红黑性质的二叉搜索树
1. 每个节点或是红色的,或是黑色的
2. 根节点是黑色的
3. 每个叶结点(NIL)是黑色的
4. 如果一个节点是红色的,则它的两个子节点都是黑色的
5. 对每个节点,从该节点到其所有子节点的简点路径上,均包含相同数量的黑色节点
《算法导论》第13章
如果第一次看到这5条性质,相信大部分人和我一样是有点蒙的,个人觉得对于红黑树的讲解比较好的还是《算法4》这本书。可能跟作者是红黑树的发明者有关系吧。
这篇文章我们最终就是要把红黑树的这几条性质搞明白。但如果直接上来就讲红黑树还是有些吃力。所以,我们从另一种树结构,2-3树作为切入点。
什么是2-3树
2-3树的特点是同一个节点可以保存一个元素,也可以保存两个元素,如下图:
对于一颗2-3树,节点可以有1个元素或者2个元素,如果节点只有1个元素,其最多只有两个子节点,左子节点比当前节点小,右子节点比当前节点大。如果一个节点有两个元素,左子节点比小的那个元素小,右子节点比大的那个元素大,中间的子节点介于两个元素之间。最后,对一颗2-3树一定是一颗绝对平衡树,也就是平衡因子为0,回忆一下我们讲AVL树的时候对平衡因子的说明,我们说对于任一节点,其平衡因子等于左子树和右子树的层高差。
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树是比较高的,在统计意义上来讲红黑树在插入和查找综合上效率是比较高的,这也是为什么红黑树为什么广泛应用在计算机各个方面。
写在最后
到这里为止,加上把前面的几篇文章都理解了的话,对于在工程中遇到的数据结构相关的问题已经没难度了。但数据结构只是基础,在工程中将数据结构和实际要解决的问题串连起来的是各种各样的算法。这个系列文章我并不打算介绍更多关于算法的内容,原因有两。首先,算法还是比较复杂的,特别是涉及到一些比如动态规划、递归算法等这类高级算法是反直觉的,这需要大量的练习去习惯计算机的思考方式,然后以计算机思考方式去考虑问题。其次,我觉得目前以我的能力没办法讲透彻、讲明白。所以这个系列文章只会介绍常用的几种排序算法和图论相关的最短路径和最小生成树算法。介绍图论算法主要是为了展示高级数据结构和算法的本质无一例外都是对一种或者多种基础数据结构的一系列操作。
随着计算机科学的发展,在不久的将来,开发者的舒适空间会进一步压缩,大量的业务代码的工作会被机器所取代,程序员的工作更多的是构建基础设施。比如,如何让公司不同地区、甚至不同国家之间的mysql相互高效的连接在一起,如何保证数据安全可靠,如何保证数据传输的效率,如何在秒级时间内从TB级别的数据里精确找到想要的那一条数据,如何通过PB级别的历史数据瞬间生成一份用户报告,如何实现机器自动编写业务代码等等。
这些技术的背后本质是什么?在我们看来这背后的本质始终都没有逃出一个词,那就是“基础”,基础包括了什么?数据结构算法、网络协议、操作系统、计算机组成原理、编译原理、数学知识等。如果你不懂得数据结构算法,你就不可能对操作系统和你工作中使用的MySQL、Redis这类中间件有深刻认识,从而也不可能设计出一个合理的、高性能的系统。如果你不懂得网络协议,写出来的网络框架无法发挥出计算机最优的性能,出了问题更是不知道如何下手。如果你不懂得操作系统里的进程调度,内存管理,你没法知道自己写的代码到底有没有问题,或者出了问题如何快速定位。如果你不会数学知识,你就没法设计出一个高效可靠的微服务限流算法,也没有一个衡量系统各项指标的工具,进而无法站在更高的角度去审视你的系统,如果不理解编译原理的话,实现机器自动写业务代码将变得异常复杂,甚至不太可能。这样的例子还有非常非常多。
这里我们通过一个例子来一步说明,比如我们知道CPU有个核心性能指标——主频,例如intel i5 2.3GHz,这代表什么呢?1GHz表示1秒钟CPU有10亿个时钟周期,2.3GHz的话1秒钟就有23亿个时间周期,这时我们就需了解计算机原理、以及操作系统相关的知识,CPU被划分成若干个时间片,操作系统中的任务(进程或者线程)会轮流载入到CPU的寄存器进行计算,每个任务运行CPU划分的最小的时间片段之后就将自己运行到哪里以及栈中的变量等信息保存起来,将自己挂起,给其它任务运行的机会,由于进程的切换成本比线程要大,所以我们更倾向于使用线程,但任务的切换始终还是会陷入到内核态,所以计算机的大牛们又发明了协程,就是在用户态实现任务的调度,避免陷入到内核。我们再延伸一下,在操作内存的时候由于会经过总线,虽然是光的速度,但相对CPU内部来说内存离CPU寄存器是比较远的,需要100个以上的时钟周期。而操作CPU的一级缓存只需要个位数的时钟周期,操作三级缓存也不过30来个时钟周期。很显然,我们可以通过控制CPU命中CPU自己的缓存就能明显提高程序性能,而这也是其它编程语言无法取代C/C++的根本原因。你可能会说,现代计算机硬件已经不是瓶颈了,节省2~3百个时钟周期实在没什么必要。确实,在很多场景中我们不用去考虑这2~3百个时钟周期的差异。但如果我们有成千上万台机器呢?这时每台机器能节省2~3百个时钟周期就是非常有意义的事情了,不光是能得到更多的计算资源,而且能够节省可观的用电成本。
所以你看,其实这个世界有些时候并不像我们想象中变化的那么快!也许在不久的将来,我们要担心的不是35岁危机,而更应该担心的是自已能不能更好的驾驭机器。
以上是关于红黑树—红黑交响曲的主要内容,如果未能解决你的问题,请参考以下文章