文章目录
1. 动机
1.1 观察体验
在接下来的这一节,我们将结识 BBST家族中的又一新成员,红黑树。这是一种特色极其鲜明的 BBST,正如它的名字所暗示的那样,其中的节点具有颜色。
是的,这就是一棵典型的红黑树,其中的节点可能是红色,也可能是黑色,二者必须其一。
而更重要的是,作为 BBST 家族的成员,它也必须能够在动态变化过程中始终保持某种意义上的平衡。
从而使得每次查找都能够在足够短的时间内完成。当然,在经过动态修改之后,它的自平衡过程也应该能够足够快地完成。这些都是红黑树的关键技术所在。不过在具体的讨论这些技术细节之前,或许我们应该首先来问这样一个问题。
既然在BBST家族中已经有了 AVL 之类的成员,而且其渐进复杂度已经能够达到 log n 的要求,那么为何还需要设计并引入红黑树这一新的变种呢?在 BBST 这个家族中红黑树的存在意义又在哪里呢?
下面我们就首先从一个角度来回答这个问题。
1.2 持久性
回顾我们以前所学过的数据结构,无论是线性的向量、列表、栈或队列,也无论半线性的树结构,以及非线性的图结构。它们大多呈现这么样一种特征,每当经过一次动态的操作,使得其中的逻辑结构发生变化之后,它都会随即完全地转入新的状态。同时将此前的状态完全的遗忘掉。
这类结构也因此称作 ephemeral data structure。也就是说它们都是随时变化的,每一个状态只会存在于某一个瞬间,而每一个瞬间都是朝生暮死,稍纵即逝的。
然而在实际应用中往往可能会有更高的要求。比如若将数据结构比作是人,那么它的每一个瞬间状态都相当于人一生中某一时刻的快照。我们获取会对它的历史档案感兴趣,并希望能够任意调阅是甚至修改某个历史时刻的档案。
因此,无论静态还是动态操作,除了指定目标,关键还需要同时指定一个版本号。用以说明我们是在这个数据结构的哪一份历史档案中去查找特定对象。如果一个数据结构能够支持这种类型的需求,就称作是 persistence structures,也就是所谓的一致性结构或者持久性结构。
乍看起来任意数据结构的持久化都不是那么困难的一件事。比如一种直截了当的方法,就是为数据结构的每一个所需的版本都独立地保存一份快照,同时将所有版本的入口组成一个搜索结构。
这里我们针对一棵真实的 BBST 记录了它整个生命期内的5个版本。
这样,一旦指定了版本号,我们就可以转入对应的快照,并按照常规搜索方法在其中定位需要操作的元素。从单次访问的效率而言,我们甚至可以说这个结构还可以接受。
是的,如果我们将整个历史快照的数目记作 h,那么每次我们只需 log h 的时间便可确定版本档案的入口。接下来再花费 logn 的时间在这份档案中进行定位和操作。
然而就空间而言,这种方法是断乎不可接受的。我们可以看到在这样的一组历史快照中,每一个元素都可能会被保存多份。渐进地来说,n 个元素中的每一个都有可能在这一组档案中被保存多达 h 份。
这就意味着空间复杂度将伴随着 h 呈线性的速度增长。
我们知道空间复杂度自然也构成了时间复杂度的一个下限。因此在整个历史过程中,为了生成和记录所有的快照,我们累计所花费的时间也会多达这样一个规模。
分摊下来,我们为了生成每一组快照都大致需要花费线性的时间创建一个完整的副本。那么我们可否就此做一改进呢?比如除了所有元素各自所占用的空间,我们是否能够将每一个版本所消耗的均摊空间量控制在 logn 的范围内呢?
答案是可以。为此我们需要利用同一数据结构相邻版本之间的关联性。
1.3 关联性
是的,对于每一组相邻的历史快照而言,后者都是在前者的基础上做过相对而言少量的更新而得的。也就是说绝大部分的数据对象在二者之中都是相同的,二者的差异只是非常非常小的一部分。
因此我们就自然地可以改用这样一种策略,也就是大量的元素都做共享,只有发生变化的那少量的数据元素我们才需要进行更新。
实际上只要我们的实现方法得当,完全可以将相邻版本之间的差异量控制在 log n 的范围。比如对于 BBST 而言,这就是一种可行的实现方法。
在这幅图中,每一条红线都对应于一个共享。比如这条红线(1,2)就意味着它所指的节点1既出现在前一个版本中,同时也出现在后一个版本中。
尽管在两个版本中它的父亲不同,但是作为数据元素,它就是它。类似的,这条红线(6,2)也意味着节点2同时被ver1和ver2号版本所共享。也可能也注意到其中还有一些蓝色的虚线。
没错,我想你已经猜到了,它们所指示的就是在相邻版本之间的更新量,也是我们不得不花费空间来进行存储的量。好在这个量可以控制在极低的水平,比如这条蓝线(8,8‘)就意味着尽管节点8和节点8’对应于同一个数据对象,但是在前和后两个版本中,它们的父节点已经发生了变化。
因此在新的快照中,我们不得不为它另存一个副本。类似的,这条蓝线(7,7‘)也意味着从7到7’的更新。
而这条蓝线(6.6‘)则示意着节点6需要更新至6’。以及节点5需要更新至5‘。
当然这类高级结构已经超出了我们这门课的范围。如果你对此感兴趣,敬请关注邓老师,稍后将要推出的另一门算法课程计算几何。
当然,你也可能必并不满足于此。比如我们能否将 BBST 前后版本的空间差异控制在 O(1)的范围呢?如果这样的话,整体的空间复杂度将进一步的优化至 n + h 而不是 h * log n。
好消息是只要方法得当,这也是可以做到的。
1.4 O(1)重构
尽管这里我们没有时间去讨论实现的细节,但是为此所应具备的一项必要条件是非常好理解的。也就是说就 BBST 的树形拓扑结构而言,相邻版本之间的差异本身不能超过常数O(1)。
然而很遗憾,绝大多数的 BBST,包括我们此前所学过的 AVL 树都不能保证这一点。所谓的拓扑结构差异无非是来自自调整过程中的旋转操作,每一次局部的旋转都意味着在结构上引入常数的差异。
因此反过来,如果需要保证前后版本在拓扑结构上的差异不超过常数,也就意味着在从前一版本转入后一版本的过程中所执行的旋转操作不得超过常数次。因此反观 AVL树的两个主要动态操作,插入操作是满足这一条的。因为正如我们所知的每次插入之后,一旦经过一次旋转,局部乃至全树的高度都会复原。然而很可惜,删除操作却并非如此。应该还记得从 AVL 树中删除一个节点之后有可能自底而上逐层引发多达 log n 次的旋转,从而导致树形拓扑结构的剧烈变化。
因此,为了使得上述的构思能够兑现,我们就需要这样一种 BBST ,它的任何动态操作,无论插入还是删除对树形拓扑结构的影响都能控制在常数的范围之内。
而红黑树正是具有这一特性的一个变种。
2. 结构
2.1 定义规则
接下来我们就来看看所谓的红黑树到底是如何定义的,以及按照这种定义它是否的确是 BBST。
实际上在其研究和发展的历史过程中,红黑树曾经有过不同的名称,也曾从不同的角度给出过等价的定义。那么这里我们只给出其中之一,为了便于讲解,甚至实现红黑树,我们需要对红黑树的模型做一个小小的扩充。
具体来说与 B 树的处理手法一样,我们需要通过增设一系列的外部节点,保证红黑数是所谓的真二叉树。也就说其中的每个内部节点都有2个孩子。尽管其中之一,甚至两个都有可能是我们新增的外部节点。
我们这里对红黑树的定义可以概括为四句话。
- 首先如果红黑树非空,那么根节点必然为黑。
- 其次,我们刚刚统一增加的所有外部节点也是黑色的,尽管他们实际上根本不必存在。
如果将红黑树比作人,那么这两条就可以概括为他戴的帽子以及穿的靴子都是黑的,那么其余的节点呢?
- 实际上只有红节点才需要有所约束,他只能有黑色的孩子。
这条约定虽然简明,但实际上蕴含了很多性质,比如不可能出现同时为红的父子两代。因此这条规则更具操作性的一种等效表述为,对于红节点来说,无论是它的孩子还是父亲,都必须是黑的。稍后就会看到这条规则,对于控制红黑树的深度是极其重要的。
- 而接下来的这条可以认为是只在控制红黑数的平衡性,因为这里要求在从任何一个外部节点通往树根的那条唯一的路径上,黑节点的数目必然都是一样的。
听到这里,你或许会感到非常困惑。是的,如此约定的一系列规则到底对应一棵什么样的树呢?这些规则背后又有什么更为简明的用意和原理呢?我们不妨来通过一个实例先回答第一个问题。
2.2 实例验证
来看红黑树的这样一个具体实例,对照此前所给定义中的4条规则,我们不妨来逐条核对一下。
首先,根节点是黑色的,这没有问题。
然而第二条在这里却似乎没有满足。因为你会注意到,这里似乎存在红色的末端节点。
但请记住,此前这里已经做过一个预处理,也就是为所有需要的节点都添加了一个,或两个外部节点。这些外部节点都是假想的,实际上并不存在。引入它们只是为了便于我们后面的分析,乃至对红黑树的实现。因此在通常的演示中,我们也可以将它们统一地忽略掉。
接下来第3条也不难验证,因为这里的每一个红节点,其父亲以及孩子都是黑的。当然,对于这些末端的页节点而言,它们的孩子也就是刚才所说的外部节点统一也是黑的,尽管在这里我们没有将它们逐一画出。
再来验证第四条。
比如对于这个外部节点650而言,从它通往树根的路径应该是这一条。不难看到,在这条路径上,除了这个假想的外节点之外,真实存在的黑节点有三个。如果这的确是一棵红黑树,那么其他的外部节点所对应的路径也应该如此。为此,我们不妨再来考察另一个黑色节点300下属的外部节点。
~
该外部节点所对应的那条通路应该是这样,不能验证除了这个外部节点本身,这条路径上还包括三个黑色的节点。当然你可以花费更多的时间逐一验证所有外部节点都具有这样的性质。
其中非常建议你同时再去验证那种只有一个外部孩子节点的情况。
2.3 提升交换
回过头来,再重新审视此前所给的这一组规则。即便在刚才,我们已经看过红黑树的实例之后,这组规则依然令人费解。那么有没有什么更为直观的解释呢?答案是肯定的。为此我们可以借助此前刚刚学过的一种数据结构。
为了更好地理解红黑树的定义,需要借助树形结构的另一种等价拓扑变换,也就所谓的提升变换。具体的将每一个红色节点都向上提升至与它的父亲平行。无论是它(36)还是它(58),以及它(55),每一个红色节点都是如此。
您应该记得每一个红色节点的父亲必然是黑的。因此,尽管可能某个黑色节点会接收左右两个孩子,但经过如此变换之后,绝对不会出现两个红色的节点彼此紧邻并排的情况。即便并列期间也必然夹有一个黑节点,也就是他们此前的父亲。
我们不妨来具体体验一下这一变换的宏观效果,上图是变换之前的红黑树,现在来做刚才所说的提升变换。
可以看到每一个红色的节点都向上提升至与它的黑父亲平齐。
2.4 末端节点
为了更好的理解提升变换至于红黑树的意义,来看一个更大的实例,这是一棵由100个节点所构成的红黑树。请特别留意其中所有的底层节点。
不难理解,在实施提升变换之前,就结构而言,这只不过是一棵普通的 BST,因此树中的底层节点通常都是高低错落、此起彼伏式分布。那么在经过提升变换之后,情况又会如何呢?
我不妨来实际地看一下,这是提升之后。
可以看到在经过这样的提升变换之后,所有底层的节点都变成沿同一水平高度。平齐的分布。
这一现象难道是巧合?如果不是,背后的原因又是什么呢?
2.5 红黑树,即是B树
尽管红黑树的规则艰深晦涩,但是从提升变换的角度来看,就会变得异常的清晰明了。实际上从这个角度来看,红黑数就是4阶的B树。
实际上在经过提升变换之后,每一棵红黑树都会自然的对应于一棵4阶的B树。 难道不是这样吗?为此你只需将每一个黑节点与经过提升之后与它高度平齐的红孩子整体的视作为一个B树的超级节点。
于是你就会发现,其实无非不过4种组合(上图),每一种黑父亲与红孩子的组合都对应于四阶B树的某一类内部节点。
~
比如某个黑父亲在此前只拥有一个右侧的红孩子,那么这个红孩子在提升之后就可以与他的父亲等同的视作为一个包含2个关键码的B树节点。
只有一个左侧红孩子的情况完全对称。
当然,如果此前某个黑父亲并没有红孩子,那么在变换之后,我们也可以将其视作为只含单个关键码的超级节点。
当然,如果某个黑父亲的左右孩子同时是红色的,那么在变换之后,我们就可以将它以及左右两个孩子是作为包含三个关键码的超级节点。
~总体看来,无论是哪一种情况,如此所得的每一个超级节点都至少拥有两个分支,同时至多也拥有不过4个分支。而凡此种种,岂不都恰好正是4阶B树的特征吗?
2.6 平衡性
既然我们已经看到红黑树与4阶B树彼此等价,那么由B树的平衡性也不难理解红黑树的平衡性。如果你愿意花费一些时间,我们这里不妨更为严谨地来证明这一点。某种意义上讲,给出这种证明是值得的,因为它可以帮助我们更好的理解红黑树。
为此,我们只需证明由 n 个内部节点所构成的任何一棵红黑树,其高度在渐进意义下都不超过logn。当然,与所有 BST 一样,拥有 n 个内部节点的红黑树也必然同时拥有恰好 n +1 个外部节点。
具体的我们只需证明这个串联不等式,或者更准确地讲,我们只需证明右侧的这个不等号,因为左侧的这个是任何 BST 都自然而然满足的。
假设这就是一个红黑树。按照规则,在从根节点出发,通往任何一个外部节点的沿途,所经过的黑节点总数必然总是一样的。
比如就在这个红黑树中,如果忽略掉人为引入的不外部节点,那么每一条这样的路径上所包含的黑节点应该恰好为6个。
也就是说,任何一条这样的极长通路上所含黑节点的总数都应该是6,此时我们就称这个红黑树的黑高度为6。尽管加上三个红色的节点,它的高度可以达到9。我知道所谓的树高,也就是全树中最深节点所对应那条通路的长度。
而在红黑树中任何一条通路上,尽管可能出现相邻的黑节点,却不允许出现相邻的红色节点。进一步的,这就意味着在每一条这样的通路上,红色的节点都不到一半,而黑色的节点都至少占一半。
因此红黑树的高度必然不会超过其黑高度的两倍。那么这里的黑高度又当如何度量呢?
你应该还记得提升变换,没错,正是提升变换,我们知道经过提升变换之后,每一棵红黑树都对应于一棵4阶的 B 树。在这样一棵B树中,所有的外部节点都像这个(上面左图)那样平齐的分布于底层。
而其中的每一个内部节点都是由此前的某个黑父亲以及它的红孩子构成。因此,此前红黑树中的每一条路径也相应地会在 B 树中对应一条路径。原先这条路径上有多少个黑节点在 B 树中对应的那条路径就有多长,这就意味着这棵 B 树的高度应该不多不少,恰好正是此前那棵红黑树的黑高度。
没错,B树的高度恰恰正是其对应的红黑树的黑高度。而为了度量一棵红黑树的黑高度,我们只需去度量其对应的那棵B树的高度即可。那么 B 数的高度应该如何度量呢?我们在此前已经做过介绍,这里我们只需将结果拿过来,也就是这个(上图最下面公式)。
2.7 接口定义
以下就借助 C++语言来给出红黑树的接口定义。
就外部的公共操作接口而言,依然无非是静态的查找操作search 以及动态的插入 insert和删除remove操作。其中红黑树的查找与常规 BST 的查找完全一样,因此这个接口可以直接沿用。
而为了保证能够始终遵守定义规则,红黑树的插入和删除过程往往还需要进行拓扑上的连接关系调整。因此这两个动态接口都需要做适当的重写。而为了帮助实现相应的拓扑结构调整,这里还提供了两个内部的操作接口。
而这两个内部接口所使用的算法,也正是我们接下来将要讨论的重点。当然,红黑树也需要动态记录并维护自己的高度信息。需要指出的是,对于红黑树而言,这里的高度所指的并不是常规意义下的高度,而是黑高度。
相应的,我们也需要重写高度更新的算法,具体的也需要从左孩子和右孩子中取出更大的那个高度,并以此作为基础,而只有在当前节点为黑时,它的高度才需要在此前的基础上再累进一个单位。
3. 插入
3.1 以曲为直
现在就来学习红黑树的动态调整算法,首先是插入操作与理解红黑树的定义一样,这里我们也必须借助 B 树的模型才能更好了解相关算法的原理及其过程。
也就是说在我们考察每一个红黑树的时候,在脑海中总是要有一棵对应的B树,后者就犹如前者的影子,时时刻刻相伴相随,与所有的 BBST 一样,在经过了动态变化之后,红黑树的组成成员不仅发生了变化,而且他们之间的拓扑连接关系也可能发生变化。
很遗憾,这种变化通常并不容易直接理解。为此我们需要借助B树的影子,具体来说,也就是红黑树在变换之前以及变换之后所对应的那棵B树。就像我们理解红黑树的定义一样,我们会发现红黑树与其对应的影子B树之间关系非常好理解,而且反过来也是如此。
而更重要的是站在新的视角来看前后两棵影子B树之间的关系也将变得一目了然。这样一种理解的方式,表面看来有些迂回,但我们很快就会感知到它的效率反而是最高的。
3.2 双红缺陷
不妨假设,将要插入的是一个新的关键码e,而且也不妨就采用 BST 的常规插入算法,于是相应的也会生成一个新的末端节点 x 。不失一般性,如果不考察平凡的情况,那么 x 就不是树根,这意味着它的父亲必然是存在的。
于是接下来我们不妨简单地将 x 染为红色。这样做的好处是红黑树的各条规则能够尽可能的得到满足,尽管还不能全部满足。
我不妨来逐条核对一下,树根节点和所有的外部节点依然是黑的,在通往各个外部节点的路径上,黑节点的数目因为没有变化,所以依然保持全局的一致性。
然而遗憾的是,第三条规则却未必满足。考察这个新插入的红色节点 x 作为末端的页节点,此时它的两个孩子都是外部节点,所以的确都是黑的。然而它的父节点颜色却不定。
在这里为了帮助那些辩认颜色有困难的同学,我们不妨约定,圆形都对应于红色的节点,而方形则对应于黑色的节点。那么八角形对应的就是颜色仍不能确定的节点,当然它必然有一种颜色可能是黑,也可能是红。
此时节点 x 的父亲 p 就是这样一个可黑可红的节点。如果他的确是黑的,那么第三条规则也同时满足整个插入操作即可成功返回。然而问题在于,节点 p 的确可能原本就是红的。
比如这样一种情况(上图右侧下图),细心的你可能已经注意到我们这里对边的画法是不完全一样的。是的,凡是指向黑色节点或者至少颜色还不定的节点的边,我们都用实线来表示,而反过来所有指向红色节点的边都用虚线表示,这种方式可以更好地帮助我们思考和分析。
因为这类虚边在经过提升变换之后,都会变成是水平方向的。是的,新插入的节点 x 与它的父亲 p 同时为红色,这是红黑树的规则所禁止的。这样一种非法的情况,也因此称作双红缺陷 double red。
那么如何来修复这种双红缺陷呢?首先要考察 x 的主父节点 g。请注意此时的g必然是存在的,否则作为树根的节点p 是不可能为红色。进一步的作为红色节点 p 的父亲,节点 g 也必然是黑色的。
此外,我们还需要考察 g 的另一个孩子 u ,形象的类比,相对于节点 x,这个节点 u 是它的叔父 uncle。当然节点u的颜色也是不定的,因此以下我们就根据这个节点 u 的颜色分两种情况分别处理。
3.3 算法框架
整个插入操作的算法过程可以描述如下
- 首先要用 BST 的标准查找接口进行一次定位,不妨假设目标尚不存在。
- 于是我们就可以创建一个节点 x,将新的关键码存入其中,并且将其作为 _hot的孩子。
所以 x 的确是一个末端的叶节点,当然,与每一个新生成的节点一样,这个 x 此时也首先被初始化为红色。
- 接下来我们要调用内部的 solveDoubleRed 算法接口,检查是否因为 x 引入而出现了双红缺陷,如果是,则修复它,最后别忘了返回新插入的节点。
那么这个solveDoubleRed 又将如何具体地分情况来解除双红缺陷呢?
3.4 RR-1
先来考虑第一种情况,叔父节点 u 是黑的。依然考察 x p 和 g 这祖孙三代节点,它们可能的位置关系, 同样包括 zig zag 之类的4种组合。在这里我们只考虑 zig-zig 和 zag-zig 两种情况,另外两种完全对称。无论哪种情况,此时的 x p g 下属都应该有4个直接的孩子。
尽管它们都有可能是外部节点,但是根据红黑树,红节点只能有黑孩子的规则,包括u在内,它们都必然是黑的。而且既然在此前,这是一棵合法的红黑树,这4个节点的黑高度也应该是一样的。
为了更好的来理解此时的双红缺陷,无论是它(上图左上图)还是它(上图右上图),不妨借助此前的提升变换,也就是将此前指向红色节点的所有虚边都收缩起来,于是局部的这祖孙三代节点就会合并为一个4阶B树中的超级节点。
乍看起来,这样的超级节点并没有违规,因为它们下属的分支都不超过4阶B树的上限。是的,其实此时的缺陷并不严重,确切的说,唯一的缺陷只是在每个超级节点中居中的这个关键码不是黑色。
因此从 B 树角度看,这种调整非常简明,我们并不需要调整 B 树的拓扑结构,而只需在违规的超级节点中对关键码重新的染色。比如对于这种情况(上图下左图)而言,只需简明的交换p和 g 的颜色即可。
而这种情况(上图下右图),只需简明的交换 x 和 g 的颜色即可。
将另外两种尚未列出的情况一并考虑,实际上只需沿用此前针对 AVL 树所涉及的3+4重构算法,即可统一的处理这种类型的所有情况。
具体的,只需找到 x p、g 这三个节点及其下属的四棵子树,按照中序遍历的次序重新命名,并对这一局部按这一种模式(上图右上图)重新的拓扑连接。
当然,这里还需要做进一步的重染色,而这种重染色操作,也可以统一为将居中的节点 b 染黑,将其左右两个孩子染红,整个调整过程以及效果从B树的角度来看是非常清晰明了的。
双红缺陷之所以是非法的,从B树的角度看,可以认为是因为在某个原本是 3 叉 的节点中插入了一个红色的关键码,从而使得原先的黑关键码不再居中,对照所有的四种情况,不难验证这一点。
而调整之后的效果呢?相当于 B 数的拓扑结构不变,而在对应的 4 叉节点中,三个关键码的颜色已经改为合法的红黑红模式。
请注意,在这种情况下,尽管红黑数的拓扑结构有所调整,但仅限于局部。
而更重要的是,这种调整是一蹴而就的,无需任何进一步的调整。因此就全树的拓扑连接关系变化量而言,必然是不超过常数 O(1)。
3.5 RR-2
再来看互补的另一种情况,也就是叔父节点 u 不是黑的,而是红的。同样的不失一般性,这里也只给出了两种情况,忽略掉对称的另两种情况。那么当叔父节点u是红色的时候,x 和 p 所构成的双红缺陷又当如何解释呢?
我们同样借助提升变换,将所有指向红色节点的虚边收缩起来。于是从 B 树的角度来看,局部的这四个节点将合并为一个包含四个关键代码的超级节点。没错,4个关键码,对应于5个分支。看出问题了?是的,无论是它(a‘)还是它(b’),这样的超级节点在 4 阶 B 树中都是非法的。而用 B 树的语言来说,它们之所以非法,是因为它们刚刚发生上溢。因此,与其说我们是在红黑树中修复双红缺陷,不如说是在对应的 4 阶 B 树 中修复上溢缺陷,这二者完全是一回事。
应该还记得在 B 树中如何修复上溢,是的,我们需要在出现问题的节点中找到居中的那个关键码,并且以它为界,将原先的大节点分裂为左右两个新的节点。而居中分界的这个关键码,则应被取出来上移并插入到父节点中的适当位置。
这样一个转换的过程,只不过是在 B 树中一个在平常不过的上溢缺陷修复过程罢了。因此非常易于理解。而将此前的红黑树转换为对应的4阶 B 树呢,从提升变化的角度来看,也非常好理解。
同样的,从变换之后的 B 树到变换之后的红黑树,从提升变换的角度也非常易于理解。总而言之,这样一个迂回的过程要比我们试图直接去理解红黑树的调整过程反过来更为简明。
概括来说,从红黑树的角度,对于这种情况,我们只需将节点 p 由红转黑,同时节点 g 由黑转红。而从 B 树的角度来看呢,则等效于对一个刚刚发生上溢的节点实施一次分裂操作,同时居中的关键码被提升并加入到父节点之后将转为红色。
请注意,经过如此调整之后,尽管上溢的这个节点的确得到了修复,然而故事未必就此结束。我们注意到在这里居中的关键码将会被提升一层,并加入至父节点中。所以到现在为止的效果,也可以等同的视作为在这个父节点中插入了一个新的关键码。
当然在 g 的左或右至少应该有一个黑色的关键码。但遗憾的是,也有可能有一个红色的邻居。而后一种情况则会导致在此处再次发生双红缺陷。不过好消息是,即便在这个位置上会再次出现双红缺陷,也不外乎是我们以上所介绍的两种情况。因此我们也大可套用以上所介绍的方法来解决新的问题。而另一个好消息是,即便会出现这样的问题,问题所发生的位置也会逐层的上升。因此,整个双红缺陷向上蔓延的过程迟早会终止,充其量不会高于树根。
最后需要强调的一点是,尽管这一调整过程,从B树的角度来看,的确发生了拓扑结构的变化,但是从红黑树的角度来看,除了若干节点的颜色会发生变化。全树的拓扑连接关系并没有任何变化。也就是说,尽管从染色操作的次数可能会高达 log n,但拓扑结构的变化却依然控制在常数的范围。
3.6 归纳回味
我们来对双红修正算法的复杂度做一分析,首先这里无非牵涉到两种基本的操作,也就是3+4重构以及对节点颜色的重新定义,二者都是局部的基本操作,各自只需常数时间,因此我们只需统计,在整个的修正过程中,二者各自总共执行了多少次。
这是整个修正算法的流程图,可以看到通过判断叔父节点的颜色,无非两个分支。
- 其中叔父节点为黑的这个分支相对简单,我们只需做一次局部的3+4调整,再做常数次的染色操作,即可完成调整。也就是说,在这种情况下,旋转至多一轮2次,而染色至多牵涉到两个节点,都是常数。
- 当然叔父节点为红色的情况略微复杂,因为尽管在每一个节点处,我们只需做常数次的重染色,但是事情未必彻底解决。因为由此可能导致在更高的节点处进而出现双红缺陷。此时我们还需要重新回到算法的入口,并等效地去试图修复新节点的双红缺陷。在最坏的情况下,这种情形有可能会出现多达 log n次。尽管如此,请注意在这样一个可能的循环过程中,我们只需做重染色,而不必做任何的结构调整。
也就是说在这种情况下,尽管在每一个高度上,我们都有可能会执行若干次重染色,但是绝对不会执行任何的旋转。反过来通过这个流程图我们也不难发现,无论整个修正算法如何运转,期间一旦做过结构的调整,整个算法就会随即结束。
因此总体而言,整个修复过程中,我们的确可能会执行很多次染色操作,但是就我们更为关注的重构操作而言,在整个修复过程中至多只会执行常数次。
你应该记得我们为什么会更加在意重构操作?是的,这类操作对于持久化结构而言是至关重要的。
当然对于插入操作的这些性能要求,AVL 树同样是满足的。然而正如我们在此前所指出的, AVL 的删除操作却不具有这样的性能,那么红黑树呢?让我们拭目以待。
4. 删除
4.1 以曲为直
关于红黑树,最后来讨论它的删除算法。相对于插入,红黑树的删除算法情况更多,也更为复杂一些。因此我们更有必要借助提升变换将红黑树映射为对应的 B 树,并站在后者的角度反过来理解前者的过程及原理。
当然,更重要的是,我们依然需要关注重构操作。无论是以下将要讨论的哪种情况,我们都不要忘了确认其对应的重构操作次数都不超过常数O(1)。
4.2 算法框架
首先来看删除算法的总体框架。首先需要调用 BST 的常规节点删除算法,也就是说通过 removeAt 例程删除掉经定位以后确认在_hot下的节点 x。应该还记得 removeAt 的语义。是的,如果返回的是节点 r ,而则意味着原先的节点 x 将由 r 替代。
比如在上幅图中节点 x 在被删除之后,将由它的某一个后代 r 来替代。当然替代者 r 有可能就是一个实际并不存在的外部节点。
尽管在此后,树的拓扑结构依然保持完整,但是红黑树的性质却未必都能继续满足。不然逐条加以验证:
- 首先,红黑树的根以及外部节点并没有受到影响。
- 但此时同样在此局部有可能会出现两个连续的红色节点。
- 而更重要的是在被删除节点所在的通路上,黑节点的数目却有可能发生变化。也就是说第四条规则也未必能够满足。
当然有一大类的情况还是非常容易处理的,也就是被删除节点 x 与它的替代者 r 之间有一个是红的,当然不可能都是红的,比如 x 之前可能是红的,r 此前是黑的,或者反过来。
对于这样一大类的情况,我们只需简明地将替代者 r 染为黑色即可。这背后的原因也不难理解,从删除操作之前的树结构可见,在此局部都包含一条指向红节点的虚边。我们讲过,这类虚边对于黑高度是没有影响的。因此在简明地将 r 置为黑色之后,都等效于在原树中删除了一条此类虚边。因此所有外部节点的黑深度将依然保持原状。
4.3 双黑缺陷
然而遗憾的是,有可能被删除的节点以及它的替代者在此前都是黑色的。这种情况我们也称之为双黑 double black。此时对于这两个节点所属的那条路径而言,黑长度必然会缩减一个单位,从而必然会被红黑树的第四条规则。
而且不幸的是,上述简明的方法也不再有效。
再给出新的方法之前,我不妨从另一个角度来体会一下问题究竟出在哪?什么角度呢?没错,B树。可以看到,如果 x 和 r 都是黑色的,那么在对应的4阶 B 树中,x 将独自成为一个超级节点。于是在唯一的这个关键码被删除之后,这个节点也就自然地发生了下溢。因此,以下我们的调整算法与其说是在红黑树中修复双黑缺陷,不如说是在 B 树中修复下溢缺陷。如此,只需借助此前 B 树的下溢修复算法,就可以得到红黑树双黑修复的相应算法。
为此,我们需要考察两个节点,首先是删除之后节点 r 的父亲 p。当然在删除之前,p 也就是 x 的父亲。此外,我们还需要在原树中考察节点 r 的兄弟,我们称之为 s。
以下我们就分4种情况分别处置。
4.4 BB-1
首先来看第一种情况,如果将双黑称作 BB,第一种情况的代号就是 BB1。这种情况的特点是,r 或者是 x 的兄弟是黑的,同时它至少有一个红色的孩子记作 t。当然这只是这种可能下的一种情况,好在其余的情况都与之对称或相似,因此同样不失一般性。
这里同样请注意,有3个节点以及4棵子树。而此时我们借用的方法就是,对此局部进行一次3+4重构。比如对于这个不失一般性的情况而言,调整的结果应该是这样(上右图)。可以看到 r 继续保持黑色,而 t 和 p 都将变换为或保持为黑色,而 s 将继承此前根节点 p 的颜色。
~
至此,你不妨稍事暂停,对此局部子树各分支的黑深度做一核对,会发现所有分支的黑深度完全一致,而且同时也延续了此前各分支的深度。当然,为此需要注意到的一个重要事实是,这里的4棵子树其黑高度都是一样的。
因此如此调整之后,红黑树的所有性质都在此局部乃至全局得以恢复,我们的删除操作自然也可以大功搞成了。当然,这一转换方法并非偶然,在其背后有着深刻的原理。那么这一原理是什么呢?没错,同样还是 B 树。因此接下来不妨就让我们转到 B 树的角度,来反观这种变换的效果。
4.5 反观回味
首先将原先的这个红黑树转换为等效的 4 阶 B 树。可以看到双黑缺陷的确对应于一次下溢。此时所幸的是,发生下溢的这个节点拥有一个足够富有的兄弟,以至于可以通过某种方式简明的消除下溢。
什么方式呢?是的,旋转。具体来说,下溢节点将从父亲那借得一个关键码,而父亲在进而向它的那个兄弟转借入一个关键码,以填补空缺。经过这样的旋转可以看到,下溢节点的确得到了修复。
接下来我们只需将经过修复之后所得的这棵 B 树,重新变换回它所对应的那棵红黑树,与我们此前直接在红黑树上所做的变换操作恰好完全等效。
请注意,这种修复是彻底的,因为尽管损失了一个关键码,但同时又补充了一个,一进一出,彼此抵消。而且新的这个关键码会依然继承它前任的颜色,因此,绝对不会在其他位置进而造成双黑。
从这个意义上讲,这种情况也是相对更简单的,这种简单性体现在,这里还至少存在足以做旋转调整的余地,也就是说,兄弟节点还足够富有,或者在等价的在红黑树上,这个兄弟节点 s 至少拥有一条虚边,或者说至少有一个红孩子,因此反过来更为困难的情况自然也就是 s 的两个孩子都同时为黑。此时我们又当如何应对呢?
4.6 BB-2R
兄弟节 s 为黑,同时它的两个孩子也都为黑的情况,不妨归作为 BB-2。这种情况,又进而分为两种子情况,它们的区别就在于此时的父节点 p 究竟是红还是黑。我们首先讨论 p 为红的情况,我们称这种情况为 BB2-2R,相应的 p 为黑时自然也就是BB-2B。
依然沿用我们一贯的技巧,首先将此前的红黑树转化为对应的 B 树。不出意外,依然在这个位置上发生了一次下溢。不同之处在于,此时我们并不能实施旋转调整。原因正如我们所看到的,此时兄弟关键码 s 独自的构成一个超级节点,这样一个超级节点已经处于下溢的边缘,并不足以借出任何的关键码。
你应该还记得在 B 树中我们是如何处理这种情况的,是的,合并。从父节点中取出一个关键码,并且以它作为粘合体,将左和右两个节点合二为一,修复的结果是这样。好了,接下来我们只需要将这棵 B 树反向地变换回对应的红黑树,就可以得到在红黑树中的一种可行调整方案。
现在,站在红黑树的角度来审视这个调整过程,可以看到其结果相当于 r 保持此前的黑色,而 s 将由黑转红,同时 p 由红整黑。可见在红黑树中的上述调整过程完全等效于在 B 树中某个节点通过与它的兄弟合并来消除下溢。
那么这一局部双黑缺陷的修复,是否同时也意味着红黑树的性质能够得以在全局得到恢复呢?
细心的你可能会发现无形中上层的节点已经损失了一个关键码 p,而这是否意味着在 B 树中接下来会再次发生下溢呢?好消息是,不会。
原因在于,这里的关键码 p 是红色的,这就意味着在它所属的那个 B 树节点中,至少还应该有一个黑色的关键码,因此在它被借出之后,不至继而发生下溢。因此与 BB-1一样,我们对 BB-2R 的修复也必然是一蹴而就的,彻底的。
好了,节点 p 也就是局部的子树根为红色的情况,我们已经知道如何处理。那么接下来倘若节点 p 是黑色呢?
4.7 BB-2B
兄弟节点 s 为黑色,同时 2 个孩子均为黑,而且局部的子树根 p 也为黑的情况,我们统一编号为 BB-2B。
同样的,站在 B 树的角度来看,此时依然会发生一次下溢,而且同样只能通过兄弟节点的合并来加以消除。而与BB-2R的不同之处在于,此时的关键码 p 是独自成为一个超级节点的,因此当这个唯一的关键码 p 被借出之后,此前的父节点将注定随即发生一次下溢。
也就说,在这种情况下双黑缺陷有可能会向上传播一层,而且不难构造这种实例,有可能会继续的再进而上升一层,以至更为极端的持续的上升下去,直到最后的树根。
尽管我们可以一如既往的沿用相应的调整方法来进行调整,并且的确也能够保证这个调整过程会在至多 log n 步之后结束。但是你不免会有些担心什么呢?是的,重构操作,难道于说在这样的一过程中重构操作也有可能会执行到多达 log n 次吗?
当然,现在我们也同样的只需回到红黑树,就可以形象来理解和记忆此前略显复杂的调整方法。需要再次强调的是,经过这样的调整,红黑树的拓扑结构并没有发生任何的实质变化,也就是说,整个调整过程所执行的重构操作不超过常数这么样一个要求,依然有可能落实。
以下我们只剩下最后一种情况,也就是兄弟节点 s 有可能不是黑色,而是红色。
4.8 BB-3
我们最后来讨论兄弟节点 s 为红色的情况。我们不妨将这种类型的双黑缺陷统一的记作 BB-3。实际上,对于这种情况,我们并不需要做什么实质的处理,而只需将其转化为此前所介绍的某两种子情况。
为此我们需要再次站在对应 B 树的角度,可以看到此时的 p 和 s 共同的结为一个 3 分支的超级节点。在此时的 B树中,我们不妨简单的令 s 和 p 互换颜色,而无需做任何实质的结构调整。
当然,在对应的红黑树中,如此却需要做一次结构调整。具体来说,也就是要围找着节点 p 做一次 zig 或者是 zag 旋转,同时翻转 s 和 p 的颜色。
看到这个结果,你多少会有些失望,因为问题并没有解决。比如原先黑高度的异常依然存在。
然而实际上,这一步转换并非没有意义。因为此前矛盾焦点在于节点 r 的兄弟 s 为红色。那么现在在无形中 r 已经拥有了一个黑的兄弟 s‘,于是此后必然会跳出 BB-3这种情况而转入此前所讨论的 3 种情况。
而更好的消息是,实际上接下来只可能转入其中的 BB-1以及 BB-2R,而绝对不会是 BB-2B。什么是 BB-2B呢?它的要点之一是,父节点 p 必须是黑的。而经过刚才的变换,同样在无形中,父节点 p 已经悄然地变为了红色。你应该记得从计算复杂度的角度来看,后面的这两种情况要更为简单,因为他们不会像 BB-2B 那样不断地向上蔓延。因此我们可以断定,经过如此调整之后,只需再做一轮调整,整个红黑树必然会得以完整的修复。
4.9 归纳体味
好,终于到了可以总结的时候了,在介绍过红黑树在各种情况下的调整算法之后,我们不难发现它与 AVL 树至少一样,每一次删除操作,在每一高度上至多只会花费常数时间。由此可知,红黑树的删除操作时间复杂度不会超过 log n。
然而,我们的目标还不止于此。是的,我们还需要更为精细的来考察其中所涉及的拓扑结构调整的工作量,也就是旋转操作的累计次数。为此,我们可以将以上4种情况的处理流程汇总为这样一个流程图。
首先是 BB-1,应该记得,在这种情况下我们的处理是一蹴而就的,成本是一次重染色以及一轮重构。也就说,在这种情况下,我们的确只需要常数次旋转操作。
而接下来的 BB-2R 从计算的角度来看更为简明。应该还记得在这里,我们只需要一轮重染色,即可完成对双黑缺陷的修复。也就是说,这种情况所涉及的旋转次数为0。
再来看更为复杂的 BB-2B,你应该记得在这种情况下我们会做一轮重染色。然而遗憾的是,此时除非我们已经抵达树根,否则我们并不能保证算法就此结束。事实上,我们往往会在一个更高的层次上回到最初的问题,并相应地进行处理。在最坏的情况下,这种不断地向上蔓延可能会多达 log n 步。
然而无论如何,我们可以看到在这种情况下,我们所做的操作只涉及重染色,而没有任何的拓扑结构调整。因此这种情况所实质对应的旋转操作次数也是0。
最后自然是最为复杂的 BB-3。你应该记得此时我们的确需要做一轮从染色以及一次旋转调整,并在随后在更高的层次上转入到问题的原点。然而正如我们已经强调指出的,此时的原点并非完全的原点。什么意思呢?你应该还记得,如果的确是从 BB3这种情况转化而来,那么接下去必然不可能是令人生厌的 BB-2B,而只能是 BB-2R 甚至是 BB-1。从流程图可以看出,无论是 BB-2R 还是 BB-1,接下来都不会做更多的迭代。而反过来只需要再进行一轮调整,即可完成整个的双黑修复。由此可见,在这种情况下,我们所需要执行的旋转操作累计也不过是常数次。
凡此种种,通过以上概括,我们可以发现红黑树的删除操作至多只需做 log n 次的重染色以及常数次的结构调整。
这也是红黑树优于 AVL 树的一个重要方面。你应该记得,我们在介绍红黑树的一开始就曾经提到,这一特性对于持久性结构的实现是至关重要的。