数据结构与算法之ACM Fellow-算法3.2 二叉查找树

打印 上一主题 下一主题

主题 1611|帖子 1611|积分 4833

数据结构与算法之ACM Fellow-算法3.2 二叉查找树

在本节中我们将学习一种能够将链表插入的灵活性和有序数组查找的高效性结合起来的符号表实现。具体来说,就是使用每个结点含有 两个 链接(链表中每个结点只含有一个链接)的二叉查找树来高效地实现符号表,这也是计算机科学中最重要的算法之一。
起首,我们必要界说一些术语。我们所使用的数据结构由 结点 组成,结点包含的 链接 可以为空( null)或者指向其他结点。在 二叉树 中,每个结点只能有一个父结点(只有一个破例,也就是 根结点,它没有父结点),而且每个结点都只有 左右 两个链接,分别指向自己的 左子结点右子结点(如图 3.2.1 所示)。尽管链接指向的是结点,但我们可以将每个链接看做指向了另一棵二叉树,而这棵树的根结点就是被指向的结点。因此我们可以将二叉树界说为一个空链接,或者是一个有左右两个链接的结点,每个链接都指向一棵(独立的 )子二叉树。在 二叉查找树 中,每个结点还包含了一个键和一个值,键之间也有顺序之分以支持高效的查找。
界说。一棵 二叉查找树(BST)是一棵二叉树,其中每个结点都含有一个 Comparable 的键(以及相干联的值)且每个结点的键都大于其左子树中的恣意结点的键而小于右子树的恣意结点的键。
我们在画出二叉查找树时会将键写在结点上。我们使用“A 是 E 的左子结点”的说法用键指代结点。我们用毗连结点的线表现链接,并将键对应的值写在结点旁边(若值不确定则省略)。除了空结点只表现为向下的一条线段以外,每个结点的链接都指向它下方的结点。和以前一样,我们在例子中只会使用索引测试用例天生的单个字母作为键,如图 3.2.2 所示。
![{%740942/image01309.gif)
3.2.1 基本实现

IT优质资源下载地址:
对应视频资源地址:资源网盘分享
更多资源资源群 资源群
群满+新共享群:备份群
算法 3.3 界说了二叉查找树(BST)的数据结构,我们会在本节中用它实现有序符号表的 API。起首我们要研究一下这个经典的数据类型,以及与它的特点紧密相干的 get()(查找)和 put()(插入)方法的实现。
3.2.1.1 数据表现

和链表一样,我们嵌套界说了一个私有类来表现二叉查找树上的一个结点。每个结点都含有一个键、一个值、一条左链接、一条右链接和一个结点计数器(有必要时我们会在图中将结点计数器的值写在结点上方)。左链接指向一棵由小于该结点的全部键组成的二叉查找树,右链接指向一棵由大于该结点的全部键组成的二叉查找树。变量 N 给出了以该结点为根的子树的结点总数。你将会看到,它简化了很多有序符号表的操纵的实现。算法 3.3 中实现的私有方法 size() 会将空链接的值看成 0,这样我们就能包管以下公式对于二叉树中的恣意结点 x 总是建立。
  1. size(x) = size(x.left) + size(x.right) + 1
复制代码
一棵二叉查找树代表了一组键(及其相应的值)的 集合,而同一个集合可以用多棵不同的二叉查找树表现(如图 3.2.3 所示)。如果我们将一棵二叉查找树的全部键投影到一条直线上,包管一个结点的左子树中的键出如今它的左边,右子树中的键出如今它的右边,那么我们一定可以得到一条有序的键列。我们会利用二叉查找树的这种天生的灵活性,用多棵二叉查找树表现同一组有序的键来实现构建和使用二叉查找树的高效算法。
!/740942/image01310.gif)
图 3.2.3 两棵能够表现同一组键的二叉查找树
3.2.1.2 查找

一样平常来说,在符号表中查找一个键大概得到两种效果。如果含有该键的结点存在于表中,我们的查找就 命中 了,然后返回相应的值。否则查找 未命中(并返回 null)。根据数据表现的递归结构我们马上就能得到,在二叉查找树中查找一个键的递归算法:如果树是空的,则查找未命中;如果被查找的键和根结点的键相等,查找命中,否则我们就(递归地)在得当的子树中继续查找。如果被查找的键较小就选择左子树,较大则选择右子树。算法 3.3(续 1)中递归的 get() 方法完全实现了这段算法。它的第一个参数是一个结点(子树的根结点),第二个参数是被查找的键。代码会包管只有该结点所表现的子树才会含有和被查找的键相等的结点。和二分查找中每次迭代之后查找的区间就会减半一样,在二叉查找树中,随着我们不断向下查找,当前结点所表现的子树的巨细也在减小(理想情况下是减半,但至少会有一个结点)。当找到一个含有被查找的键的结点(命中)或者当前子树变为空(未命中)时这个过程才会竣事。从根结点开始,在每个结点中查找的进程都会递归地在它的一个子结点上睁开,因此一次查找也就界说了树的一条路径。对于命中的查找,路径在含有被查找的键的结点处竣事。对于未命中的查找,路径的终点是一个空链接,如图 3.2.4 所示。
!/740942/image01311.gif)
图 3.2.4 二叉查找树中的查找命中(左)和未命中(右)
算法 3.3 基于二叉查找树的符号表
  1. public class BST<Key extends Comparable<Key>, Value>
  2. {
  3. private Node root;               // 二叉查找树的根结点
  4. private class Node
  5. {
  6.    private Key key;              // 键
  7.    private Value val;            // 值
  8.    private Node left, right;     // 指向子树的链接
  9.    private int N;                // 以该结点为根的子树中的结点总数
  10.    public Node(Key key, Value val, int N)
  11.    {  this.key = key; this.val = val; this.N = N; }
  12. }
  13. public int size()
  14. {  return size(root);  }
  15. private int size(Node x)
  16. {
  17.    if (x == null) return 0;
  18.    else           return x.N;
  19. }
  20. public Value get(Key key)
  21. // 请见算法3.3(续1)
  22. public void put(Key key, Value val)
  23. // 请见算法3.3(续1)
  24. // max()、min()、floor()、ceiling()方法请见算法3.3(续2)
  25. // select()、rank()方法请见算法3.3(续3)
  26. // delete()、deleteMin()、deleteMax()方法请见算法3.3(续4)
  27. // keys()方法请见算法3.3(续5)
  28. }
复制代码
这段代码用二叉查找树实现了有序符号表的 API,树由 Node 对象组成,每个对象都含有一对键值、两条链接和一个结点计数器 N。每个 Node 对象都是一棵含有 N 个结点的子树的根结点,它的左链接指向一棵由小于该结点的全部键组成的二叉查找树,右链接指向一棵由大于该结点的全部键组成的二叉查找树。 root 变量指向二叉查找树的根结点 Node 对象(这棵树包含了符号表中的全部键值对)。本节会连续给出其他方法的实现。
算法 3.3(续 1)的实现过程如下所示。
算法 3.3(续 1)二叉查找树的查找和排序方法的实现
  1. public Value get(Key key)
  2. {  return get(root, key);  }
  3. private Value get(Node x, Key key)
  4. {  // 在以x为根结点的子树中查找并返回key所对应的值;
  5. // 如果找不到则返回null
  6. if (x == null) return null;
  7. int cmp = key.compareTo(x.key);
  8. if      (cmp < 0) return get(x.left, key);
  9. else if (cmp > 0) return get(x.right, key);
  10. else return x.val;
  11. }
  12. public void put(Key key, Value val)
  13. {  // 查找key,找到则更新它的值,否则为它创建一个新的结点
  14. root = put(root, key, val);
  15. }
  16. private Node put(Node x, Key key, Value val)
  17. {
  18. // 如果key存在于以x为根结点的子树中则更新它的值;
  19. // 否则将以key和val为键值对的新结点插入到该子树中
  20. if (x == null) return new Node(key, val, 1);
  21. int cmp = key.compareTo(x.key);
  22. if      (cmp < 0) x.left  = put(x.left,  key, val);
  23. else if (cmp > 0) x.right = put(x.right, key, val);
  24. else x.val = val;
  25. x.N = size(x.left) + size(x.right) + 1;
  26. return x;
  27. }
复制代码
这段代码实现了有序符号表 API 中的 put() 和 get() 方法,它们的递归实现也是本章稍后将会讨论的其他几种实现的模板。每个方法的实现既可以看做是实用的代码,也可以看做是之前讨论的递推猜想的证明。
3.2.1.3 插入

算法 3.3(续 1)中的查找代码几乎和二分查找的一样简单,这种简洁性是二叉查找树的重要特性之一。而二叉查找树的另一个更重要的特性就是插入的实现难度和查找差不多。当查找一个不存在于树中的结点并竣事于一条空链接时,我们必要做的就是将链接指向一个含有被查找的键的新结点(详见图 3.2.5)。算法 3.3(续 1)中递归的 put() 方法的实现逻辑和递归查找很相似:如果树是空的,就返回一个含有该键值对的新结点;如果被查找的键小于根结点的键,我们会继续在左子树中插入该键,否则在右子树中插入该键。
!/740942/image01312.gif)
图 3.2.5 二叉查找树的插入操纵
3.2.1.4 递归

这些递归实现值得我们花点儿时间去理解其中的运行细节。可以将递归调用 的代码想象成 沿着树向下走:它会将给定的键和每个结点的键相比较并根据效果向左或者向右移动到下一个结点。然后可以将递归调用 的代码想象成 沿着树向上爬。对于 get() 方法,这对应着一系列的返回指令( return),但是对于 put() 方法,这意味着重置搜索路径上每个父结点指向子结点的链接,并增长路径上每个结点中的计数器的值。在一棵简单的二叉查找树中,唯一的新链接就是在最底层指向新结点的链接,重置更上层的链接可以通过比较语句来制止。同样,我们只必要将路径上每个结点中的计数器的值加 1,但我们使用了更加通用的代码,使之等于结点的全部子结点的计数器之和加 1。在本节和下一节中,我们会学习一些更加高级但原理雷同的算法,但它们在搜索路径上必要改变的链接更多,也必要适应性更强的代码来更新结点计数器。基本的二叉查找树的实现经常黑白递归的(请见练习 3.2.13)——我们在实现中使用了递归,一来是为了便于读者理解代码的工作方式,二来也是为学习更加复杂的算法做准备。
图 3.2.6 是对我们的尺度索引用例轨迹的一份详细的研究,它向你展示了二叉树是如何生长的。新结点会毗连到树底层的空链接上,树的其他部分则不会改变。例如,第一个被插入的键就是根结点,第二个被插入的键是根结点的两个子结点之一,以此类推。由于每个结点都含有两个链接,树会逐渐长大而不是萎缩。不但云云,由于只有查找或者插入路径上的结点才会被访问,所以随着树的增长,被访问的结点数目占树的总结点数的比例也会不断的降低。
!/740942/image01313.gif)
图 3.2.6 使用二叉查找树的尺度索引用例的轨迹
3.2.2 分析

使用二叉查找树的算法的运行时间取决于树的外形,而树的外形又取决于键被插入的先后顺序。在最好的情况下,一棵含有
个结点的树是完全平衡的,每条空链接和根结点的距离都为
。在最坏的情况下,搜索路径上大概有
个结点。如图 3.2.7 所示。但在一样平常情况下树的外形和最好情况更靠近。
!/740942/image01314.gif)
图 3.2.7 二叉查找树的大概外形
对于很多应用来说,图 3.2.8 所示的简单模型都是适用的:我们假设键的分布是(均匀)随机的,或者说它们的插入顺序是 随机的。对这个模型的分析而言,二叉查找树和快速排序几乎就是“双胞胎”。树的根结点就是快速排序中的第一个切分元素(左侧的键都比它小,右侧的键都比它大),而这对于全部的子树同样适用,这和快速排序中对子数组的递归排序完全对应。这使我们能够分析得到二叉查找树的一些性子。
!/740942/image01315.gif)
图 3.2.8 一棵典型的二叉查找树,由 256 个随机键组成
命题 C。在由
个随机键构造的二叉查找树中,查找命中平均所需的比较次数为
(约
)。
证明。一次竣事于给定结点的命中查找所需的比较次数为查找路径的深度加 1。如果将树中的全部结点的深度加起来,我们就能够得到一棵树的 内部路径长度。因此,在二叉查找树中的平均比较次数即为平均内部路径长度加 1。我们可以使用 2.3 节的命题 K 的证明得到它:令
为由
个随机排序的不同键构造得到的二叉查找树的内部路径长度,则查找命中的平均成本为(
)。我们有
,且对于
我们可以根据二叉查找树的递归结构直接得到一个归纳关系式:

其中
这一项表现根结点使得树中的全部
个非根结点的路径上都加了 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() 方法的实现
  1. public Key min()
  2. {
  3. return min(root).key;
  4. }
  5. private Node min(Node x)
  6. {
  7. if (x.left == null) return x;
  8. return min(x.left);
  9. }
  10. public Key floor(Key key)
  11. {
  12. Node x = floor(root, key);
  13. if (x == null) return null;
  14. return x.key;
  15. }
  16. private Node floor(Node x, Key key)
  17. {
  18. if (x == null) return null;
  19. int cmp = key.compareTo(x.key);
  20. if (cmp == 0) return x;
  21. if (cmp < 0)  return floor(x.left, key);
  22. Node t = floor(x.right, key);
  23. if (t != null) return t;
  24. else           return x;
  25. }
复制代码
每个公有方法都对应着一个私有方法,它担当一个额外的链接作为参数指向某个结点,通过正文中形貌的递归方法查找返回 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() 方法的实现
  1. public Key select(int k)
  2. {
  3. return select(root, k).key;
  4. }
  5. private Node select(Node x, int k)
  6. {   // 返回排名为k的结点
  7. if (x == null) return null;
  8. int t = size(x.left);
  9. if      (t > k) return select(x.left,  k);
  10. else if (t < k) return select(x.right, k-t-1);
  11. else            return x;
  12. }
  13. public int rank(Key key)
  14. {  return rank(key, root);  }
  15. private int rank(Key key, Node x)
  16. {  // 返回以x为根结点的子树中小于x.key的键的数量
  17. if (x == null) return 0;
  18. int cmp = key.compareTo(x.key);
  19. if      (cmp < 0) return rank(key, x.left);
  20. else if (cmp > 0) return 1 + size(x.left) + rank(key, x.right);
  21. else              return size(x.left);
  22. }
复制代码
这段代码使用了和我们已经在本章中学习过的其他实现中一样的递归模式实现了 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() 方法的实现
  1. public void deleteMin()
  2. {
  3. root = deleteMin(root);
  4. }
  5. private Node deleteMin(Node x)
  6. {
  7. if (x.left == null) return x.right;
  8. x.left = deleteMin(x.left);
  9. x.N = size(x.left) + size(x.right) + 1;
  10. return x;
  11. }
  12. public void delete(Key key)
  13. {  root = delete(root, key);  }
  14. private Node delete(Node x, Key key)
  15. {
  16. if (x == null) return null;
  17. int cmp = key.compareTo(x.key);
  18. if      (cmp < 0) x.left  = delete(x.left,  key);
  19. else if (cmp > 0) x.right = delete(x.right, key);
  20. else
  21. {
  22.    if (x.right == null) return x.left;
  23.    if (x.left == null) return x.right;
  24.    Node t = x;
  25.    x = min(t.right);  // 请见算法3.3(续2)
  26.    x.right = deleteMin(t.right);
  27.    x.left = t.left;
  28. }
  29. x.N = size(x.left) + size(x.right) + 1;
  30. return x;
  31. }
复制代码
如前文所述,这段代码实现了 Hibbard 的二叉查找树中对结点的即时删除。 delete() 方法的代码很简洁,但不简单。也许理解它的最好办法就是读懂正文中的讲解,试着自己实现它并对比自己的代码和这段代码。一样平常情况下这段代码的效率不错,但对于大规模的应用来说大概会有一点问题(请见练习 3.2.42)。 deleteMax() 的实现和 deleteMin() 类似,只需左右互换即可。
3.2.3.7 范围查找

要实现能够返回给定范围内键的 keys() 方法,我们起首必要一个遍历二叉查找树的基本方法,叫做 中序遍历。要说明这个方法,我们先看看如何能够将二叉查找树中的全部键按照顺序打印出来。要做到这一点,我们应该先打印出根结点的左子树中的全部键(根据二叉查找树的界说它们应该都小于根结点的键),然后打印出根结点的键,最后打印出根结点的右子树中的全部键(根据二叉查找树的界说它们应该都大于根结点的键),如右侧的代码所示。
  1. private void print(Node x)
  2. {
  3.    if (x == null) return;
  4.    print(x.left);
  5.    StdOut.println(x.key);
  6.    print(x.right);
  7. }
复制代码
按顺序打印二叉查找树中的全部键
和以前一样,刚才的形貌也递推地证明白这段代码能够顺序打印树中的全部键。为了实现担当两个参数并能够将给定范围内的键返回给用例的 keys() 方法,我们可以修改一下这段代码,将全部落在给定范围以内的键加入一个队列 Queue 并跳过那些不大概含有所查找键的子树。和 BinarySearchST 一样,用例不必要知道我们使用 Queue 来收集符合条件的键。我们使用什么数据结构来实现 Iterable 并不重要,用例只要能够使用 Java 的 foreach 语句遍历返回的全部键就可以了。
二叉查找树的范围查找操纵的实现如算法 3.3(续 5)所示。
算法 3.3(续 5)二叉查找树的范围查找操纵
  1. public Iterable<Key> keys()
  2. {  return keys(min(), max());  }
  3. public Iterable<Key> keys(Key lo, Key hi)
  4. {
  5. Queue<Key> queue = new Queue<Key>();
  6. keys(root, queue, lo, hi);
  7. return queue;
  8. }
  9. private void keys(Node x, Queue<Key> queue, Key lo, Key hi)
  10. {
  11. if (x == null) return;
  12. int cmplo = lo.compareTo(x.key);
  13. int cmphi = hi.compareTo(x.key);
  14. if (cmplo < 0) keys(x.left, queue, lo, hi);
  15. if (cmplo <= 0 && cmphi >= 0) queue.enqueue(x.key);
  16. if (cmphi > 0) keys(x.right, queue, lo, hi);
  17. }
复制代码
为了确保以给定结点为根的子树中全部在指定范围之内的键加入队列,我们会(递归地)查找根结点的左子树,然后查找根结点,然后(递归地)查找根结点的右子树。
!/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() 方法的实现:
  1. public Value get(Key key)
  2. {
  3.    Node x = root;
  4.    while (x != null)
  5.    {
  6.       int cmp = key.compareTo(x.key);
  7.       if (cmp == 0) return x.val;
  8.       else if (cmp < 0) x = x.left;
  9.       else if (cmp > 0) x = x.right;
  10.    }
  11.    return null;
  12. }
复制代码
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。 提示:这个使命比看起来要困难,它和你调用前三题中各个方法的顺序有关。
解答
  1. private boolean isBST()
  2. {
  3.    if (!isBinaryTree(root)) return false;
  4.    if (!isOrdered(root, min(), max())) return false;
  5.    if (!hasNoDuplicates(root)) return false;
  6.    return true;
  7. }
复制代码
3.2.33 选择 / 排名检查。编写一个方法,对于 0 到 size()-1 之间的全部 i,检查 i 和 rank(select(i)) 是否相等,并检查二叉查找树中的的恣意键 key 和 select(rank(key)) 是否相等。
3.2.34 线性符号表。你的目标是实现一个扩展的符号表 ThreadedST,支持以下两个运行时间为常数的操纵:
  1. Key next(Key key),key的下一个键(若key为最大键则返回空)
  2. 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企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

用户国营

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表