数据结构之红黑树,二进制的奇妙冒险!

目录

简述

1. 何为红黑树

2. 红黑树由来

3. 树平衡手段

4. 平衡限制条件

5. 增删节点逻辑

1. 增加节点

2. 删除节点

6. 红黑状态

1. 平衡限制带来的优化

2. 相对深度计算

3. 旋转的红黑状态变化

4. 删除的红黑状态变化

5. 惰性检查

Cheers!


要说在程序员间广为人知而又颇具难度的数据结构,红黑树算是首当其冲。如果掌握其原理,茶余饭后也能增加一份吹牛资本。但网上大量博文介绍均以红黑树性质证明其结构可行(以结果证明原因?),让人着实费解。本文将从红黑树设计目的出发分析其原理构成,希望能为大家提供新的理解思路。当然,博主才疏学浅,如有漏洞或错误欢迎在评论区指出。

那么事不宜迟,各位看官就同我来看看今日的瞎掰小知识吧。

简述

本篇博客较长,在此简述红黑树的设计思路,若看官们对部分细节存在疑问可根据目录跳转查看详细内容与证明。

红黑树源于二叉排序树,在原基础上通过左右旋进行树深度平衡,并且以"左右节点的子树最小深度之差不超过1"作为是否执行平衡操作的判断条件。其红黑状态设计目的是为了优化各个子树间的深度比较方法,用红(1)黑(0)在二叉树上进行二进制的加减法运算,并实现子树间的相对深度表达,其中对于二进制加法进位还增加了惰性检查机制以降低树平衡频率。

大佬们制作的在线红黑树操作模拟网页:红黑树模拟,用于学习与验证比较方便。


1. 何为红黑树

红黑树是一种具备自排序性质的数据结构,且每次操作(增删改查)的时间复杂度均为O(logN)。自排序性质再加上高性能使得红黑树在各种场景中被应用,如c++中stl的set与map结构,还有java中的hashmap(JDK1.8版本中采用了哈希表+链表+红黑树的结构)。当然hashmap也是一种优秀的数据结构,博主在之后的文章中会对其进行介绍。


2. 红黑树由来

其最初设想来源于二叉排序树的缺陷:若不进行干预,二叉排序树的时间复杂度O(N*logN)可能会随着数据写入降低至最低O(N*N)水平,退化为链表结构。为了解决这一问题,诸多大佬们提出了树的自平衡概念,在每次操作中不断平衡树结构让其接近于完全二叉树结构,从而维持时间复杂度在O(N*logN)水平。

在此基础上,演变出了两种有名的平衡二叉树,AVL树与红黑树。两者原理类似,但AVL树的平衡限制条件更高,为"任意节点左右子树的深度差最大为1",所以在增删操作中,触发平衡策略的频率较高。而红黑树更偏向于工业化应用,通过适当放宽平衡限制条件,换取了更低频率的平衡操作。因此在高读写的业务场景中,红黑树的表现会更优秀一些。而在高读低写的业务场景中,AVL树可能会更有优势。当然,除非追求特定方面的极致性能,一般情况下使用红黑树均已足够。


3. 树平衡手段

对于以下这颗失衡的二叉排序树,各位看官们应该都能想到其平衡办法:把根节点10转变为节点18的左子节点,各节点的树深度就平均了,且其仍然保持着二叉排序树的性质(左节点总小于父节点,而右节点总大于父节点)。

这实际上就是树平衡中左旋的雏形(相对的也有右旋),通过调整右子节点与父节点的关系,从而实现左右子树的深度调整。此时各位看官可能有话想说:举例模型过于简单,如果18节点存在左子节点那不是冲突了吗?正好,我们接下来看一例完全的树结构模型。

图中LL/LR/RL/RR并不是单一的一个节点,而是代表了左子树L的左右子树(LL与LR),与右子树R的左右子树(RL与RR)。可以确认的是在整个右旋过程中,涉及变化的只有三个部分:M、R节点以及RL子树。之前提出的问题在该图得到了解答:当右子节点R成为父节点M的父节点时,父节点M就空出了其右子树的位置,此时将右子树被替换下来的左子树(RL)接替到该位置,右旋操作便完成了。

当然,一切要按规矩来,子树RL之所以能接替在节点M的右子分支上,是因为二叉排序树的性质:当前节点右子树R下的值均大于当前节点M的值。那么节点M的右子树R的左子树RL,其所有值也必然大于M,将其接替在节点M的右子分支上并不会破坏二叉排序树的性质。同理,RR子树接替在节点M的右子分支上也不会破坏二叉排序树性质(虽然不需要这样做)。

从上述例子中可以看出,该方法可通用于二叉排序树上任何节点并保持其性质稳定。而其达到的具体效果如何呢?根据图示,左旋的影响为:子树LL/LR深度均+1,子树RL深度不变,子树RR深度-1。

了解这一点对后续设计平衡策略至关重要,毕竟左旋和右旋是我们平衡树的手段,不熟悉就难以对各种场景进行处理。另外还有右旋的操作方式与影响,相信各位看官都能根据左旋进行推敲,这边直接上结论。

右旋的影响为:子树RL/RR深度均+1,子树LR深度不变,子树LL深度-1。

为了方便记忆,可以这样理解左右旋。左旋:将节点从右边分到左边,右旋:将节点从左边分到右边。讲回两种操作的深度影响,可以看到仅有LL或者RR才会出现深度降低,同时另一侧树深度上升。在完整的应用场景里,这还缺少了降低LR和RL子树深度的方法。这时候可能有看官想到了:如果在LL/LR/RL/RR四个子树里LR深度最大且超出限制,那就先基于L和LR左旋,把深度交换一部分给LL,让LL变成四个子树中深度最大的,然后就可以基于L和M右旋了。同样,可以推理降低RL子树深度的方式。

将不同场景的深度降低方式总结下来,一共是四种(不用特地记忆,理解即可):

1)降低LL:直接右旋

2)降低LR:先左旋,和LL进行深度调整,最后右旋

3)降低RL:先右旋,和RR进行深度调整,最后左旋

4)降低RR:直接左旋

各位看官看到这里辛苦了,可以先捋捋思路,对博主提供的看点进行验证推翻。例如在LL/LR/RL/RR四个子树的模型下,出现某一子树深度最大的情况如何平衡。当然,能用左右旋以外的方法解决最好,请务必在评论区与博主分享一下。


4. 平衡限制条件

既然调整树深度的工具已经到手,就可以将这颗二叉排序树修剪成我们想要的样子了。接下来请大家自由发挥。

常见的平衡限制条件基本是与树深度相关的,比如最大深度 / 最小深度,亦或是两者并用(AVL树),另外平衡限制条件对树上任意节点都生效,这一点请勿遗漏。

对于AVL树来说,其要求为左右子树互相之间的最大深度 - 最小深度之差不超过1,若超过则进行平衡。如果我们想要打造一颗自己的平衡二叉树,设置的平衡限制条件必须能明确树的倾斜边界。即任何情况下树中最大深度和最小深度的叶子节点(也包括nil节点)深度之差是可确定范围的,否则树歪起来没有限制可就糟糕了。

讲了这么多都是虚的,不如实际动手设计一颗平衡二叉树。平衡限制条件就暂定为"左右子树的最大深度之差不超过1",其平衡边界是否为可确定的呢?让我们即刻探究一下。其中用于验证的树模型结构可以稍微简化一下,默认右子树的深度总是小于左子树,这样最大深度就位于最左边的左子树上,最小深度则位于最右边的右子树上。再加上之前预设的平衡限制条件可以得到下图(其中L/RL1/RL2等都代表一颗子树,其子节点就不画出来了):

从图中可以观察到,RL1节点的最大深度为K-1(K为树最大深度),但若除去父节点的深度,留给RL1自己的深度仅有K-3(减去M/R两个节点深度),同样可得RL2自身深度为K-5,RL3自身深度为K-7。不难推测其构成了-2的等差数列,第n个子树RLn的通项公式为self_dep(RLn)=K-2*n-1,其中K为整颗树上的最大深度,同时n也等于RLn节点的父节点深度减一。而当self_dep(RLn)<=1时,作为其兄弟节点的右子树Rn深度为0已无法再继续向下分裂,从而抵达了最小深度所在之处。

当K为奇数时,有最终项K-2*n-1=0可得n=(K-1)/2,其父节点深度dep=n+1,即min_dep=(n+1)/2。

当K为偶数时,有最终项K-2*n-1=1可得n=K/2-1,其父节点深度dep=n+1,即min_dep=n/2。

由上可知,在"左右子树的最大深度之差不超过1"的平衡条件中,平衡树中最大深度不会超出最小深度的两倍,其倾斜边界可确定,条件可行。那么有没有错误示范呢?有的,比如"左右子树的最小深度之差不超过1"的平衡条件就无法确定倾斜边界。下图的树结构就符合平衡条件,但树已经歪至天际。其最大深度可以无限增长且与最小深度无关,倾斜边界无法确定。

那么最小深度就无法作为平衡条件了吗?并不是。上述样例的问题是因为其忽略了二叉树性质,当前节点可选一颗子树作为最小深度,此时另一颗子树的深度限制就被解除了,从而实现绕过平衡条件进行增长。知道了漏洞所在,只要进行填补即可。我们使用新的平衡限制条件为"左右节点的子树最小深度之差不超过1",此条件覆盖了向下一层的所有节点,不存在绕过平衡条件的情况。并且碰巧的是,红黑树采用的平衡限制条件就是它

该平衡条件的边界证明与之前相似,我们默认树模型中右子树的最小深度大于等于左子树的最小深度,来探究最右侧的树深度能达到多少。同样我们采取的是"弃左保右"策略,通过让左子树继承父节点的min_dep,解放右子树的深度限制,可以达到父节点min_dep+1的深度。但在该平衡条件中受到LL/LR/RLn/Rn四颗子树中的最小值限制,Rn的min_dep是无法一直进行增长的,其具体模型如下所示:

图中self_dep为当前节点除去父节点深度后剩下的深度,当其等于零时说明已经抵达最大深度节点处。让我们把self_dep数据单独抽取出来,每层均存在四个节点,分别代表了LL/LR/RL/RR。其值规律为:

即每加两层深度,RR子树自身深度下降一,LL/LR/RL/RR四颗子树任意一颗self_dep降至0时扩展结束。其中LL为最小值,可知当self_dep(LL)=0时,RR子树可达到的深度已经增长到2K或者2K-1(初始深度为K,K,K,K+1情况下)。

综上所述,可得结论:在"左右节点的子树最小深度之差不超过1"的平衡条件中,平衡树中最大深度不会超出最小深度的两倍。是否感觉很眼熟?是的,平衡条件"左右子树的最大深度之差不超过1"得到的倾斜边界也为这个,因此若想区分不同平衡树,使用平衡条件进行描述是个更好的方法。


5. 增删节点逻辑

调整树的工具和平衡树的理想形状都已设计完成,接下来要做的就是让其向外提供服务了。外部的操作主要分为增删改查四部分,其中改和查不影响树结构(改是指value,并不是key),直接让其走二叉排序树的逻辑即可。重要的是其中增加节点与删除节点两部分。

1. 增加节点

增加节点首先要确定节点位置,这一点可通过二叉排序树的查逻辑实现,而后将其作为叶子节点连接到对应位置上。然后向上更新父节点的深度数据一路到根节点,例如平衡条件设置的是"左右子树的最大深度之差不超过1",则可在每个节点上存储对应的最大深度值,同时父节点最大深度=max(左子树最大深度,右子树最大深度)+1。一旦发现超出了平衡条件,就要根据子树的深度情况进行旋转,之前树平衡手段中所说的内容博主这边再放一遍:

1)降低LL:直接右旋

2)降低LR:先左旋,和LL进行深度调整,最后右旋

3)降低RL:先右旋,和RR进行深度调整,最后左旋

4)降低RR:直接左旋

因为不存在一次性增加多个节点的情况,所以能确保每次最多一个节点突破平衡条件。当然,每次旋转完后记得更新相关节点存储的深度数据,并且向上更新至根节点。各位小伙伴可根据自己设定的平衡条件确认不同情况下的平衡流程。

2. 删除节点

删除节点的查找部分不再赘述,主要是其对树结构的破坏需要注意。一共三种情况:

1)叶子节点删除。只需要将深度信息向上更新,如果触发平衡条件就进行旋转,与增加节点的流程基本一致。

2)非叶子节点删除。这种情况下直接删除目标节点会引起树结构大规模变动,设计较为麻烦。一种较好的方式是将删除节点与其后继节点或者前继节点进行交换,可以在不破坏二叉排序树性质的情况下,将删除非叶子节点转变为删除叶子节点。其中还可能会存在一种情况:找到的后继节点存在右子节点。该情况下将后继节点直接删除即可,用右子节点接替原位置并不会引起性质破坏,但向上的深度更新仍是要做的,不同场景下旋转情况有所不同,此处暂不做枚举介绍。

至此为止,实现一颗平衡树所需要的结构流程都已设计完毕,大家没事的时候可以手写试试


6. 红黑状态

最后,让我们将注意力调回红黑树上原来这是篇红黑树博客,红黑树其命名来源于树中的红黑节点状态。之所以在原有平衡树结构上增加了该设计,其目的是优化增删过程中存储的深度数据,改为以更少内存占用的红黑二进制状态存储,且平衡判断逻辑更加简单顺滑。其中二进制的进位还采用了惰性检测的机制降低平衡频率,为了让讨论的模型尽量简单以下部分将不会加入该机制,改为在最后分段解释。

1. 平衡限制带来的优化

红黑树的平衡限制条件之前已经讨论过,其内容为"左右节点的子树最小深度之差不超过1"。相比起左右节点的子树最小深度差值可能会超过1(此时触发平衡),左右节点的最小深度差值永远不会超过1(可自行证明)。若能将左右节点的子树深度比较转换为左右节点比较,则各节点只需存储一位二进制便可模拟所有场景【左右子树均为1或0时代表深度相等,为1和0时代表深度差1】。

那么是否存在方法转换比较的模式呢?让我们来分析一下平衡条件的触发场景。抛开左右的排列组合(即默认右子树深度高一些),其只有一种情况,如下图所示。其中的self_dep均代表以自身为根节点的最小深度值。

LL/LR其中的一个最小深度可以为K+1,并不会影响条件触发。至于RL子树,首先不能为K,否则在RL与RR之间已经触发平衡条件。其次也不能为K+2,因为此时R最小深度已达到K+3,超出L节点最小深度2层,因此RL只能等于K+1

普通的判断步骤是LL/LR/RL/RR最小深度值相互比较,查看其中是否存在超出1的差值。但在树结构上我们可以挖掘到更多信息:RL与RR的最小深度均大于等于K+1,可得R的最小深度为K+2,同理L的最小深度为K+1。若以左右节点比较的方式来描述该场景则为:当触发平衡限制时,可知左右子树存在深度差,且深度更大的一侧子树中,其左右子树也存在深度差(深度差只能为1)。该因果关系反向也能成立,看官们若有兴趣可自行证明。

将上述得到的结论以二进制形式应用,便衍生出了新的深度检查机制:每个节点存储一位二进制,其值代表与兄弟节点的相对深度。当左右子树的相对深度为1和0,且为1的一侧子树中,其左右子树深度也1和0时,触发平衡限制。看起来比较绕口,让我们将其画成图:

可以看到当两个相连节点均为1时,若高位节点的兄弟节点为0,此时触发平衡限制。从这里可以推导出的另一个结论是平衡情况下,不会存在两个相连节点同时为1。红色在红黑树中代表1,黑色代表0。红节点的父节点或子节点必为黑节点的结论可从此处得来。

2. 相对深度计算

既然利用相对深度进行判断可行且占用空间较少,那么得想办法实现相对深度的计算。

新增的节点默认值可设置为1(相对深度为1)。而父节点相对深度推算可根据子节点情况进行:仅当左右节点相对深度均为1时,说明整体的最小深度增加了1,此时需要向上进位,将父节点值增加1(深度增加),左右节点重置为0,等待下一层深度的进位。简单来看,就是基于二叉树结构进行的二进制加法。

个别小伙伴可能会有担心:如果父节点已经为1,子节点再上进位会怎么样?答案是不会出现该情况:在父节点为1的情况下,若子节点任意一个为1时就会触发平衡,没有凑成两个1节点的机会。当然,如果你将平衡条件放宽就会出现这种情况,但那时一个节点所需要保存的也不只是一个二进制位了。

到目前为止,二进制模式已经支持红黑树增量操作中的平衡判断了:

1)新增节点默认为1。

2)增加节点后,检查其兄弟节点是否为1,若为1则向上进位,然后在父节点重复该步。

3)若无法进位,检查其父节点是否为1,若为1则需基于父节点和祖父节点进行平衡操作。

另外需要一提的是根节点固定为0,因为其没有父节点也没有兄弟节点,没有存储相对深度的必要。在整个二进制模式中充当的更像是处理数据溢出的角色。

3. 旋转的红黑状态变化

删除操作对二进制状态的改变类似于做二进制减法,而左右旋的改变既不是加法也不是减法(也可能博主是没想到加减法的方式),而是节点转移。让我们暂且放下删除操作,先来看看旋转的影响吧。

旋转的场景较少,此处我们分析降低RR深度与RL深度两种情况即可(左子树的可以类推)。

1)降低RR节点:左旋操作。

在旋转过程中,确定相对深度的坐标系非常重要,我们需要基于该值重新推算旋转后各节点的相对深度。例如以M节点为顶点的这颗子树向外提供的相对深度基于实际深度K+2(上图所示),那么在旋转后,R节点替代了M节点且实际深度达到K+3,在原K+2的基础上增加1,因此R节点当前的相对深度应为1。然后依次向下推导相对深度,剩余的M与RR,L与RL实际深度相同,因此均为0(均为1时相对深度就不平衡了)。至于LL和LR子树,其作为兄弟节点就没有发生变化过,那么相对深度保持原状即可。

2)降低RL节点:先右旋后左旋操作。

对于RL节点的深度降低,需要基于R与RL进行右旋操作将节点转移给右子树。受到平衡条件限制,RL的子树RLL与RLR最小深度只可能是K+1,否则若存在K+2的深度,与RRL/RRR产生差距2,在RL与RR层就已发生平衡旋转(更新都是从下向上发起的)。

可以从上图观察到,旋转操作执行完毕后,RL替代了原顶点R且实际深度未变,因此相对深度也仍保持原值1。而位于下一层的R与RLL节点存在1的深度差距,因此R节点设置为1,RLL节点相对深度设置为0。剩余的RLR/RR,RRL/RRR实际深度不变,相对深度也保持不变。

在一步右旋操作后,当前树状态已转换为(1)中的情况,后续的左旋操作不再赘述。根据上述模拟得到的信息,实际代码实现时只需对发生变动的节点进行状态位设置即可。

4. 删除的红黑状态变化

新增节点和旋转操作的二进制运算模式均已设计完毕,让我们来看看最后的删除操作。新增节点采用的是二进制加法进位,那么大家也容易想到删除节点使用的方法:二进制减法退位。而增加节点的时候默认赋值1,在删除节点时自然也要将1收回。

在5.2节中,介绍了删除操作的几种情况:1)删除叶子节点 2)删除非叶子节点。其中在删除非叶子节点时,通过后继节点或前继节点的替换,可以将其转换为删除叶子节点的模式,所以我们主要观察删除叶子节点的场景即可。

在图中,L和RL分别代表一颗子树,且RL可能为空,RR为删除的目标节点,且是叶子节点。那么删除有以下几种情况:

1)RR节点为1,RL只能为0(两者均为1时会进位),即空节点。删除节点后未触发平衡条件,删除结束。

2)RR节点为0,当前位不足时,需要向高位借一。

在红黑树中借位有两种方式:<1> 通过让父节点减一,左右节点同时加一。<2> 通过左右旋从兄弟子树获取深度。

其中方式<2>一般用于特定情况下优化步骤。因为目标叶子节点删除后,其归属的子树最小深度减1,变相让兄弟节点的相对深度加1。若兄弟节点为1或兄弟节点的子节点为1,此时便触发或超出了平衡限制条件,需要进行旋转操作。且基于平衡限制进行的旋转操作,最终必然会出现进位(可参考6.3节中的旋转操作),这就抵消了删除节点时向原父节点的借位操作。

因此与其删除节点触发父节点借位,再旋转平衡后重新进位抵消,不如先旋转获取深度,然后执行删除操作。

最终可得删除(减法)执行步骤:

1)目标节点若为1,则将其降为0后减法结束。否则转步骤2。

2)目标节点为0。若兄弟节点为1或兄弟节点的子节点为1,则基于兄弟节点与父节点进行旋转。之后转步骤3。

3)兄弟节点已无额外深度可提供,则需要向父节点借1,然后执行减法。若父节点为1流程结束,否则将父节点设置为目标节点重复步骤2。(即向更上层借位)

待减法流程完成后,最后将原目标节点删除即可。另外根节点的借位无限制,可无视其状态位直接借位。

上图为兄弟节点为1的情况,旋转后向R借位,最后删除RR节点。

上图为兄弟节点的子节点为1的情况,若图中RLR为1,则需要先左旋后右旋,具体请参照降低LR或RL的方法。

5. 惰性检查

基于红黑状态的增删以及旋转操作均已实现,最后让我们来看看红黑树的惰性检查机制吧。

相比起前面即刻触发的进位操作,红黑树选择了将其与平衡限制检查的操作合并。即发现当前节点与父节点均为1时,才检查父节点的兄弟节点。若兄弟节点值为1,则触发进位操作。否则判断已超出平衡限制,触发旋转操作。

从上图中可以看见,左右节点同为1时并不会立刻触发进位。仅当前目标与父节点同为1,此时不得不进行进位操作,否则会影响平衡判断。其中基于M和R1的左旋也与之前稍有不同:并不是让R1直接进位为1,而是将其拖延下来,存储于M与R2上,等待M/R2的任意子节点为1时才进位。

该机制源于树的深度范围:在平衡限制条件"左右节点的子树最小深度之差不超过1"下,其倾斜边界为树的最大深度不超出最小深度两倍。但实际平衡时,左右子树的倾斜情况各不相同,最大深度的差距从差2到差两倍不等。

如果在保证倾斜边界(也就是深度范围)的情况下,尽量等到树不能再倾斜后再进行平衡,此时一次平衡操作所转移的节点数更多,效率更高(相比起小倾斜度平衡,高平衡频率)。但实际情况中,节点的增加方式完全是随机的,上述抵达倾斜边界的情况并不可控。

聪明的设计者决定在原有的平衡限制条件上做一个越界操作,也就是我们前面看到的惰性检查机制。该机制导致左右节点的红色进位必须要子节点为红色"推着"才能走,红节点每向上走一层,就要增加一层的节点用以进位。向上走N层,就必须要增加N层的子节点,最终实现每次平衡时均达到倾斜边界的效果。

(惰性检查机制下的节点进位,其实已经越过了原有"左右节点的子树最小深度差不超过1"的平衡限制条件)

惰性检查在保证查询深度范围的情况下,通过小幅度降低查询效率的代价,换取更低的平衡频率,提高增删处理效率。且其在频繁增删节点的场景中,也起到了降低红黑状态进退位频率的作用。

无论是在最初的平衡条件上,亦或是惰性检查的机制上,红黑树的设计者都做了不少的读写效率取舍。方方面面的调整也是为了更贴合各种场景的泛型应用,至少对于你我来说,封装好的红黑树已是日常开发中趁手的好工具了。


Cheers!

好,到此为止红黑树的原理解析全部完成(热烈鼓掌)!各位看官们辛苦了,今天的小知识有get到吗?

另外可以看几个简单问题来检验下学习成果呦。

1. 在无惰性检查机制时,左右节点分别为红和黑时代表了什么意义?

2. 什么时候树中的红节点占比最多?

3. 常态情况下为什么没有相连的红节点?

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

都看到这里了    不点个赞再走么

猜你喜欢

转载自blog.csdn.net/Vis_Stu/article/details/108284020
今日推荐