原文:普林斯顿大学算法课程
译者:飞龙
协议:CC BY-NC-SA 4.0
2.2 归并排序
原文:
algs4.cs.princeton.edu/22mergesort
译者:飞龙
协议:CC BY-NC-SA 4.0
我们在本节中考虑的算法基于一种简单的操作,称为合并:将两个有序数组组合成一个更大的有序数组。这个操作立即适用于一种简单的递归排序方法,称为归并排序:将数组分成两半,对这两半进行排序(递归),然后合并结果。
归并排序保证以与 N log N 成正比的时间对 N 个项目的数组进行排序���无论输入是什么。它的主要缺点是它使用与 N 成正比的额外空间。
抽象原地归并。
Merge.java 中的方法merge(a, lo, mid, hi)
将子数组a[lo..mid]
与a[mid+1..hi]
的归并结果放入一个有序数组中,将结果留在a[lo..hi]
中。虽然希望实现这种方法而不使用大量额外空间,但这样的解决方案非常复杂。相反,merge()
将所有内容复制到辅助数组,然后再次归并到原始数组。
自顶向下的归并排序。
Merge.java 是基于这种抽象原地归并的递归归并排序实现。这是利用分治范式进行高效算法设计的最著名的例子之一。
命题。
自顶向下的归并排序使用 1/2 N lg N 和 N lg N 比较,并且最多需要 6 N lg N 次数组访问来对长度为 N 的任何数组进行排序。
改进。
通过对实现进行一些经过深思熟虑的修改,我们可以大大减少归并排序的运行时间。
-
对小子数组使用插入排序。 通过对待处理的小情况进行不同处理,我们可以改进大多数递归算法。对小子数组使用插入排序将使典型归并排序实现的运行时间提高 10 到 15%。
-
测试数组是否已经有序。 通过添加一个测试来跳过对
merge()
的调用,如果a[mid]
小于或等于a[mid+1]
,我们可以将已经有序的数组的运行时间减少为线性。通过这种改变,我们仍然执行所有递归调用,但对于任何已排序的子数组,运行时间是线性的。 -
消除对辅助数组的复制。 可以消除用于归并的辅助数组的复制时间(但不是空间)。为此,我们使用两次调用排序方法,一次从给定数组中获取输入并将排序后的输出放入辅助数组;另一次从辅助数组中获取输入并将排序后的输出放入给定数组。通过这种方法,在一些令人费解的递归技巧中,我们可以安排递归调用,使计算在每个级别切换输入数组和辅助数组的角色。
MergeX.java 实现了这些改进。
可视化。
MergeBars.java 提供了带有小子数组截止的归并排序可视化。
自底向上的归并排序。
即使我们考虑将两个大子数组合并在一起,事实上大多数合并都是将微小的子数组合并在一起。 另一种实现归并排序的方法是组织合并,使我们在一次遍历中执行所有微小数组的合并,然后进行第二次遍历以成对合并这些数组,依此类推,直到进行涵盖整个数组的合并。 这种方法比标准递归实现需要更少的代码。 我们首先进行 1 对 1 的合并(将单个项目视为大小为 1 的子数组),然后进行 2 对 2 的合并(合并大小为 2 的子数组以生成大小为 4 的子数组),然后进行 4 对 4 的合并,依此类推。 MergeBU.java 是底部向上归并排序的实现。
命题。
底部向上的归并排序使用了介于 1/2 N lg N 和 N lg N 次比较,以及最多 6 N lg N 次数组访问来对长度为 N 的任意数组进行排序。
命题。
没有基于比较的排序算法可以保证使用少于 lg(N!) ~ N lg N 次比较对 N 个项目进行排序。
命题。
归并排序是一种渐进最优的基于比较的排序算法。 也就是说,归并排序在最坏情况下使用的比较次数以及任何基于比较的排序算法可以保证的最小比较次数都是~N lg N。
练习
-
给出追踪,展示如何使用自顶向下的归并排序和自底向上的归并排序对键
E A S Y Q U E S T I O N
进行排序的方式。解决方案。
-
回答底部向上归并排序的练习 2.2.2。
解决方案。
-
如果抽象的原地合并仅在两个输入子数组按排序顺序排列时才产生正确的输出,那么是否正确? 证明你的答案,或提供一个反例。
解决方案。 是的。如果子数组按排序顺序排列,那么原地合并会产生正确的输出。 如果一个子数组未按排序顺序排列,则其条目将按照它们在输入中出现的顺序出现在输出中(与另一个子数组的条目交错)。
-
给出自顶向下和自底向上归并排序算法在 n = 39 时每次合并后的子数组大小序列。
解决方案。
-
自顶向下的归并排序:2, 3, 2, 5, 2, 3, 2, 5, 10, 2, 3, 2, 5, 2, 3, 2, 5, 10, 20, 2, 3, 2, 5, 2, 3, 2, 5, 10, 2, 3, 2, 5, 2, 2, 4, 9, 19, 39。
-
底部向上的归并排序:2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 8, 8, 8, 8, 7, 16, 16, 32, 39。查看代码 MergeSizes.java。
-
-
假设自顶向下的归并排序修改为在
a[mid] <= a[mid+1]
时跳过对merge()
的调用。 证明对于已排序顺序的数组,使用的比较次数是线性的。解决方案。 由于数组已经排序,不会调用
merge()
。 当 N 是 2 的幂时,比较次数将满足递归 T(N) = 2 T(N/2) + 1,其中 T(1) = 0。 -
在库软件中使用类似 aux[]的静态数组是不明智的,因为多个客户端可能同时使用该类。给出一个不使用静态数组的 Merge.java 实现。
创造性问题
-
更快的合并。 实现一个
merge()
的版本,将a[]
的后半部分以递减顺序复制到aux[]
,然后将其合并回a[]
。 这个改变允许你从内部循环中删除测试每个半部分是否已耗尽的代码。 注意:结果排序不是稳定的。private static void merge(Comparable[] a, int lo, int mid, int hi) { for (int i = lo; i <= mid; i++) aux[i] = a[i]; for (int j = mid+1; j <= hi; j++) aux[j] = a[hi-j+mid+1]; int i = lo, j = hi; for (int k = lo; k <= hi; k++) if (less(aux[j], aux[i])) a[k] = aux[j--]; else a[k] = aux[i++]; }
-
改进。 编写一个程序 MergeX.java,实现文本中描述的三个归并排序改进:添加对小子数组的截止,测试数组是否已经有序,通过在递归代码中切换参数来避免复制。
-
逆序数。 开发并实现一个线性对数算法 Inversions.java,用于计算给定数组中的逆序数(插入排序为该数组执行的交换次数—参见第 2.1 节)。这个数量与 Kendall tau 距离 有关;参见第 2.5 节。
-
索引排序。 开发一个版本的 Merge.java,该版本不重新排列数组,而是返回一个
int[] perm
,使得perm[i]
是数组中第 i 小的条目的索引。
实验
网络练习
-
每个项最多进行 log N 次比较的归并。 设计一个合并算法,使得每个项最多比较对数次数。 (在标准合并算法中,当合并大小为 N/2 的两个子数组时,一个项可以比较 N/2 次。)
参考链接
-
对于排序 Young 表格的下界。 一个 Young 表格 是一个 N×N 矩阵,使得条目在列和行上都是有序的。证明对于排序 N² 个条目(只能通过成对比较访问数据)需要 Theta(N² log N) 次比较。
解决方案概述。如果条目 (i, j) 在 i + j 的 1/2 范围内,则所有 2N-1 个网格对角线彼此独立。对对角线进行排序需要 N² log N 次比较。
-
给定一个大小为 2N 的数组
a
,其中前 N 个项按升序排列在位置 0 到 N-1,以及一个大小为 N 的数组b
,其中 N 个项按升序排列,将数组b
合并到数组a
中,使得a
包含所有项按升序排列。使用 O(1) 额外内存。提示:从右向左合并。
-
k-近排序。 假设你有一个包含 N 个不同项的数组
a[]
,几乎是有序的:每个项最多离其在排序顺序中的位置不超过 k 个位置。设计一个算法,在时间复杂度为 N log k 的情况下对数组进行排序。提示:首先,对从 0 到 2k 的子数组进行排序;最小的 k 个项将处于正确的位置。接下来,对从 k 到 3k 的子数组进行排序;最小的 2k 个项现在将处于正确的位置。
-
找到一组输入,对于这组输入,归并排序比对包含 N 个不同键的数组进行排序时的比较次数严格少于 1/2 N lg N。
解决方案:一个 N = 2^k + 1 个键的逆序排序数组使用大约 1/2 N lg N - (k/2 - 1) 次比较。
-
最坏情况的输入数组。 编写一个程序 MergeWorstCase.java,该程序接受一个命令行参数 n,并创建一个长度为 n 的输入数组,使得归并排序进行最大数量的比较。
-
编写一个程序 SecureShuffle.java,从标准输入中读取一系列字符串并进行安全洗牌。使用以下算法:将每张卡片与一个介于 0 和 1 之间的随机实数关联起来。根据其关联的实数对值进行排序。使用
java.security.SecureRandom
生成随机实数。使用Merge.indexSort()
获取随机排列。 -
合并两个不同���度的数组。 给定大小为 M 和 N 的两个有序数组
a[]
和b[]
,其中 M ≥ N,设计一个算法将它们合并成一个新的有序数组c[]
,使用 ~ N lg M 次比较。提示:使用二分查找。
注意:存在一个 下界 为 Omega(N log (1 + M/N)) 次比较。这是因为有 M+N 个 N 个可能的合并结果。决策树论证表明,这至少需要 lg (M+N 个 N) 次比较。我们注意到 n 个 r 个选择 >= (n/r)^r。
-
合并三个数组。 给定大小为 N 的三个有序数组
a[]
、b[]
和c[]
,设计一个算法将它们合并成一个新的有序数组d[]
,在最坏情况下最多使用 ~ 6 N 次比较(或者,更好地说,~ 5 N 次比较)。 -
合并三个数组。 给定三个大小为 N 的排序数组
a[]
、b[]
和c[]
,证明没有基于比较的算法可以在最坏情况下使用少于 ~ 4.754887503 N 次比较将它们合并成一个新的排序数组d[]
。 -
具有 N^(3/2)逆序对的数组。 证明任何基于比较的算法,可以对具有 N^(3/2)或更少逆序对的数组进行排序,在最坏情况下必须进行 ~ 1/2 N lg N 次比较。
证明概要:将数组分成 sqrt(N) 个连续的子数组,每个子数组有 sqrt(N) 个项目,使得不同子数组之间没有逆序对,但每个子数组内的项目顺序是任意的。这样的数组最多有 N^(3/2) 个逆序对——每个 sqrt(N) 子数组中最多有 ~N/2 个逆序对。根据排序的下界,对每个子数组进行排序需要 ~ sqrt(N) lg sqrt(N) 次比较,总共需要 ~ 1/2 N lg N 次比较。
-
最优非遗忘排序。 设计算法,使用最少的比较次数(在最坏情况下)对长度为 3、4、5、6、7 和 8 的数组进行排序。
解决方案。 已知最优解使用 3、5、7、10、13 和 16 次比较,分别。已知 Ford-Johnson 合并插入算法对于 n <= 13 是最优的。在最坏情况下,它需要进行 sum(ceil(log2), k=1…n) 次比较。
2.3 快速排序
原文:
algs4.cs.princeton.edu/23quicksort
译者:飞龙
协议:CC BY-NC-SA 4.0
快速排序很受欢迎,因为它不难实现,适用于各种不同类型的输入数据,并且在典型应用中比任何其他排序方法都要快得多。它是原地排序(仅使用一个小型辅助栈),平均需要时间与 N log N 成正比来对 N 个项进行排序,并且具有极短的内部循环。
基本算法。
快速排序是一种分而治之的排序方法。它通过分区数组为两部分,然后独立对这两部分进行排序。方法的关键在于分区过程,该过程重新排列数组以满足以下三个条件:
-
条目
a[j]
在数组中处于最终位置,对于某个j
。 -
a[lo]
到a[j-1]
中没有任何条目大于a[j]
。 -
a[j+1]
到a[hi]
中没有任何条目小于a[j]
。
我们通过分区实现完整排序,然后递归地将该方法应用于子数组。这是一种随机化算法,因为它在对数组进行排序之前对数组进行随机洗牌。
分区。
要完成实现,我们需要实现分区方法。我们采用以下一般策略:首先,我们任意选择a[lo]
作为分区项—即将进入最终位置的项。接下来,我们从数组的左端开始扫描,直到找到一个大于(或等于)分区项的条目,然后我们从数组的右端开始扫描,直到找到一个小于(或等于)分区项的条目。停止扫描的两个条目在最终分区数组中是不正确的,因此我们交换它们。当扫描索引交叉时,为了完成分区过程,我们只需将分区项a[lo]
与左子数组的最右边的条目(a[j]
)交换并返回其索引j
。
快速排序。
Quick.java 是一个使用上述分区方法的快速排序实现。
实现细节。
在实现快速排序方面存在一些微妙的问题,这些问题反映在这段代码中,并值得一提。
-
原地分区。 如果我们使用额外的数组,分区就很容易实现,但并不比将分区版本复���回原始数组的额外成本值得。
-
保持边界。 如果数组中最小项或最大项是分区项,我们必须注意指针不要跑到数组的左端或右端。
-
保持随机性。 随机洗牌使数组处于随机顺序。由于它均匀对待子数组中的所有项,Quick.java 具有其两个子数组也处于随机顺序的特性。这一事实对算法的可预测性至关重要。保持随机性的另一种方法是在
partition()
中选择一个随机项进行分区。 -
终止循环。 正确测试指针是否交叉比起初看起来要棘手一些。一个常见的错误是忽略了数组可能包含其他与分区项相同值的键。
-
处理具有与划分项目键相等的键的项目。 最好停止扫描具有大于或等于划分项目键的键的项目的左扫描,以及停止扫描具有小于或等于划分项目键的键的项目的右扫描。尽管这种策略似乎会导致涉及具有与划分项目键相等的键的项目的不必要的交换,但这对于避免在某些典型应用程序中出现二次运行时间至关重要。
-
终止递归。 在实现快速排序时的一个常见错误是没有确保始终将一个项目放在正确位置,然后当划分项目恰好是数组中最大或最小的项目时,陷入无限递归循环。
命题。
快速排序平均使用~2 N ln N 次比较(和其中六分之一的交换)来对具有不同键的长度为 N 的数组进行排序。
命题。
快速排序在最坏情况下使用~N²/2 次比较,但随机洗牌可以防止这种情况发生。
运行时间的标准偏差约为 0.65 N,因此随着 N 的增长,运行时间趋于平均值,并且不太可能远离平均值。在您的计算机上对大数组进行排序时,快速排序使用二次比较的概率远小于您的计算机被闪电击中的概率!
改进。
快速排序是由 C. A. R. Hoare 于 1960 年发明的,并自那时以来一直被许多人研究和完善。
-
切换到插入排序。 与归并排序一样,对于微小数组,切换到插入排序是值得的。截断的最佳值取决于系统,但在大多数情况下,任何值在 5 到 15 之间可能都能很好地工作。
-
三取样划分。 改进快速排序性能的另一种简单方法是使用从数组中取出的一小部分项目的中位数作为划分项目。这样做将给出一个稍微更好的划分,但需要计算中位数的成本。事实证明,大部分可用的改进来自选择大小为 3 的样本(然后在中间项目上进行划分)。
可视化。
QuickBars.java 使用三取样划分和对小子数组进行截断的快速排序进行可视化。
熵最优排序。
在应用程序中经常出现具有大量重复排序键的数组。在这种应用程序中,有可能将排序时间从线性对数减少到线性。
一个直接的想法是将数组划分为三部分,分别用于具有小于、等于和大于划分项目键的项目。完成这种划分是一个经典的编程练习,由 E. W. Dijkstra 推广为荷兰国旗问题,因为它类似于对具有三种可能键值的数组进行排序,这可能对应于国旗上的三种颜色。
Dijkstra 的解决方案基于数组的单向左到右遍历,维护指针lt
,使得a[lo..lt-1]
小于v
,指针gt
,使得a[gt+1..hi]
大于v
,指针i
,使得a[lt..i-1]
等于 v,a[i..gt]
尚未检查。
从i
等于lo
开始,我们使用Comparable
接口给出的 3 路比较来处理a[i]
,以处理三种可能的情况:
-
a[i]
小于v
:交换a[lt]
和a[i]
,并同时增加lt
和i
-
a[i]
大于v
:交换a[i]
和a[gt]
,并减少gt
-
a[i]
等于v
:增加i
Quick3way.java 是这种方法的一个实现。
命题。
三路划分的快速排序是熵最优的。
可视化。
Quick3wayBars.java 可视化了使用三向切分的快速排序。
练习
-
展示
partition()
如何以E A S Y Q U E S T I O N
数组进行划分的跟踪风格。 -
展示快速排序如何对数组
E A S Y Q U E S T I O N
进行排序的快速排序跟踪风格。(在这个练习中,忽略初始洗牌。) -
编写一个程序 Sort2distinct.java,对已知只包含两个不同关键值的数组进行排序。
-
当对一个包含 N 个相同项的数组进行排序时,
Quick.sort()
会进行多少次比较?解决方案。 〜 N lg N 次比较。每个划分将数组分成两半,加上或减去一个。
-
展示熵最优排序如何首先对数组
B A B A B A B A C A D A B R A
进行划分的跟踪风格。
创造性问题
-
螺母和螺栓。(G. J. E. Rawlins)。你有一堆混合的 N 个螺母和 N 个螺栓,需要快速找到相应的螺母和螺栓配对。每个螺母恰好匹配一个螺栓,每个螺栓恰好匹配一个螺母。通过将螺母和螺栓配对,你可以看出哪个更大。但不能直接比较两个螺母或两个螺栓。给出一个解决问题的高效方法。
提示:根据问题定制快速排序。顺便说一句:对于这个问题,已知只有一个非常复杂的确定性 O(N log N)算法。
-
最佳情况。 编写一个程序 QuickBest.java,为
Quick.sort()
生成一个最佳情况数组(无重复项):一个包含 N 个不同键的数组,具有每个划分产生的子数组大小最多相差 1 的特性(与 N 个相等键的数组产生相同子数组大小的情况相同)。在这个练习中,忽略初始洗牌。 -
快速三向切分。(J. Bentley 和 D. McIlroy)。实现一个基于保持相等键在子数组的左右两端的熵最优排序 QuickBentleyMcIlroy.java。维护索引 p 和 q,使得 a[lo…p-1]和 a[q+1…hi]都等于 a[lo],一个索引 i,使得 a[p…i-1]都小于 a[lo],一个索引 j,使得 a[j+1…q]都大于 a[lo]。在内部划分循环代码中添加代码,如果 a[i]等于 v,则交换 a[i]和 a[p](并增加 p),如果 a[j]等于 v,则交换 a[j]和 a[q](并减少 q),然后再进行通常的 a[i]和 a[j]与 v 的比较。在划分循环结束后,添加代码将相等的键交换到正确位置。
网络练习
-
QuickKR.java 是最简单的快速排序实现之一,并出现在 K+R 中。说服自己它是正确的。它将如何执行?所有相等的键呢?
-
随机化快速排序。 修改
partition()
,使其总是从数组中均匀随机选择划分项(而不是最初对数组进行洗牌)。与 Quick.java 比较性能。 -
Antiquicksort. Java 6 中用于对原始类型进行排序的算法是由 Bentley 和 McIlroy 开发的 3 路快速排序的变体。对于实践中出现的大多数输入,包括已经排序的输入,它非常高效。然而,使用 M. D. McIlroy 在 A Killer Adversary for Quicksort 中描述的巧妙技术,可以构造使系统排序在二次时间内运行的病态输入。更糟糕的是,它会溢出函数调用堆栈。要看到 Java 6 中的排序库崩溃,请尝试一些不同大小的致命输入:10,000, 20,000, 50,000, 100,000, 250,000, 500,000, 和 1,000,000。您可以使用程序 IntegerSort.java 进行测试,该程序接受一个命令行输入 N,从标准输入读取 N 个整数,并使用系统排序对它们进行排序。
-
糟糕的分区。 当所有键相等时,不停止在相等键上会使快速排序变为二次的原因是什么?
解决方案。 这是在我们在相等键上停止时对 AAAAAAAAAAAAAAA 进行分区的结果。它将数组不均匀地分成了一个大小为 0 的子问题和一个大小为 14 的子问题。
这是在我们在相等键上停止时对 AAAAAAAAAAAAAAA 进行分区的结果。它将数组均匀地分成了两个大小为 7 的子问题。
-
将项目与自身进行比较。 展示我们的快速排序实现可以将项目与自身进行比较,即对某个索引
i
调用less(i, i)
。修改我们的实现,使其永远不会将项目与自身进行比较。 -
霍尔原始快速排序。 实现霍尔原始快速排序算法的一个版本。它类似于我们的两路分区算法,只是枢轴不会交换到其最终位置。相反,枢轴留在两个子数组中的一个,没有元素固定在其最终位置,指针交叉的两个子数组会递归排序。
解决方案。 HoareQuick.java。我们注意到,虽然这个版本非常优雅,但它不会保留子数组中的随机性。根据 Sedgewick 的博士论文,“这种偏差不仅使方法的分析几乎不可能,而且还会显著减慢排序过程。”
-
双轴快速排序。 实现 Yaroslavskiy 的双轴快速排序的版本。
解决方案。 QuickDualPivot.java 是一个非常类似于 Quick3way.java 的实现。
-
三轴快速排序。 实现类似 Kushagra-Ortiz-Qiao-Munro 的三轴快速排序的版本。
-
比较次数。 给出一个长度为 n 的数组族,使得标准快速排序分区算法进行 (i) n + 1 次比较,(ii) n 次比较,(iii) n - 1 次比较,或者证明不存在这样的数组族。
解决方案:升序;降序;无。
2.4 优先队列
原文:
algs4.cs.princeton.edu/24pq
译者:飞龙
协议:CC BY-NC-SA 4.0
许多应用程序要求我们按顺序处理具有键的项目,但不一定是完全排序的顺序,也不一定一次处理所有项目。通常,我们收集一组项目,然后处理具有最大键的项目,然后可能收集更多项目,然后处理具有当前最大键的项目,依此类推。在这种环境中,一个适当的数据类型支持两个操作:删除最大和插入。这样的数据类型称为优先队列。
API。
优先队列的特点是删除最大和插入操作。按照惯例,我们将仅使用less()
方法比较键,就像我们对排序所做的那样。因此,如果记录可以具有重复的键,最大意味着具有最大键值的任何记录。为了完善 API,我们还需要添加构造函数和测试是否为空操作。为了灵活性,我们使用一个实现了Comparable
的通用类型Key
的通用实现。
程序 TopM.java 是一个优先队列客户端,它接受一个命令行参数M,从标准输入读取交易,并打印出M个最大的交易。
基本实现。
我们在第 1.3 节中讨论的基本数据结构为我们提供了四个立即的实现优先队列的起点。
-
数组表示(无序)。 也许最简单的优先队列实现是基于我们的推入栈代码。优先队列中插入的代码与栈中的推入相同。要实现删除最大,我们可以添加类似于选择排序的内部循环的代码,将最大项与末尾的项交换,然后删除那个,就像我们对栈的
pop()
所做的那样。程序 UnorderedArrayMaxPQ.java 使用这种方法实现了一个优先队列。 -
数组表示(有序)。 另一种方法是添加插入的代码,将较大的条目向右移动一个位置,从而保持数组中的条目有序(就像插入排序一样)。因此,最大的项始终在末尾,优先队列中删除最大的代码与栈中的弹出相同。程序 OrderedArrayMaxPQ.java 使用这种方法实现了一个优先队列。
-
链表表示(无序和反向有序)。 类似地,我们可以从我们的推入栈的链表代码开始,修改
pop()
的代码以找到并返回最大值,或者修改push()
的代码以保持项目以相反顺序,并修改pop()
的代码以取消链接并返回列表中的第一个(最大)项目。
所有刚讨论的基本实现都具有插入或删除最大操作在最坏情况下需要线性时间的特性。找到一个保证两个操作都快速的实现是一个更有趣的任务,也是本节的主要内容。
堆定义。
二叉堆是一种数据结构,可以高效支持基本的优先队列操作。在二叉堆中,项目存储在一个数组中,使得每个键都保证大于(或等于)另外两个特定位置的键。反过来,这两个键中的每一个必须大于另外两个键,依此类推。如果我们将键视为在具有从每个键到已知较小键的两个键的边的二叉树结构中,这种排序是很容易看到的。
定义。 如果每个节点中的键大于(或等于)该节点的两个子节点(如果有的话)中的键,则二叉树是堆有序的。
命题。 堆有序二叉树中最大的键位于根节点。
我们可以对任何二叉树施加堆排序限制。然而,使用像下面这样的完全二叉树特别方便。
我们通过层级顺序在数组中顺序表示完全二叉树,根位于位置 1,其子节点位于位置 2 和 3,它们的子节点位于位置 4、5、6 和 7,依此类推。
定义。 二叉堆是一组按照完全堆排序的二叉树中的键排列的节点集合,在数组中按层级顺序表示(不使用第一个条目)。
在堆中,位置为 k 的节点的父节点在位置 k/2;反之,位置为 k 的节点的两个子节点在位置 2k 和 2k + 1。我们可以通过对数组索引进行简单算术来上下移动:从 a[k] 向上移动树,我们将 k 设置为 k/2;向下移动树,我们将 k 设置为 2k 或 2k+1。
堆上的算法。
我们在长度为 n + 1 的私有数组 pq[]
中表示大小为 n 的堆,其中 pq[0]
未使用,堆在 pq[1]
到 pq[n]
中。我们仅通过私有辅助函数 less()
和 exch()
访问键。我们考虑的堆操作通过首先进行可能违反堆条件的简单修改,然后通���遍历堆,根据需要修改堆以确保堆条件在任何地方都得到满足来工作。我们将这个过程称为重新堆化,或恢复堆顺序。
-
自底向上重新堆化(上浮)。如果堆顺序被违反,因为一个节点的键变大于该节点的父节点的键,那么我们可以通过将节点与其父节点交换来向修复违规迈进。交换后,节点比其两个子节点都大(一个是旧父节点,另一个比旧父节点小,因为它是该节点的子节点),但节点可能仍然比其父节点大。我们可以以相同的方式修复该违规,依此类推,向上移动堆,直到到达具有较大键的节点,或根节点。
private void swim(int k) { while (k > 1 && less(k/2, k)) { exch(k, k/2); k = k/2; } }
-
自顶向下堆化(下沉)。如果堆顺序被违反,因为一个节点的键变小于一个或两个子节点的键,那么我们可以通过将节点与其两个子节点中较大的一个交换来向修复违规迈进。这种交换可能导致子节点违规;我们以相同的方式修复该违规,依此类推,向下移动堆,直到到达两个子节点都较小或底部的节点。
private void sink(int k) { while (2*k <= N) { int j = 2*k; if (j < N && less(j, j+1)) j++; if (!less(k, j)) break; exch(k, j); k = j; } }
基于堆的优先队列。
这些 sink()
和 swim()
操作为优先队列 API 的高效实现提供了基础,如下图所示,并在 MaxPQ.java 和 MinPQ.java 中实现。
-
插入。我们在数组末尾添加新项,增加堆的大小,然后通过该项向上游走以恢复堆的条件。
-
移除最大值。我们将顶部的最大项取出,将堆的末尾项放在顶部,减少堆的大小,然后通过该项向下沉入堆中以恢复堆的条件。
命题。 在一个包含 n 项的优先队列中,堆算法对插入最多需要 1 + lg n 次比较,对移除最大值最多需要 2 lg n 次比较。
实际考虑。
我们以几个实际考虑结束对堆优先队列 API 的研究。
-
多路堆。修改我们的代码以构建基于完整堆排序三元或d元树的数组表示并不困难。在降低树高度的较低成本和在每个节点找到三个或d个子节点中最大成本��间存在权衡。
-
数组调整。我们可以添加一个无参数构造函数,在
insert()
中添加数组加倍的代码,在delMax()
中添加数组减半的代码,就像我们在第 1.3 节中为堆栈所做的那样。当优先队列的大小是任意的且数组被调整大小时,对数时间界是摊销的。 -
键的不可变性。优先队列包含由客户端创建的对象,但假设客户端代码不会更改键(这可能会使堆的不变性无效)。
-
索引优先队列。在许多应用中,允许客户端引用已经在优先队列中的项目是有意义的。一种简单的方法是为每个项目关联一个唯一的整数索引。
IndexMinPQ.java 是这个 API 的基于堆的实现;IndexMaxPQ.java 类似,但用于面向最大的优先队列。Multiway.java 是一个客户端,将几个排序的输入流合并成一个排序的输出流。
堆排序。
我们可以使用任何优先队列来开发排序方法。我们将所有要排序的键插入到面向最小的优先队列中,然后重复使用删除最小值按顺序删除它们。当使用堆作为优先队列时,我们获得堆排序。
着眼于排序任务,我们放弃了隐藏优先队列的堆表示的概念,并直接使用swim()
和sink()
。这样做允许我们在不需要任何额外空间的情况下对数组进行排序,通过在要排序的数组内维护堆。堆排序分为两个阶段:堆构造,在这个阶段我们将原始数组重新组织成堆,和sortdown,在这个阶段我们按递减顺序从堆中取出项目以构建排序结果。
-
堆构造。我们可以在时间上按比例完成这项任务n lg n,通过从数组的左侧到右侧进行,使用
swim()
来确保扫描指针左侧的条目组成一个堆排序完整树,就像连续的优先队列插入一样。一个更有效的巧妙方法是从右到左进行,使用sink()
来随着我们的前进制作子堆。数组中的每个位置都是一个小子堆的根;sink()
也适用于这样的子堆。如果一个节点的两个子节点是堆,那么在该节点上调用sink()
会使根在那里的子树成为堆。 -
Sortdown。在堆排序期间,大部分工作是在第二阶段完成的,在这个阶段我们从堆中移除剩余的最大项目,并将其放入数组位置中,随着堆的缩小而腾出。
Heap.java 是堆排序的完整实现。下面是每次下沉后数组内容的跟踪。
命题。 基于 sink 的堆构造是线性时间的。
命题。 堆排序使用少于 2 n lg n 次比较和交换来对 n 个项目进行排序。
在 sortdown 期间重新插入堆中的大多数项目都会一直到底部。因此,我们可以通过避免检查项目是否已到达其位置来节省时间,简单地提升两个子节点中较大的一个,直到到达底部,然后沿着堆向上移动到正确的位置。这个想法通过增加额外的簿记来减少了比较次数。
练习
-
假设序列
P R I O * R * * I * T * Y * * * Q U E * * * U * E
(其中字母表示插入,星号表示删除最大值)应用于最初为空的优先队列。给出删除最大值操作返回的值序列。
解决方案。R R P O T Y I I U Q E U(PQ 上剩下 E)
-
批评以下想法:为了在常数时间内实现查找最大值,为什么不跟踪迄今为止插入的最大值,然后在查找最大值时返回该值?
解决方案。在删除最大值操作后,需要从头开始更新最大值。
-
提供支持插入和删除最大值的优先队列实现,每种实现对应一个基础数据结构:无序数组、有序数组、无序链表和有序链表。给出您在上一个练习中四种实现的每个操作的最坏情况下界的表格。
部分解决方案。OrderedArrayMaxPQ.java 和 UnorderedArrayMaxPQ.java
-
排序为降序的数组是否是面向最大值的堆。
答案。是的。
-
假设您的应用程序将有大量插入操作,但只有少量删除最大值操作。您认为哪种优先队列实现最有效:堆、无序数组、有序数组?
答案。无序数组。插入是常数时间。
-
假设您的应用程序将有大量查找最大值操作,但相对较少的插入和删除最大值操作。您认为哪种优先队列实现最有效:堆、无序数组、有序数组?
答案。有序数组。在常数时间内找到最大值。
-
在一个没有重复键的大小为n的堆中,删除最大值操作期间必须交换的最小项数是多少?给出一个大小为 15 的堆,使得最小值得以实现。对连续两次和三次删除最大值操作,回答相同的问题。
部分答案:(a) 2。
-
设计一个线性时间的认证算法来检查数组
pq[]
是否是一个面向最小值的堆。解决方案。参见 MinPQ.java 中的
isMinHeap()
方法。 -
证明基于下沉的堆构建最多使用 2n次比较和最多n次交换。
解决方案。只需证明基于下沉的堆构建使用的交换次数少于n次,因为比较次数最多是交换次数的两倍。为简单起见,假设二叉堆是完美的(即每一层都完全填满的二叉树)且高度为h。
我们定义树中节点的高度为以该节点为根的子树的高度。当一个高度为k的键被下沉时,它最多可以与其下面的k个键交换。由于在高度k处有 2^(h−k)个节点,总交换次数最多为:KaTeX parse error: No such environment: eqnarray* at position 8: \begin{̲e̲q̲n̲a̲r̲r̲a̲y̲*̲}̲ h + 2(h-1) + 4…
第一个等式是针对非标准求和的,但通过数学归纳法很容易验证该公式成立。第二个等式成立是因为高度为h的完美二叉树有 2^(h+1) − 1 个节点。
证明当二叉树不完美时结果成立需要更加小心。您可以使用以下事实来证明:在具有n个节点的二叉堆中,高度为k的节点的数量最多为 ceil(n / 2^(k+1))。
替代解决方案。我们定义树中节点的高度为以该节点为根的子树的高度。
-
首先,观察到一个具有n个节点的二叉堆有n − 1 个链接(因为每个链接是一个节点的父节点,每个节点都有一个父链接,除了根节点)。
-
下沉一个高度为k的节点最多需要k次交换。
-
我们将对每个高度为k的节点收取k个链接,但不一定是在下沉节点时所采取的路径上的链接。相反,我们对从节点沿着左-右-右-右-…路径的k个链接收费。例如,在下图中,根节点收取 4 个红色链接;蓝色节点收取 3 个蓝色链接;依此类推。
-
注意,没有链接会被收费超过一个节点。(仅通过从根节点向右链接获得的链接不会被收费给任何节点。)
-
因此,总交换次数最多为n。由于每次交换最多有 2 次比较,因此比较次数最多为 2n。
-
创意问题
-
计算数论。 编写一个程序 CubeSum.java,打印出所有形式为(a³ + b³)的整数,其中(a)和(b)是介于 0 和(n)之间的整数,按排序顺序打印,而不使用过多的空间。也就是说,不要计算一个包含(n²)个和并对它们进行排序的数组,而是构建一个最小导向的优先队列,最初包含((0³, 0, 0), (1³ + 1³, 1, 1), (2³ + 2³, 2, 2), \ldots, (n³ + n³, n, n))。然后,在优先队列非空时,移除最小项(i³ + j³,; i, ; j)),打印它,然后,如果(j < n),插入项((i³ + (j+1)³,; i,; j+1))。使用这个程序找到所有介于 0 和(10⁶)之间的不同整数(a, b, c)和(d),使得(a³ + b³ = c³ + d³),例如(1729 = 9³ + 10³ = 1³ + 12³)。
-
查找最小值。 在 MaxPQ.java 中添加一个
min()
方法。你的实现应该使用恒定的时间和额外的空间。解决方案:添加一个额外的实例变量,指向最小项。在每次调用
insert()
后更新它。如果优先队列变为空,则将其重置为null
。 -
动态中位数查找。 设计一个数据类型,支持对数时间的插入,常数时间的查找中位数,以及对数时间的删除中位数。
解决方案。 将中位数键保留在 v 中;对于小于 v 键的键,使用一个最大导向的堆;对于大于 v 键的键,使用一个最小导向的堆。要插入,将新键添加到适当的堆中,用从该堆中提取的键替换 v。
-
下界。 证明不可能开发一个 MinPQ API 的实现,使得插入和删除最小值都保证使用~n log log n比较。
解决方案。 这将产生一个n log log n比较排序算法(插入n个项目,然后重复删除最小值),违反了第 2.3 节的命题。
-
索引优先队列实现。 通过修改 MaxPQ.java 来实现 IndexMaxPQ.java:将
pq[]
更改为保存索引,添加一个数组keys[]
来保存键值,并添加一个数组qp[]
,它是pq[]
的逆——qp[i]
给出i
在pq[]
中的位置(索引j
,使得pq[j]
是i
)。然后修改代码以维护这些数据结构。使用约定,如果i
不在队列中,则qp[i]
为-1
,并包括一个测试此条件的方法contains()
。您需要修改辅助方法exch()
和less()
,但不需��修改sink()
或swim()
。
网络练习
-
堆排序的最佳、平均和最差情况。 对于对长度为n的数组进行堆排序,最佳情况、平均情况和最差情况的比较次数分别是多少?
解决方案。 如果允许重复项,最佳情况是线性时间(n个相等的键);如果不允许重复项,最佳情况是~n lg n比较(但最佳情况输入是非平凡的)。平均情况和最差情况的比较次数是~2 n lg n比较。详细信息请参阅堆排序的分析。
-
堆化的最佳和最差情况。 对于n个项目的数组进行堆化所需的最少和最多比较/交换次数是多少?
解决方案。 对包含n个项目的数组进行降序堆化需要 0 次交换和n − 1 次比较。对包含n个项目的数组进行升序堆化需要~ n次交换和~ 2n次比较。
-
出租车数。 找到可以用两种不同方式的整数立方和表示的最小整数(1,729),三种不同方式(87,539,319),四种不同方式(6,963,472,309,248),五种不同方式(48,988,659,276,962,496),以及六种不同方式(24,153,319,581,254,312,065,344)。这样的整数被命名为出租车数以纪念著名的拉马努金故事。目前尚不清楚可以用七种不同方式表示为整数立方和的最小整数。编写一个程序 Taxicab.java,该程序读取一个命令行参数 N,并打印出所有非平凡解 a³ + b³ = c³ + d³,其中 a、b、c 和 d 小于或等于 N。
-
计算数论。 找到方程 a + 2b² = 3c³ + 4d⁴的所有解,其中 a、b、c 和 d 小于 100,000。提示:使用一个最小堆和一个最大堆。
-
中断处理。 在编写可以被中断的实时系统时(例如,通过鼠标点击或无线连接),需要立即处理中断,然后再继续当前活动。如果中断应按照到达的顺序处理,则 FIFO 队列是适当的数据结构。然而,如果不同的中断具有不同的优先级(例如,),则需要优先级队列。
-
排队网络的模拟。 M/M/1 队列用于双并行队列等。数学上难以分析复杂的排队网络。因此使用模拟来绘制等待时间分布等。需要优先级队列来确定下一个要处理的事件。
-
Zipf 分布。 使用前面练习的结果从具有参数 s 和n的Zipf 分布中进行抽样。该分布可以取 1 到n之间的整数值,并以概率 1/k^s / sum_(i = 1 to n) 1/i^s 取值 k。例如:莎士比亚的戏剧《哈姆雷特》中的单词,s 约等于 1。
-
随机过程。 从n个箱子开始,每个箱子包含一个球。随机选择其中一个n个球,并将球随机移动到一个箱���中,使得球被放置在具有m个球的箱子中的概率为m/n。经过多次迭代后,结果是什么样的球分布?使用上述描述的随机抽样方法使模拟更有效率。
-
最近邻。 给定长度为m的n个向量 x[1]、x[2]、…、x[N]和另一个相同长度的向量x,找到距离x最近的 20 个向量。
-
在一张图纸上画圆。 编写一个程序来找到以原点为中心,与整数 x 和 y 坐标的 32 个点相切的圆的半径。提示:寻找一个可以用几种不同方式表示为两个平方和的数字。答案:有两个勾股三元组的斜边为 25:15² + 20² = 25²,7² + 24² = 25²,得到 20 个这样的格点;有 22 个不同的斜边为 5,525 的勾股三元组;这导致 180 个格点。27,625 是比 64 更多的最小半径。154,136,450 有 35 个勾股三元组。
-
完美幂。 编写一个程序 PerfectPower.java 来打印所有可以表示为 64 位
long
整数的完美幂:4, 8, 9, 16, 25, 27, … 完美幂是可以写成 a^b 的数字,其中 a 和 b ≥ 2 为整数。 -
浮点加法。 添加n个浮点数,避免舍入误差。删除最小的两个:将它们相加,然后重新插入。
-
首次适应装箱。 17/10 OPT + 2, 11/9 OPT + 4(递减)。使用最大锦标赛树,其中选手是 N 个箱子,值=可用容量。
-
具有最小/最大值的栈。 设计一个数据类型,支持推入、弹出、大小、最小值和最大值(其中最小值和最大值是栈上的最小和最大项目)。所有操作在最坏情况下应该花费常数时间。
*提示:*将每个栈条目与当前栈上的最小和最大项目关联起来。
-
具有最小/最大值的队列。 设计一个数据类型,支持入队、出队、大小、最小值和最大值(其中最小值和最大值是队列上的最小和最大项目)。所有操作应该在常摊时间内完成。
*提示:*完成前面的练习,并模拟使用两个栈的队列。
-
2^i + 5^j。 按升序打印形式为 2^i * 5^j 的数字。
-
最小-最大堆。 设计一个数据结构,通过将项目放入大小为n的单个数组中,支持常数时间内的最小值和最大值,以及对数时间内的插入、删除最小值和删除最大值,具有以下属性:
-
数组表示一个完全二叉树。
-
偶数级别节点中的键小于(或等于)其子树中的键;奇数级别节点中的键大于(或等于)其子树中的键。请注意,最大值存储在根节点,最小值存储在根节点的一个子节点中。
解决方案。 最小-最大堆和广义优先队列
-
-
范围最小查询。 给定一个包含n个项目的序列,从索引 i 到 j 的范围最小查询是 i 和 j 之间最小项目的索引。设计一个数据结构,在线性时间内预处理n个项目的序列,以支持对数时间内的范围最小查询。
-
证明具有n个节点的完全二叉树恰好有*ceiling(n/2)*个叶节点(没有子节点的节点)。
-
具有最小值的最大导向优先队列。 在最大导向的二叉堆中查找最小键的运行时间增长顺序是什么。
*解决方案:线性—最小键可能在任何一个ceiling(n/2)*个叶节点中。
-
具有最小值的最大导向优先队列。 设计一个数据类型,支持对数时间内的插入和删除最大值,以及常数时间内的最大值和最小值。
解决方案。 创建一个最大导向的二叉堆,并存储迄今为止插入的最小键(除非此堆变为空,否则永远不会增加)。
-
大于 x 的第 k 个最大项目。 给定一个最大导向的二叉堆,设计一个算法来确定第 k 个最大项目是否大于或等于 x。你的算法应该在与 k 成比例的时间内运行。
*解决方案:*如果节点中的键大于或等于 x,则递归搜索左子树和右子树。当探索的节点数等于 k 时停止(答案是是),或者没有更多节点可探索时(否)。
-
最小导向二叉堆中的第 k 个最小项目。 设计一个 k log k 算法,找到包含n个项目的最小导向二叉堆 H 中的第 k 个最小项目。
解决方案。 构建一个新的最小导向堆 H’。我们不会修改 H。将 H 的根插入 H’中,同时插入其堆索引 1。现在,重复删除 H’中的最小项目 x,并将 x 的两个子项从 H 插入 H’。从 H’中删除的第 k 个项目是 H 中第 k 小的项目。
-
随机队列。 实现一个
RandomQueue
,使得每个操作都保证最多花费对数时间。*提示:*不能承受数组加倍。使用链表无法以 O(1)时间定位随机元素。相反,使用具有显式链接的完全二叉树。 -
具有随机删除的 FIFO 队列。 实现一个支持以下操作的数据类型:插入一个项目,删除最近添加的项目,和删除一个随机项目。每个操作在最坏情况下应该花费(最多)对数时间。
解决方案:使用具有显式链接的完全二叉树;为添加到数据结构中的第 i 个项目分配长整型优先级i。
-
两个排序数组的前 k 个和。 给定两个长度为n的排序数组 a[]和 b[],找到形式为 a[i] + b[j]的最大 k 个和。
提示:使用优先队列(类似于出租车问题),您可以实现一个 O(k log n)算法。令人惊讶的是,可以在 O(k)时间内完成,但是算法比较复杂。
-
堆构建的实证分析。 通过实证比较线性时间的自底向上堆构建和朴素的线性对数时间的自顶向下堆构建。一定要在一系列n值上进行比较。LaMarca 和 Ladner报告称,由于缓存局部性,对于大n值(当堆不再适合缓存时),朴素算法在实践中可能表现更好,即使后者执行的比较和交换要少得多。
-
多路堆的实证分析。 实证比较 2-、4-和 8 路堆的性能。LaMarca 和 Ladner提出了几种优化方法,考虑了缓存效果。
-
堆排序的实证分析。 实证比较 2-、4-和 8 路堆排序的性能。LaMarca 和 Ladner提出了几种优化方法,考虑了缓存效果。他们的数据表明,经过优化(并调整内存)的 8 路堆排序可以比经典堆排序快两倍。
-
通过插入堆化。 假设您通过反复将下一个键插入二叉堆来在n个键上构建二叉堆。证明总比较次数最多为~ n lg n。
答案:比较次数最多为 lg 1 + lg 2 + … + lg n = lg (n!) ~ n lg n。
-
**堆化下界。(Gonnet 和 Munro)**证明任何基于比较的二叉堆构建算法在最坏情况下至少需要~1.3644 N 次比较。
答案:使用信息论论证,类似于排序下界。对于 n 个不同键的 n!个可能堆(N 个整数的排列),但有许多堆对应于相同的排序。例如,有两个堆(c a b 和 c b a),对应于 3 个元素 a < b < c。对于完美堆(n = 2^h - 1),有 A(h) = n! / prod((2k-1)(2^(h-k)), k=1…h)个堆对应于n个元素 a[0] < a[1] < … < a[n-1]。(参见Sloane 序列 A056971。)因此,任何算法必须能够输出 P(h) = prod((2k-1)(2^(h-k)), k=1…h)可能的答案之一。使用一些花哨的数学,��可以证明 lg P(h) ~ 1.3644 n。
注意:通过对手论证,下界可以改进为~ 3/2 n(Carlsson–Chen);该问题的最佳已知算法在最坏情况下需要~ 1.625 n次比较(Gonnet 和 Munro)。
-
股票交易撮合引擎。 连续限价订单簿:交易员不断发布买入或卖出股票的竞价。限价订单意味着买方(卖方)以指定价格或以下(或以上)的价格下达购买(出售)一定数量给定股票的订单。订单簿显示买单和卖单,并按价格然后按时间对其进行排名。匹配引擎匹配兼容的买家和卖家;如果存在多个可能的买家,则通过选择最早下单的买家来打破平局。为每支股票使用两个优先队列,一个用于买家,一个用于卖家。
金融市场电子交易。
-
随机二叉堆。 假设您用 1 到 n 的整数的随机排列填充长度为 n 的数组。对于 n = 5 和 6,生成的数组是最小定向二叉堆的概率是多少?
解决方案:分别为 1/15 和 1/36。这里有一个很好的讨论。
2.5 排序应用
原文:
algs4.cs.princeton.edu/25applications
译者:飞龙
协议:CC BY-NC-SA 4.0
排序算法和优先队列在各种应用中被广泛使用。本节的目的是简要概述其中一些应用。
对各种类型的数据进行排序。
我们的实现对Comparable
对象的数组进行排序。这种 Java 约定允许我们使用 Java 的回调机制对实现了Comparable
接口的任何类型的对象数组进行排序。
-
事务示例。 程序 Transaction.java 基于事务发生时间实现了事务数据类型的
Comparable
接口。 -
指针排序。 我们正在使用的方法在经典文献中被称为指针排序,因为我们处理的是对键的引用,而不是移动数据本身。
-
键是不可变的。 如果允许客户在排序后更改键的值,那么数组可能不会保持排序。在 Java 中,通过使用不可变键来确保键值不变是明智的。
-
交换成本低廉。 使用引用的另一个优点是我们避免了移动完整项的成本。引用方法使得交换的成本在一般情况下大致等于比较的成本。
-
备用排序。 有许多应用程序,我们希望根据情况使用两种不同的顺序对我们正在排序的对象。Java 的
Comparator
接口有一个名为compare()
的公共方法,用于比较两个对象。如果我们有一个实现了此接口的数据类型,我们可以将Comparator
传递给sort()
(它传递给less()
)如 Insertion.java 中所示。 -
具有多个键的项。 在典型应用中,项具有多个可能需要用作排序键的实例变量。在我们的事务示例中,一个客户可能需要按帐号号码对事务列表进行排序;另一个客户可能需要按地点对列表进行排序;其他客户可能需要使用其他字段作为排序键。我们可以定义多个比较器,如 Transaction.java 中所示。
-
具有比较器的优先队列。 使用比较器的灵活性对于优先队列也很有用。MaxPQ.java 和 MinPQ.java 包括一个以
Comparator
作为参数的构造函数。 -
稳定性。 如果排序方法在数组中保留相等键的相对顺序,则称其为稳定。例如,在我们的互联网商务应用中,我们按照事务到达的顺序将其输入到数组中,因此它们按照数组中的时间字段顺序排列。现在假设应用程序要求将事务按位置分开以进行进一步处理。一个简单的方法是按位置对数组进行排序。如果排序是不稳定的,那么每个城市的事务在排序后可能不一定按时间顺序排列。我们在本章中考虑的一些排序方法是稳定的(插入排序和归并排序);许多排序方法则不是(选择排序、希尔排序、快速排序和堆排序)。
我应该使用哪种排序算法?
确定哪种算法是最佳的取决于应用和实现的细节,但我们已经研究了一些通用方法,它们在各种应用中几乎与最佳方法一样有效。下表是一个概括我们在本章中研究的排序算法的重要特征的一般指南。
性质。 快速排序是最快的通用排序方法。
在大多数实际情况下,快速排序是首选方法。如果稳定性很重要且有空间可用,则归并排序可能是最佳选择。在一些性能关键的应用中,重点可能仅仅是对数字进行排序,因此可以避免使用引用的成本,而是对原始类型进行排序。
-
排序原始类型。 我们可以通过将
Comparable
替换为原始类型名称,并将对less()
的调用替换为类似a[i] < a[j]
的代码,为原始类型开发更高效的排序代码。但是,对于浮点类型,需要注意处理-0.0 和 NaN。 -
Java 系统排序。 Java 的主要系统排序方法
Arrays.sort()
在java.util
库中表示一组重载方法:-
每种原始类型的不同方法。
-
一种用于实现
Comparable
的数据类型的方法。 -
一种使用
Comparator
的方法。
Java 的系统程序员选择使用快速排序(带有 3 路分区)来实现原始类型方法,并使用归并排序来实现引用类型方法。这些选择的主要实际影响是在速度和内存使用(对于原始类型)与稳定性和性能保证(对于引用类型)之间进行权衡。
-
缩减。
我们可以使用排序算法来解决其他问题的想法是算法设计中一种基本技术的例子,称为缩减。缩减是一种情况,其中为一个问题开发的算法用于解决另一个问题。我们从一些排序的基本示例开始。
-
重复项。 在一个包含
Comparable
对象的数组中是否有重复的键?数组中有多少个不同的键?哪个值出现最频繁?通过排序,您可以在线性对数时间内回答这些问题:首先对数组进行排序,然后通过排序后的数组进行一次遍历,注意在有序数组中连续出现的重复值。 -
排名。 一个排列(或排名)是一个包含 N 个整数的数组,其中 0 到 N-1 之间的每个整数恰好出现一次。两个排名之间的Kendall tau 距离是在两个排名中顺序不同的对数。例如,
0 3 1 6 2 5 4
和1 0 3 6 4 2 5
之间的 Kendall tau 距离是四,因为在两个排名中,对 0-1、3-1、2-4、5-4 的顺序不同,但所有其他对的顺序相同。 -
优先队列缩减。 在第 2.4 节中,我们考虑了两个问题的示例,这些问题可以简化为对优先队列的一系列操作。TopM.java 在输入流中找到具有最高键的 M 个项目。Multiway.java 将 M 个排序的输入流合并在一起,以生成一个排序的输出流。这两个问题都可以通过大小为 M 的优先队列轻松解决。
-
中位数和顺序统计。 与排序相关的一个重要应用是找到一组键的中位数(具有一半键不大于它,一半键不小于它的值)。这个操作在统计学和其他各种数据处理应用中是一个常见的计算。找到中位数是选择的一个特殊情况:找到一组数字中第 k 小的数字。通过排序,可以很容易在线性对数时间内解决这个问题。方法
select()
我们描述了一种在线性时间内解决问题的方法:维护变量
lo
和hi
来限定包含要选择的项目的索引k
的子数组,并使用快速排序分区来缩小子数组的大小,如下所示:-
如果
k
等于j
,那么我们完成了。 -
否则,如果
k < j
,那么我们需要继续在左子数组中工作(通过将hi
的值更改为j-1
) -
否则,如果
k > j
,那么我们需要继续在右子数组中工作(通过将lo
更改为j+1
)。
区间收缩,直到只剩下
k
。终止时,a[k]
包含第(k+1)小的条目,a[0]
到a[k-1]
都小于(或等于)a[k]
,而a[k+1]
到数组末尾都大于(或等于)a[k]
。select()
方法在 Quick.java 中实现了这种方法,但在客户端需要进行类型转换。QuickPedantic.java 中的select()
方法是更加严谨的代码,避免了需要进行类型转换。 -
对排序应用的简要调查。
-
商业计算。 政府机构、金融机构和商业企业通过对信息进行排序来组织大部分信息。无论信息是按名称或编号排序的账户、按时间或地点排序的交易、按邮政编码或地址排序的邮件、按名称或日期排序的文件,还是其他任何信息,处理这些数据肯定会涉及到某种排序算法。
-
搜索信息。 将数据保持有序可以通过经典的二分搜索算法高效地搜索数据。
-
运筹学。 假设我们有 N 个工作要完成,其中第 j 个工作需要 t[j]秒的处理时间。我们需要完成所有工作,但希望通过最小化工作的平均完成时间来最大化客户满意度。最短处理时间优先规则,即按处理时间递增顺序安排工作,已知可以实现这一目标。另一个例子是负载平衡问题,其中我们有 M 个相同的处理器和 N 个工作要完成,我们的目标是在处理器上安排所有工作,以便最后一个工作完成的时间尽可能早。这个具体问题是 NP 难题(参见第六章),因此我们不指望找到一个实际的方法来计算最佳的安排。已知一种能够产生良好安排的方法是最长处理时间优先规则,即按处理时间递减顺序考虑工作,将每个工作分配给最先可用的处理器。
-
事件驱动模拟。 许多科学应用涉及模拟,计算的目的是模拟现实世界的某个方面,以便更好地理解它。进行这种模拟可能需要适当的算法和数据结构。我们在第 6.1 节中考虑了一个粒子碰撞模拟,说明了这一点。
-
数值计算。 科学计算通常关注准确性(我们距离真实答案有多接近?)。当我们进行数百万次计算时,准确性非常重要,特别是在使用计算机上常见的浮点数表示实数时。一些数值算法使用优先队列和排序来控制计算中的准确性。
-
组合搜索。 人工智能中的一个经典范例是定义一组配置,其中每个配置都有从一个配置到下一个配置的明确定义的移动和与每个移动相关联的优先级。还定义了一个起始配置和一个目标配置(对应于已解决问题)。A算法*是一个问题解决过程,其中我们将起始配置放在优先队列中,然后执行以下操作直到达到目标:移除优先级最高的配置,并将可以通过一次移动到达的所有配置添加到队列中(不包括刚刚移除的配置)���
-
普里姆算法和迪杰斯特拉算法是处理图的经典算法。优先队列在组织图搜索中起着基础性作用,实现高效的算法。
-
Kruskal 算法是另一��经典的图算法,其边具有权重,取决于按权重顺序处理边。其运行时间由排序的成本主导。
-
赫夫曼压缩是一种经典的数据压缩算法,它依赖于通过将具有整数权重的一组项目组合起来,以产生一个新的项目,其权重是其两个组成部分的和。使用优先队列立即实现此操作。
-
字符串处理算法通常基于排序。例如,我们将讨论基于首先对字符串后缀进行排序的算法,用于查找一组字符串中的最长公共前缀以及给定字符串中的最长重复子字符串。
练习
-
考虑
String
的compareTo()
方法的以下实现。第三行如何提高效率?public int compareTo(String t) { String s = this; if (s == t) return 0; // this line int n = Math.min(s.length(), t.length()); for (int i = 0; i < n; i++) { if (s.charAt(i) < t.charAt(i)) return -1; else if (s.charAt(i) > t.charAt(i)) return +1; } return s.length() - t.length(); }
解决方案:如果
s
和t
是对同一字符串的引用,则避免直接比较单个字符。 -
批评下面的类实现,该类旨在表示客户账户余额。为什么
compareTo()
是Comparable
接口的一个有缺陷的实现?public class Customer implements Comparable<Customer> { private String name; private double balance; public int compareTo(Customer that) { if (this.balance < that.balance - 0.005) return -1; if (this.balance > that.balance + 0.005) return +1; return 0; } }
解决方案:它违反了
Comparable
合同。可能a.compareTo(b)
和b.compareTo(c)
都为 0,但a.compareTo(c)
为正(或负)。 -
解释为什么选择排序不稳定。
解决方案。 它交换非相邻元素。在下面的示例中,第一个 B 被交换到第二个 B 的右侧。
-
编写一个程序 Frequency.java,从标准输入读取字符串,并按频率降序打印每个字符串出现的次数。
创造性问题
-
调度。 编写一个程序 SPT.java,从标准输入读取作业名称和处理时间,并打印一个最小化平均完成时间的调度,如文本中所述。
-
负载平衡。 编写一个程序 LPT.java,将整数 M 作为命令行参数,从标准输入读取 N 个作业名称和处理时间,并打印一个调度分配作业给 M 个处理器,以近似最小化最后一个作业完成的时间,如文本中所述。
备注。 结果解决方案保证在最佳解决方案的 33%之内(实际上为 4/3 - 1/(3N))。
-
按反向域排序。 编写一个数据类型 Domain.java,表示域名,包括一个适当的
compareTo()
方法,其中自然顺序是反向域名顺序。例如,cs.princeton.edu
的反向域是edu.princeton.cs
。这对于 Web 日志分析很有用。编写一个客户端,从标准输入读取域名,并按排序顺序打印反向域。 -
垃圾邮件活动。 要发起非法的垃圾邮件活动,您有一个来自各种域的电子邮件地址列表(即在@符号后面的电子邮件地址部分)。为了更好地伪造寄件人地址,您希望从同一域的另一个用户发送电子邮件。例如,您可能想要伪造从 wayne@princeton.edu 发送到 rs@princeton.edu 的电子邮件。您将如何处理电子邮件列表以使此成为一个高效的任务?
解决方案。 首先按照反向域排序。
-
公正选举。 为了防止对字母表末尾出现的候选人产生偏见,加利福尼亚州通过以下顺序对其 2003 年州长选票上出现的候选人进行排序:
R W Q O J M V A H B S G Z X N T C I E K U P D Y F L
创建一个数据类型 California.java,其中这是自然顺序。编写一个客户端,根据此顺序对字符串进行排序。假设每个字符串仅由大写字母组成。
-
肯德尔距离。 编写一个程序 KendallTau.java,以线性对数时间计算两个排列之间的肯德尔距离。
-
**稳定的优先队列。**开发一个稳定的优先队列实现 StableMinPQ.java(返回以插入顺序返回重复键)。
-
**平面上的点。**为 Point2D.java 数据类型编写三个
static
静态比较器,一个按照它们的 x 坐标比较点,一个按照它们的 y 坐标比较点,一个按照它们与原点的距离比较点。为 Point2D 数据类型编写两个非静态比较器,一个按照它们到指定点的距离比较,一个按照它们相对于指定点的极角比较。 -
**一维区间数据类型。**为 Interval1D.java 编写三个
static
比较器,一个按照它们的左端点比较区间,一个按照它们的右端点比较区间,一个按照它们的长度比较区间。 -
**按名称对文件进行排序。**编写一个程序 FileSorter.java,该程序接受一个目录名称作为命令行输入,并按文件名打印出当前目录中的所有文件。提示:使用java.io.File数据类型。
-
**博纳定理。**真或假:如果对矩阵的每一列进行排序,然后对每一行进行排序,那么列仍然是有序的。解释你的答案。
答案。正确。
-
**不同值。**编写一个程序 Distinct.java,它接受整数 M、N 和 T 作为命令行参数,然后使用文本中给出的代码执行以下实验的 T 次试验:生成 0 到 M-1 之间的 N 个随机整数值,并计算生成的不同值的数量。将程序运行 T = 10 和 N = 10³、10⁴、10⁵ 和 10⁶,其中 M = 1/2 N、N 和 2N。概率论表明,不同值的数量应该约为 M(1 - e^(-alpha)),其中 alpha = N/M—打印一个表格来帮助您确认您的实验验证了这个公式。
Web 练习
-
**计数器数据类型。**修改 Counter.java,使其实现
Comparable
接口,通过计数比较计数器。 -
**成绩数据类型。**编写一个程序 Grade.java 来表示成绩的数据类型(A、B+等)。它应该使用 GPA 对成绩进行自然排序,实现
Comparable
接口。 -
**学生数据类型。**编写一个数据类型 Student.java,表示大学课程中的学生。每个学生应该有一个登录名(String)、一个部分号(整数)和一个成绩(Grade)。
-
**不区分大小写的顺序。**编写一个代码片段,读取一系列字符串并按升序排序,忽略大��写。
String[] a = new String[N]; for (int i = 0; i < N. i++) { a[i] = StdIn.readString(); } Arrays.sort(a, String.CASE_INSENSITIVE_ORDER);
-
**不区分大小写的比较器。**实现自己版本的比较器
String.CASE_INSENSITIVE_ORDER
。public class CaseInsensitive implements Comparator<String> { public int compare(String a, String b) { return a.compareToIgnoreCase(b); } }
-
**降序字符串比较器。**实现一个比较器,按降序而不是升序对字符串进行排序。
public class Descending implements Comparator<String> { public int compare(String a, String b) { return b.compareToIgnoreCase(a); } }
或者,您可以使用
Collections.reverseOrder()
。它返回一个Comparator
,它施加实现Comparable
接口的对象的自然顺序的反向排序。 -
**按非英语字母表排序字符串。**编写一个程序,根据非英语字母表对字符串进行排序,包括重音符号、分音符号和像西班牙语中的 ch 这样的预组合字符。
提示:使用 Java 的java.text.Collator API。例如,在 UNICODE 中,
Rico
在Réal
之前按字典顺序出现,但在法语中,Réal
首先出现。import java.util.Arrays; import java.text.Collator; ... Arrays.sort(words, Collator.getInstance(Locale.FRENCH));
-
史密斯规则。 在供应链管理中出现了以下问题。你有一堆工作要在一台机器上安排。(给出例子。)工作 j 需要 p[j]单位的处理时间。工作 j 有一个正权重 w[j],表示其相对重要性 - 将其视为存储原材料的库存成本为工作 j 存储 1 个时间单位。如果工作 j 在时间 t 完成处理,那么它的��本为 t * w[j]美元。目标是安排工作的顺序,以最小化每个工作的加权完成时间之和。编写一个程序
SmithsRule.java
,它从命令行参数 N 和由它们的处理时间 p[j]和权重 w[j]指定的 N 个工作列表中读取,并输出一个最佳的处理工作顺序。提示: 使用史密斯规则:按照处理时间与权重比率的顺序安排工作。这种贪婪规则事实证明是最优的。 -
押韵的词。 对于你的诗歌课程,你想要列出一张押韵词的列表。完成这个任务的一种简单方法如下:
-
将一个单词字典读入一个字符串数组中。
-
将每个单词的字母倒转,例如,
confound
变为dnuofnoc
。 -
对结果数组中的单词进行排序。
-
将每个单词的字母倒转回原始状态。
现在单词
confound
将会与astound
和compound
等单词相邻。编写一个程序 Rhymer.java,从标准输入中读取一系列单词,并按照上述指定的顺序打印它们。现在重复一遍,但使用一个自定义的
Comparator
,按从右到左的字典顺序排序。 -
-
众数。 给出一个 O(N log N)的算法,用于计算序列 N 个整数中出现最频繁的值。
-
最接近的 1 维对。 给定一个包含 N 个实数的序列,找到值最接近的整数对。给出一个 O(N log N)的算法。
-
最远的 1 维对。 给定一个包含 N 个实数的序列,找到值最远的整数对。给出一个 O(N)的算法。
-
具有许多重复项的排序。 假设你有一个包含 N 个元素的序列,其中最多有 log N 个不同的元素。描述如何在 O(N log log N)时间内对它们进行排序。
-
几乎有序。 给定一个包含 N 个元素的数组,每个元素最多离其目标位置 k 个位置,设计一个能在 O(N log k)时间内排序的算法。
-
对链表进行排序。 给定一个包含 N 个元素的单链表,如何在保证 O(N log N)时间内、稳定地、且只使用 O(1)额外空间的情况下对其进行排序?
-
Goofysort(Jim Huggins)。 论证 Goofy.java 按升序对数组进行排序。作为要排序的项目数量 N 的函数,最佳情况运行时间是多少?作为要排序的项目数量 N 的函数,最坏情况运行时间是多少?
-
令人愉悦的区间。 给定一个包含 N 个非负整数的数组(代表一个人每天的情感值),一个区间的幸福度是该区间中值的总和乘以该区间中最小的整数。设计一个 O(N log N)的分治算法来找到最幸福的区间。
解决方案。 这里是一个归并排序风格的解决方案。
-
将元素分为中间部分:a[l…m-1],a[m],a[m+1…r]
-
递归地计算左半部分中的最佳区间
-
递归地计算右半部分中的最佳区间
-
计算包含 a[m]的最佳区间
-
返回三个区间中最佳的一个为了效率的关键步骤是在线性时间内计算包含
a[m]
的最佳区间。这里是一个贪婪的解决方案:如果包含a[m]
的最佳区间只包含一个元素,那就是a[m]
。如果包含多于一个元素,那么必须包含a[m-1]
和a[m+1]
中较大的一个,所以将其添加到区间中。重复这个过程,以此类推。返回通过这个过程构建的任何大小的最佳区间。
-
-
Equality detector. 假设你有 N 个元素,并且想确定至少有 N/2 个元素相等。假设你只能执行相等性测试操作。设计一个算法,在 O(N log N) 次相等性测试中找到一个代表元素(如果存在的话)。提示:分治法。注意:也可以在 O(N) 次测试中完成。
-
Maxima. 给定平面上的 n 个点集,点 (xi, yi) 支配点 (xj, yj) 如果 xi > xj 并且 yi > yj。极大值是一个不被集合中任何其他点支配的点。设计一个 O(n log n) 的算法来找到所有极大值。应用:在 x ��上是空间效率,在 y 轴上是时间效率。极大值是有用的算法。提示:根据 x 坐标升序排序;从右到左扫描,记录迄今为止看到的最高 y 值,并将其标记为极大值。
-
Min and max. 给定一个包含 N 个元素的数组,尽可能少地比较找到最小值和最大值。暴力法:找到最大值(N-1 次比较),然后找到剩余元素的最小值(N-2 次比较)。
Solution 1. 分治法:在每一半中找到最小值和最大值(2T(N/2) 次比较),返回 2 的最小值和 2 的最大值(2 次比较)。T(1) = 0,T(2) = 1,T(N) = 2T(N/2) + 2。递归解:T(N) = ceil(3N/2) - 2。
Solution 2. 将元素分成一对一对,并比较每对中的两个元素。将最小的元素放在 A 中,最大的元素放在 B 中。如果 n 是奇数,将元素 n 放在 A 和 B 中。这需要 floor(n/2) 次比较。现在直接计算 A 中的最小值(ceil(n/2) - 1 次比较)和 B 中的最大值(ceil(N/2) - 1 次比较)。[事实上,这是最佳的解决方案。]
-
Sorting by reversals. [ Mihai Patrascu] 给定一个数组 a[1…n],使用以下类型的操作进行排序:选择两个索引 i 和 j,并反转 a[i…j] 中的元素。这个操作的成本为 j-i+1。目标:O(n log² n)。
-
L1 norm. 平面上有 N 个电路元件。你需要沿电路运行一根特殊的导线(平行于 x 轴)。每个电路元件必须连接到特殊导线。你应该把特殊导线放在哪里?提示:中位数最小化 L1 范数。
-
Median given two sorted arrays. 给定大小为 N[1] 和 N[2] 的两个已排序数组,以 O(log N) 时间找到所有元素的中位数,其中 N = N[1] + N[2]。或者在 O(log k) 时间内找到第 k 大的元素。
-
Three nearby numbers in an array. 给定一个浮点数数组
a[]
,设计一个线性对数时间复杂度的算法,找到三个不同的整数 i, j, 和 k,使得 |a[i] - a[j]| + |a[j] - a[k]| + |a[k] - a[i]| 最小。Hint: 如果 a[i] <= a[j] <= a[k],那么 |a[i] - a[j]| + |a[j] - a[k]| + |a[k] - a[i]| = 2 (a[k] - a[i])。
-
Three nearby numbers in three arrays. 给定三个浮点数数组
a[]
,b[]
, 和c[]
,设计一个线性对数时间复杂度的算法,找到三个整数 i, j, 和 k,使得 |a[i] - b[j]| + |b[j] - c[k]| + |c[k] - a[i]| 最小。 -
Minimum dot product. 给定相同长度的两个向量,找到两个向量的点积尽可能小的排列。
-
Two-sum. 给定一个包含 N 个整数的数组,设计一个线性对数时间复杂度的算法,找到一对整数,使它们的和最接近零。
Solution: 按绝对值排序,最佳对现在是相邻的。
-
3-sum in quadratic time. 3-sum 问题是在整数数组中找到和最接近零的三元组。描述一个使用线性空间和二次时间的解决方案。
Hint:解决以下子问题。给定 N 个整数的排序列表和目标整数 x,在线性时间内确定最接近 x 的两个整数。
-
Bandwidth. 给定带宽要求的区间,找到最大带宽需求(以及需要该最大带宽的区间)。
解决方案。 按开始时间对区间进行排序;按照这个顺序将区间插入 PQ,但使用结束时间作为键。在插入下一个区间之前,比较其开始时间与 PQ 上最小区间的结束时间:如果大于,删除 PQ 上的最小区间。始终跟踪 PQ 上的累积带宽。
-
时间戳。 给定 N 个时间戳,当文件从 Web 服务器请求时,找到没有文件到达的最长时间间隔。解决方案:按时间戳排序。扫描排序列表以识别最大间隙。 (与空闲时间相同。)
-
票务范围。 给定一个形式为 A1、A2、A11、A10、B7、B9、B8、B3 的票务座位列表,找到最大的非空相邻座位块,例如,A3-A9。 (与空闲时间相同。)
-
十进制主导。 给定一个具有 N 个可比较键的数组,设计一个算法来检查是否有一个值出现的次数超过 N/10 次。你的算法应该在期望的线性时间内运行。
解决方案。 使用快速选择找到第 N/10 大的值;检查它是否是主导值;如果不是,在具有 9N/10 个值的子数组中递归。
或者,使用 9 个计数器。
-
局部最小和最大。 给定 N 个不同的可比较项,重新排列它们,使得每个内部项要么大于其前后两项,要么小于其前后两项。
提示:对前半部分和后半部分进行排序和交错。
-
h 指数。 给定一个由 N 个正整数组成的数组,它的h 指数是最大的整数h,使得数组中至少有h个条目大于或等于h。设计一个算法来计算数组的h指数。
提示:中位数或类似快速排序的分区和分治。
-
软件版本号。 定义一个比较器,比较两个版本号(例如 1.2.32 和 1.2.5)的时间顺序。假设版本号是仅由十进制数字和.字符组成的字符串。.字符分隔字段;它不是小数点。
-
稳定的选择排序。 你需要做什么修改才能使选择排序稳定?
解决方案:首先,在找到最小剩余键时,始终选择最左边的条目;其次,不是用一次交换将最小键移动到最前面,而是将所有大于它的元素向右移动一个位置。
-
最大数。 给定 n 个正整数,将它们连接起来,使它们形成最大的数。例如,如果数字是 123、12、96 和 921,则结果应该是 9692112312。
解决方案。 定义一个比较器,通过将两个数字连接在一起(例如,对于 96 和 921,比较 96921 与 92196),看哪个字符串在字典顺序上最大。
-
最大数。 给定三个长度为 n 的数组 A、B 和 C,确定有多少个三元组 a 在 A 中,b 在 B 中,c 在 C 中,使得 a < b < c?
3. 搜索
原文:
algs4.cs.princeton.edu/30searching
译者:飞龙
协议:CC BY-NC-SA 4.0
概述。
现代计算和互联网使得大量信息变得可访问。高效搜索这些信息的能力对计算至关重要。本章描述了几十年来在众多应用中证明有效的经典搜索算法。我们使用术语符号表来描述一个抽象机制,我们可以保存信息(一个值),��后通过指定一个键进行搜索和检索。
-
3.1 基础符号表包括无序和有序的实现,使用数组或链表。
-
3.2 二叉查找树描述了二叉查找树。
-
3.3 平衡查找树描述了红黑树,这是一种保证每个符号表操作具有对数性能的数据结构。
-
3.4 哈希表描述了两种经典的哈希算法:分离链接和线性探测。
-
3.5 应用介绍了集合数据类型,并包括了符号表和集合的众多应用。
本章的 Java 程序。
以下是本章的 Java 程序列表。点击程序名称以访问 Java 代码;点击参考号以获取简要描述;阅读教材以获取详细讨论。
REF 程序 描述 / JAVADOC - FrequencyCounter.java 频率计数器 3.1 SequentialSearchST.java 顺序查找 3.2 BinarySearchST.java 二分查找 3.3 BST.java 二叉查找树 3.4 RedBlackBST.java 红黑树 3.5 SeparateChainingHashST.java 分离链接哈希表 3.6 LinearProbingHashST.java 线性探测哈希表 - ST.java 有序符号表 - SET.java 有序集合 - DeDup.java 去重 - AllowFilter.java 允许列表过滤器 - BlockFilter.java 阻止列表过滤器 - LookupCSV.java 字典查找 - LookupIndex.java 索引和倒排索引 - FileIndex.java 文件索引 - SparseVector.java 稀疏向量
3.1 基本符号表
原文:
algs4.cs.princeton.edu/31elementary
译者:飞龙
协议:CC BY-NC-SA 4.0
符号表。
符号表 的主要目的是将 值 与 键 关联起来。客户端可以将键值对插入符号表,并期望以后能够搜索与给定键关联的值。
API。
这是 API。我们考虑了几种设计选择,以使我们的实现代码一致、紧凑和有用。
-
泛型. 我们考虑在不指定正在处理的键和值类型的情况下使用泛型的方法。
-
重复键. 每个键只关联一个值(表中没有重复键)。当客户端将一个包含该键(和关联值)的键值对放入已经包含该键的表中时,新值将替换旧值。这些约定定义了关联数组抽象,您可以将符号表视为类似于数组的结构,其中键是索引,值是数组条目。
-
空值. 没有键可以与值
null
关联。这个约定直接与我们在 API 中规定的get()
应该对不在表中的键返回null
相关。这个约定有两个(预期的)后果:首先,我们可以通过测试get()
是否返回null
来测试符号表是否定义了与给定键关联的值。其次,我们可以使用调用put()
时将null
作为第二个(值)参数来实现删除。 -
删除. 符号表中的删除通常涉及两种策略之一:惰性删除,其中我们将表中的键与
null
关联,然后可能在以后的某个时间删除所有这些键,以及急切删除,其中我们立即从表中删除键。正如刚才讨论的,代码put(key, null)
是delete(key)
的一个简单(惰性)实现。当我们给出一个(急切)delete()
的实现时,它旨在替换此默认值。 -
迭代器.
keys()
方法返回一个Iterable<Key>
对象,供客户端用于遍历键。 -
键相等性. Java 要求所有对象实现一个
equals()
方法,并为标准类型(如Integer
、Double
和String
)以及更复杂类型(如Date
、File
和URL
)提供实现。对于涉及这些类型数据的应用程序,您可以直接使用内置实现。例如,如果x
和y
是String
值,则x.equals(y)
为true
当且仅当x
和y
长度相同且在每个字符位置上都相同。在实践中,键可能更复杂,如 Person.java。对于这样的客户定义键,您需要重写equals()
。Java 的约定是equals()
必须实现一个等价关系:-
自反性:
x.equals(x)
为true
。 -
对称性:
x.equals(y)
当且仅当y.equals(x)
为true
时,true
。 -
传递性: 如果
x.equals(y)
和y.equals(z)
为true
,那么x.equals(z)
也是true
。
此外,
equals()
必须以Object
作为参数,并满足以下属性:-
一致性: 多次调用
x.equals(y)
一致地返回相同的值,前提是没有修改任何对象 -
非空:
x.equals(null)
返回false
。
最佳实践是使
Key
类型不可变,因为否则无法保证一致性。 -
有序符号表。
在典型应用中,键是Comparable
对象,因此存在使用代码a.compareTo(b)
来比较两个键a
和b
的选项。几个符号表实现利用Comparable
暗示的键之间的顺序来提供put()
和get()
操作的高效实现。更重要的是,在这种实现中,我们可以将符号表视为按顺序保留键,并考虑一个定义了许多自然和有用的涉及相对键顺序的操作的显著扩展 API。对于键是Comparable
的应用程序,我们实现以下 API:
-
最小值和最大值。对于一组有序键来说,可能最自然的查询是询问最小和最大的键。我们已经在第 3.4 节讨论优先队列时遇到了这些操作的需求。
-
下界和上界。给定一个键,通常有必要执行下界操作(找到小于或等于给定键的最大键)和上界操作(找到大于或等于给定键的最小键)。这个命名法来自于实数上定义的函数(实数 x 的下界是小于或等于 x 的最大整数,实数 x 的上界是大于或等于 x 的最小整数)。
-
排名和选择。确定新键在顺序中的位置的基本操作是排名操作(找到小于给定键的键数)和选择操作(找到具有给定排名的键)。我们已经在第 2.5 节讨论排序应用时遇到了这些操作的需求。
-
范围查询。有多少个键落在给定范围内?哪些键在给定范围内?回答这些问题的两个参数为
size()
和keys()
方法在许多应用中非常有用,特别是在大型数据库中。 -
删除最小值和删除最大值。我们的有序符号表 API 添加了基本 API 方法来删除最大和最小键(及其关联的值)。
-
异常情况。当一个方法应该返回一个键,而表中没有符合描述的键时,我们的约定是抛出异常。
-
键相等性(重新审视)。在 Java 中的最佳实践是使
compareTo()
与所有Comparable
类型中的equals()
一致。也就是说,对于任何给定Comparable
类型中的对象对a
和b
,应该满足(a.compareTo(b) == 0)
和a.equals(b)
具有相同的值。
示例客户端。
我们考虑两种客户端:一个测试客户端,用于跟踪算法在小输入上的行为,以及一个性能客户端。
-
测试客户端。我们符号表实现中的
main()
客户端从标准输入中读取一系列字符串,通过将值 i 与输入中的第 i 个键关联来构建符号表,然后打印表。 -
频率计数器。程序 FrequencyCounter.java 是一个符号表客户端,它在标准输入中找到每个字符串(至少具有给定阈值长度的字符)的出现次数,然后遍历键以找到出现最频繁的键。
无序链表中的顺序搜索。
程序 SequentialSearchST.java 实现了一个包含键和值的节点链表的符号表。要实现get()
,我们通过列表扫描,使用equals()
将搜索键与列表中每个节点中的键进行比较。如果找到匹配项,则返回相关值;如果没有,则返回null
。要实现put()
,我们也通过列表扫描,使用equals()
将客户键与列表中每个节点中的键进行比较。如果找到匹配项,则将与该键关联的值更新为第二个参数中给定的值;如果没有,则创建一个具有给定键和值的新节点,并将其插入列表开头。这种方法称为顺序搜索。
命题 A.
在(无序)链表符号表中,不成功的搜索和插入都使用 N 次比较,在最坏情况下成功的搜索使用 N 次比较。特别是,将 N 个键插入到最初为空的链表符号表中使用 ~N²/2 次比较。
在有序数组中进行二分查找
. 程序 BinarySearchST.java 实现了有序符号表 API。底层数据结构是两个并行数组,键按顺序保存。实现的核心是rank()
方法,它返回小于给定键的键数。对于get()
,rank 告诉我们如果键在表中,则键应该被找到的确切位置(如果不在表中,则不在表中)。对于put()
,rank 告诉我们当键在表中时精确更新值的位置,当键不在表中时精确放置键的位置。我们将所有较大的键向后移动一个位置以腾出空间(从后向前工作),并将给定的键和值插入到各自数组中的适当位置。
-
二分查找. 我们将键保持在有序数组中的原因是为了可以使用数组索引来显著减少每次搜索所需的比��次数,使用一种著名的经典算法称为二分查找。基本思想很简单:我们维护索引到排序键数组的指示符,限定可能包含搜索键的子数组。要搜索,我们将搜索键与子数组中间的键进行比较。如果搜索键小于中间键,则在子数组的左半部分搜索;如果搜索键大于中间键,则在子数组的右半部分搜索;否则中间键等于搜索键。
-
其他操作. 由于键保持在有序数组中,大多数基于顺序的操作都是紧凑且简单的。
命题 B.
在具有 N 个键的有序数组中进行二分查找,在最坏情况下搜索(成功或失败)不会超过 lg N + 1 次比较。
命题 C.
将新键插入有序数组中在最坏情况下使用 ~ 2N 个数组访问,因此将 N 个键插入到最初为空的表中在最坏情况下使用 ~ N² 个数组访问。
练习
-
编写一个客户端程序 GPA.java,创建一个将字母等级映射到数字分数的符号表,如下表所示,然后从标准输入读取字母等级列表,并计算并打印 GPA(对应等级的数字分数的平均值)。
A+ A A- B+ B B- C+ C C- D F 4.33 4.00 3.67 3.33 3.00 2.67 2.33 2.00 1.67 1.00 0.00
-
开发一个符号表实现 ArrayST.java,它使用(无序)数组作为底层数据结构来实现我们的基本符号表 API。
-
为 SequentialSearchST.java 实现
size()
、delete()
和keys()
。 -
为 BinarySearchST.java 实现
delete()
方法。 -
为 BinarySearchST.java 实现
floor()
方法。
创意问题
-
**测试客户端。**编写一个测试客户端 TestBinarySearchST.java,用于测试
min()
、max()
、floor()
、ceiling()
、select()
、rank()
、deleteMin()
、deleteMax()
和keys()
的实现。 -
**认证。**在 BinarySearchST.java 中添加
assert
语句,以检查每次插入和删除后的算法不变性和数据结构完整性。例如,每个索引i
应始终等于rank(select(i))
,并且数组应始终保持有序。
网页练习
-
**电话号码数据类型。**编写一个实现美国电话号码的数据类型 PhoneNumber.java,包括一个
equals()
方法。 -
**学生数据类型。**编写一个实现具有姓名和班级的学生的数据类型 Student.java,包括一个
equals()
方法。
3.2 二叉搜索树
原文:
algs4.cs.princeton.edu/32bst
译者:飞龙
协议:CC BY-NC-SA 4.0
我们研究了一种符号表实现,它将链表中的插入灵活性与有序数组中的搜索效率结合起来。具体来说,每个节点使用两个链接会导致基于二叉搜索树数据结构的高效符号表实现,这被认为是计算机科学中最基本的算法之一。
定义. 二叉搜索树(BST)是一种二叉树,其中每个节点都有一个Comparable
键(和一个相关联的值),并满足一个限制条件,即任何节点中的键都大于该节点左子树中所有节点的键,且小于该节点右子树中所有节点的键。
基本实现。
程序 BST.java 使用二叉搜索树实现了有序符号表 API。我们定义一个内部私有类来定义 BST 中的节点。每个节点包含一个��、一个值、一个左��接、一个右链接和一个节点计数。左链接指向具有较小键的项目的 BST,右链接指向具有较大键的项目的 BST。实例变量N
给出了根节点下子树中的节点计数。这个字段有助于实现各种有序符号表操作,你将看到。
-
搜索. 一个递归算法用于在 BST 中搜索键,直接遵循递归结构:如果树为空,则搜索未命中;如果搜索键等于根节点的键,则搜索命中。否则,我们在适当的子树中搜索(递归)。递归的
get()
方法直接实现了这个算法。它以一个节点(子树的根)作为第一个参数,以一个键作为第二个参数,从树的根和搜索键开始。 -
插入. 插入比搜索实现稍微困难一些。实际上,对于树中不存在的键的搜索会在一个空链接处结束,我们需要做的就是用包含键的新节点替换该链接。递归的
put()
方法使用了与递归搜索相似的逻辑来完成这个任务:如果树为空,我们返回一个包含键和值的新节点;如果搜索键小于根节点的键,我们将左链接设置为将键插入左子树的结果;否则,我们将右链接设置为将键插入右子树的结果。
分析。
算法在二叉搜索树上的运行时间取决于树的形状,而树的形状又取决于键的插入顺序。
对于许多应用程序来说,使用以下简单模型是合理的:我们假设键是(均匀)随机的,或者等效地说,它们是以随机顺序插入的。
命题。
在由 N 个随机键构建的 BST 中,搜索命中平均需要约 2 ln N(约 1.39 lg N)次比较。
命题。
在由 N 个随机键构建的 BST 中,插入和搜索未命中平均需要约 2 ln N(约 1.39 lg N)次比较。
下面的可视化展示了以随机顺序向二叉搜索树中插入 255 个键的结果。它显示了键的数量(N)、从根到叶子节点的路径上节点的最大数量(max)、从根到叶子节点的路径上节点的平均数量(avg)、在完全平衡的二叉搜索树中从根到叶子节点的路径上节点的平均数量(opt)。
<media/bst-255random.mov>
您的浏览器不支持视频标签。
基于顺序的方法和删除。
二叉搜索树被广泛使用的一个重要原因是它们可以让我们保持键有序。因此,它们可以作为实现有序符号表 API 中众多方法的基础。
-
最小值和最大值。 如果根节点的左链接为空,则二叉搜索树中的最小键是根节点的键;如果左链接不为空,则二叉搜索树中的最小键是左链接引用的节点为根的子树中的最小键。查找最大键类似,向右移动而不是向左移动。
-
下取整和上取整。 如果给定的键 key 小于二叉搜索树根节点的键,则 key 的下取整(小于或等于 key 的二叉搜索树中的最大键)必须在左子树中。如果 key 大于根节点的键,则 key 的下取整可能在右子树中,但只有在右子树中存在小于或等于 key 的键时才可能;如果没有(或者 key 等于根节点的键),则根节点的键就是 key 的下取整。查找上取整类似,交换右子树和左子树。
-
选择。 假设我们寻找排名为 k 的键(即 BST 中恰好有 k 个其他键比它小)。如果左子树中的键数 t 大于 k,我们在左子树中查找排名为
k
的键;如果 t 等于 k,我们返回根节点的键;如果 t 小于 k,我们在右子树中查找排名为 k - t - 1 的键。 -
排名。 如果给定的键等于根节点的键,则返回左子树中键数 t;如果给定的键小于根节点的键,则返回左子树中键的排名;如果给定的键大于根节点的键,则返回 t 加一(计算根节点的键)再加上右子树中键的排名。
-
删除最小值和最大值。 对于删除最小值,我们向左移动直到找到一个具有空左链接的节点,然后用其右链接替换指向该节点的链接。对于删除最大值,对称方法适用。
-
删除。 我们可以类似地删除任何只有一个子节点(或没有子节点)的节点,但是如何删除具有两个子节点的节点呢?我们剩下两个链接,但是父节点只有一个位置可以放置它们中的一个。1962 年 T. Hibbard 首次提出的解决这个困境的方法是通过用其后继替换节点 x 来删除节点 x。因为
x
有一个右子节点,其后继是其右子树中具有最小键的节点。替换保持了树中的顺序,因为在x.key
和后继的键之间没有其他键。我们通过四个(!)简单的步骤完成了用其后继替换 x 的任务:-
在
t
中保存要删除的节点的链接 -
将
x
设置为其后继min(t.right)
。 -
将
x
的右链接(应指向包含所有大于x.key
的键的二叉搜索树)设置为deleteMin(t.right)
,即删除后包含所有大于x.key
的键的二叉搜索树的链接。 -
将
x
的左链接(原本为空)设置为t.left
(所有小于被删除键和其后继的键)。
尽管这种方法能够完成任务,但它有一个缺点,在某些实际情况下可能会导致性能问题。问题在于使用后继者是任意的,而不是对称的。为什么不使用前任者呢?
每个二叉查找树包含 150 个节点。然后我们通过 Hibbard 删除方法重复删除和随机插入键。二叉查找树向左倾斜。
<media/hibbard-150random.mov>
您的浏览器不支持视频标签。
-
范围搜索。 为了实现返回给定范围内键的
keys()
方法,我们从一个基本的递归二叉查找树遍历方法开始,称为中序遍历。为了说明这种方法,我们考虑按顺序打印二叉查找树中所有键的任务。为此,首先打印左子树中的所有键(根据二叉查找树的定义,这些键小于根键),然后打印根键,然后打印右子树中的所有键(根据二叉查找树的定义,这些键大于根键)。 -
private void print(Node x) { if (x == null) return; print(x.left); StdOut.println(x.key); print(x.right); }
要实现带有两个参数的
keys()
方法,我们修改这段代码,将在范围内的每个键添加到一个Queue
中,并跳过不能包含范围内键的子树的递归调用。
-
建议。
搜索、插入、查找最小值、查找最大值、floor、ceiling、rank、select、删除最小值、删除最大值、删除和范围计数操作在最坏情况下都需要时间与树的高度成比例。
练习
-
给出五种键
A X C S E R H
的排序方式,当插入到一个初始为空的二叉查找树时,产生最佳情况的树。解决方案。 任何首先插入 H;在 A 和 E 之前插入 C;在 R 和 X 之前插入 S 的序列。
-
在 BST.java 中添加一个计算树高度的方法
height()
。开发两种实现:一个递归方法(需要与树高成比例的线性时间和空间),以及像size()
那样为树中的每个节点添加一个字段的方法(需要线性空间和每次查询的常数时间)。 -
为了测试文本中给出的
min()
、max()
、floor()
、ceiling()
、select()
、rank()
、deleteMin()
、deleteMax()
和keys()
的实现,编写一个测试客户端 TestBST.java。 -
给出二叉查找树的
get()
、put()
和keys()
的非递归实现。*解决方案:*NonrecursiveBST.java
创意问题
-
完美平衡。 编写一个程序 PerfectBalance.java,将一组键插入到一个初始为空的二叉查找树中,使得生成的树等同于二叉搜索,即对于二叉查找树中任何键的搜索所做的比较序列与二叉搜索对相同键集的比较序列相同。
提示:将中位数放在根节点,并递归构建左子树和右子树。
-
认证。 在 BST.java 中编写一个名为
isBST()
的方法,该方法以一个Node
作为参数,并在参数节点是二叉查找树根节点时返回true
,否则返回false
。 -
子树计数检查。 在 BST.java 中编写一个递归方法
isSizeConsistent()
,该方法以一个Node
作为参数,并在该节点根的数据结构中N
字段一致时返回true
,否则返回false
。 -
选择/排名检查。 在 BST.java 中编写一个名为
isRankConsistent()
的方法,检查对于所有i
从0
到size() - 1
,是否i
等于rank(select(i))
,以及对于二叉查找树中的所有键,是否key
等于select(rank(key))
。
Web 练习
-
伟大的树-列表递归问题。 二叉搜索树和循环双向链表在概念上都是由相同类型的节点构建的 - 一个数据字段和两个指向其他节点的引用。 给定一个二叉搜索树,重新排列引用,使其成为一个循环双向链表(按排序顺序)。 尼克·帕兰特将其描述为有史以来设计的最整洁的递归指针问题之一。 提示:从左子树创建一个循环链接列表 A,从右子树创建一个循环链接列表 B,并使根节点成为一个节点的循环链接列表。 然后合并这三个列表。
-
BST 重建。 给定 BST 的前序遍历(不包括空节点),重建树。
-
真或假。 给定 BST,设 x 是叶节点,y 是其父节点。 那么 y 的键要么是大于 x 的键中最小的键,要么是小于 x 的键中最大的键。 答案:真。
-
真或假。 设 x 是 BST 节点。 可以通过沿着树向根遍历直到遇到具有非空右子树的节点(可能是 x 本身);然后在右子树中找到最小键来找到 x 的下一个最大键(x 的后继)。
-
具有恒定额外内存的树遍历。 描述如何使用恒定额外内存(例如,没有函数调用堆栈)执行中序树遍历。
提示:在树下行的过程中,使子节点指向父节点(并在树上行的过程中反转它)。
-
反转 BST。 给定一个标准 BST(其中每个键都大于其左子树中的键,小于其右子树中的键),设计一个线性时间算法将其转换为反转 BST(其中每个键都小于其左子树中的键,大于其右子树中的键)。 结果树形状应对称于原始形状。
-
BST 的层序遍历重建。 给定一系列键,设计一个线性时间算法来确定它是否是某个 BST 的层序遍历(并构造 BST 本身)。
-
在 BST 中查找两个交换的键。 给定一个 BST,其中两个节点中的两个键已被交换,找到这两个键。
解决方案。 考虑 BST 的中序遍历 a[]。 有两种情况需要考虑。 假设只有一个索引 p,使得 a[p] > a[p+1]。 然后交换键 a[p]和 a[p+1]。 否则,存在两个索引 p 和 q,使得 a[p] > a[p+1]和 a[q] > a[q+1]。 假设 p < q。 然后,交换键 a[p]和 a[q+1]。
3.3 平衡搜索树
原文:
algs4.cs.princeton.edu/33balanced
译者:飞龙
协议:CC BY-NC-SA 4.0
本节正在大力施工中。
我们在本节介绍了一种类型的二叉搜索树,其中成本保证为对数。我们的树几乎完美平衡,高度保证不会大于 2 lg N。
2-3 搜索树。
获得我们需要保证搜索树平衡的灵活性的主要步骤是允许我们树中的节点保存多个键。
定义。
一个2-3 搜索树是一棵树,要么为空,要么:
-
一个2 节点,带有一个键(和相关值)和两个链接,一个指向具有较小键的 2-3 搜索树的左链接,一个指向具有较大键的 2-3 搜索树的右链接
-
一个3 节点,带有两个键(和相关值)和三个链接,一个指向具有较小键的 2-3 搜索树的左链接,一个指向具有节点键之间的键的 2-3 搜索树的中间链接,一个指向具有较大键的 2-3 搜索树的右链接。
一个完美平衡的 2-3 搜索树(或简称 2-3 树)是指其空链接与根之间的距离都相同。
-
搜索。 要确定 2-3 树中是否存在一个键,我们将其与根处的键进行比较:如果它等于其中任何一个键,则有一个搜索命中;否则,我们跟随从根到对应于可能包含搜索键的键值区间的子树的链接,然后在该子树中递归搜索。
-
插入到 2 节点中。 要在 2-3 树中插入新节点,我们可能会进行一次不成功的搜索,然后挂接到底部的节点,就像我们在二叉搜索树中所做的那样,但新树不会保持完美平衡。如果搜索终止的节点是一个 2 节点,要保持完美平衡很容易:我们只需用包含其键和要插入的新键的 3 节点替换该节点。
-
插入到由单个 3 节点组成的树中。 假设我们想要插入到一个仅由单个 3 节点组成的微小 2-3 树中。这样的树有两个键,但在其一个节点中没有新键的空间。为了能够执行插入操作,我们暂时将新键放入一个4 节点中,这是我们节点类型的自然扩展,具有三个键和四个链接。创建 4 节点很方便,因为很容易将其转换为由三个 2 节点组成的 2-3 树,其中一个带有中间键(在根处),一个带有三个键中最小的键(由根的左链接指向),一个带有三个键中最大的键(由根的右链接指向)。
-
插入到父节点为 2 节点的 3 节点中。 假设搜索在底部结束于其父节点为 2 节点的 3 节点。在这种情况下,我们仍然可以为新键腾出空间,同时保持树的完美平衡,方法是制作一个临时的 4 节点,然后按照刚才描述的方式拆分 4 节点,但是,而不是创建一个新节点来保存中间键,将中间键移动到节点的父节点。
-
插入到父节点为 3 节点的 3 节点中。 现在假设搜索结束于父节点为 3 节点的节点。同样,我们制作一个临时的 4 节点,然后将其拆分并将其中间键插入父节点。父节点是 3 节点,所以我们用刚刚拆分的临时新 4 节点替换它,其中包含来自 4 节点拆分的中间键。然后,我们对该节点执行完全相同的转换。也就是说,我们拆分新的 4 节点并将其中间键插入其父节点。扩展到一般情况很明显:我们沿着树向上移动,拆分 4 节点并将它们的中间键插入它们的父节点,直到达到一个 2 节点,我们用一个不需要进一步拆分的 3 节点替换它,或者直到达到根节点处的 3 节点。
-
拆分根节点。 如果从插入点到根节点沿着整个路径都是 3 节点,我们最终会在根节点处得到一个临时的 4 节点。在这种情况下,我们将临时的 4 节点拆分为三个 2 节点。
-
局部转换。 2-3 树插入算法的基础是所有这些转换都是纯粹局部的:除了指定的节点和链接之外,不需要检查或修改 2-3 树的任何部分。每次转换更改的链接数量受到小常数的限制。这些转换中的每一个都将一个键从 4 节点传递到树中的父节点,然后相应地重构链接,而不触及树的任何其他部分。
-
全局属性。 这些局部转换保持了树是有序和平衡的全局属性:从根到任何空链接的路径上的链接数量是相同的。
命题。
在具有 N 个键的 2-3 树中,搜索和插入操作保证最多访问 lg N 个节点。
然而,我们只完成了实现的一部分。虽然可以编写代码来对表示 2 和 3 节点的不同数据类型执行转换,但我们描述的大部分任务在这种直接表示中实现起来很不方便。
红黑 BST。
刚刚描述的 2-3 树插入算法并不难理解。我们考虑一种简单的表示法,称为红黑 BST,可以自然地实现。
-
编码 3 节点。 红黑 BST 背后的基本思想是通过从标准 BST(由 2 节点组成)开始,并添加额外信息来编码 3 节点,从而对 2-3 树进行编码。我们认为链接有两种不同类型:红色链接,将两个 2 节点绑在一起表示 3 节点,以及黑色链接,将 2-3 树绑在一起。具体来说,我们将 3 节点表示为由单个向左倾斜的红色链接连接的两个 2 节点。我们将以这种方式表示 2-3 树的 BST 称为红黑 BST。
使用这种表示的一个优点是,它允许我们在不修改的情况下使用我们的
get()
代码进行标准 BST 搜索。 -
1-1 对应关系。 给定任何 2-3 树,我们可以立即推导出相应的红黑 BST,只需按照指定的方式转换每个节点即可。反之,如果我们在红黑 BST 中水平绘制红色链接,所有空链接距离根节点的距离相同,然后将由红色链接连接的节点合并在一起,结果就是一个 2-3 树。
红黑 BST 和 2-3 树](…/Images/2a82ce5ba078c8217adc45ad5e5d7a47.png)
-
颜色表示。 由于每个节点只被一个链接(从其父节点)指向,我们通过在节点中添加一个
boolean
实例变量颜色来编码链接的颜色,如果来自父节点的链接是红色,则为true
,如果是黑色,则为false
。按照惯例,空链接为黑色。 -
旋转。 我们将考虑的实现可能允许右倾斜的红链接或操作中连续两个红链接,但它总是在完成之前纠正这些条件,通过巧妙使用称为旋转的操作来切换红链接的方向。首先,假设我们有一个需要旋转以向左倾斜的右倾斜红链接。这个操作称为左旋转。实现将左倾斜的红链接转换为右倾斜的右旋转操作等同于相同的代码,左右互换。
-
翻转颜色。 我们将考虑的实现也可能允许黑色父节点有两个红色子节点。颜色翻转操作将两个红色子节点的颜色翻转为黑色,并将黑色父节点的颜色翻转为红色。
-
插入到单个 2 节点中。
-
在底部插入到 2 节点。
-
在具有两个键的树中(在 3 节点中)插入。
-
保持根节点为黑色。
-
在底部插入到 3 节点。
-
将红链接向上传递树。
实现。
程序 RedBlackBST.java 实现了一个左倾斜的红黑 BST。程序 RedBlackLiteBST.java 是一个更简单的版本,只实现了 put、get 和 contains。
删除。
命题。
具有 N 个节点的红黑 BST 的高度不超过 2 lg N。
命题。
在红黑 BST 中,以下操作在最坏情况下需要对数时间:搜索、插入、查找最小值、查找最大值、floor、ceiling、rank、select、删除最小值、删除最大值、删除和范围计数。
属性。
具有 N 个节点的红黑 BST 中从根到节点的平均路径长度约为~1.00 lg N。
可视化。
以下可视化展示了 255 个键按随机顺序插入到红黑 BST 中。
练习
-
哪些是合法的平衡红黑 BST?
解决方案。 (iii) 和 (iv)。 (i) 不平衡,(ii) 不是对称顺序或平衡的。
-
真或假:如果您将键按递增顺序插入到红黑 BST 中,则树的高度是单调递增的。
解决方案。 真的,请看下一个问题。
-
描述当按升序插入键构建红黑 BST 时,插入字母
A
到K
时产生的红黑 BST。然后,描述当按升序插入键构建红黑 BST 时通常会发生什么。解决方案。 以下可视化展示了 255 个键按升序插入到红黑 BST 中。
-
回答前两个问题,当键按降序插入时的情况。
解决方案。 错误。以下可视化展示了 255 个键按降序插入到红黑 BST 中。
-
创建一个测试客���端 TestRedBlackBST.java。
创造性问题
-
认证. 在 RedBlackBST.java 中添加一个方法
is23()
,以检查没有节点连接到两个红链接,并且没有右倾斜的红链接。 添加一个方法isBalanced()
,以检查从根到空链接的所有路径是否具有相同数量的黑链接。 将这些方法与isBST()
结合起来创建一个方法isRedBlackBST()
,用于检查树是否是 BST,并且满足这两个条件。 -
旋转的基本定理. 证明任何 BST 都可以通过一系列左旋和右旋转变换为具有相同键集的任何其他 BST。
解决方案概述: 将第一个 BST 中最小的键旋转到根节点沿着向左的脊柱;然后对结果的右子树进行递归,直到得到高度为 N 的树(每个左链接都为 null)。 对第二个 BST 执行相同的操作。 备注:目前尚不清楚是否存在一种多项式时间算法,可以确定将一个 BST 转换为另一个 BST 所需的最小旋转次数(即使对于至少有 11 个节点的 BST,旋转距离最多为 2N - 6)。
-
删除最小值. 通过保持与文本中给出的向树的左脊柱下移的转换的对应关系,同时保持当前节点不是 2 节点的不变性,为 RedBlackBST.java 实现
deleteMin()
操作。 -
删除最大值. 为 RedBlackBST.java 实现
deleteMax()
操作。 请注意,涉及的转换与前一个练习中的转换略有不同,因为红链接是向左倾斜的。 -
删除. 为 RedBlackBST.java 实现
delete()
操作,将前两个练习的方法与 BST 的delete()
操作结合起来。
网络练习
-
给定一个排序的键序列,描述如何在线性时间内构建包含这些键的红黑 BST。
-
假设在红黑 BST 中进行搜索,在从根节点开始跟踪 20 个链接后终止,以下划线填写下面关于任何不成功搜索的最佳(整数)界限,您可以从这个事实中推断出来
-
从根节点至少要遵循 ______ 条链接
-
从根节点最多需要遵循 _______ 条链接
-
-
使用每个节点 1 位,我们可以表示 2、3 和 4 节点。 我们需要多少位来表示 5、6、7 和 8 节点。
-
子串反转. 给定长度为 N 的字符串,支持以下操作:select(i) = 获取第 i 个字符,并且 reverse(i, j) = 反转从 i 到 j 的子串。
解决方案概述. 在平衡搜索树中维护字符串,其中每个节点记录子树计数和一个反转位(如果从根到节点的路径上存在奇数个反转位,则交换左右子节点的角色)。 要实现 select(i),从根节点开始进行二分搜索,使用子树计数和反转位。 要实现 reverse(i, j),在 select(i)和 select(j)处拆分 BST 以形成三个 BST,反转中间 BST 的位,并使用连接操作将它们重新组合在一起。 旋转时维护子树计数和反转位。
-
BST 的内存. BST、RedBlackBST 和 TreeMap 的内存使用情况是多少?
解决方案. MemoryOfBSTs.java.
-
随机化 BST. 程序 RandomizedBST.java 实现了一个随机化 BST,包括删除操作。 每次操作的预期 O(log N)性能。 期望仅取决于算法中的随机性; 它不依赖于输入分布。 必须在每个节点中存储子树计数字段; 每次插入生成 O(log N)个随机数。
命题. 树具有与按随机顺序插入键时相同的分布。
-
连接. 编写一个函数,该函数以两个随机化 BST 作为输入,并返回包含两个 BST 中元素并集的第三个随机化 BST。 假设没有重复项。
-
伸展 BST。 程序 SplayBST.java 实现了一个伸展树。
-
随机队列。 实现一个 RandomizedQueue.java,使得所有操作在最坏情况下都需要对数时间。
-
具有许多更新的红黑色 BST。 当在红黑色 BST 中执行具有已经存在的键的
put()
时,我们的 RedBlackBST.java 会执行许多不必要的isRed()
和size()
调用。优化代码,以便在这种情况下跳过这些调用。
3.4 哈希表
原文:
algs4.cs.princeton.edu/34hash
译者:飞龙
协议:CC BY-NC-SA 4.0
如果键是小整数,我们可以使用数组来实现符号表,通过将键解释为数组索引,以便我们可以将与键 i 关联的值存储在数组位置 i 中。在本节中,我们考虑哈希,这是一种处理更复杂类型键的简单方法的扩展。我们通过进行算术运算将键转换为数组索引来引用键值对。
使用哈希的搜索算法由两个独立部分组成。第一步是计算哈希函数,将搜索键转换为数组索引。理想情况下,不同的键将映射到不同的索引。这种理想通常超出我们的能力范围,因此我们必须面对两个或更多不同键可能哈希到相同数组索引的可能性。因此,哈希搜索的第二部分是处理这种情况的冲突解决过程。
哈希函数。
如果我们有一个可以容纳 M 个键值对的数组,则需要一个函数,可以将任何给定的键转换为该数组的索引:在范围[0, M-1]内的整数。我们寻求一个既易于计算又均匀分布键的哈希函数。
-
典型例子。 假设我们有一个应用程序,其中键是美国社会安全号码。例如,社会安全号码 123-45-6789 是一个分为三个字段的 9 位数。第一个字段标识发放号码的地理区域(例如,第一个字段为 035 的号码来自罗德岛,第一个字段为 214 的号码来自马里兰),其他两个字段标识个人。有十亿个不同的社会安全号码,但假设我们的应用程序只需要处理几百个键,因此我们可以使用大小为 M = 1000 的哈希表。实现哈希函数的一种可能方法是使用键中的三位数。使用右侧字段中的三位数可能比使用左侧字段中的三位数更可取(因为客户可能不均匀地分布在地理区域上),但更好的方法是使用所有九位数制成一个整数值,然后考虑下面描述的整数的哈希函数。
-
正整数。 用于哈希整数的最常用方法称为模块化哈希:我们选择数组大小 M 为素数,并且对于任何正整数键 k,计算 k 除以 M 的余数。这个函数非常容易计算(在 Java 中为 k % M),并且在 0 和 M-1 之间有效地分散键。
-
浮点数。 如果键是介于 0 和 1 之间的实数,我们可能只需乘以 M 并四舍五入到最接近的整数以获得 0 和 M-1 之间的索引。尽管这是直观的,但这种方法有缺陷,因为它给予键的最高有效位更多权重;最低有效位不起作用。解决这种情况的一种方法是使用键的二进制表示进行模块化哈希(这就是 Java 所做的)。
-
字符串。 模块化哈希也适用于长键,如字符串:我们只需将它们视为巨大的整数。例如,下面的代码计算了一个 String s 的模块化哈希函数,其中 R 是一个小素数(Java 使用 31)。
int hash = 0; for (int i = 0; i < s.length(); i++) hash = (R * hash + s.charAt(i)) % M;
-
复合键。 如果键类型具有多个整数字段,我们通常可以像刚才描述的
String
值一样将它们混合在一起。例如,假设搜索键的类型为 USPhoneNumber.java,其中包含三个整数字段:区域(3 位区号)、交换(3 位交换)和分机(4 位分机)。在这种情况下,我们可以计算数字int hash = (((area * R + exch) % M) * R + ext) % M;
-
Java 约定。 Java 帮助我们解决了每种数据类型都需要一个哈希函数的基本问题,要求每种数据类型必须实现一个名为
hashCode()
的方法(返回一个 32 位整数)。对象的hashCode()
实现必须与equals
一致。也就是说,如果a.equals(b)
为真,则a.hashCode()
必须与b.hashCode()
具有相同的数值。如果hashCode()
值相同,则对象可能相等也可能不相等,我们必须使用equals()
来确定哪种情况成立。 -
将
hashCode()
转换为数组索引。 由于我们的目标是一个数组索引,而不是 32 位整数,因此我们在实现中将hashCode()
与模块化哈希结合起来,以产生 0 到 M-1 之间的整数,如下所示:private int hash(Key key) { return (key.hashCode() & 0x7fffffff) % M; }
该代码掩盖了符号位(将 32 位整数转换为 31 位非负整数),然后通过除以 M 来计算余数,就像模块化哈希一样。
-
用户定义的
hashCode()
。 客户端代码期望hashCode()
在可能的 32 位结果值中均匀分散键。也就是说,对于任何对象x
,你可以编写x.hashCode()
,并且原则上期望以相等的可能性获得 2³² 个可能的 32 位值中的任何一个。Java 为许多常见类型(包括String
、Integer
、Double
、Date
和URL
)提供了渴望实现此功能的hashCode()
实现,但对于您��己的类型,您必须自己尝试。程序 PhoneNumber.java 演示了一种方法:从实例变量中生成整数并使用模块化哈希。程序 Transaction.java 演示了一种更简单的方法:使用实例变量的hashCode()
方法将每个转换为 32 位int
值,然后进行算术运算。
在为给定数据类型实现良好的哈希函数时,我们有三个主要要求:
-
它应该是确定性的—相同的键必须产生相同的哈希值。
-
计算效率应该高。
-
它应该均匀分布键。
为了分析我们的哈希算法并对其性能提出假设,我们做出以下理想化假设。
假设 J(均匀哈希假设)。
我们使用的哈希函数在 0 和 M-1 之间的整数值之间均匀分布键。
使用分离链接进行哈希。
哈希函数将键转换为数组索引。哈希算法的第二个组成部分是冲突解决:处理两个或更多个要插入的键哈希到相同索引的情况的策略。冲突解决的一个直接方法是为 M 个数组索引中的每一个构建一个键-值对的链表,这些键的哈希值为该索引。基本思想是选择足够大的 M,使得列表足够短,以便通过两步过程进行有效搜索:哈希以找到可能包含键的列表,然后顺序搜索该列表以查找键。
程序 SeparateChainingHashST.java 实现了一个带有分离链接哈希表的符号表。它维护了一个 SequentialSearchST 对象的数组,并通过计算哈希函数来选择哪个SequentialSearchST
可以包含键,并然后使用SequentialSearchST
中的get()
和put()
来完成工作。程序 SeparateChainingLiteHashST.java 类似,但使用了一个显式的Node
嵌套类。
命题 K。 在具有 M 个列表和 N 个键的分离链接哈希表中,假设 J 下,列表中键的数量在 N/M 的小常数因子范围内的概率极其接近 1。N/M 的小常数因子范围内的概率极其接近 1。 (假设一个理想的哈希函数。)
这个经典的数学结果很有说服力,但它完全依赖于假设 J。然而,在实践中,相同的行为发生。
性质 L. 在具有 M 个列表和 N 个键的分离链接哈希表中,搜索和插入的比较次数(相等测试)与 N/M 成正比。
使用线性探测进行哈希。
实现哈希的另一种方法是将 N 个键值对存储在大小为 M > N 的哈希表中,依赖表中的空条目来帮助解决冲突。这种方法称为开放寻址哈希方法。最简单的开放寻址方法称为线性探测:当发生冲突(当我们哈希到已经被不同于搜索键的键占据的表索引时),我们只需检查表中的下一个条目(通过增加索引)。有三种可能的结果:
-
键等于搜索键:搜索命中
-
空位置(索引位置处的空键):搜索未命中
-
键不等于搜索键:尝试下一个条目
程序 LinearProbingHashST.java 是使用这种方法实现符号表 ADT 的实现。
与分离链接一样,开放寻址方法的性能取决于比率 α = N/M,但我们对其进行了不同的解释。对于分离链接,α 是每个列表的平均项目数,通常大于 1。对于开放寻址,α 是占用的表位置的百分比;它必须小于 1。我们将 α 称为哈希表的负载因子。
命题 M. 在大小为 M 的线性探测哈希表中,N = α M 个键,平均探测次数(在假设 J 下)对于搜索命中约为 ~ 1/2 (1 + 1 / (1 - α)),对于搜索未命中或插入约为 ~ 1/2 (1 + 1 / (1 - α)²)。
问与答。
-
为什么 Java 在
String
的hashCode()
中使用 31? -
它是质数,因此当用户通过另一个数字取模时,它们没有公共因子(除非它是 31 的倍数)。31 也是梅森素数(如 127 或 8191),是一个比某个 2 的幂少 1 的素数。这意味着如果机器的乘法指令很慢,那么取模可以通过一次移位和一次减法完成。
-
如何从类型为
double
的变量中提取位以用��哈希? -
Double.doubleToLongBits(x)
返回一个 64 位的long
整数,其位表示与double
值x
的浮点表示相同。 -
使用
(s.hashCode() % M)
或Math.abs(s.hashCode()) % M
进行哈希到 0 到 M-1 之间的值有什么问题? -
如果第一个参数为负数,则
%
运算符返回一个非正整数,这将导致数组索引越界错误。令人惊讶的是,绝对值函数甚至可以返回一个负整数。如果其参数为Integer.MIN_VALUE
,那么由于生成的正整数无法用 32 位的二进制补码整数表示,这种情况就会发生。这种错误将非常难以追踪,因为它只会发生 40 亿分之一的时间![字符串"polygenelubricants"的哈希码为-2³¹。]
练习
-
下面的
hashCode()
实现是否合法?public int hashCode() { return 17; }
解决方案。 是的,但这将导致所有键都哈希到相同的位置,这将导致性能不佳。
-
分析使用分离链接、线性探测和二叉搜索树(BSTs)处理
double
键的空间使用情况。将结果呈现在类似第 476 页上的表中。解决方案。
-
顺序搜索。 24 + 48N.
SequentialSearch
符号表中的Node
占用 48 字节的内存(16 字节开销,8 字节键,8 字节值,8 字节下一个,8 字节内部类开销)。SequentialSearch
对象占用 24 字节(16 字节开销,8 字节第一个)加上节点的内存。请注意,booksite 版本每个
SequentialSearch
对象额外使用 8 字节(4 用于 N,4 用于填充)。 -
分离链接。 56 + 32M + 48N。
SeparateChaining
符号表消耗 8M + 56 字节(16 字节开销,20 字节数组开销,8 字节指向数组,每个数组条目的引用 8 字节,4 字节 M,4 字节 N,4 字节填充),再加上 M 个SequentialSearch
对象的内存。
-
创意练习
-
哈希攻击。 找到 2^N 个长度为 N 的字符串,它们具有相同的
hashCode()
值,假设String
的hashCode()
实现(如Java 标准中指定的)如下:public int hashCode() { int hash = 0; for (int i = 0; i < length(); i++) hash = (hash * 31) + charAt(i); return hash; }
解决方案。 很容易验证
"Aa"
和"BB"
哈希到相同的hashCode()
值(2112)。现在,任何由这两个字符串以任何顺序连接在一起形成的长度为 2N 的字符串(例如,BBBBBB,AaAaAa,BBAaBB,AaBBBB)将哈希到相同的值。这里是一个具有相同哈希值的 10000 个字符串的列表。 -
糟糕的哈希函数。 考虑以下用于早期 Java 版本的
String
的hashCode()
实现:public int hashCode() { int hash = 0; int skip = Math.max(1, length() / 8); for (int i = 0; i < length(); i += skip) hash = (hash * 37) + charAt(i); return hash; }
解释为什么你认为设计者选择了这种实现,然后为什么你认为它被放弃,而选择了上一个练习中的实现。
解决方案。 这样做是希望更快地计算哈希函数。确实,哈希值计算得更快,但很可能许多字符串哈希到相同的值。这导致在许多真实输入(例如,长 URL)上性能显著下降,这些输入都哈希到相同的值,例如,
http://www.cs.princeton.edu/algs4/34hash/*****java.html
。
网络练习
-
假设我们希望重复搜索一个长度为 N 的链表,每个元素都包含一个非常长的字符串键。在搜索具有给定键的元素时,我们如何利用哈希值? 解决方案:预先计算列表中每个字符串的哈希值。在搜索键 t 时,将其哈希值与字符串 s 的哈希值进行比较。只有在它们的哈希值相等时才比较字符串 s 和 t。
-
为以下数据类型实现
hashCode()
和equals()
。要小心,因为很可能许多点的 x、y 和 z 都是小整数。public class Point2D { private final int x, y; ... }
答案:一个解决方案是使哈希码的前 16 位是 x 的前 16 位和 y 的后 16 位的异或,将哈希码的后 16 位是 x 的后 16 位和 y 的前 16 位的异或。因此,如果 x 和 y 只有 16 位或更少,那么不同点的 hashCode 值将不同。
-
以下点的
equals()
实现有什么问题?public boolean equals(Point q) { return x == q.x && y == q.y; }
equals()
的错误签名。这是equals()
的重载版本,但它没有覆盖从Object
继承的版本。这将破坏任何使用Point
与HashSet
的代码。这是更常见的错误之一(与在覆盖equals()
时忽略覆盖hashCode()
一样)。 -
以下代码片段将打印什么?
import java.util.HashMap; import java.util.GregorianCalendar; HashMap st = new HashMap<gregoriancalendar string="">(); GregorianCalendar x = new GregorianCalendar(1969, 21, 7); GregorianCalendar y = new GregorianCalendar(1969, 4, 12); GregorianCalendar z = new GregorianCalendar(1969, 21, 7); st.put(x, "human in space"); x.set(1961, 4, 12); System.out.println(st.get(x)); System.out.println(st.get(y)); System.out.println(st.get(z));</gregoriancalendar>
它将打印 false,false,false。日期 7/21/1969 被插入到哈希表中,但在哈希表中的值被更改为 4/12/1961。因此,尽管日期 4/12/1961 在哈希表中,但在搜索 x 或 y 时,我们将在错误的桶中查找,找不到它。我们也找不到 z,因为日期 7/21/1969 不再是哈希表中的键。
这说明在哈希表的键中只使用不可变类型是一个好习惯。Java 设计者可能应该使
GregorianCalendar
成为一个不可变对象,以避免出现这样的问题。 -
密码检查器。 编写一个程序,从命令行读取一个字符串和从标准输入读取一个单词字典,并检查它是否是一个“好”密码。在这里,假设“好”意味着它(i)至少有 8 个字符长,(ii)不是字典中的一个单词,(iii)不是字典中的一个单词后跟一个数字 0-9(例如,hello5),(iv)不是由一个数字分隔的两个单词(例如,hello2world)。
-
反向密码检查器。 修改前一个问题,使得(ii)-(v)也适用于字典中单词的反向(例如,olleh 和 olleh2world)。巧妙的解决方案:将每个单词及其反向插入符号表。
-
镜像网站。 使用哈希来确定哪些文件需要更新以镜像网站。
-
生日悖论。 假设您的音乐点播播放器随机播放您的 4000 首歌曲(带替换)。您期望等待多久才能听到一首歌曲第二次播放?
-
布隆过滤器。 支持插入、存在。通过允许一些误报来使用更少的空间。应用:ISP 缓存网页(特别是大图像、视频);客户端请求 URL;服务器需要快速确定页面是否在缓存中。解决方案:维护一个大小为 N = 8M(M = 要插入的元素数)的位数组。从 0 到 N-1 选择 k 个独立的哈希函数。
-
CRC-32。 哈希的另一个应用是计算校验和以验证某个数据文件的完整性。要计算字符串
s
的校验和,import java.util.zip.CRC32; ... CRC32 checksum = new CRC32(); checksum.update(s.getBytes()); System.out.println(checksum.getValue());
-
完美哈希。 另请参见 GNU 实用程序 gperf。
-
密码学安全哈希函数。 SHA-1 和 MD5。可以通过将字符串转换为字节或每次读取一个字节时计算它。程序 OneWay.java 演示了如何使用
java.security.MessageDigest
对象。 -
指纹。 哈希函数(例如,MD5 和 SHA-1)也可用于验证文件的完整性。将文件哈希为一个短字符串,将字符串与文件一起传输,如果传输文件的哈希与哈希值不同,则数据已损坏。
-
布谷鸟哈希。 在均匀哈希的最大负载下为 log n / log log n。通过选择两者中负载最小的来改进为 log log n。(如果选择 d 中负载最小的,则仅改进为 log log n / log d。)布谷鸟哈希 实现了常数平均插入时间和常数最坏情况搜索:每个项目有两个可能的插槽。如果空,则放入两个可用插槽中的任一个;如果不是,则将另一个插槽中的另一个项目弹出并移动到其另一个插槽中(并递归)。"这个名字来源于一些布谷鸟物种的行为,母鸟将蛋推出另一只鸟的巢来产卵。"如果进入重新定位循环,则重新散列所有内容。
-
协变等于。 CovariantPhoneNumber.java 实现了一个协变的
equals()
方法。 -
后来者先服务线性探测。 修改 LinearProbingHashST.java,使得每个项目都插入到它到达的位置;如果单元格已经被占用,则该项目向右移动一个条目(规则重复)。
-
罗宾汉线性探测。 修改 LinearProbingHashST.java,使得当一个项目探测到已经被占用的单元格时,当前位移较大的项目(两者中的一个)获得单元格,另一个项目向右移动一个条目(规则重复)。
-
冷漠图。 给定实线上的 V 个点,其冷漠图是通过为每个点添加一个顶点并在两个顶点之间添加一条边形成的图,当且仅当两个对应点之间的距离严格小于一时。设计一个算法(在均匀哈希假设下),以时间比��于 V + E 计算一组 V 点的冷漠图。
解决方案. 将每个实数向下取整到最近的整数,并使用哈希表来识别所有向同一整数取整的点。现在,对于每个点 p,使用哈希表找到所有向 p 的取整值内的整数取整的点,并为距离小于一的每对点添加一条边(p, q)。参见此参考链接以了解为什么这需要线性时间。
3.5 搜索应用
原文:
algs4.cs.princeton.edu/35applications
译者:飞龙
协议:CC BY-NC-SA 4.0
本节正在大规模施工中。
从计算机的早期时代,当符号表允许程序员从在机器语言中使用数值地址进展到在汇编语言中使用符号名称,到新千年的现代应用,当符号名称在全球计算机网络中具有意义时,快速搜索算法在计算中发挥了并将继续发挥重要作用。符号表的现代应用包括组织科学数据,从在基因组数据中搜索标记或模式到绘制宇宙;在网络上组织知识,从在线商务搜索到将图书馆放在线;以及实现互联网基础设施,从在网络上的机器之间路由数据包到共享文件系统和视频流。
集合 API。
一些符号表客户端不需要值,只需要将键插入表中并测试键是否在表中。由于我们不允许重复键,这些操作对应于以下 API,我们只对表中的键集感兴趣。
为了说明 SET.java 的用途,我们考虑过滤客户端,从标准输入读取一系列键,并将其中一些写入标准输出。
-
*去重。*程序 DeDup.java 从输入流中删除重复项。
-
白名单和黑名单过滤。另一个经典示例,使用单独的文件中的键来决定哪些来自输入流的键传递到输出流。程序 AllowFilter.java 实现了白名单,其中文件中的任何键都会传递到输出,而文件中没有的键将被忽略。程序 BlockFilter.java 实现了黑名单,其中文件中的任何键都将被忽略,而文件中没有的键将传递到输出。
字典客户端。
最基本的符号表客户端通过连续的put操作构建符号表,以支持get请求。下面列举的熟悉示例说明了这种方法的实用性。
作为一个具体的例子,我们考虑一个符号表客户端,您可以使用它查找使用逗号分隔值(.csv)文件格式保存的信息。LookupCSV.java 从命令行指定的逗号分隔值文件中构建一组键值对,然后打印出与从标准输入读取的键对应的值。命令行参数是文件名和两个整数,一个指定用作键的字段,另一个指定用作值的字段。
索引客户端。
这个应用是符号表客户端的另一个典型示例。我们有大量数据,想知道感兴趣的字符串出现在哪里。这似乎是将多个值与每个键关联起来,但实际上我们只关联一个SET
。FileIndex.java 将一系列文件名作为命令行参数,并构建一个符号表,将每个关键字与可以找到该关键字的文件名的SET
关联起来。然后,它从标准输入接受关键字查询。
MovieIndex.java 读取一个包含表演者和电影的数据文件。
稀疏向量和矩阵。
程序 SparseVector.java 使用索引-值对的符号表实现了一个稀疏向量。内存与非零数目成比例。set和get操作在最坏情况下需要 log n 时间;计算两个向量的点积所需的时间与两个向量中的非零条目数成比例。
系统符号表。
Java 有几个用于集合和符号表的库函数。API 类似,但你可以将null
值插入符号表。
-
TreeMap 库使用红黑树。保证每次插入/搜索/删除的性能为 log N。他们的实现为每个节点维护三个指针(两个子节点和父节点),而我们的实现只存储两个。
-
Sun 在 Java 1.5 中的
HashMap
实现使用具有分离链接的哈希表。表大小为 2 的幂(而不是素数)。这用 AND 替换了相对昂贵的% M 操作。默认负载因子= 0.75。为防止一些编写不佳的哈希函数,他们对hashCode
应用以下混淆例程。static int hash(Object x) { int h = x.hashCode(); h += ~(h << 9); h ^= (h >>> 14); h += (h << 4); h ^= (h >>> 10); return h; }
Q + A
Q. 运行性能基准测试时,插入、搜索和删除操作的合理比例是多少?
A. 这取决于应用程序。Java 集合框架针对大约 85%的搜索/遍历,14%的插入/更新和 1%的删除进行了优化。
练习
创意练习
-
词汇表。 编写一个 ST 客户端 Concordance.java,在标准输出中输出标准输入流中字符串的词汇表。
-
稀疏矩阵。 为稀疏 2D 矩阵开发一个 API 和一个实现。支持矩阵加法和矩阵乘法。包括行和列向量的构造函数。
解决方案:SparseVector.java 和 SparseMatrix.java。
网页练习
-
修改
FrequencyCount
以读取一个文本文件(由 UNICODE 字符组成),并打印出字母表大小(不同字符的数量)和一个按频率降序排序的字符及其频率表。 -
集合的交集和并集。 给定两组字符串,编写一个代码片段,计算一个包含这两组中出现的字符串的第三组(或任一组)。
-
双向符号表。 支持 put(key, value)和 getByKey(key)或 getByValue(value)。在幕后使用两个符号表。例如:DNS 和反向 DNS。
-
突出显示浏览器超链接。 每次访问网站时,保留上次访问网站的时间,这样你只会突出显示那些在过去一个月内访问过的网站。
-
频率符号表。 编写一个支持以下操作的抽象数据类型 FrequencyTable.java:
hit(Key)
和count(Key)
。hit
操作将字符串出现的次数增加一。count
操作返回给定字符串出现的次数,可能为 0。应用:网页计数器,网页日志分析器,音乐点播机统计每首歌曲播放次数等。 -
非重叠区间搜索。 给定一个非重叠整数(或日期)区间列表,编写一个函数,接受一个整数参数,并确定该值位于哪个(如果有)区间中,例如,如果区间为 1643-2033、5532-7643、8999-10332、5666653-5669321,则查询点 9122 位于第三个区间,8122 不在任何区间中。
-
注册调度。 一所东北部知名大学的注册处最近安排一名教师在完全相同的时间上教授两门不同的课程。通过描述一种检查此类冲突的方法来帮助注册处避免未来的错误。为简单起见,假设所有课程从 9 点开始,每门课程持续 50 分钟,时间分别为 9、10、11、1、2 或 3。
-
列表。 实现以下列表操作:size()、addFront(item)、addBack(item)、delFront(item)、delBack(item)、contains(item)、delete(item)、add(i, item)、delete(i)、iterator()。所有操作应高效(对数时间)。提示:使用两个符号表,一个用于高效查找列表中的第 i 个元素,另一个用于按项目高效搜索。Java 的 List 接口包含这些方法,但没有提供支持所有操作高效的实现。
-
间接 PQ。 编写一个实现间接 PQ 的程序 IndirectPQ.java。
-
LRU 缓存。 创建一个支持以下操作的数据结构:
access
和remove
。访问操作将项目插入到数据结构中(如果尚未存在)。删除操作删除并返回最近访问的项目。提示:在双向链表中按访问顺序维护项目,并在符号表中使用键=项目,值=链表中的位置。当访问一个元素时,从链表中删除它并重新插入到开头。当删除一个元素时,从末尾删除它并从符号表中删除它。 -
UniQueue. 创建一个数据类型,它是一个队列,但是一个元素只能被插入队列一次。使用存在性符号表来跟踪所有曾经被插入的元素,并忽略重新插入这些项目的请求。
-
带随机访问的符号表。 创建一个支持插入键值对、搜索键并返回关联值、删除并返回随机值的数据类型。提示:结合符号表和随机队列。
-
纠正拼写错误。 编写一个程序,从标准输入中读取文本,并用建议的替换替换任何常见拼写错误的单词,并将结果打印到标准输出。使用这个常见拼写错误列表(改编自Wikipedia)。
-
移至前端。 编码:需要排名查询、删除和插入。解码:需要查找第 i 个、删除和插入。
-
可变字符串。 创建一个支持字符串上述操作的数据类型:
get(int i)
、insert(int i, char c)
和delete(int i)
,其中get
返回字符串的第 i 个字符,insert
插入字符 c 并使其成为第 i 个字符,delete
删除第 i 个字符。使用二叉搜索树。提示:使用 BST(键=0 到 1 之间的实数,值=字符)使得树的中序遍历产生适当顺序的字符。使用
select()
找到第 i 个元素。在位置 i 插入字符时,选择实数为当前位置 i-1 和 i 的键的平均值。 -
幂法和最大特征值。 要计算具有最大幅度的特征值(及相应的特征向量),请使用幂法。在技术条件下(最大两个特征值之间的差距),它会迅速收敛到正确答案。
-
进行初始猜测 x[1]
-
y[n] = x[n] / ||x[n]||
-
x[n+1] = A y[n]
-
λ = x[n+1]^T y[n]
-
n = n + 1 如果 A 是稀疏的,那么这个算法会利用稀疏性。例如:Google PageRank。
-
-
外积。 向
Vector
添加一个方法outer
,使得a.outer(b)
返回两个长度为 N 的向量 a 和 b 的外积。结果是一个 N×N 矩阵。 -
网络链接的幂律分布。(Michael Mitzenmacher)全球网络的入度和出度遵循幂律分布。可以通过优先附加过程来建模。假设每个网页只有一个外链。每个页面逐一创建,从指向自身的单个页面开始。以概率 p < 1,它将链接到现有页面之一,随机选择。以概率 1-p,它将链接到现有页面,概率与该页面的入链数成比例。这一规则反映了新网页倾向于指向热门页面的普遍趋势。编写一个程序来模拟这个过程,并绘制入链数的直方图。
-
VMAs. Unix 内核中用于管理一组虚拟内存区域(VMAs)的 BST。每个 VMA 代表 Unix 进程中的一部分内存。VMAs 的大小从 4KB 到 1GB 不等。还希望支持范围查询,以确定哪些 VMAs 与给定范围重叠。参考资料
-
**互联网对等缓存。**由互联网主机发送的每个 IP 数据包都带有一个必须对于该源-目的地对是唯一的 16 位 ID。Linux 内核使用以 IP 地址为索引的 AVL 树。哈希会更快,但希望避免攻击者发送具有最坏情况输入的 IP 数据包。参考资料
-
文件索引变体。
-
移除停用词,例如,a,the,on,of。使用另一个集合来实现。
-
支持多词查询。这需要进行集合交集操作。如果总是先与最小集合进行交集,那么这将花费与最小集合大小成正比的时间。
-
实现 OR 或其他布尔逻辑。
-
记录文档中单词的位置或单词出现的次数。
-
-
**算术表达式解释器。**编写一个程序 Interpreter.java 来解析和评估以下形式的表达式。
>> x := 34 x := 34.0 >> y := 23 * x y := 782.0 >> z := x ^ y z := Infinity >> z := y ^ 2 z := 611524.0 >> x x := 34.0 >> x := sqrt 2 x := 1.4142135623730951
变体。
-
添加更复杂的表达式,例如,z = 7 * (x + y * y),使用传统的运算符优先级。
-
添加更多的错误检查和恢复。
-
4. 图
原文:
algs4.cs.princeton.edu/40graphs
译者:飞龙
协议:CC BY-NC-SA 4.0
概述。
项目之间的成对连接在大量计算应用程序中起着至关重要的作用。这些连接所暗示的关系引发了一系列自然问题:是否有一种方法可以通过遵循这些连接将一个项目连接到另一个项目?有多少其他项目连接到给定项目?这个项目和另一个项目之间的连接的最短链是什么?下表展示了涉及图处理的各种应用程序的多样性。
我们逐步介绍了四种最重要的图模型:无向图(具有简单连接),有向图(其中每个连接的方向很重要),带权重的图(其中每个连接都有一个相关联的权重),以及带权重的有向图(其中每个连接都有方向和权重)。
-
4.1 无向图介绍了图数据类型,包括深度优先搜索和广度优先搜索。
-
4.2 有向图介绍了有向图数据类型,包括拓扑排序和强连通分量。
-
4.3 最小生成树描述了最小生成树问题以及解决它的两种经典算法:Prim 算法和 Kruskal 算法。
-
4.4 最短路径介绍了最短路径问题以及解决它的两种经典算法:Dijkstra 算法和 Bellman-Ford 算法。
本章中的 Java 程序。
下面是本章中的 Java 程序列表。单击程序名称以访问 Java 代码;单击参考号以获取简要描述;阅读教科书以获取详细讨论。
REF 程序 描述 / JAVADOC - Graph.java 无向图 - GraphGenerator.java 生成随机图 - DepthFirstSearch.java 图中的深度优先搜索 - NonrecursiveDFS.java 图中的 DFS(非递归) 4.1 DepthFirstPaths.java 图中的路径(DFS) 4.2 BreadthFirstPaths.java 图中的路径(BFS) 4.3 CC.java 图的连通分量 - Bipartite.java 二分图或奇环(DFS) - BipartiteX.java 二分图或奇环(BFS) - Cycle.java 图中的环 - EulerianCycle.java 图中的欧拉回路 - EulerianPath.java 图中的欧拉路径 - SymbolGraph.java 符号图 - DegreesOfSeparation.java 分离度 - Digraph.java 有向图 - DigraphGenerator.java 生成随机有向图 4.4 DirectedDFS.java 有向图中的深度优先搜索 - NonrecursiveDirectedDFS.java 有向图中的深度优先搜索(非递归) - DepthFirstDirectedPaths.java 有向图中的路径(深度优先搜索) - BreadthFirstDirectedPaths.java 有向图中的路径(广度优先搜索) - DirectedCycle.java 有向图中的环 - DirectedCycleX.java 有向图中的环(非递归) - DirectedEulerianCycle.java 有向图中的欧拉回路 - DirectedEulerianPath.java 有向图中的欧拉路径 - DepthFirstOrder.java 有向图中的深度优先顺序 4.5 Topological.java 有向无环图中的拓扑排序 - TopologicalX.java 拓扑排序(非递归) - TransitiveClosure.java 传递闭包 - SymbolDigraph.java 符号有向图 4.6 KosarajuSharirSCC.java 强连通分量(Kosaraju–Sharir 算法) - TarjanSCC.java 强连通分量(Tarjan 算法) - GabowSCC.java 强连通分量(Gabow 算法) - EdgeWeightedGraph.java 加权边图 - Edge.java 加权边 - LazyPrimMST.java 最小生成树(延时 Prim 算法) 4.7 PrimMST.java 最小生成树(Prim 算法) 4.8 KruskalMST.java 最小生成树(Kruskal 算法) - BoruvkaMST.java 最小生成树(Boruvka 算法) - EdgeWeightedDigraph.java 加权有向图 - DirectedEdge.java 加权有向边 4.9 DijkstraSP.java 最短路径(Dijkstra 算法) - DijkstraUndirectedSP.java 无向图的最短路径(Dijkstra 算法) - DijkstraAllPairsSP.java 全局最短路径 4.10 AcyclicSP.java 有向无环图中的最短路径 - AcyclicLP.java 有向无环图中的最长路径 - CPM.java 关键路径法 4.11 BellmanFordSP.java 最短路径(贝尔曼-福特算法) - EdgeWeightedDirectedCycle.java 加权有向图中的环 - Arbitrage.java 套汇检测 - FloydWarshall.java 全局最短路径(稠密图) - AdjMatrixEdgeWeightedDigraph.java 加权图(稠密图)
4.1 无向图
原文:
algs4.cs.princeton.edu/41graph
译者:飞龙
协议:CC BY-NC-SA 4.0
图。
图是一组顶点和连接一对顶点的边的集合。我们在 V-1 个顶点的图中使用 0 到 V-1 的名称表示顶点。
术语表。
这里是我们使用的一些定义。
-
自环是连接顶点与自身的边。
-
如果它们连接相同的一对顶点,则两条边是平行的。
-
当一条边连接两个顶点时,我们说这两个顶点相邻,并且该边关联这两个顶点。
-
一个顶点的度是与其关联的边的数量。
-
子图是构成图的边(和相关顶点)的子集,构成一个图。
-
图中的路径是由边连接的顶点序列,没有重复的边。
-
一个简单路径是一个没有重复顶点的路径。
-
循环是一条路径(至少有一条边),其第一个和最后一个顶点相同。
-
简单循环是一个没有重复顶点(除了第一个和最后一个顶点必须重复)的循环。
-
一条路径或循环的长度是其边的数量。
-
如果存在包含它们两者的路径,则我们说一个顶点连接到另一个顶点。
-
如果从每个顶点到每个其他顶点都存在路径,则图是连通的。
-
一个非连通的图由一组连通分量组成,这些连通分量是最大连通子图。
-
无环图是一个没有循环的图。
-
树是一个无环连通图。
-
森林是一组不相交的树。
-
连通图的生成树是包含该图所有顶点且为单棵树的子图。图的生成森林是其连通分量的生成树的并集。
-
二分图是一个我们可以将其顶点分为两组的图,使得所有边连接一组中的顶点与另一组中的顶点。
无向图数据类型。
我们实现以下无向图 API。
关键方法adj()
允许客户端代码迭代给定顶点相邻的顶点。值得注意的是,我们可以在adj()
所体现的基本抽象上构建本节中考虑的所有算法。
我们准备了测试数据 tinyG.txt、mediumG.txt 和 largeG.txt,使用以下输入文件格式。
图客户端.java 包含典型的图处理代码。
图表示。
我们使用邻接表表示法,其中我们维护一个以顶点索引的数组,数组中的每个元素是与每个顶点通过边连接的顶点的列表。
图.java 使用邻接表表示法实现了图 API。邻接矩阵图.java 使用邻接矩阵表示法实现了相同的 API。
深度优先搜索。
深度优先搜索是一种经典的递归方法,用于系统地检查图中的每个顶点和边。要访问一个顶点
-
将其标记为已访问。
-
访问(递归地)所有与其相邻且尚未标记的���点。
深度优先搜索.java 实现了这种方法和以下 API:
寻找路径。
修改深度优先搜索以确定两个给定顶点之间是否存在路径以及找到这样的路径(如果存在)。我们试图实现以下 API:
为了实现这一点,我们通过将edgeTo[w]
设置为v
来记住将我们带到每个顶点w
的边缘v-w
,这是第一次。换句话说,v-w
是从源到w
的已知路径上的最后一条边。搜索的结果是以源为根的树;edgeTo[]
是该树的父链接表示。深度优先路径.java 实现了这种方法。
广度优先搜索。
深度优先搜索找到从源顶点 s 到目标顶点 v 的一条路径。我们经常有兴趣找到最短这样的路径(具有最小数量的边)。广度优先搜索是基于这个目标的经典方法。要从s
到v
找到最短路径,我们从s
开始,并在我们可以通过一条边到达的所有顶点中检查v
,然后我们在我们可以通过两条边从s
到达的所有顶点中检查v
,依此类推。
要实现这种策略,我们维护一个队列,其中包含所有已标记但其邻接列表尚未被检查的顶点。我们将源顶点放入队列,然后执行以下步骤,直到队列为空:
-
从队列中移除下一个顶点
v
。 -
将所有未标记的与
v
相邻的顶点放入队列并标记它们。
广度优先路径.java 是实现Paths
API 的一个实现,用于找到最短路径。它依赖于 FIFO 队列.java。
连通分量。
我们下一个直接应用深度优先搜索的是找到图的连通分量。回想一下第 1.5 节,“连接到”是将顶点划分为等价类(连通分量)的等价关系。对于这个任务,我们定义以下 API:
CC.java 使用 DFS 实现此 API。
命题。 DFS 在时间上标记与给定源连接的所有顶点,其时间与其度数之和成正比,并为客户提供从给定源到任何标记顶点的路径,其时间与其长度成正比。
**命题。**对于从s
可达的任何顶点v
,BFS 计算从s
到v
的最短路径(从s
到v
没有更少的边的路径)。在最坏情况下,BFS 花费时间与 V + E 成正比。
命题。 DFS 使用预处理时间和空间与 V + E 成正比,以支持图中的常数时间连接查询。
更多深度优先搜索应用。
我们用 DFS 解决的问题是基础的。深度优先搜索还可以用于解决以下问题:
-
*循环检测:*给定图是否无环?循环.java 使用深度优先搜索来确定图是否有循环,如果有,则返回一个。在最坏情况下,它花费时间与 V + E 成正比。
-
*双色性:*给定图的顶点是否可以被分配为两种颜色,以便没有边连接相同颜色的顶点?二分图.java 使用深度优先搜索来确定图是否具有二��图;如果是,则返回一个;如果不是,则返回一个奇数长度的循环。在最坏情况下,它花费时间与 V + E 成正比。
-
桥: 桥(或割边)是一条删除后会增加连接组件数量的边。等价地,仅当边不包含在任何循环中时,边才是桥。桥.java 使用深度优先搜索在图中找到桥。在最坏情况下,它花费时间与 V + E 成正比。
-
双连通性:一个关节点(或割点)是一个移除后会增加连接组件数量的顶点。如果没有关节点,则图形是双连通的。Biconnected.java 使用深度优先搜索来查找桥梁和关节点。在最坏情况下,它的时间复杂度为 V + E。
-
平面性:如果可以在平面上绘制图形,使得没有边相互交叉,则图形是平面的。 Hopcroft-Tarjan 算法是深度优先搜索的高级应用,它可以在线性时间内确定图形是否是平面的。
符号图。
典型应用涉及使用字符串而不是整数索引来处理图形,以定义和引用顶点。为了适应这些应用程序,我们定义了具有以下属性的输入格式:
-
顶点名称是字符串。
-
指定的分隔符分隔顶点名称(以允许名称中包含空格的可能性)。
-
每行表示一组边,将该行上的第一个顶点名称连接到该行上命名的每个其他顶点。
输入文件 routes.txt 是一个小例子。
输入文件 movies.txt 是来自互联网电影数据库的一个更大的示例。该文件包含列出电影名称后跟电影中表演者列表的行。
-
API。 以下 API 允许我们为这种输入文件使用我们的图处理例程。
-
*实现。*SymbolGraph.java 实现了 API。它构建了三种数据结构:
-
一个符号表
st
,具有String
键(顶点名称)和int
值(索引) -
一个作为反向索引的数组
keys[]
,给出与每个整数索引关联的顶点名称 -
使用索引构建的
Graph
G
,以引用顶点
-
-
*分离度。*DegreesOfSeparation.java 使用广度优先搜索来查找社交网络中两个个体之间的分离度。对于演员-电影图,它玩的是凯文·贝肯游戏。
练习
-
为 Graph.java 创建一个复制构造函数,该构造函数以图
G
作为输入,并创建并初始化图的新副本。客户端对G
所做的任何更改都不应影响新创建的图。 -
向 BreadthFirstPaths.java 添加一个
distTo()
方法,该方法返回从源到给定顶点的最短路径上的边数。distTo()
查询应在常数时间内运行。 -
编写一个程序 BaconHistogram.java,打印凯文·贝肯号的直方图,指示 movies.txt 中有多少表演者的贝肯号为 0、1、2、3 等。包括那些具有无限号码的类别(与凯文·贝肯没有联系)。
-
编写一个
SymbolGraph
客户端 DegreesOfSeparationDFS.java,该客户端使用深度优先而不是广度优先搜索来查找连接两个表演者的路径。 -
使用第 1.4 节的内存成本模型确定
Graph
表示具有V
个顶点和E
条边的图所使用的内存量。解决方案。 56 + 40V + 128E。MemoryOfGraph.java 根据经验计算,假设没有缓��
Integer
值—Java 通常会缓存-128 到 127 之间的整数。
创意问题
-
**并行边检测。**设计一个线性时间算法来计算图中的平行边数。
提示:维护一个顶点的邻居的布尔数组,并通过仅在需要时重新初始化条目来重复使用此数组。
-
双边连通性。 在图中,桥是一条边,如果移除,则会将一个连通图分隔成两个不相交的子图。没有桥的图被称为双边连通。开发一个基于 DFS 的数据类型 Bridge.java,用于确定给定图是否是边连通的。
网页练习
-
找一些有趣的图。它们是有向的还是无向的?稀疏的还是密集的?
-
度。 顶点的度是与之关联的边的数量。向
Graph
添加一个方法int degree(int v)
,返回顶点 v 的度数。 -
假设在运行广度优先搜索时使用堆栈而不是队列。它仍然计算最短路径吗?
-
使用显式堆栈的 DFS。 给出 DFS 可能出现堆栈溢出的示例,例如,线图。修改 DepthFirstPaths.java,使其使用显式堆栈而不是函数调用堆栈。
-
完美迷宫。 生成一个完美迷宫像这样的
编写一个程序 Maze.java,它接受一个命令行参数 n,并生成一个随机的 n×n 完美迷宫。如果迷宫完美,则每对迷宫中的点之间都有一条路径,即没有无法访问的位置,没有循环,也没有开放空间。这里有一个生成这样的迷宫的好算法。考虑一个 n×n 的单元格网格,每个单元格最初与其四个相邻单元格之间都有一堵墙。对于每个单元格(x, y),维护一个变量
north[x][y]
,如果存在将(x, y)和(x, y + 1)分隔的墙,则为true
。我们有类似的变量east[x][y]
,south[x][y]
和west[x][y]
用于相应的墙壁。请注意,如果(x, y)的北面有一堵墙,则north[x][y] = south[x][y+1] = true
。通过以下方式拆除一些墙壁来构建迷宫:-
从较低级别单元格(1, 1)开始。
-
随机找到一个您尚未到达的邻居。
-
如果找到一个,就移动到那里,拆除墙壁。如果找不到,则返回上一个单元格。
-
重复步骤 ii.和 iii.,直到您访问了网格中的每个单元格。
提示:维护一个(n+2)×(n+2)的单元格网格,以避免繁琐的特殊情况。
这是由卡尔·埃克洛夫使用此算法创建的一个 Mincecraft 迷宫。
-
-
走出迷宫。 给定一个 n×n 的迷宫(就像在前一个练习中创建的那样),编写一个程序,如果存在路径,则从起始单元格(1, 1)到终点单元格(n, n)找到一条路径。要找到迷宫的解决方案,请运行以下算法,从(1, 1)开始,并在到达单元格(n, n)时停止。
explore(x, y) ------------- - Mark the current cell (x, y) as "visited." - If no wall to north and unvisited, then explore(x, y+1). - If no wall to east and unvisited, then explore(x+1, y). - If no wall to south and unvisited, then explore(x, y-1). - If no wall to west and unvisited, then explore(x-1, y).
-
迷宫游戏。 开发一个迷宫游戏,就像来自gamesolo.com的这个,您在其中穿过迷宫,收集奖品。
-
演员图。 计算凯文·贝肯数的另一种(也许更自然)方法是构建一个图,其中每个节点都是一个演员。如果两个演员一起出现在一部电影中,则它们之间通过一条边连接。通过在演员图上运行 BFS 来计算凯文·贝肯数。比较与文本中描述的算法的运行时间。解释为什么文本中的方法更可取。答案:它避免了多个平行边。因此,它更快,使用的内存更少。此外,它更方便,因为您不必使用电影名称标记边缘-所有名称都存储在顶点中。
-
好莱坞宇宙的中心。 我们可以通过计算他们的好莱坞数来衡量凯文·贝肯是一个多好的中心。凯文·贝肯的好莱坞数是所有演员的平均贝肯数。另一位演员的好莱坞数计算方式相同,但我们让他们成为源,而不是凯文·贝肯。计算凯文·贝肯的好莱坞数,并找到一个演员和一个女演员,他们的好莱坞数更好。
-
好莱坞宇宙的边缘。 找到(与凯文·贝肯相连的)具有最高好莱坞数的演员。
-
单词梯子。 编写一个程序 WordLadder.java,从命令行中获取两个 5 个字母的字符串,并从标准输入中读取一个 5 个字母的单词列表,然后打印出连接这两个字符串的最短单词梯子(如果存在)。如果两个单词在一个字母上不同,那么它们可以在一个单词梯子链中连接起来。例如,以下单词梯子连接了 green 和 brown。
green greet great groat groan grown brown
你也可以尝试在这个 6 个字母单词列表上运行你的程序。
-
更快的单词梯子。 为了加快速度(如果单词列表非常大),不要编写嵌套循环来尝试所有成对的单词是否相邻。对于 5 个字母的单词,首先对单词列表进行排序。只有最后一个字母不同的单词将在排序后的列表中连续出现。再排序 4 次,但将字母向右循环移动一个位置,以便在一个排序列表中连续出现在第 i 个字母上不同的单词。
尝试使用一个更大的单词列表来测试这种方法,其中包含不同长度的单词。如果两个长度不同的单词���有最后一个字母不同,则它们是相邻的。
-
假设你删除无向图中的所有桥梁。结果图的连通分量是否是双连通分量?答案:不是,两个双连通分量可以通过一个关节点连接。
桥梁和关节点。
桥梁(或割边)是一条移除后会断开图的边。关节点(或割点)是一个移除后(以及移除所有关联边后)会断开剩余图的顶点。桥梁和关节点很重要,因为它们代表网络中的单点故障。蛮力方法:删除边(或顶点)并检查连通性。分别需要 O(E(V + E))和 O(V(V + E))的时间。可以通过巧妙地扩展 DFS 将两者都改进为 O(E + V)。
-
双连通分量。 一个无向图是双连通的,如果对于每一对顶点 v 和 w,v 和 w 之间有两条顶点不重叠的路径。(或者等价地,通过任意两个顶点的简单循环。)我们在边上定义一个共圆等价关系:如果 e1 = e2 或者存在包含 e1 和 e2 的循环,则 e1 和 e2 在同一个双连通分量中。两个双连通分量最多共享一个公共顶点。一个顶点是关节点,当且仅当它是多于一个双连通分量的公共部分时。程序 Biconnected.java 标识出桥梁和关节点。
-
双连通分量。 修改
Biconnected
以打印构成每个双连通分量的边。提示:每个桥梁都是自己的双连通分量;要计算其他双连通分量,将每个关节点标记为已访问,然后运行 DFS,跟踪从每个 DFS 起点发现的边。 -
对随机无向图的连通分量数量进行数值实验。在 1/2 V ln V 附近发生相变。(参见 Algs Java 中的属性 18.13。)
-
流氓。(安德鲁·阿普尔。)在一个无向图中,一个怪物和一个玩家分别位于不同的顶点。在角色扮演游戏 Rogue 中,玩家和怪物轮流行动。每轮中,玩家可以移动到相邻的顶点或原地不动。确定玩家在怪物之前可以到达的所有顶点。假设玩家先行动。
-
流氓。(安德鲁·阿普尔。)在一个无向图中,一个怪物和一个玩家分别位于不同的顶点。怪物的目标是落在与玩家相同的顶点上。为怪物设计一个最佳策略。
-
关节点。 设 G 是一个连通的无向图。考虑 G 的 DFS 树。证明顶点 v 是 G 的关节点当且仅当(i)v 是 DFS 树的根并且有多于一个子节点,或者(ii)v 不是 DFS 树的根并且对于 v 的某个子节点 w,w 的任何后代(包括 w)和 v 的某个祖先之间没有反向边。换句话说,v 是关节点当且仅当(i)v 是根并且有多于一个子节点,或者(ii)v 有一个子节点 w,使得 low[w] >= pre[v]。
-
谢尔宾斯基垫。 一个优美的欧拉图的例子。
-
优先连接图。 如下创建一个具有 V 个顶点和 E 条边的随机图:以任意顺序开始具有 V 个顶点 v1,…,vn。均匀随机选择序列的一个元素并添加到序列的末尾。重复 2E 次(使用不断增长的顶点列表)。将最后的 2E 个顶点配对以形成图。
大致来说,等价于按照两个端点的度数的乘积成比例的概率逐个添加每条边。参考。
-
维纳指数。 一个顶点的维纳指数是该顶点与所有其他顶点之间的最短路径距离之和。图 G 的维纳指数是所有顶点对之间的最短路径距离之和。被数学化学家使用(顶点=原子,边=键)。
-
随机游走。 从迷宫中走出(或图中的 st 连通性)的简单算法:每一步,朝一个随机方向迈出一步。对于完全图,需要 V log V 时间(收集优惠券);对于线图或环,需要 V² 时间(赌徒的失败)。一般来说,覆盖时间最多为 2E(V-1),这是 Aleliunas、Karp、Lipton、Lovasz 和 Rackoff 的经典结果。
-
删除顺序。 给定一个连通图,确定一个顺序来删除顶点,使得每次删除后图仍然连通。你的算法在最坏情况下应该花费与 V + E 成比例的时间。
-
树的中心。 给定一个树(连通且无环)的图,找到一个顶点,使得它与任何其他顶点的最大距离最小化。
提示:找到树的直径(两个顶点之间的最长路径)并返回中间的一个顶点。
-
树的直径。 给定一个树(连通且无环)的图,找到最长的路径,即一对顶点 v 和 w,它们之间的距离最远。你的算法应该在线性时间内运行。
提示。 选择任意顶点 v。计算从 v 到每个其他顶点的最短路径。设 w 是最大最短路径距离的顶点。计算从 w 到每个其他顶点的最短路径。设 x 是最大最短路径距离的顶点。从 w 到 x 的路径给出直径。
-
使用并查集查找桥梁。 设 T 是一个连通图 G 的生成树。图 G 中的每条非树边 e 形成一个由边 e 和树中连接其端点的唯一路径组成的基本环。证明一条边是桥梁当且仅当它不在某个基本环上。因此,所有桥梁都是生成树的边。设计一个算法,使用 E + V 时间加上 E + V 并查集操作,找到所有桥梁(和桥梁组件)。
-
非递归深度优先搜索。 编写一个程序 NonrecursiveDFS.java,使用显式堆栈而不是递归来实现深度优先搜索。
这是 Bin Jiang 在 1990 年代初提出的另一种实现。唯一额外的内存是用于顶点堆栈,但该堆栈必须支持任意删除(或至少将任意项移动到堆栈顶部)。
private void dfs(Graph G, int s) { SuperStack<Integer> stack = new SuperStack<Integer>(); stack.push(s); while (!stack.isEmpty()) { int v = stack.peek(); if (!marked[v]) { marked[v] = true; for (int w : G.adj(v)) { if (!marked[w]) { if (stack.contains(w)) stack.delete(w); stack.push(w); } } } else { // v's adjacency list is exhausted stack.pop(); } } }
这里是另一种实现。这可能是最简单的非递归实现,但在最坏情况下使用的空间与 E + V 成比例(因为一个顶点的多个副本可能在堆栈上),并且以标准递归 DFS 的相反顺序探索与 v 相邻的顶点。此外,
edgeTo[v]
条目可能被更新多次,因此可能不适用于回溯应用。private void dfs(Graph G, int s) { Stack<Integer> stack = new Stack<Integer>(); stack.push(s); while (!stack.isEmpty()) { int v = stack.pop(); if (!marked[v]) { marked[v] = true; for (int w : G.adj(v)) { if (!marked[w]) { edgeTo[w] = v; stack.push(w); } } } } }
-
非递归深度优先搜索。 解释为什么以下非递归方法(类似于 BFS,但使用堆栈而不是队列)不实现深度优先搜索。
private void dfs(Graph G, int s) { Stack<Integer> stack = new Stack<Integer>(); stack.push(s); marked[s] = true; while (!stack.isEmpty()) { int v = stack.pop(); for (int w : G.adj(v)) { if (!marked[w]) { stack.push(w); marked[w] = true; edgeTo[w] = v; } } } }
*解决方案:*考虑由边 0-1、0-2、1-2 和 2-1 组成的图,其中顶点 0 为源。
-
Matlab 连通分量。 在 Matlab 中,bwlabel() 或 bwlabeln() 用于标记 2D 或 kD 二进制图像中的连通分量。bwconncomp() 是更新版本。
-
互补图中的最短路径。 给定一个图 G,设计一个算法来找到从 s 到互补图 G’ 中每个其他顶点的最短路径(边的数量)。互补图包含与 G 相同的顶点,但只有当边 v-w 不在 G 中时才包含边 v-w。你能否比明确计算互补图 G’ 并在 G’ 中运行 BFS 做得更好?
-
删除一个顶点而不断开图。 给定一个连通图,设计一个线性时间算法来找到一个顶点,其移除(删除顶点和所有关联边)不会断开图。
提示 1(使用 DFS):从某个顶点 s 运行 DFS,并考虑 DFS 中完成的第一个顶点。
提示 2(使用 BFS):从某个顶点 s 运行 BFS,并考虑具有最大距离的任何顶点。
-
生成树。 设计一个算法,以时间复杂度为 V + E 计算一个连通图的生成树。提示:使用 BFS 或 DFS。
-
图中的所有路径。 编写一个程序 AllPaths.java,枚举图中两个指定顶点之间的所有简单路径。提示:使用 DFS 和回溯。警告:图中可能存在指数多个简单路径,因此对于大型图,没有算法可以高效运行。