【从浅到深的算法技巧】我们应该使用哪种排序算法

5.8.2 我们应该使用哪种排序算法

我们学习了许多种排序算法,这个问题就变得很自然了。排序算法的好坏很大程度上取决于它的应用场景和具体实现,但我们也学习了一些通用的算法,它们能在很多情况下达到和最佳算法接近的性能。

下表总结了在本章中我们学习过的排序算法的各种重要性质。除了希尔排序(它的复杂度只是一个近似)、插入排序(它的复杂度取决于输入元索的排列情况)和快速排序的两个版本(它们的复杂度和概率有关,取决于输入元素的分布情况)之外,将这些运行时间的增长数量级乘以适当的常数就能够大致估计出其运行时间。这里的常数有时和算法有关(比如堆排序的比较次数是归并排序的两倍,且两者访问数组的次数都比快速排序多得多),但主要取决于算法的实现、Java编译器以及你的计算机,这些因素决定了需要执行的机器指令的数量以及每条指令所需的执行时间。最重要的是,因为这些都是常数,你能通过较小的N得到的实验数据和我们的标准双倍测试来推测较大的N所需的运行时间。

各种排序算法的性能特点
算法 是否稳定 是否稳定是否为原地排序 排序时间复杂度 排序空间复杂度 备注
选择排序 N*N 1
插入排序 介于N和N*N之间 1 取决于输入元素的排列情况
希尔排序 NlogN 1
快速排序 MogN lgN 运行效率由概率提供保证
三向快速排序 介于N和MogN之间 lgN
归并排序 MogN N 运行效率由概率保证,同时也取决于输入元素的分布情况
堆排序 MogN 1

性质T:快速排序是最快的通用排序算法。

例证:总的来说,快速排序之所以最快是因为它的内循环中的指令很少(而且它还能利用缓存,因为它总是顺序地访问数据),所以它的运行时间的增长数量级为cMgN,而这里的c比其他线性对数级别的排序算法的相应常数都要小。在使用三向切分之后,快速排序对于实际应用中可能出现的某些分布的输入变成线性级别的了,而其他的排序算法则仍然需要线性对数时间。

因此,在大多数实际情况中,快速排序是最佳选择。例如,我们已经见过一个明显的例外:如果稳定性很重要而空间又不是问题,归并排序可能是最好的。有了SortCompare 这样的工具,再加上一点时间和努力, 你能够更仔细地比较这些算法的性能并实现我们讨论过的各种改进方案。也许证明性质T的最好方式正如这里所说,在运行时间至关重要的任何排序应用中认真地考虑使用快速排序。

5.8.2.1 原始类型数据排序

一些性能优先的应用的重点可 能是将数字排序,因此更合理的做法是跳过引用直接将原始数据类型的数据排序。例如,想想将一个double类型的数组和一个Double类型的数组排序的差别。对于前者我们可以直接交换这些数并将数组排序;而对于后者,我们交换的是存储了这些数字的Double对象的引用。如果我们只是在将一大组数排序的话, 跳过引用可以为我们节省存储所有引用所需的空间和通过引用来访问数字的成本,更不用说那些调用compareTo()和less()方法的开销了。把Comparable接口替换为原始数据类型名,重定义less()方法或者干脆将调用less()的地方替换为a[i] < a[j] 这样的代码,我们就能得到可以将原始数据类型的数据更快地排序的各种算法(请见练习2.1.26)

5.8.2.2 Java 系统库的排序算法

为了 演示上表所示的数据,这里我们考虑Java系统库中的主要排序方法java.util.Arrays. sort()。根据不同的参数类型,它实际上代表了一系列排序方法:

1.每种原始数据类型都有一个不同的排序方法;

2.一个适用于所有实现了Comparable接口的数据类型的排序方法;

3.一个适用于实现了比较器Comparator的数据类型的排序方法。

Java的系统程序员选择对原始数据类型使用(三向切分的)快速排序,对引用类型使用归并排序。这些选择实际上也暗示着用速度和空间(对于原始数据类型)来换取稳定性(对于引用类型)。

我们讨论过的这些算法和思想是包括Java的许多现代系统的核心组成部分。当为实际应用开发Java程序时,你会发现Java的Arrays . sort(实现(可能再加上你自已实现的compareTo()或者compare()已经基本够用了,因为它使用的三向快速排序和归并排序都是经典。

5.8.3 问题的归约

使用排序算法来解决其他问题的思想是算法设计领域的基本技巧一归约的一个例子。归约指的是为解决某个问题而发明的算法正好可以用来解决另一种问题。 应用程序员对于归约的概念已经很熟悉了(无论是否明确地知道这一点) ——每次你在使用解决问题 B的方法来解决问题A时,你都是在将A归约为B。实际上,实现算法的一个目标就是使算法的适用性尽可能广泛,使得问题的归约更简单。作为例子,我们先看看几个简单的排序问题。很多这种问题都以算法测验的形式出现,而解决它们的第一-想法往往是平方级别的暴力破解。但很多情况下如果先将数据排序,那么解决剩下的问题就只需要线性级别的时间了,这样归约后的运行时间的增长数量级就由平方级别降低到了线性对数级别。

5.8.3.1 找出重复元素

在一个 Comparable对象的数组中是否存在重复元素?有多少重复元素?哪个值出现得最频繁?对于小数组,用平方级别的算法将所有元索互相比较-一遍就足 以解答这些问题。但这么做对于大数组行不通。 但有了排序,你就能在线性对数的时间内回答这些问题:首先将数组排序,然后遍历有序的数组,记录连续出现的重复元素即可。例如,下面就是一段统计数组中不重 复的元素个数的代码。只要稍稍修改这段代码你就能回答上面的问题,还可以打印所有不同元素的值、所有重复元素的值,等等,即使数组很大也无妨。

5.8.3.2 排名

一组排列(或是排名)就是一组N个整数的数组,其中0到N-1的每个数都只出现一次。两个排列之间的Kendall tau距离就是在两组数列中顺序不同的数对的数目。例如,0316254和1036425之间的Kendall tau距离是4,因为0-1、 3-1、2-4、5-4这4对数字在两组排列中的相对顺序不同,但其他数字的相对顺序都是相同的。这种统计方法的应用十分广泛。在社会学中它被用于研究社会选择和投票理论,在分子生物学中被用于使用基因表达图谱比较基因,在网络中被用于搜索引擎结果的排名,等等。某个排列和标准排列(即每个元素都在正确位置上的排列)的Kendall tau距离就是其中逆序数对的数量。

统计a[]中不重复元素的个数
Quick. sort(a);

int count = 1;  //假设a.length> 0

if (a[i].compareToCa[i-1]) != 0{

   count++;

}
5.8.3.3 优先队列

我们已经见过两个被归约为优先队列操作的问题的例子。一个是TopM.它能够找到输人流中M个最大的元素;另一个是Multiway,它能够将M个输人流归并为一个有序的输出流。这两个问题都可以轻易用长度为M的优先队列解决。

5.8.3.4 中位数与顺序统计

一个和排序有关但又不需要完全排序的重要应用就是找出一组元素的中位数(中间值,它不大于半的元素又不小于另半元素)。 查找中位数在统计学计算和许多数据处理的应用程序中都很常见。它是一种特殊的选择:找到组数中的第k小的元素( 如下页代码所示)。“选择” 在处理实验数据和其他数据中应用广泛,使用中位数和其他顺序统计来切分一个数组也很常见。一般,我只需要处理一个很大的数组中的一小部分,在这种情况下,一个程序可以选择,比如将前10%的元素完全排序即可。我们的TopM用优先队列为无界限输人解决了这个问题。除了TopM,另一种选择是直接将数组中的元素排序。在调用Quick. sort(a)之后,数组中的k个最小的元素就是数组的前k个元素,其中k小于数组长度。但这种方法需要调用排序,所以运行时间的增长数量级是线性对数的。

还有更好的办法吗?当k很小或者很大时找出数组中的k个最小值都很简单,但当k和数组大小成- - 定比例时这个任务就变得比较困难了,比如找到中位数(k=N2)。让人惊讶的是其实上面的selectO方法能够在线性时间内解决这个问题(这个实现需要在用例中进行类型转换:去掉这个限制的代码请见本书的网站)。为了完成这个任务: selectO用两个变量hi和1o来限制含有要选择的k元素的子数组,并用快速排序的切分法来缩小子数组的范围。请回想partitionO方法,它会将数组的a[1o]至a[hi]重新排列并返回一个整数j使得a[1o…j-1]小于等于a[j]且a[j+1…hi]大于等于a[j]。那么,如果k - j,问题就解决了。如果k < j,我们就需要切分左子数组(令hi .j-1) ;如果k➢j,我们则需要切分右子数组(令10 - j+1)。这个循环保证了数组中1o左边的元素都小于等于a[1o…hi],而hi右边的元素都大于等于a[1o. .hi]。我们不断地切分直到子数组中只含有第k个元素,此时a[kJ含有最小的(k+1).个元素,a[0] 到a[k-1]都小于等于a[k],而a[k+1]及其后的元素都大于等于a[k]。至于为何这个算法是线性级别的,是因为346假设每次都正好将数组二分,那么比较的总次数为(N+N2+N4+8N8…)直到找到第k的元素,这个和显然小于2N。和快速排序一样, 这里也需要一点数学 知识来得到比较的上界,它比快速排序略高。这个算法和快速排序的另一个共同点是这段分析依赖于使用随机的切分元素,因此它的性能保证也来自于概率。

找到一组数中的第k小元素
public static Comparable{

	select(Comparable[] a, int k){

		StdRandom. shuffle(a);

		int lo = 0, hi = a.length - 1;

		while(hi > lo){

			int j = partition(a. lo, hi);

			if( j  ==k )  return a[k];

			else if(j > k) hi = j - 1;

			else if(j < k) lo = j + 1;

		}

		return a[k];

	}

}

命题U:平均来说,基于切分的选择算法的运行时间是线性级别的。

证明:该命题的分析和快速排序的命题K的证明类似,但要复杂得多。结论就是算法的平均比较次数为~2N+2kn(Nlk)+2(N k)In(NI(N k)),这对于所有合法的k值都是线性的。例如,这个公式说明找到中位数(k-=N/2)平均需要-(2+2In2)N次比较。注意,最坏的情况下算法的运行时间仍然是平方级别的,但与快速排序一样,将数组乱序化可以有效防止这种情况出现。

你可能感兴趣的:(从浅到深的算法技巧,算法,排序算法,数据结构)