
我们可以根据二叉查找树的递归结构直接得到一个归纳关系式:
其中
这一项表现根结点使得树中的全部
个非根结点的路径上都加了 1。表达式的其他项代表了全部子树,它们的计算方法和巨细为
的二叉查找树的方法雷同。整理表达式后我们会发现,这个归纳公式和我们在2.3节中为快速排序得到的公式几乎完全雷同,因此我们同样可以得到
。
命题 D。在由 个随机键构造的二叉查找树中插入操纵和查找未命中平均所需的比较次数为 (约 )。
证明。插入操纵和查找未命中平均比查找命中必要一次额外的比较。这一点由归纳法不难得到(请见练习 3.2.16)。
命题 C 说明在二叉查找树中查找随机键的成本比二分查找高约 39%。命题 D 说明这些额外的成本是值得的,由于插入一个新键的成本是对数级别的——这是基于二分查找的有序数组所不具备的灵活性,由于它的插入操纵所需访问数组的次数是线性级别的。和快速排序一样,比较次数的尺度差很小,因此
越大这个公式越准确。
实验
我们的随机键模型和典型的符号表使用情况是否相符?按照惯例,这个问题的答案必要具体问题具体分析,由于在不同的应用场景中性能的差别大概很大。幸好,对于大多数用例,这个模型都能很好地适应。
作为例子,我们研究用 FrequencyCounter 处理长度大于等于 8 的单词时 put() 操纵的成本。从图 3.2.9 可以看到,每次操纵的平均成本从 BinarySearchST 的 484 次数组访问降低到了二叉查找树的 13 次,这也再次验证了理论模型所预测的对数级别的性能。根据命题 C 和命题 D,这个数值的公道巨细应该是符号表巨细的自然对数的两倍左右,由于对于一个几乎充满的符号表,大多数操纵都是查找。这个预测至少有以下禁绝确性:
- 很多操纵都是在较小的符号表中举行的;
- 键不随机;
- 符号表大概太小,近似值 禁绝确。
!/740942/image01321.gif)
图 3.2.9 使用二叉查找树,运行 java FrequencyCounter 8 < tale.txt 的成本
无论如何,通过表 3.2.1 你都能看到,对于 FrequencyCounter 这个预测的误差只有若干次比较。事实上,大多数误差都能通过对近似值的数学表达式的改进得到解释(请见练习 3.2.35)。
表 3.2.1 使用二叉查找树的 FrequencyCounter 的每次 put() 操纵平均所需的比较次数
tale.txtleipzig1M.txt单词数不同单词数比较次数单词数不同单词数比较次数模型预测实际次数模型预测实际次数全部单词135 63510 67918.617.521 191 455534 58023.422.1长度大于等于 8 的单词14 3505 13117.613.94 239 597299 59322.721.4长度大于等于 10 的单词4 5822 26015.413.11 610 829165 55520.519.3
3.2.3 有序性相干的方法与删除操纵
二叉查找树得以广泛应用的一个重要缘故原由就是它能够
保持键的有序性,因此它可以作为实现有序符号表 API(请见 3.1.2 节)中的众多方法的底子。这使得符号表的用例不但能够通过键还能通过键的相对顺序来访问键值对。下面,我们要研究有序符号表 API 中各个方法的实现。
3.2.3.1 最大键和最小键
如果根结点的左链接为空,那么一棵二叉查找树中最小的键就是根结点;如果左链接非空,那么树中的最小键就是左子树中的最小键。这不但形貌了算法 3.3(续 2)中 min() 方法的递归实现,同时也递推地证明白它能够在二叉查找树中找到最小的键。简单的循环也能等价实现这段形貌,但为了保持一致性我们使用了递归。我们可以让递归调用返回键 Key 而非结点对象 Node,但我们后面还会用到这方法来找出含有最小键的结点。找出最大键的方法也是类似的,只是变为查找右子树而已。
3.2.3.2 向上取整和向下取整
如果给定的键 key
小于 二叉查找树的根结点的键,那么小于等于 key 的最大键 floor(key)
一定 在根结点的左子树中;如果给定的键 key
大于 二叉查找树的根结点,那么只有当根结点右子树中存在小于等于 key 的结点时,小于等于 key 的最大键才会出如今右子树中,否则根结点就是小于等于 key 的最大键。这段形貌说明白 floor() 方法的递归实现,同时也递推地证明白它能够计算出预期的效果。将“左”变为“右”(同时将
小于 变为
大于)就能够得到 ceiling() 的算法。向下取整函数的计算如图 3.2.10 所示。
!/740942/image01322.gif)
图 3.2.10 计算 floor() 函数
3.2.3.3 选择操纵
二叉查找树中的选择操纵和 2.5 节中我们学习过的基于切分的数组选择操纵类似。我们在二叉查找树的每个结点中维护的子树结点计数器变量 N 就是用来支持此操纵的。
算法 3.3(续 2)二叉查找树中 max()、 min()、 floor()、 ceiling() 方法的实现- public Key min()
- {
- return min(root).key;
- }
- private Node min(Node x)
- {
- if (x.left == null) return x;
- return min(x.left);
- }
- public Key floor(Key key)
- {
- Node x = floor(root, key);
- if (x == null) return null;
- return x.key;
- }
- private Node floor(Node x, Key key)
- {
- if (x == null) return null;
- int cmp = key.compareTo(x.key);
- if (cmp == 0) return x;
- if (cmp < 0) return floor(x.left, key);
- Node t = floor(x.right, key);
- if (t != null) return t;
- else return x;
- }
复制代码 每个公有方法都对应着一个私有方法,它担当一个额外的链接作为参数指向某个结点,通过正文中形貌的递归方法查找返回 null 或者含有指定 Key 的结点 Node。 max() 和 ceiling() 的实现分别与 min() 和 floor() 方法基本雷同,只是将代码中的 left 和 right(以及>和<)调换而已。
假设我们想找到排名为
的键(即树中正好有
个小于它的键)。如果左子树中的结点数
大于
,那么我们就
继续(递归地)在左子树中查找排名为
的键;如果
等于
,我们就返回根结点中的键;如果
小于
,我们就(递归地)在右子树中查找排名为(
)的键。和刚才一样,这段形貌既说明白 select() 方法的递归实现同时也证明白它的精确性,此过程如图 3.2.11 所示。
!/740942/image01324.gif)
图 3.2.11 二叉查找树中的 select() 操纵
3.2.3.4 排名
rank() 是 select() 的逆方法,它会返回给定键的排名。它的实现和 select() 类似:如果给定的键和根结点的键相等,我们返回左子树中的结点总数
;如果给定的键小于根结点,我们会返回该键在左子树中的排名(递归计算);如果给定的键大于根结点,我们会返回
(根结点)加上它在右子树中的排名(递归计算)。
二叉查找树中选择和排名操纵的实现如算法 3.3(续 3)所示。
算法 3.3(续 3)二叉查找树中 select() 和 rank() 方法的实现- public Key select(int k)
- {
- return select(root, k).key;
- }
- private Node select(Node x, int k)
- { // 返回排名为k的结点
- if (x == null) return null;
- int t = size(x.left);
- if (t > k) return select(x.left, k);
- else if (t < k) return select(x.right, k-t-1);
- else return x;
- }
- public int rank(Key key)
- { return rank(key, root); }
- private int rank(Key key, Node x)
- { // 返回以x为根结点的子树中小于x.key的键的数量
- if (x == null) return 0;
- int cmp = key.compareTo(x.key);
- if (cmp < 0) return rank(key, x.left);
- else if (cmp > 0) return 1 + size(x.left) + rank(key, x.right);
- else return size(x.left);
- }
复制代码 这段代码使用了和我们已经在本章中学习过的其他实现中一样的递归模式实现了 select() 和 rank() 方法。它依赖于本节开始处给出的 size() 方法来统计每个结点以下的子结点总数。
3.2.3.5 删除最大键和删除最小键
二叉查找树中最难实现的方法就是 delete() 方法,即从符号表中删除一个键值对。作为热身运动,我们先考虑 deleteMin() 方法(删除最小键所对应的键值对),如图 3.2.12 所示。和 put() 一样,我们的递归方法担当一个指向结点的链接,并返回一个指向结点的链接。这样我们就能够方便地改变树的结构,将返回的链接赋给作为参数的链接。对于 deleteMin(),我们要不断深入根结点的左子树中直至遇见一个空链接,然后将指向该结点的链接指向该结点的右子树(只必要在递归调用中返回它的右链接即可)。此时已经没有任何链接指向要被删除的结点,因此它会被垃圾收集器清理掉。我们给出的尺度递归代码在删除结点后会精确地设置它的父结点的链接并更新它到根结点的路径上的全部结点的计数器的值。 deleteMax() 方法的实现和 deleteMin() 完全类似。
!/740942/image01326.gif)
图 3.2.12 删除二叉查找树中的最小结点
3.2.3.6 删除操纵
我们可以用类似的方式删除恣意只有一个子结点(或者没有子结点)的结点,但应该怎样删除一个拥有两个子结点的结点呢?删除之后我们要处理两棵子树,但被删除结点的父结点只有一条空出来的链接。T. Hibbard 在 1962 年提出了解决这个困难的第一个方法,在删除结点 x 后用它的
后继结点 填补它的位置。由于 x 有一个右子结点,因此它的后继结点就是其右子树中的最小结点。这样的替换仍然能够包管树的有序性,由于 x.key 和它的后继结点的键之间不存在其他的键。我们能够用 4 个简单的步调完成将 x 替换为它的后继结点的使命(具体过程如图 3.2.13 所示):
- 将指向即将被删除的结点的链接生存为 t;
- 将 x 指向它的后继结点 min(t.right);
- 将 x 的右链接(原本指向一棵全部结点都大于 x.key 的二叉查找树)指向 deleteMin(t.right),也就是在删除后全部结点仍然都大于 x.key 的子二叉查找树;
- 将 x 的左链接(本为空)设为 t.left(其下全部的键都小于被删除的结点和它的后继结点)。
!/740942/image01327.gif)
图 3.2.13 二叉查找树中的删除操纵
在递归调用后我们会修正被删除的结点的父结点的链接,并将由此结点到根结点的路径上的全部结点的计数器减 1(这里计数器的值仍然会被设为其全部子树中的结点总数加一)。尽管这种方法能够精确地删除一个结点,它的一个缺陷是大概会在某些实际应用中产生性能问题。这个问题在于选用后继结点是一个随意的决定,且没有考虑树的对称性。可以使用它的前趋结点吗?实际上,前趋结点和后继结点的选择应该是随机的。详细讨论请见练习 3.2.42。
二叉查找树中删除操纵的实现如算法 3.3(续 4)所示。
算法 3.3(续 4)二叉查找树的 delete() 方法的实现- public void deleteMin()
- {
- root = deleteMin(root);
- }
- private Node deleteMin(Node x)
- {
- if (x.left == null) return x.right;
- x.left = deleteMin(x.left);
- x.N = size(x.left) + size(x.right) + 1;
- return x;
- }
- public void delete(Key key)
- { root = delete(root, key); }
- private Node delete(Node x, Key key)
- {
- if (x == null) return null;
- int cmp = key.compareTo(x.key);
- if (cmp < 0) x.left = delete(x.left, key);
- else if (cmp > 0) x.right = delete(x.right, key);
- else
- {
- if (x.right == null) return x.left;
- if (x.left == null) return x.right;
- Node t = x;
- x = min(t.right); // 请见算法3.3(续2)
- x.right = deleteMin(t.right);
- x.left = t.left;
- }
- x.N = size(x.left) + size(x.right) + 1;
- return x;
- }
复制代码 如前文所述,这段代码实现了 Hibbard 的二叉查找树中对结点的即时删除。 delete() 方法的代码很简洁,但不简单。也许理解它的最好办法就是读懂正文中的讲解,试着自己实现它并对比自己的代码和这段代码。一样平常情况下这段代码的效率不错,但对于大规模的应用来说大概会有一点问题(请见练习 3.2.42)。 deleteMax() 的实现和 deleteMin() 类似,只需左右互换即可。
3.2.3.7 范围查找
要实现能够返回给定范围内键的 keys() 方法,我们起首必要一个遍历二叉查找树的基本方法,叫做
中序遍历。要说明这个方法,我们先看看如何能够将二叉查找树中的全部键按照顺序打印出来。要做到这一点,我们应该先打印出根结点的左子树中的全部键(根据二叉查找树的界说它们应该都小于根结点的键),然后打印出根结点的键,最后打印出根结点的右子树中的全部键(根据二叉查找树的界说它们应该都大于根结点的键),如右侧的代码所示。
- private void print(Node x)
- {
- if (x == null) return;
- print(x.left);
- StdOut.println(x.key);
- print(x.right);
- }
复制代码按顺序打印二叉查找树中的全部键
和以前一样,刚才的形貌也递推地证明白这段代码能够顺序打印树中的全部键。为了实现担当两个参数并能够将给定范围内的键返回给用例的 keys() 方法,我们可以修改一下这段代码,将全部落在给定范围以内的键加入一个队列 Queue 并跳过那些不大概含有所查找键的子树。和 BinarySearchST 一样,用例不必要知道我们使用 Queue 来收集符合条件的键。我们使用什么数据结构来实现 Iterable 并不重要,用例只要能够使用 Java 的 foreach 语句遍历返回的全部键就可以了。
二叉查找树的范围查找操纵的实现如算法 3.3(续 5)所示。
算法 3.3(续 5)二叉查找树的范围查找操纵- public Iterable<Key> keys()
- { return keys(min(), max()); }
- public Iterable<Key> keys(Key lo, Key hi)
- {
- Queue<Key> queue = new Queue<Key>();
- keys(root, queue, lo, hi);
- return queue;
- }
- private void keys(Node x, Queue<Key> queue, Key lo, Key hi)
- {
- if (x == null) return;
- int cmplo = lo.compareTo(x.key);
- int cmphi = hi.compareTo(x.key);
- if (cmplo < 0) keys(x.left, queue, lo, hi);
- if (cmplo <= 0 && cmphi >= 0) queue.enqueue(x.key);
- if (cmphi > 0) keys(x.right, queue, lo, hi);
- }
复制代码 为了确保以给定结点为根的子树中全部在指定范围之内的键加入队列,我们会(递归地)查找根结点的左子树,然后查找根结点,然后(递归地)查找根结点的右子树。
!/740942/image01328.gif)
二叉查找树的范围查找
3.2.3.8 性能分析
二叉查找树中和有序性相干的操纵的效率如何?要研究这个问题,我们起首要知道
树的高度(即树中恣意结点的最大深度)。给定一棵树,树的高度决定了全部操纵在最坏情况下的性能(范围查找除外,由于它的额外成本和返回的键的数目成正比)。
命题 E。在一棵二叉查找树中,全部操纵在最坏情况下所需的时间都和树的高度成正比。
证明。树的全部操纵都沿着树的一条或两条路径行进。根据界说,路径的长度不大概大于树的高度。
我们估计树的高度(即最坏情况下的成本)将会大于我们在 3.2.2 节中界说的平均内部路径长度(这个平均值已经包含了全部较短的路径),但会高多少呢?也许在你看来这个问题和命题 C 和命题 D 解答的问题类似,但它的解答其实要困难得多,完全超出了本书的范畴。1979 年,J. Robson 证明白随机键构造的二叉查找树的平均高度为树中结点数的对数级别,随后 L. Devroye 证明白对于充足大的
,这个值趋近于
。因此,如果我们的应用中的插入操纵能够适用于这个随机模型,我们距离实现一个支持对数级别的全部操纵的符号表的目标就已经不远了。我们可以认为随机构造的树中的全部路径长度都小于
,但如果构造树的键不是随机的怎么办?在下一节中你会看到在实际应用中这个问题其实没有意义,由于另有
平衡二叉查找树,它能包管无论键的插入顺序如何,树的高度都将是总键数的对数。
总的来说,二叉查找树的实现并不困难,且当树的构造和随机模型近似时在各种实际应用场景中它都能举行快速地查找和插入。对于我们的例子(以及其他很多实际应用场景)来说,二叉查找树将不大概完成的使命变为大概。另外,很多程序员都偏爱基于二叉查找树的符号表的缘故原由是它还支持高效的 rank()、 select()、 delete() 以及范围查找等操纵。但同时,正如我们所夸大过的,在某些场景中二叉查找树在最坏情况下的恶劣性能仍然是不可担当的。二叉查找树的基本实现的良好性能依赖于其中的键的分布充足随机以消除长路径。对于快速排序,我们可以先将数组打乱;而对于符号表的 API,我们无能为力,由于符号表的用例控制着各种操纵的先后顺序。但最坏情况在实际应用也有大概出现——用例将全部键按照顺序或者逆序插入符号表就会增长这种情况出现的概率,而在没有明确的警告来制止这种行为时有些用例肯定会尝试这么做。这就是我们探求更好的算法和数据结构的主要缘故原由,这些算法和数据结构我们会在下一节学习。
本书中简单的符号表实现的成本列在表 3.2.2 中。
表 3.2.2 简单的符号表实现的成本总结
算法(数据结构)最坏情况下的运行时间的增长数目级(
N 次插入之后)平均情况下的运行时间的增长数目级(
N 次插入随机键之后)是否支持有序性相干的操纵查找插入查找命中插入顺序查询(无序链表)!/740942/image00798.gif)!/740942/image00798.gif)!/740942/image00986.gif)!/740942/image00798.gif)否二分查找(有序数组)!/740942/image00915.gif)!/740942/image00798.gif)!/740942/image00915.gif)!/740942/image00986.gif)是二叉树查找(二叉查找树)!/740942/image00798.gif)!/740942/image00798.gif)!/740942/image01317.gif)!/740942/image01317.gif)是
答疑
问 我见过二叉查找树,但它的实现没有使用递归。这两种方式各有哪些优缺点?
答 一样平常来说,递归的实现更轻易验证其精确性,而非递归的实现效率更高。在练习 3.2.13 中你必要用另一种方法实现 get(),你大概会留意到性能上的改进。如果树不是平衡的,函数调用的栈的深度大概会成为递归实现的一个问题。我们使用递归的一个主要缘故原由是使读者能够轻松过渡到下一节中的平衡二叉查找树,而且递归版本显然更易于实现和调试。
问 维护 Node 对象中的结点计数器似乎必要很多代码,这有必要吗?为什么不只用一个变量来生存整棵树中的结点总数来实现用例中的 size() 方法?
答 rank() 和 select() 方法必要知道每个结点所代表的子树中的结点总数。如果你不必要实现这些操纵,可以去掉这个变量以简化代码(请见练习 3.2.12)。要包管全部结点中的计数器的精确性的确很轻易堕落,但这个值在调试中同样有效。你也可以用递归的方法实现用例中的 size() 函数,但这样统计全部结点的运行时间大概是
线性 的。这十分危险,由于如果不知道这么一个简单的操纵会云云耗时,用例的性能大概会变得很差。
练习
3.2.1 将 E A S Y Q U E S T I O N 作为键按顺序插入一棵初始为空的二叉查找树中(方便起见设第 i 个键对应的值为 i),画出天生的二叉查找树。构造这棵树必要多少次比较?
3.2.2 将 A X C S E R H 作为键按顺序插入将会构造出一棵最坏情况下的二叉查找树结构,最下方的结点的两个链接全部为空,其他结点都含有一个空链接。用这些键给出构造最坏情况下的树的其他 5 种分列。
3.2.3 给出 A X C S E R H 的 5 种能够构造出
最优 二叉查找树的分列。
3.2.4 假设某棵二叉查找树的全部键均为 1 至 10 的整数,而我们要查找 5。那么以下哪个
不大概 是键的检查序列?
a. 10, 9, 8, 7, 6, 5
b. 4, 10, 8, 7, 5, 3
c. 1, 10, 2, 9, 3, 8, 4, 7, 6, 5
d. 2, 7, 3, 8, 4, 5
e. 1, 2, 10, 4, 8, 5
3.2.5 假设已知某棵二叉查找树中的每个结点的查找频率,且我们可以以恣意顺序用它们构造一棵树。我们是应该按照查找频率的顺序由高到低或是由低到高将它们插入,还是用其他某种顺序?证明你的结论。
3.2.6 为二叉查找树添加一个方法 height() 来计算树的高度。实现两种方案:一种使用递归(用时为线性级别,所需空间和树高成正比),一种模仿 size() 在每个结点中添加一个变量(所需空间为线性级别,查询耗时为常数)。
3.2.7 为二叉查找树添加一个方法 avgCompares() 来计算一棵给定的树中的一次随机命中查找平均所需的比较次数(即树的内部路径长度除以树的巨细再加 1)。实现两种方案:一种使用递归(用时为线性级别,所需空间和树高成正比),一种模仿 size() 在每个结点中添加一个变量(所需空间为线性级别,查询耗时为常数)。
3.2.8 编写一个静态方法 optCompares(),担当一个整型参数 N 并计算一棵最优(完善平衡的)二叉查找树中的一次随机查找命中平均所需的比较次数,如果树中的链接数目为 2 的幂,那么全部的空链接都应该在同一层,否则则分布在最底部的两层中。
3.2.9 对于
、3、4、5 和 6,画出用
个键大概构造出的全部不同外形的二叉查找树。
3.2.10 编写一个测试用例 TestBST.java 来测试正文中 min()、 max()、 floor()、 ceiling()、 select()、 rank()、 delete()、 deleteMin()、 deleteMax() 和 keys() 方法的实现。可以参考 3.1.3.1 节的尺度索引用例,使它担当其他合适的下令行参数。
3.2.11 高度为
且含有
个结点的二叉树能有多少种外形?使用
个不同的键能有多少种不同的方式构造一棵高度为
的二叉查找树?(参考练习 3.2.2)
3.2.12 实现一种二叉查找树,舍弃 rank() 和 select() 方法并且不在 Node 对象中使用计数器。
3.2.13 为二叉查找树实现非递归的 put() 和 get() 方法。
部分解答,以下是 get() 方法的实现:
- public Value get(Key key)
- {
- Node x = root;
- while (x != null)
- {
- int cmp = key.compareTo(x.key);
- if (cmp == 0) return x.val;
- else if (cmp < 0) x = x.left;
- else if (cmp > 0) x = x.right;
- }
- return null;
- }
复制代码put() 的实现更复杂一些,由于它必要生存一个指向底层结点的链接,以便使之成为新结点的父结点。你还必要额外遍历一遍查找路径来更新全部的结点计数器以包管结点插入的精确性。由于在性能优先的实现中查找的次数比插入多得多,有必要使用这段 get() 代码,而相应的 put() 实现则无关紧要。
3.2.14 实现非递归的 min()、 max()、 floor()、 ceiling()、 rank() 和 select() 方法。
3.2.15 对于右下方的二叉查找树,给出计算下列方法的过程中结点的访问序列。
![{%740942/image01332.gif)
a. floor("Q")
b. select(5)
c. ceiling("Q")
d. rank("J")
e. size("D", "T")
f. keys("D", "T")
3.2.16 设一棵树的
外部路径长度 为从根结点到空链接的全部路径上的结点总数。证明对于巨细为
的恣意二叉树,其外部路径长度和内部路径长度之差为
(可以参考命题 C)
3.2.17 从练习 3.2.1 构造的二叉查找树中将全部键按照插入顺序逐个删除并画出每次删除所得到的树。
3.2.18 从练习 3.2.1 构造的二叉查找树中将全部键按照字母顺序逐个删除并画出每次删除所得到的树。
3.2.19 从练习 3.2.1 构造的二叉查找树中逐次删除树的根结点并画出每次删除所得到的树。
3.2.20 请证明:对于含有
个结点的二叉查找树,担当两个参数的 size() 方法所需的运行时间最多为树高的倍数加上查找范围内的键的数目。
3.2.21 为二叉查找树添加一个 randomKey() 方法来在和树高成正比的时间内从符号表中随机返回一个键。
3.2.22 请证明:若一棵二叉查找树中的一个结点有两个子结点,那么它的后继结点不会有左子结点,前趋结点不会有右子结点。
3.2.23 delete() 方法符合交换律吗?(先删除 x 后删除 y 和先删除 y 后删除 x 能够得到雷同的效果吗?)
3.2.24 请证明:使用基于比较的算法构造一棵二叉查找树所需的最小比较次数为
。
提高题
3.2.25 完善平衡。编写一段程序,用一组键构造一棵和二分查找等价的二叉查找树。也就是说,在这棵树中查找恣意键所产生的比较序列和在这组键中使用二分查找所产生的比较序列完全雷同。
3.2.26 准确的概率。计算用
个随机的互不雷同的键构造出练习 3.2.9 中的每一棵树的概率。
3.2.27 内存使用。基于 1.4 节的假设,对于
对键值比较二叉查找树和 BinarySearchST 以及 SequentialSearchST 的内存使用情况。不必要记载键值自己占用的内存,只统计它们的引用。用图精确形貌一棵以 String 为键、 Integer 为值的二叉查找树(比如 FrequencyCounter 构造的那种)的内存使用情况,然后估计 FrequencyCounter 在使用二叉查找树处理《双城记》时树的内存使用情况(精确到字节)。
3.2.28 缓存。修改二叉查找树的实现,将最近访问的结点 Node 生存在一个变量中,这样 get() 或 put() 再次访问同一个键时就只必要常数时间了(参考练习 3.1.25)。
3.2.29 二叉树检查。编写一个递归的方法 isBinaryTree(),担当一个结点 Node 为参数。如果以该结点为根的子树中的结点总数和计数器的值
相符则返回 true,否则返回 false。
留意:这项检查也能包管数据结构中不存在环,因此这的确是一棵二叉树!
3.2.30 有序性检查。编写一个递归的方法 isOrdered(),担当一个结点 Node 和 min、 max 两个键作为参数。如果以该结点为根的子树中的全部结点都在 min 和 max 之间, min 和 max 的确分别是树中的最小和最大的结点且二叉查找树的有序性对树中的全部键都建立,返回 true,否则返回 false。
3.2.31 等值键检查。编写一个方法 hasNoDuplicates(),担当一个结点 Node 为参数。如果以该结点为根的二叉查找树中不含有等值的键则返回 true,否则返回 false。假设树已经通过了前几道练习的检查。
3.2.32 验证。编写一个方法 isBST(),担当一个结点 Node 为参数。若该结点是一个二叉查找树的根结点则返回 true,否则返回 false。
提示:这个使命比看起来要困难,它和你调用前三题中各个方法的顺序有关。
解答:
- private boolean isBST()
- {
- if (!isBinaryTree(root)) return false;
- if (!isOrdered(root, min(), max())) return false;
- if (!hasNoDuplicates(root)) return false;
- return true;
- }
复制代码 3.2.33 选择 / 排名检查。编写一个方法,对于 0 到 size()-1 之间的全部 i,检查 i 和 rank(select(i)) 是否相等,并检查二叉查找树中的的恣意键 key 和 select(rank(key)) 是否相等。
3.2.34 线性符号表。你的目标是实现一个扩展的符号表 ThreadedST,支持以下两个运行时间为常数的操纵:
- Key next(Key key),key的下一个键(若key为最大键则返回空)
- Key prev(Key key),key的上一个键(若key为最小键则返回空)
复制代码要做到这一点必要在结点中增长 pred 和 succ 两个变量来生存结点的前趋和后继结点,并相应修改 put()、 deleteMin()、 deleteMax() 和 delete() 方法来维护这两个变量。
3.2.35 改进的分析。为了更好地解释正文表格中的试验效果请改进它的数学模型。证明随着
的增大,在一棵随机构造的二叉查找树中,一次命中查找所需的平均比较次数会趋近于
,其中
,即
欧拉常数。
提示:参考 2.3 节中对快速排序的分析,
的积分趋近于
。
3.2.36 迭代器。可否实现一个非递归版本的 keys() 方法,其使用的额外空间和树的高度成正比(和查找范围内的键的多少无关)?
3.2.37 按层遍历。编写一个方法 printLevel(),担当一个结点 Node 作为参数,按照层级顺序打印以该结点为根的子树(即按每个结点到根结点的距离的顺序,同一层的结点应该按从左至右的顺序)。
提示:使用队列 Queue。
3.2.38 绘图。为二叉查找树添加一个方法 draw(),按照正文中的样式将树绘制出来。
提示:在结点中用变量生存坐标并用递归的方法设置这些变量。
实验题
3.2.39 平均情况。用履历数据评估在一棵由
个随机结点构造的二叉查找树中,一次命中的查找和未命中的查找平均所需的比较次数的平均差和尺度差,其中
、
和
,重复实验 100 遍。将你的实验效果和练习 3.2.35 给出的计算平均比较次数的公式举行对比。
3.2.40 树的高度。用履历数据评估一棵由
个随机结点构造的二叉查找树的平均高度,其中
、
和
,重复实验 100 遍。将你的试验效果和正文中给出的估计值
举行对比。
3.2.41 数组表现。
开发一个二叉查找树的实现,用三个数组表现一棵树(预先分配为构造函数中所指定的最大长度):一个数组用来生存键,一个数组用来生存左链接的索引,一个数组用来生存右链接的索引。比较你的程序和尺度实现的性能。
3.2.42 Hibbard 删除方法的性能问题。编写一个程序,从下令行担当一个参数
并构造一棵由
个随机键天生的二叉查找树,然后进入一个循环。在循环中它先删除一个随机键( delete(select(StdRandom.uniform(N)))),然后再插入一个随机键,云云循环
次。循环竣事后,计算并打印树的内部平均路径长度(内部路径长度除以
再加 1)。对于
、
和
,运行你的程序来验证一个有些违反直觉的假设:这个过程会增长树的平均路径长度,增长的长度和
的平方根成正比。使用能够随机选择前趋或后继结点的 delete() 方法重复这个实验。
3.2.43 put()/ get() 方法的比例。用履历数据评估当使用 FrequencyCounter 来统计 100 万个随机整数中每个数的出现频率时,二叉查找树中 put() 方法和 get() 方法所消耗的时间的比例。
3.2.44 绘制成本图。改造二叉查找树的实现来绘制本节所示的那种能够显示计算中每次 put() 操纵成本的图。
3.2.45 实际耗时。改造 FrequencyCounter,使用 Stopwatch 和 StdDraw 绘图,其中
轴表现 get() 和 put() 调用的总数,
轴为总运行时间,每次调用之后即在当前运行时间处绘制一个点。使用 SequentialSearchST 和你的程序处理《双城记》,再用 BinarySearchST 处理一遍,最后用二叉查找树处理一遍,然后讨论运行的效果。
留意:曲线中突然的跳跃大概是
缓存 导致的,这已经超出了这个问题的讨论范围(请见练习 3.1.39)。
3.2.46 二叉查找树的临界点。使用随机 double 值作为键,分别找出使得二叉查找树的符号表比二分查找要快 10、100 倍和 1000 倍的
值。分析并预测
的巨细并通过实验验证它。
3.2.47 平均查找耗时。用实验研究和计算在一棵由
个随机结点构造的二叉查找树中到达恣意结点的平均路径长度(内部路径长度除以
再加 1)的平均差和尺度差,对于 100 到 10 000 之间的每个
重复实验 1000 遍。将效果绘制成和图 3.2.14 相似的一张 Tufte 图,并画上函数
的曲线(请见练习 3.2.35 和练习 3.2.39)。
!/740942/image01340.jpeg)
图 3.2.14 一棵随机构造的二叉查找树中由根到达恣意结点的平均路径长度
本文由博客一文多发平台 OpenWrite 发布!
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。