数据结构与算法之ACM Fellow-算法3.3 平衡查找树
我们在前面几节中学习过的算法已经能够很好地用于许多应用程序中,但它们在最坏情况下的性能还是很糟糕。在本节中我们会介绍一种二分查找树并能 保证 无论如何构造它,它的运行时间都是对数级别的。理想情况下我们希望能够保持二分查找树的平衡性。在一棵含有 ![N/740943/image00798.gif) 个结点的树中,我们希望树高为 ![\sim\lg N/740943/image00914.gif),这样我们就能保证所有查找都能在 ![\sim\lg N/740943/image00914.gif) 次比较内结束,就和二分查找一样(请见命题 B)。不幸的是,在动态插入中保证树的完美平衡的代价太高了。在本节中,我们稍稍放松完美平衡的要求并将学习一种能够保证符号表 API 中所有操作(范围查找除外)均能够在对数时间内完成的数据结构。
3.3.1 2-3 查找树
附赠网盘下载地址
对应视频资源地址+链接:资源网盘分享
更多资源+夸克网盘资源群 资源群
群满+新夸克共享群:备份群
为了保证查找树的平衡性,我们需要一些灵活性,因此在这里我们允许树中的一个结点保存多个键。确切地说,我们将一棵标准的二叉查找树中的结点称为 2- 结点(含有一个键和两条链接),而现在我们引入 3- 结点,它含有两个键和三条链接。2- 结点和 3- 结点中的每条链接都对应着其中保存的键所分割产生的一个区间。
定义。一棵 2-3 查找树 或为一棵空树,或由以下结点组成:
2- 结点,含有一个键(及其对应的值)和两条链接,左链接指向的 2-3 树中的键都小于该结点,右链接指向的 2-3 树中的键都大于该结点。
3- 结点,含有两个键(及其对应的值)和三条链接,左链接指向的 2-3 树中的键都小于该结点,中链接指向的 2-3 树中的键都位于该结点的两个键之间,右链接指向的 2-3 树中的键都大于该结点。
和以前一样,我们将指向一棵空树的链接称为 空链接。2-3 查找树如图 3.3.1 所示。
![{%}/740943/image01341.gif)
图 3.3.1 2-3 查找树示意图
一棵 完美平衡 的 2-3 查找树中的所有空链接到根结点的距离都应该是相同的。简洁起见,这里我们用 2-3 树 指代一棵完美平衡的 2-3 查找树(在其他情况下这个词应该表示一种更一般的结构)。稍后我们将会学习定义并高效地实现 2- 结点、3- 结点和 2-3 树的基本操作。现在先假设我们已经能够自如地操作它们并来看看应该如何将它们用作查找树。
3.3.1.1 查找
将二叉查找树的查找算法一般化我们就能够直接得到 2-3 树的查找算法。要判断一个键是否在树中,我们先将它和根结点中的键比较。如果它和其中任意一个相等,查找命中;否则我们就根据比较的结果找到指向相应区间的链接,并在其指向的子树中递归地继续查找。如果这是个空链接,查找未命中。具体查找过程如图 3.3.2 所示。
![/740943/image01342.gif)
图 3.3.2 2-3 树中的查找命中(左)和未命中(右)
3.3.1.2 向 2- 结点中插入新键
要在 2-3 树中插入一个新结点,我们可以和二叉查找树一样先进行一次未命中的查找,然后把新结点挂在树的底部。但这样的话树无法保持完美平衡性。我们使用 2-3 树的主要原因就在于它能够在插入后继续保持平衡。如果未命中的查找结束于一个 2- 结点,事情就好办了:我们只要把这个 2- 结点替换为一个 3- 结点,将要插入的键保存在其中即可(如图 3.3.3 所示)。如果未命中的查找结束于一个 3- 结点,事情就要麻烦一些。
![/740943/image01343.gif)
图 3.3.3 向 2- 结点中插入新的键
3.3.1.3 向一棵只含有一个 3- 结点的树中插入新键
在考虑一般情况之前,先假设我们需要向一棵只含有一个 3- 结点的树中插入一个新键。这棵树中有两个键,所以在它唯一的结点中已经没有可插入新键的空间了。为了将新键插入,我们先临时将新键存入该结点中,使之成为一个 4- 结点。它很自然地扩展了以前的结点并含有 3 个键和 4 条链接。创建一个 4- 结点很方便,因为很容易将它转换为一棵由 3 个 2- 结点组成的 2-3 树,其中一个结点(根)含有中键,一个结点含有 3 个键中的最小者(和根结点的左链接相连),一个结点含有 3 个键中的最大者(和根结点的右链接相连)。这棵树既是一棵含有 3 个结点的二叉查找树,同时也是一棵完美平衡的 2-3 树,因为其中所有的空链接到根结点的距离都相等。插入前树的高度为 0,插入后树的高度为 1。这个例子很简单但却值得学习,它说明了 2-3 树是如何生长的,如图 3.3.4 所示。
![{%}/740943/image01344.gif)
图 3.3.4 向一棵只含有一个 3- 结点的树中插入新键
3.3.1.4 向一个父结点为 2- 结点的 3- 结点中插入新键
作为第二轮热身,假设未命中的查找结束于一个 3- 结点,而它的父结点是一个 2- 结点。在这种情况下我们需要 在维持树的完美平衡的前提下为新键腾出空间。我们先像刚才一样构造一个临时的 4- 结点并将其分解,但此时我们不会为中键创建一个新结点,而是将其移动至原来的父结点中。你可以将这次转换看成将指向原 3- 结点的一条链接替换为新父结点中的原中键左右两边的两条链接,并分别指向两个新的 2- 结点。根据我们的假设,父结点中是有空间的:父结点是一个 2- 结点(一个键两条链接),插入之后变为了一个 3- 结点(两个键 3 条链接)。另外,这次转换也并不影响(完美平衡的)2-3 树的主要性质。树仍然是有序的,因为中键被移动到父结点中去了;树仍然是完美平衡的,插入后所有的空链接到根结点的距离仍然相同。请确认你完全理解了这次转换——它是 2-3 树的动态变化的核心,其过程如图 3.3.5 所示。
![/740943/image01345.gif)
图 3.3.5 向一个父结点为 2- 结点的 3- 结点中插入新键
3.3.1.5 向一个父结点为 3- 结点的 3- 结点中插入新键
现在假设未命中的查找结束于一个父结点为 3- 结点的结点。我们再次和刚才一样构造一个临时的 4- 结点并分解它,然后将它的中键插入它的父结点中。但父结点也是一个 3- 结点,因此我们再用这个中键构造一个新的临时 4- 结点,然后 在这个结点上进行相同的变换,即分解这个父结点并将 它的 中键插入到 它的 父结点中去。推广到一般情况,我们就这样一直向上不断分解临时的 4- 结点并将中键插入更高层的父结点,直至遇到一个 2- 结点并将它替换为一个不需要继续分解的 3- 结点,或者是到达 3- 结点的根。该过程如图 3.3.6 所示。
![/740943/image01346.gif)
图 3.3.6 向一个父结点为 3- 结点的 3- 结点中插入新键
3.3.1.6 分解根结点
如果从插入结点到根结点的路径上全都是 3- 结点,我们的根结点最终变成一个临时的 4- 结点。此时我们可以按照向一棵只有一个 3- 结点的树中插入新键的方法处理这个问题。我们将临时的 4- 结点分解为 3 个 2- 结点,使得树高加 1,如图 3.3.7 所示。请注意,这次最后的变换仍然保持了树的完美平衡性,因为它变换的是根结点。
![/740943/image01347.gif)
图 3.3.7 分解根结点
3.3.1.7 局部变换
将一个 4- 结点分解为一棵 2-3 树可能有 6 种情况,都总结在了图 3.3.8 中。这个 4- 结点可能是根结点,可能是一个 2- 结点的左子结点或者右子结点,也可能是一个 3- 结点的左子结点、中子结点或者右子结点。2-3 树插入算法的根本在于这些变换都是 局部的:除了相关的结点和链接之外不必修改或者检查树的其他部分。每次变换中,变更的链接数量不会超过一个很小的常数。需要特别指出的是,不光是在树的底部,树中的 任何地方 只要符合相应的模式,变换都可以进行。每个变换都会将 4- 结点中的一个键送入它的父结点中,并重构相应的链接而不必涉及树的其他部分。
![/740943/image01348.jpeg)
图 3.3.8 在一棵 2-3 树中分解一个 4- 结点的情况汇总
3.3.1.8 全局性质
这些 局部 变换不会影响树的 全局 有序性和平衡性:任意空链接到根结点的路径长度都是相等的。作为参考,图 3.3.9 所示的是当一个 4- 结点是一个 3- 结点的中子结点时的完整变换情况。如果在变换之前根结点到所有空链接的路径长度为 ![h/740943/image00826.gif),那么变换之后该长度仍然为 ![h/740943/image00826.gif)。所有的变换都具有这个性质,即使是将一个 4- 结点分解为两个 2- 结点并将其父结点由 2- 结点变为 3- 结点,或是由 3- 结点变为一个临时的 4- 结点时也是如此。当根结点被分解为 3 个 2- 结点时,所有空链接到根结点的路径长度才会加 1。如果你还没有完全理解,请完成练习 3.3.7。它要求你为其他的 5 种情况画出图 3.3.8 的扩展图来证明这一点。理解所有局部变换都不会影响整棵树的有序性和平衡性是理解这个算法的关键。
![/740943/image01349.gif)
图 3.3.9 4- 结点的分解是一次局部变换,不会影响树的有序性和平衡性
和标准的二叉查找树由上向下生长不同,2-3 树的生长是由下向上的。如果你花点时间仔细研究一下图 3.3.10,就能很好地理解 2-3 树的构造方式。它给出了我们的标准索引测试用例中产生的一系列 2-3 树,以及一系列由同一组键按照升序依次插入到树中时所产生的所有 2-3 树。还记得在二叉查找树中,按照升序插入 10 个键会得到高度为 9 的一棵最差查找树吗?如果使用 2-3 树,树的高度是 2。
![/740943/image01350.gif)
图 3.3.10 2-3 树的构造轨迹
以上的文字已经足够为我们定义一个使用 2-3 树作为数据结构的符号表的实现了。2-3 树的分析和二叉查找树的分析大不相同,因为我们主要感兴趣的是 最坏情况下 的性能,而非一般情况(这种情况下我们会用随机键模型分析预期的性能)。在符号表的实现中,一般我们无法控制用例会按照什么顺序向表中插入键,因此对最坏情况的分析是唯一能够提供性能保证的办法。
命题 F。在一棵大小为 ![N/740943/image00798.gif) 的 2-3 树中,查找和插入操作访问的结点必然不超过 ![\lg N/740943/image00915.gif) 个。
证明。一棵含有 ![N/740943/image00798.gif) 个结点的 2-3 树的高度在 ![\lfloor\log_3N\rfloor=\lfloor(\lg N)/(\lg3)\rfloor/740943/image01351.gif)(如果树中全是 3- 结点)和 ![\lfloor\lg N\rfloor/740943/image00945.gif)(如果树中全是 2- 结点)之间(请见练习 3.3.4)。
因此我们可以确定 2-3 树在最坏情况下仍有较好的性能。每个操作中处理每个结点的时间都不会超过一个很小的常数,且这两个操作都只会访问一条路径上的结点,所以任何查找或者插入的成本都肯定不会超过对数级别。通过对比图 3.3.11 中的 2-3 树和图 3.2.8 中由相同的键构造的二叉查找树,你也可以看到,完美平衡的 2-3 树要平展得多。例如,含有 10 亿个结点的一棵 2-3 树的高度仅在 19 到 30 之间。我们最多只需要访问30个结点就能够在10亿个键中进行任意查找和插入操作,这是相当惊人的。
![/740943/image01352.gif)
图 3.3.11 由随机键构造的一棵典型的 2-3 树
但是,我们和真正的实现还有一段距离。尽管我们可以用不同的数据类型表示 2- 结点和 3- 结点并写出变换所需的代码,但用这种直白的表示方法实现大多数的操作并不方便,因为需要处理的情况实在太多。我们需要维护两种不同类型的结点,将被查找的键和结点中的每个键进行比较,将链接和其他信息从一种结点复制到另一种结点,将结点从一种数据类型转换到另一种数据类型,等等。实现这些不仅需要大量的代码,而且它们所产生的额外开销可能会使算法比标准的二叉查找树更慢。平衡一棵树的初衷是为了消除最坏情况,但我们希望这种保障所需的代码能够越少越好。幸运的是你将看到,我们只需要一点点代价就能用一种统一的方式完成所有变换。
3.3.2 红黑二叉查找树
上文所述的 2-3 树的插入算法并不难理解,现在我们会看到它也不难实现。我们要学习一种名为 红黑二叉查找树 的简单数据结构来表达并实现它。最后的代码量并不大,但理解这些代码是如何工作的以及为什么能够工作却需要一番仔细的探究。
3.3.2.1 替换 3- 结点
红黑二叉查找树背后的基本思想是用标准的二叉查找树(完全由 2- 结点构成)和一些额外的信息(替换 3- 结点)来表示 2-3 树。我们将树中的链接分为两种类型: 红链接 将两个 2- 结点连接起来构成一个 3- 结点, 黑链接 则是 2-3 树中的普通链接。确切地说,我们将 3- 结点表示为由一条 左斜 的红色链接(两个 2- 结点其中之一是另一个的左子结点)相连的两个 2- 结点,如图 3.3.12 所示。这种表示法的一个优点是,我们 无需修改 就可以直接使用标准二叉查找树的 get()
方法。对于任意的 2-3 树,只要对结点进行转换,我们都可以立即派生出一棵对应的二叉查找树。我们将用这种方式表示 2-3 树的二叉查找树称为 红黑二叉查找树(以下简称为 红黑树)。
![/740943/image01353.gif)
图 3.3.12 由一条红色左链接相连的两个 2- 结点表示一个 3- 结点
3.3.2.2 一种等价的定义
红黑树的另一种 定义 是含有红黑链接并满足下列条件的二叉查找树:
-
红链接均为左链接;
-
没有任何一个结点同时和两条红链接相连;
-
该树是 完美黑色平衡 的,即任意空链接到根结点的路径上的黑链接数量相同。
满足这样定义的红黑树和相应的 2-3 树是一一对应的。
3.3.2.3 一一对应
如果我们将一棵红黑树中的红链接画平,那么所有的空链接到根结点的距离都将是相同的(如图 3.3.13 所示)。如果我们将由红链接相连的结点合并,得到的就是一棵 2-3 树。相反,如果将一棵 2-3 树中的 3- 结点画作由红色左链接相连的两个 2- 结点,那么不会存在能够和两条红链接相连的结点,且树必然是完美黑色平衡的,因为黑链接即 2-3 树中的普通链接,根据定义这些链接必然是完美平衡的。无论我们选择用何种方式去定义它们,红黑树都 既是 二叉查找树, 也是 2-3 树,如图 3.3.14 所示。因此,如果我们能够在保持一一对应关系的基础上实现 2-3 树的插入算法,那么我们就能够将两个算法的优点结合起来:二叉查找树中简洁高效的查找方法和 2-3 树中高效的平衡插入算法。
![/740943/image01354.jpeg)
图 3.3.13 将红链接画平时,一棵红黑树就是一棵 2-3 树
![/740943/image01355.jpeg)
图 3.3.14 红黑树和 2-3 树的一一对应关系
3.3.2.4 颜色表示
方便起见,因为每个结点都只会有一条指向自己的链接(从它的父结点指向它),我们将链接的颜色保存在表示结点的 Node
数据类型的布尔变量 color
中。如果指向它的链接是红色的,那么该变量为 true
,黑色则为 false
。我们约定空链接为黑色。为了代码的清晰我们定义了两个常量 RED
和 BLACK
来设置和测试这个变量。我们使用私有方法 isRed()
来测试一个结点和它的父结点之间的链接的颜色。当我们提到一个结点的颜色时,我们指的是指向该结点的链接的颜色,反之亦然。颜色表示的代码实现如图 3.3.15 所示。
![/740943/image01356.jpeg)
图 3.3.15 红黑树的结点表示
3.3.2.5 旋转
在我们实现的某些操作中可能会出现红色右链接或者两条连续的红链接,但在操作完成前这些情况都会被小心地 旋转 并修复。旋转操作会改变红链接的指向。首先,假设我们有一条红色的右链接需要被转化为 左链接(请见图 3.3.16)。这个操作叫做 左旋转,它对应的方法接受一条指向红黑树中的某个结点的链接作为参数。假设被指向的结点的右链接是红色的,这个方法会对树进行必要的调整并返回一个指向包含同一组键的子树且其左链接为红色的根结点的链接。如果你对照图示中调整前后的情况逐行阅读这段代码,你会发现这个操作很容易理解:我们只是将用两个键中的较小者作为根结点变为将较大者作为根结点。实现将一个红色左链接转换为一个红色右链接的一个 右旋转 的代码完全相同,只需要将 left
和 right
互换即可(如图 3.3.17 所示)。
![/740943/image01357.jpeg)
图 3.3.16 左旋转 h
的右链接
![/740943/image01358.gif)
图 3.3.17 右旋转 h
的左链接
3.3.2.6 在旋转后重置父结点的链接
无论左旋转还是右旋转,旋转操作都会返回一条链接。我们总是会用 rotateRight()
或 rotateLeft()
的返回值重置父结点(或是根结点)中相应的链接。返回的链接可能是左链接也可能是右链接,但是我们总会将它赋予父结点中的链接。这个链接可能是红色也可能是黑色—— rotateLeft()
和 rotateRight()
都通过将 x.color
设为 h.color
保留它原来的颜色。这可能会产生两条连续的红链接,但我们的算法会继续用旋转操作修正这种情况。例如,代码 h = rotateLeft(h);
将旋转结点 h
的红色右链接,使得 h
指向了旋转后的子树的根结点(组成该子树中的所有键和旋转前相同,只是根结点发生了变化)。这种简洁的代码是我们使用递归实现二叉查找树的各种方法的主要原因。你会看到,它使得旋转操作成为了普通插入操作的一个简单补充。
在插入新的键时我们可以使用旋转操作帮助我们保证 2-3 树和红黑树之间的一一对应关系,因为旋转操作可以保持红黑树的两个重要性质: 有序性 和 完美平衡性。也就是说,我们在红黑树中进行旋转时无需为树的有序性或者完美平衡性担心。下面我们来看看应该如何使用旋转操作来保持红黑树的另外两个重要性质(不存在两条连续的红链接和不存在红色的右链接)。我们先用一些简单的情况热热身。
3.3.2.7 向单个 2- 结点中插入新键
一棵只含有一个键的红黑树只含有一个 2- 结点。插入另一个键之后,我们马上就需要将它们旋转。如果新键小于老键,我们只需要新增一个红色的结点即可,新的红黑树和单个 3- 结点完全等价。如果新键大于老键,那么新增的红色结点将会产生一条红色的右链接。我们需要使用 root = rotateLeft(root);
来将其旋转为红色左链接并修正根结点的链接,插入操作才算完成。两种情况的结果均为一棵和单个 3- 结点等价的红黑树,其中含有两个键,一条红链接,树的黑链接高度为 1,如图 3.3.18 所示。
![/740943/image01359.gif)
图 3.3.18 向单个 2- 结点中插入一个新键
3.3.2.8 向树底部的 2- 结点插入新键
用和二叉查找树相同的方式向一棵红黑树中插入一个新键会在树的底部新增一个结点(为了保证有序性),但总是用红链接将新结点和它的父结点相连。如果它的父结点是一个 2- 结点,那么刚才讨论的两种处理方法仍然适用。如果指向新结点的是父结点的左链接,那么父结点就直接成为了一个 3- 结点;如果指向新结点的是父结点的右链接,这就是一个错误的 3- 结点,但一次左旋转就能够修正它,如图 3.3.19 所示。
![/740943/image01360.gif)
图 3.3.19 向树底部的 2- 结点插入一个新键
3.3.2.9 向一棵双键树(即一个 3- 结点)中插入新键
这种情况又可分为三种子情况:新键小于树中的两个键,在两者之间,或是大于树中的两个键。每种情况中都会产生一个同时连接到两条红链接的结点,而我们的目标就是修正这一点。
-
三者中最简单的情况是新键 大于 原树中的两个键,因此它被连接到 3- 结点的右链接。此时树是平衡的,根结点为中间大小的键,它有两条红链接分别和较小和较大的结点相连。如果我们将两条链接的颜色都由红变黑,那么我们就得到了一棵由三个结点组成、高为 2 的平衡树。它正好能够对应一棵 2-3 树,如图 3.3.20(左)。其他两种情况最终也会转化为这种情况。
-
如果新键 小于 原树中的两个键,它会被连接到最左边的空链接,这样就产生了两条连续的红链接,如图 3.3.20(中)。此时我们只需要将上层的红链接右旋转即可得到第一种情况(中值键为根结点并和其他两个结点用红链接相连)。
-
如果新键介于原树中的两个键 之间,这又会产生两条连续的红链接,一条红色左链接接一条红色右链接,如图 3.3.20(右)。此时我们只需要将下层的红链接左旋转即可得到第二种情况(两条连续的红色左链接)。
![/740943/image01361.jpeg)
图 3.3.20 向一棵双键树(即一个 3- 结点)中插入一个新键的三种情况
总的来说,我们通过 0 次、1 次和 2 次旋转以及颜色的变化得到了期望的结果。在 2-3 树中, 请确认你完全理解了这些转换,它们是红黑树的动态变化的关键。
3.3.2.10 颜色转换
如图 3.3.21 所示,我们专门用一个方法 flipColors()
来转换一个结点的两个红色子结点的颜色。除了将子结点的颜色由红变黑之外,我们同时还要将父结点的颜色由黑变红。这项操作最重要的性质在于它和旋转操作一样是局部变换,不会影响 整棵树的黑色平衡性。根据这一点,我们马上能够在下面完整地实现红黑树。
![/740943/image01362.jpeg)
图 3.3.21 通过转换链接的颜色来分解 4- 结点
3.3.2.11 根结点总是黑色
在 3.3.2.9 所述的情况中,颜色转换会使根结点变为红色。这也可能出现在很大的红黑树中。严格地说,红色的根结点说明根结点是一个 3- 结点的一部分,但实际情况并不是这样。因此我们在每次插入后都会将根结点设为黑色。注意,每当根结点由红变黑时树的黑链接高度就会加 1。
3.3.2.12 向树底部的 3- 结点插入新键
现在假设我们需要在树的底部的一个 3- 结点下加入一个新结点。前面讨论过的三种情况都会出现,如图 3.3.22 所示。指向新结点的链接可能是 3- 结点的右链接(此时我们只需要转换颜色即可),或是左链接(此时我们需要进行右旋转然后再转换颜色),或是中链接(此时我们需要先左旋转下层链接然后右旋转上层链接,最后再转换颜色)。颜色转换会使到中结点的链接变红,相当于将它送入了父结点。这意味着在父结点中继续插入一个新键,我们也会继续用相同的办法解决这个问题。
![/740943/image01363.jpeg)
图 3.3.22 向树底部的 3- 结点插入一个新键
3.3.2.13 将红链接在树中向上传递
2-3 树中的插入算法需要我们分解 3- 结点,将中间键插入父结点,如此这般直到遇到一个 2- 结点或是根结点。我们所考虑过的所有情况都正是为了达成这个目标:每次必要的旋转之后我们都会进行颜色转换,这使得中结点变红。在父结点看来,处理这样一个红色结点的方式和处理一个新插入的红色结点完全相同,即继续把红链接转移到中结点上去。图 3.3.23 中总结的三种情况显示了在红黑树中实现 2-3 树的插入算法的关键操作所需的步骤:要在一个 3- 结点下插入新键,先创建一个临时的 4- 结点,将其分解并将红链接由中间键传递给它的父结点。重复这个过程,我们就能将红链接在树中向上传递,直至遇到一个 2- 结点或者根结点。
![/740943/image01364.jpeg)
图 3.3.23 红黑树中红链接向上传递
总之,只要谨慎地使用左旋转、右旋转和颜色转换这三种简单的操作,我们就能够保证插入操作后红黑树和 2-3 树的一一对应关系。在沿着插入点到根结点的路径向上移动时在所经过的每个结点中顺序完成以下操作,我们就能完成插入操作:
-
如果右子结点是红色的而左子结点是黑色的,进行左旋转;
-
如果左子结点是红色的且它的左子结点也是红色的,进行右旋转;
-
如果左右子结点均为红色,进行颜色转换。
你应该花点时间确认以上步骤处理了前文描述的所有情况。请注意,第一个操作表示将一个 2- 结点变为一个 3- 结点和插入的新结点与树底部的 3- 结点通过它的中链接相连的两种情况。
3.3.3 实现
因为保持树的平衡性所需的操作是 由下向上 在每个所经过的结点中进行的,将它们植入我们已有的实现中十分简单:只需要在递归调用之后完成这些操作即可,如算法 3.4 所示。上一段中列出的三种操作都可以通过一个检测两个结点的颜色的 if
语句完成。尽管实现所需的代码量很小,但如果没有我们学习过的两种抽象数据结构(2-3 树和红黑树)作为铺垫,这段实现仍然会非常难以理解。在检查了三到五个结点的颜色之后(也许还需要进行一两次旋转以及颜色转换),我们就可以得到一棵近乎完美平衡的二叉查找树。
算法 3.4 红黑树的插入算法
public class RedBlackBST<Key extends Comparable<Key>, Value> { private Node root; private class Node // 含有color变量的Node对象(请见3.3.2.4节) private boolean isRed(Node h) // 请见3.3.2.4节 private Node rotateLeft(Node h) // 请见图3.3.16 private Node rotateRight(Node h) // 请见图3.3.17 private void flipColors(Node h) // 请见图3.3.21 private int size() // 请见算法3.3 public void put(Key key, Value val) { // 查找key,找到则更新其值,否则为它新建一个结点 root = put(root, key, val); root.color = BLACK; } private Node put(Node h, Key key, Value val) { if (h == null) // 标准的插入操作,和父结点用红链接相连 return new Node(key, val, 1, RED); int cmp = key.compareTo(h.key); if (cmp < 0) h.left = put(h.left, key, val); else if (cmp > 0) h.right = put(h.right, key, val); else h.val = val; if (isRed(h.right) && !isRed(h.left)) h = rotateLeft(h); if (isRed(h.left) && isRed(h.left.left)) h = rotateRight(h); if (isRed(h.left) && isRed(h.right)) flipColors(h); h.N = size(h.left) + size(h.right) + 1; return h; } }除了递归调用后的三条
if
语句,红黑树中put()
的递归实现和二叉查找树中put()
的实现完全相同。它们在查找路径上保证了红黑树和 2-3 树的一一对应关系,使得树的平衡性接近完美。第一条if
语句会将任意含有红色右链接的 3- 结点(或临时的 4- 结点)向左旋转;第二条if
语句会将临时的 4- 结点中两条连续红链接中的上层链接向右旋转;第三条if
语句会进行颜色转换并将红链接在树中向上传递(详情请见正文)。
图 3.3.24 给出了使用我们的标准索引测试用例进行测试的轨迹和用同一组键按照升序构造一棵红黑树的测试轨迹。仅从红黑树的三种标准操作的角度分析这些例子对我们理解问题很有帮助,之前我们也是这样做的。另一个基本练习是检查它们和 2-3 树的一一对应关系(可以对比图 3.3.10 中由同一组键构造的 2-3 树)。在两种情况中你都能通过思考 将 P 插入红黑树 所需的转换来检验你对算法的理解程度(请见练习 3.3.12)。
![/740943/image01365.jpeg)
图 3.3.24 红黑树的构造轨迹
3.3.4 删除操作
算法 3.4 中的 put()
方法是本书中最复杂的实现之一,而红黑树的 deleteMin()
、 deleteMax()
和 delete()
的实现更麻烦,我们将它们的完整实现留做练习,但这里仍然需要学习它们的基本原理。要描述删除算法,首先我们要回到 2-3 树。和插入操作一样,我们也可以定义一系列局部变换来在删除一个结点的同时保持树的完美平衡性。这个过程比插入一个结点更加复杂,因为我们不仅要在(为了删除一个结点而)构造临时 4- 结点时沿着查找路径 向下 进行变换,还要在分解遗留的 4- 结点时沿着查找路径 向上 进行变换(同插入操作)。
3.3.4.1 自顶向下的 2-3-4 树
作为第一轮热身,我们先学习一个沿查找路径既能向上也能向下进行变换的稍简单的算法:2-3-4 树的插入算法,2-3-4 树 中允许存在我们以前见过的 4- 结点。它的插入算法沿查找路径向下进行变换是为了保证当前结点不是 4- 结点(这样树底才有空间来插入新的键),沿查找路径向上进行变换是为了将之前创建的 4- 结点配平,如图 3.3.25 所示。向下的变换和我们在 2-3 树中分解 4- 结点所进行的变换 完全相同。如果根结点是 4- 结点,我们就将它分解成三个 2- 结点,使得树高加 1。在向下查找的过程中,如果遇到一个父结点为 2- 结点的 4- 结点,我们将 4- 结点分解为两个 2- 结点并将中间键传递给它的父结点,使得父结点变为一个 3- 结点;如果遇到一个父结点为 3- 结点的 4- 结点,我们将 4- 结点分解为两个 2- 结点并将中间键传递给它的父结点,使得父结点变为一个 4- 结点;我们不必担心会遇到父结点为 4- 结点的 4- 结点,因为插入算法本身就保证了这种情况不会出现。到达树的底部之后,我们也只会遇到 2- 结点或者 3- 结点,所以我们可以插入新的键。要用红黑树实现这个算法,我们需要:
-
将 4- 结点表示为由三个 2- 结点组成的一棵平衡的子树,根结点和两个子结点都用红链接相连;
-
在 向下 的过程中分解所有 4- 结点并进行颜色转换;
-
和插入操作一样,在 向上 的过程中用旋转将 4- 结点配平2。
![/740943/image01366.gif)
图 3.3.25 自顶向下的 2-3-4 树的插入算法中的变换
令人惊讶的是,你只需要移动算法 3.4 的 put()
方法中的一行代码就能实现 2-3-4 树中的插入操作:将 colorFlip()
语句(及其 if
语句)移动到递归调用之前( null
测试和比较操作之间)。在多个进程可以同时访问同一棵树的应用中这个算法优于 2-3 树,因为它操作的总是当前结点的一个或两个链接。我们下面要讲的删除算法和它的插入算法类似,而且也适用于 2-3 树。
3.3.4.2 删除最小键
在第二轮热身中我们要学习 2-3 树中删除最小键的操作。我们注意到从树底部的 3- 结点中删除键是很简单的,但 2- 结点则不然。从 2- 结点中删除一个键会留下一个空结点,一般我们会将它替换为一个空链接,但这样会破坏树的完美平衡性。所以我们需要这样做:为了保证我们不会删除一个 2- 结点,我们沿着左链接向下进行变换,确保当前结点不是 2- 结点(可能是 3- 结点,也可能是临时的 4- 结点)。首先,根结点可能有两种情况。如果根是 2- 结点且它的两个子结点都是 2- 结点,我们可以直接将这三个结点变成一个 4- 结点;否则我们需要保证根结点的左子结点不是 2- 结点,如有必要可以从它右侧的兄弟结点“借”一个键来。以上情况如图 3.3.26 所示。在沿着左链接向下的过程中,保证以下情况之一成立:
-
如果当前结点的左子结点不是 2- 结点,完成;
-
如果当前结点的左子结点是 2- 结点而它的亲兄弟结点不是 2- 结点,将左子结点的兄弟结点中的一个键移动到左子结点中;
-
如果当前结点的左子结点和它的亲兄弟结点都是 2- 结点,将左子结点、父结点中的最小键和左子结点最近的兄弟结点合并为一个 4- 结点,使父结点由 3- 结点变为 2- 结点或者由 4- 结点变为 3- 结点。
![/740943/image01367.gif)
图 3.3.26 删除最小键操作中的变换
在遍历的过程中执行这个过程,最后能够得到一个含有最小键的 3- 结点或者 4- 结点,然后我们就可以直接从中将其删除,将 3- 结点变为 2- 结点,或者将 4- 结点变为 3- 结点。然后我们再回头向上分解所有临时的 4- 结点。
3.3.4.3 删除操作
在查找路径上进行和删除最小键相同的变换同样可以保证在查找过程中任意当前结点均不是 2- 结点。如果被查找的键在树的底部,我们可以直接删除它。如果不在,我们需要将它和它的后继结点交换,就和二叉查找树一样。因为当前结点必然不是 2- 结点,问题已经转化为在一棵根结点不是 2- 结点的子树中删除最小的键,我们可以在这棵子树中使用前文所述的算法。和以前一样,删除之后我们需要向上回溯并分解余下的 4- 结点。
本节末尾的练习中有几道是关于这些删除算法的例子和实现的。有兴趣理解或实现删除算法的读者应该掌握这些练习中的细节。对算法研究感兴趣的读者应该认识到这些方法的重要性,因为这是我们见过的第一种能够同时实现高效的 查找、 插入 和 删除 操作的符号表实现。下面我们将会验证这一点。
3.3.5 红黑树的性质
研究红黑树的性质就是要检查对应的 2-3 树并对相应的 2-3 树进行分析的过程。我们的最终结论是 所有基于红黑树的符号表实现都能保证操作的运行时间为对数级别(范围查找除外,它所需的额外时间和返回的键的数量成正比)。我们重复并强调这一点是因为它十分重要。
3.3.5.1 性能分析
首先,无论键的插入顺序如何,红黑树都几乎是完美平衡的(请见图 3.3.27)。这从它和 2-3 树的一一对应关系以及 2-3 树的重要性质可以得到。
![/740943/image01368.jpeg)
图 3.3.27 使用随机键构造的典型红黑树,没有画出空链接
命题 G。一棵大小为 ![N/740943/image00798.gif) 的红黑树的高度不会超过 ![2\lg N/740943/image01224.gif)。
简略的证明。红黑树的最坏情况是它所对应的 2-3 树中构成最左边的路径结点全部都是 3- 结点而其余均为 2- 结点。最左边的路径长度是只包含 2- 结点的路径长度(![\sim\lg N/740943/image00914.gif))的两倍。要按照某种顺序构造一棵平均路径长度为 ![2\lg N/740943/image01224.gif) 的最差红黑树虽然可能,但并不容易。如果你喜欢数学,你也许会喜欢在练习 3.3.24 中探究这个问题的答案。
这个上界是比较保守的。使用随机的键序列和典型应用中常见的键序列进行的实验都证明,在一棵大小为 ![N/740943/image00798.gif) 的红黑树中一次查找所需的比较次数约为(![1.00\lg N-0.5/740943/image01369.gif))。另外,在实际情况下你不太可能遇到比这个数字高得多的平均比较次数,如表 3.3.1 所示。
表 3.3.1 使用 RedBlackBST
的 FrequencyCounter
的每次 put()
操作平均所需的比较次数
tale.txtleipzig1M.txt单词数不同单词数比较次数单词数不同单词数比较次数模型预测实际次数模型预测实际次数所有单词135 63510 67913.613.521 191 455534 58019.419.1长度大于等于 8 的单词14 3505 13112.612.14 239 597299 59318.718.4长度大于等于 10 的单词4 5822 26011.411.51 610 829165 55517.517.3
命题 H。一棵大小为 ![N/740943/image00798.gif) 的红黑树中,根结点到任意结点的平均路径长度为 ![\sim1.00\lg N/740943/image01370.gif)。
例证。和典型的二叉查找树(例如图 3.2.8 中所示的树)相比,一棵典型的红黑树的平衡性是很好的,例如图 3.3.27 所示(甚至是图 3.3.28 中由升序键列构造的红黑树)。表 3.3.1 显示的数据表明
FrequencyCounter
在运行中构造的红黑树的路径长度(即查找成本)比初等二叉查找树低 40% 左右,和预期相符。自红黑树的发明以来,无数的实验和实际应用都印证了这种性能改进。
![/740943/image01371.jpeg)
图 3.3.28 使用升序键列构造的一棵红黑树,没有画出空链接
以使用 FrequencyCounter
在处理长度大于等于 8 的单词时 put()
操作的成本为例,我们可以看到平均成本降低得更多(如图 3.3.29 所示)。这又一次验证了理论模型所预测的对数级别的运行时间,只不过这次的惊喜比二叉查找树的小,因为性质 G 已经向我们保证了这一点。节约的总成本低于在查找上节约的 40% 的成本,因为除了比较我们也统计了旋转和颜色变换的次数。
![/740943/image01372.jpeg)
图 3.3.29 使用 RedBlackBST
,运行 java FrequencyCounter 8 < tale.txt
的成本
红黑树的 get()
方法不会检查结点的颜色,因此平衡性相关的操作不会产生任何负担;因为树是平衡的,所以查找比二叉查找树更快。每个键只会被插入一次,但却可能被查找无数次,因此最后我们只用了很小的代价(和二分查找不同,我们可以保证插入操作是对数级别的)就取得了和最优情况近似的查找时间(因为树是接近完美平衡的,且查找过程中不会进行任何平衡性的操作)。查找的内循环只会进行一次比较并更新一条链接,非常简短,和二分查找的内循环类似(只有比较和索引运算)。这是我们见到的第一个能够保证对数级别的查找和插入操作的实现,它的内循环更紧凑。它通过了各种应用的考验,包括许多库实现。
3.3.5.2 有序符号表 API
红黑树最吸引人的一点是它的实现中最复杂的代码仅限于 put()
(和删除)方法。二叉查找树中的查找最大和最小键、 select()
、 rank()
、 floor()
、 ceiling()
和范围查找方法 不做任何变动 即可继续使用,因为红黑树也是二叉查找树而这些操作也不会涉及结点的颜色。算法 3.4 和这些方法(以及删除方法)一起完整地实现了我们的有序符号表 API。这些方法都能从红黑树近乎完美的平衡性中受益,因为它们最多所需的时间都和树高成正比。因此命题 G 和命题 E 一起保证了 所有操作 的运行时间是对数级别的。
命题 I。在一棵红黑树中,以下操作在最坏情况下所需的时间是对数级别的:查找(
get()
)、插入(put()
)、查找最小键、查找最大键、floor()
、ceiling()
、rank()
、select()
、删除最小键(deleteMin()
)、删除最大键(deleteMax()
)、删除(delete()
)和范围查询(range()
)。证明。我们已经讨论过
put()
、get()
和delete()
方法。对于其他方法,代码可以从 3.2 节中照搬(它们不涉及结点颜色)。命题 G 和命题 E 可以保证算法是对数级别的,所有操作在所经过的结点上只会进行常数次数的操作也说明了这一点。
各种符号表实现的性能总结如表 3.3.2 所示。
表 3.3.2 各种符号表实现的性能总结
算法(数据结构)最坏情况下的运行时间的增长数量级( N 次插入之后)平均情况下的运行时间的增长数量级( N 次随机插入之后)是否支持有序性相关的操作查找插入查找插入顺序查询(无序链表)![/740943/image00798.gif)![/740943/image00798.gif)![/740943/image00986.gif)![/740943/image00798.gif)否二分查找(有序数组)![/740943/image00915.gif)![/740943/image00798.gif)![/740943/image00915.gif)![/740943/image00986.gif)是二叉树查找(BST)![/740943/image00798.gif)![/740943/image00798.gif)![/740943/image01317.gif)![/740943/image01317.gif)是2-3 树查找(红黑树)![/740943/image01224.gif)![/740943/image01224.gif)![/740943/image01373.gif)![/740943/image01373.gif)是
想想看,这样的保证是一个非凡的成就。在信息世界的汪洋大海中,表的大小可能上千亿,但我们仍能够确保在几十次比较之内就完成这些操作。
答疑
问 为什么不允许存在红色右链接和 4- 结点?
答 它们都是可用的,并且已经应用了几十年了。在练习中你会遇到它们。只允许红色左链接的存在能够减少可能出现的情况,因此实现所需的代码会少得多。
问 为什么不在 Node
类型中使用一个 Key
类型的数组来表示 2- 结点、3- 结点和 4- 结点?
答 问得好。这正是我们在 B- 树(请见第6章)的实现中使用的方案,它的每个结点中可以保存更多的键。因为 2-3 树中的结点较少,数组所带来的额外开销太高了。
问 在分解一个 4- 结点时,我们有时会在 rotateRight()
中将右结点的颜色设为 RED
(红)然后立即在 flipColors()
中将它的颜色变为 BLACK
(黑)。这不是浪费时间吗?
答 是的,有时我们还会不必要地反复改变中结点的颜色。从整体来看,多余的几次颜色变换和将所有方法的运行时间的增长数量级从线性级别提升到对数级别不是一个级别的。当然,在有性能要求的应用中,你可以将 rotateRight()
和 flipColors()
的代码在所需要的地方展开来消除那些额外的开销。我们在删除中也会使用这两个方法。在能够保证树的完美平衡的前提下,它们更加容易使用、理解和维护。
练习
3.3.1 将键 E A S Y Q U T I O N
按顺序插入一棵空 2-3 树并画出结果。
3.3.2 将键 Y L P M X H C R A E S
按顺序插入一棵空 2-3 树并画出结果。
3.3.3 使用什么顺序插入键 S E A C H X M
能够得到一棵高度为 1 的 2-3 树?
3.3.4 证明含有 ![N/740943/image00798.gif) 个键的 2-3 树的高度在 ![\sim\lfloor\log_3N\rfloor/740943/image01374.gif) 即 ![0.63\lg N/740943/image01375.gif)(树完全由 3- 结点组成)和 ![\sim\lfloor\lg N\rfloor/740943/image01376.gif)(树完全由 2- 结点组成)之间。
3.3.5 右图显示了 ![N=1/740943/image01163.gif) 到 6 之间大小为 ![N/740943/image00798.gif) 的所有 不同的 2-3 树(无先后次序)。请画出 ![N=7/740943/image01377.gif)、8、9 和 10 的大小为 ![N/740943/image00798.gif) 的所有不同的 2-3 树。
![/740943/image01378.gif)
3.3.6 计算用 ![N/740943/image00798.gif) 个随机键构造练习 3.3.5 中每棵 2-3 树的概率。
3.3.7 以图 3.3.9 为例为图 3.3.8 中的其他 5 种情况画出相应的示意图。
3.3.8 画出使用三个 2- 结点和红链接一起表示一个 4- 结点的所有可能方法(不一定只能使用红色左链接)。
3.3.9 下图中哪些是红黑树(粗的链接为红色)?
![/740943/image01379.gif)
3.3.10 将含有键 E A S Y Q U T I O N
的结点按顺序插入一棵空红黑树并画出结果。
3.3.11 将含有键 Y L P M X H C R A E S
的结点按顺序插入一棵空红黑树并画出结果。
3.3.12 在我们的标准索引测试用例中插入键 P
并画出插入的过程中每次变换(颜色转换或是旋转)后的红黑树。
3.3.13 真假判断:如果你按照升序将键顺序插入一棵红黑树中,树的高度是单调递增的。
3.3.14 用字母 A 到 K 按顺序构造一棵红黑树并画出结果,然后大致说明在按照升序插入键来构造一棵红黑树的过程中发生了什么(可以参考正文中的图例)。
3.3.15 在键按照降序插入红黑树的情况下重新回答上面两道练习。
3.3.16 向右图所示的红黑树(黑色加粗部分的链接为红色)中插入 n
并画出结果(图中只显示了插入时的查找路径,你的解答中只需包含这些结点即可)。
![/740943/image01380.gif)
3.3.17 随机生成两棵均含有 16 个结点的红黑树。画出它们(手绘或者代码绘制均可)并将它们和使用同一组键构造的(非平衡的)二叉查找树进行比较。
3.3.18 对于 2 到 10 之间的 ![N/740943/image00798.gif),画出所有大小为 ![N/740943/image00798.gif) 的不同红黑树(请参考练习 3.3.5)。
3.3.19 每个结点只需要 1 位来保存结点的颜色即可表示 2- 结点、3- 结点和 4- 结点。使用二叉树,我们在每个结点需要几位信息才能表示 5- 结点、6- 结点、7- 结点和 8- 结点?
3.3.20 计算一棵大小为 ![N/740943/image00798.gif) 且完美平衡的二叉查找树的内部路径长度,其中 ![N/740943/image00798.gif) 为 2 的幂减 1。
3.3.21 基于你为练习 3.2.10 给出的答案编写一个测试用例 TestRB.java。
3.3.22 找出一组键的序列使得用它顺序构造的二叉查找树比用它顺序构造的红黑树的高度更低,或者证明这样的序列不存在。
提高题
3.3.23 没有平衡性限制的 2-3 树。使用 2-3 树(不一定平衡)作为数据结构实现符号表的基本 API。树中的 3- 结点中的红链接可以左斜也可以右斜。树底部的 3- 结点和新结点通过 黑色 链接相连。实验并估计随机构造的这样一棵大小为 ![N/740943/image00798.gif) 的树的平均路径长度。
3.3.24 红黑树的最坏情况。找出如何构造一棵大小为 ![N/740943/image00798.gif) 的最差红黑树,其中从根结点到几乎所有空链接的路径长度均为 ![2\lg N/740943/image01224.gif)。
3.3.25 自顶向下的 2-3-4 树。使用平衡 2-3-4 树作为数据结构实现符号表的基本 API。在树的表示中使用红黑链接并实现正文所述的插入算法,其中在沿查找路径向下的过程中分解 4- 结点并进行颜色转换,在回溯向上的过程中将 4- 结点配平。
3.3.26 自顶向下一遍完成。修改你为练习 3.3.25 给出的答案, 不使用 递归。在沿查找路径向下的过程中分解并平衡 4- 结点(以及 3- 结点),最后在树底插入新键即可。
3.3.27 允许红色右链接。修改你为练习 3.3.25 给出的答案,允许红色右链接的存在。
3.3.28 自底向上的 2-3-4 树。使用平衡 2-3-4 树作为数据结构实现符号表的基本 API。在树的表示中使用红黑链接并用和算法 3.4 相同的递归方式实现 自底向上 的插入。你的插入方法应该只需要分解查找路径底部的 4- 结点(如果有的话)。
3.3.29 最优存储。修改 RedBlackBST
的实现,用下面的技巧实现无需为结点颜色的存储使用额外的空间:要将结点标记为红色,只需交换它的左右链接。要检测一个结点是否是红色,检测它的左子结点是否大于它的右子结点。你需要修改一些比较语句来适应链接的交换。这个技巧将变量的比较变成了键的比较,显然成本会更高,但它说明在需要的情况下这个变量是可以被删掉的。
3.3.30 缓存。修改 RedBlackBST
的实现,将最近访问的结点 Node
保存在一个变量中,这样 get()
或 put()
在再次访问同一个键时就只需要常数时间了(请参考练习 3.1.25)。
3.3.31 树的绘制。为 RedBlackBST 添加一个 draw()
方法,像正文一样绘制出红黑树。
3.3.32 AVL 树。AVL 树是一种二叉查找树,其中任意结点的两棵子树的高度最多相差 1(最早的平衡树算法就是基于使用旋转保持 AVL 树中子树高度的平衡)。证明将其中由高度为偶数的结点指向高度为奇数的结点的链接设为红色就可以得到一棵(完美平衡的)2-3-4 树,其中红色链接可以是右链接。 附加题:使用 AVL 树作为数据结构实现符号表的 API。一种方法是在每个结点中保存它的高度并在递归调用后使用旋转来根据需要调整这个高度;另一种方法是在树的表示中使用红黑链接并使用类似练习 3.3.39 和练习 3.3.40 的 moveRedLeft()
和 moveRedRight()
的方法。
3.3.33 验证。为 RedBlackBST
实现一个 is23()
方法来检查是否存在同时和两条红链接相连的结点和红色右链接,以及一个 isBalanced()
方法来检查从根结点到所有空链接的路径上的黑链接的数量是否相同。将这两个方法和练习 3.2.32 的 isBST()
方法结合起来实现一个 isRedBlackBST()
来检查一棵树是否是红黑树。
3.3.34 所有的 2-3 树。编写一段代码来生成高度为 2、3 和 4 的所有结构不同的 2-3 树,分别共有 2、7 和 122 种( 提示:使用符号表)。
3.3.35 2-3 树。编写一段程序 TwoThreeST.java,使用两种结点类型来直接表示和实现 2-3 查找树。
3.3.36 2-3-4-5-6-7-8 树。说明平衡的 2-3-4-5-6-7-8 树中的查找和插入算法。
3.3.37 无记忆性。请证明红黑树不是 没有记忆 的。例如,如果你向树中插入一个小于所有键的新键,然后立即删除树的最小键,你可能得到一棵不同的树。
3.3.38 旋转的基础定理。请证明,使用一系列左旋转或者右旋转可以将一棵二叉查找树转化为由同一组键生成的其他任意一棵二叉查找树。
3.3.39 删除最小键。实现红黑树的 deleteMin()
方法,在沿着树的最左路径向下的过程中实现正文所述的变换,保证当前结点不是 2- 结点。
解答:
private Node moveRedLeft(Node h) { // 假设结点h为红色,h.left和h.left.left都是黑色, // 将h.left或者h.left的子结点之一变红 flipColors(h); if (isRed(h.right.left)) { h.right = rotateRight(h.right); h = rotateLeft(h); } return h; } public void deleteMin() { if (isRed(root.left) && !isRed(root.right)) root.color = RED; root = deleteMin(root); if (!isEmpty()) root.color = BLACK; } private Node deleteMin(Node h) { if (h.left == null) return null; if (!isRed(h.left) && !isRed(h.left.left)) h = moveRedLeft(h); h.left = deleteMin(h.left); return balance(h); }
其中的 balance()
方法由下一行代码和算法 3.4 的递归 put()
方法中的最后 5 行代码组成:
if (isRed(h.right)) h = rotateLeft(h);
这里的 flipColors()
方法将会补全三条链接的颜色,而不是正文中实现插入操作时实现的 flipColors()
方法。对于删除,我们会将父结点设为 BLACK
(黑)而将两个子结点设为 RED
(红)。
3.3.40 删除最大键。实现红黑树的 deleteMax()
方法。需要注意的是因为红链接都是左链接,所以这里用到的变换和上一道练习中的稍有不同。
解答:
private Node moveRedRight(Node h) { // 假设结点h为红色,h.right和h.right.left都是黑色, // 将h.right或者h.right的子结点之一变红 flipColors(h) if (!isRed(h.left.left)) h = rotateRight(h); return h; } public void deleteMax() { if (!isRed(root.left) && !isRed(root.right)) root.color = RED; root = deleteMax(root); if (!isEmpty()) root.color = BLACK; } private Node deleteMax(Node h) { if (isRed(h.left)) h = rotateRight(h); if (h.right == null) return null; if (!isRed(h.right) && !isRed(h.right.left)) h = moveRedRight(h); h.right = deleteMax(h.right); return balance(h); }
3.3.41 删除操作。将上两题中的方法和二叉查找树的 delete()
方法结合起来,实现红黑树的删除操作。
解答:
public void delete(Key key) { if (!isRed(root.left) && !isRed(root.right)) root.color = RED; root = delete(root, key); if (!isEmpty()) root.color = BLACK; } private Node delete(Node h, Key key) { if (key.compareTo(h.key) < 0) { if (!isRed(h.left) && !isRed(h.left.left)) h = moveRedLeft(h); h.left = delete(h.left, key); } else { if (isRed(h.left)) h = rotateRight(h); if (key.compareTo(h.key) == 0 && (h.right == null)) return null; if (!isRed(h.right) && !isRed(h.right.left)) h = moveRedRight(h); if (key.compareTo(h.key) == 0) { h.val = get(h.right, min(h.right).key); h.key = min(h.right).key; h.right = deleteMin(h.right); } else h.right = delete(h.right, key); } return balance(h); }
实验题
3.3.42 统计红色结点。编写一段程序,统计给定的红黑树中红色结点所占的比例。对于 ![N=10^4/740943/image01337.gif)、![10^5/740943/image00848.gif) 和 ![10^6/740943/image00849.gif),用你的程序统计至少 100 棵随机构造的大小为 ![N/740943/image00798.gif) 的红黑树并得出一个猜想。
3.3.43 成本图。改造 RedBlackBST
的实现来绘制本节中能够显示计算中每次 put()
操作的成本的图(请参考练习 3.1.38)。
3.3.44 平均查找用时。用实验研究和计算在一棵由 ![N/740943/image00798.gif) 个随机结点构造的红黑树中到达一个随机结点的平均路径长度(内部路径长度除以 ![N/740943/image00798.gif) 再加 1)的平均差和标准差,对于 1 到 10 000 之间的每个 ![N/740943/image00798.gif) 至少重复实验 1000 遍。将结果绘制成和图 3.3.30 相似的 Tufte 图,并画上函数 ![\lg N-0.5/740943/image01381.gif) 的曲线。
![/740943/image01382.jpeg)
图 3.3.30 随机构造的红黑树中到达一个随机结点的平均路径长度
3.3.45 统计旋转。改进你为练习 3.3.43 给出的程序,用图像绘制出在构造红黑树的过程中旋转和分解结点的次数并讨论结果。
3.3.46 红黑树的高度。改进你为练习 3.3.43 给出的程序,用图像绘制出所有红黑树的高度并讨论结果。