算法(第四版)2.1 初级排序算法
深入学习这些相对简单的算法的原因在于:
- 第一,我们将通过它们熟悉一些术语和简单的技巧;
- 第二,这些简单的算法在某些情况下比我们之后将会讨论的复杂算法更有效;
- 第三,以后你会发现,它们有助于我们改进复杂算法的效率。
排序算法类的模板
/**
* sort Example
*/
public class Example {
public static void sort(Comparable[] a){
}
private static boolean less(Comparable v, Comparable w){
return v.compareTo(w) < 0;
}
private static void exch(Comparable[] a, int i, int j){
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
private static void show(Comparable[] a){
// print array element in single line
for (int i = 0; i < a.length; i++) {
StdOut.print(a[i] + " ");
}
StdOut.println();
}
public static boolean isSorted(Comparable[] a){
// test array is sorted
for (int i = 0; i < a.length; i++) {
if (less(a[i],a[i-1])) return false;
}
return true;
}
public static void main(String[] args) {
// read string from stinput, and output after sorted
String[] a = In.readStrings();
sort(a);
assert isSorted(a);
show(a);
}
}
几个对所有算法都很重要的问题
验证
无论数组的初始状态是什么,排序算法都能成功吗?谨慎起见,我们会在测试代码中添加一条语句assert isSorted(a);
来确认排序后数据顺序都是有序的。
运行时间
评估算法的性能。首先要计算各个排序算法在不同的随机输入下的基本操作的次数(包括比较和交换,或者是读写数组的次数)。
然后,我们用这些数据来评估算法的相对性能并介绍在实验中验证这些猜想所使用的工具。
排序成本模型。在研究排序算法时,我们需要计算比较和交换的数量。对于不交换元素的算法,我们会计算访问数组的次数。
额外的内存占用
排序算法的额外内存开销和运行时间是同等重要的。
排序算法可以分为两类:
- 原地排序算法:除了函数调用所需的栈和固定数目的实例变量之外无需额外内存的排序算法。
- 其他排序算法:需要额外内存空间来存储另一份数组副本的排序算法。
数据类型
我们的排序算法模板适用于任何实现了Comparable
接口的数据类型。在创建自己的数据类型时,我们只要实现Comparable
接口就能保证用力代码可以将其排序。要做到这一点,只需要实现一个compareTo()
方法来定义目标类型对象的自然次序,如下面的Date
数据类型所示。
/**
* 定义一个可比较类型
*/
public class Date implements Comparable<Date> {
private final int day;
private final int month;
private final int year;
public Date(int d, int m, int y){
day = d; month = m; year = y;
}
public int day(){
return day;
}
public int month(){
return month;
}
public int year(){
return year;
}
@Override
public int compareTo(Date that) {
if (this.year > that.year) return +1;
if (this.year < that.year) return -1;
if (this.month > that.month) return +1;
if (this.month < that.month) return -1;
if (this.day > that.day) return +1;
if (this.day < that.day) return -1;
return 0;
}
@Override
public String toString() {
return month + "/" + day + "/" + year;
}
}
选择排序
一种最简单的排序算法是这样的:首先,找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置(如果第一个元素就是最小元素那么它就和自己交换)。再次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此往复,只到将整个数组排序。这种方法叫做选择排序,因为它在不断地选择剩余元素之中的最小者。
命题A。对于长度为
N
的数组,选择排序需要大约(N^2/2)次比较和N次交换。证明。可以通过算法的排序轨迹来证明这一点。我们用一张N*N的表格来表示排序的轨迹,其中每个非灰色字符都表示一次比较。表格中大约一半的元素不是灰色的——即对角线和其上部分的元素。对角线上的每个元素都对应着一次交换。通过查看代码我们可以精确地得到,0到N-1的任意i都会进行一次交换和N-1-i次比较,因此总共有N次交换以及((N-1)+(N-2)+...+2+1=N(N-1)/2 ~ N^2/2)次比较。
代码:
/**
* Selection sort
*/
public class Selection {
public static void sort(Comparable[] a){
// 将a[]按升序排列
int N = a.length; // 数组的长度
for (int i = 0; i < N; i++) {
// 将a[i]和a[i+1..N]中最小的元素交换位置
int min = i; // 最小元素的索引
for (int j = i + 1; j < N; j++) {
if (less(a[j],a[min])) min = j;
}
exch(a, i, min);
}
}
// less()、exch()、isSorted()和main()方法见“排序类算法模板”
}
该算法将第i
小的元素放在a[i]
之中。数组的第i
个位置的左边是i
个最小的元素且它们不会再被访问。
总得来说,选择排序是一种很容易理解和实现的简单排序算法,它有两个很鲜明的特点。
- 运行时间和输入无关。为了找出最小的元素而扫描一遍数组并不能为下一遍扫描提供什么信息。这种性质在某些情况下是一个缺点,因为使用选择排序的人可能会惊讶地发现,一个已经有序的数组或是主键全部相等的数组和一个元素随机排列的数组所用的时排序时间竟然一样长。我们将会看到,其他算法会更善于利用输入的初始状态。
- 数据移动是最少的。每次交换都会改变两个数组元素的值,因此选择排序用了
N
次交换——交换次数和数组的大小是线性关系。我们将研究的其他任何算法都不具备这个特征(大部分的增长数量级都是线性对数或是平方级别)
插入排序
通常人们整理桥牌的方法是一张一张的来,将每一张牌插入到其他已经有序的牌中的适当的位置。在计算机的实现中,为了给要插入的元素腾出空间,我们需要将其余所有元素在插入之前都向右移动一位。这种算法叫做插入排序。
命题B。对于随机排列的长度为
N
且主键不重复的数组,平均情况下插入排序需要$ ~ N2/4$次比较以及$N2/4$次交换。最坏情况下需要 (~ N^2/2)次比较和(~ N^2/2)次交换,最好情况下需要N-1
次比较和0次交换。证明。和命题A一样,通过一个N*N的轨迹表可以很容易就得到交换和比较的次数。最坏的情况下对角线之下的所有元素都需要移动位置,最好的情况下都不需要。对于随机排列的数组,在平均情况下每个元素都可能向后移动半个数组的长度,因此交换总数是对角线之下的元素总数的二分之一。
比较的总次数时交换的次数加上一个额外的项,该项为N减去被插入的元素正好是已知的最小元素的次数。在最坏的情况下(逆序数组),这一项对于总数可以忽略不计;在最好的情况下(数组已经有序),这一项等于N-1。
/**
* sort Insertion
*/
public class Insertion {
public static void sort(Comparable[] a) {
// 将a[]按升序排列
int N = a.length;
for (int i = 0; i < N; i++) {
// 将a[i]插入到a[i-1]、a[i-2]、a[i-3]...之中
for (int j = i; j > 0 && less(a[j], a[j - 1]); j--) {
exch(a, j, j - 1);
}
}
}
// less()、exch()、isSorted()和main()方法见“排序类算法模板”
}
对于1
到N-1
之间的每一个i
,将a[i]
与a[0]
到a[i-1]
中比它小的所有元素依次有序地交换。在索引i
由作至右变化的过程中,它的左边的元素总是有序的,所以当i
到达数组的右端时排序就完成了。
我们要考虑的更一般的情况是部分有序的数组。倒置指的是数组中的两个顺序颠倒的元素。比如EXAMPLE
中有11对倒置:E-A、X-A、X-M、X-P、X-L、X-E、M-L、M-E、P-L、P-E以及L-E。如果数组中倒置的数量小于数组大小的某个倍数,那么我们就说这个数组是部分有序的。
下面是几种典型的部分有序的数组:
- 数组中每个元素距离它的最终位置都不远;
- 一个有序的大树组接一个小数组
- 数组中只有几个元素的位置不正确。
插入排序对这样的数组很有效,而选择排序则不然。事实上,当倒置的数量很少时,插入排序很可能比本章中的其他任何算法都要快。
命题C。插入排序需要的交换次数和数组中倒置的数量相同,需要的比较次数大于等于倒置的数,小于等于倒置的数量加上数组的大小再减一。
证明。每次交换都改变了两个顺序颠倒的元素的位置,相当于减少了一对倒置,当倒置数量为
0
时,排序就完成了。每次交换都对应着一次比较,且1
到N-1
之间的每个i
都可能需要一次额外的比较(在a[i]
没有到达数组的左端时)
比较两种排序算法
性质D。对于随机排序的无重复主键的数组,插入排序和选择排序的运行时间是平方级别的,两者之比应该是一个较小的常数。
public class SortCompare {
public static double time(String alg, Double[] a){
Stopwatch timer = new Stopwatch();
if (alg.equals("Insertion")) Insertion.sort(a);
if (alg.equals("Selection")) Selection.sort(a);
if (alg.equals("Shell")) Shell.sort(a);
if (alg.equals("Merge")) Merge.sort(a);
if (alg.equals("Quick")) Quick.sort(a);
if (alg.equals("Heap")) Heap.sort(a);
return timer.elapsedTime();
}
public static double timeRandomInput(String alg, int N, int T){
// 使用算法alg将T个长度为N的数组排序
double total = 0.0;
Double[] a = new Double[N];
for (int t = 0; t < T; t++) {
// 进行一次测试(生成数组并排序)
for (int i = 0; i < N; i++) {
a[i] = StdRandom.uniform();
}
total += time(alg, a);
}
return total;
}
public static void main(String[] args) {
String alg1 = args[0];
String alg2 = args[1];
int N = Integer.parseInt(args[2]);
int T = Integer.parseInt(args[3]);
double t1 = timeRandomInput(alg1, N, T); // 算法1的总时间
double t2 = timeRandomInput(alg2, N, T); // 算法2的总时间
StdOut.printf("For %d random Doubles
%s is", N, alg1);
StdOut.printf(" %.1f times faster than %s
", t2/t1, alg2);
}
}
$ java sort.SortCompare Insertion Selection 1000 100
For 1000 random Doubles
Insertion is 0.6 times faster than Selection
// 书中写的是插入比选择快,是1.7 我的结果总是0.6 0.5 这样子
希尔排序
对于大规模乱序数组插入排序很慢,因为它只会交换相邻的元素,因此元素只能一点一点地从数组的一端移动到另一端。
希尔排序为了加快速度简单地改进了插入排序,交换不相邻的元素以对数组的局部进行排序,并最终使用插入排序将局部的数组排序。
希尔排序的思想是使数组中任意间隔为h
的元素都是有序的。这样的数组称为h
有序数组。换句话说,一个h
有序数组就是h
个互相独立的有序数组编织在一起组成的数组。在排序时,如果h
很大,我们就能将元素移动到很远的地方,为实现更小的h
有序创造方便。用这种方式,对于任意以1
结尾的h
序列,我们都能够将数组排序。这就是希尔排序。我们的希尔排序使用了序列(1/2(3^k-1)),从N/3开始递减至1。我们把这个序列称为递增序列。下面的算法实时计算了它的递增序列,另一种方式是将递增序列存储在一个数组中。
实现希尔排序的一种方式是对于每个h
,用插入排序将h
个子数组独立地排序。但因为子数组是相互独立的,一个更简单的方法是在h
子数组中将每个元素交换到比它大的元素之前去(将比它大的元素向右移动一格)。只需要在插入排序的代码中将移动元素的距离由1
改为h
即可。这样,希尔排序的实现就转化为了一个类似插入排序但使用不同增量的过程。
希尔排序更高效的原因是它权衡了子数组的规模和有序性。排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这两种情况都很适合插入排序。子数组部分有序的程度取决于递增序列的选择。
/**
* sort Shell
*/
public class Shell {
public static void sort(Comparable[] a){
// 将a[]按升序排列
int N = a.length;
int h = 1;
while (h < N/3) h = 3*h + 1;
while (h >= 1){
// 将数组变为h有序
for (int i = h; i < N; i++) {
// 将a[i]插入到a[i-h],a[i-2*h],a[i-3h]...之中
for (int j = i; j >= h && less(a[j], a[j-h]); j -= h) {
exch(a, j, j-h);
}
}
h = h / 3;
}
}
// less()、exch()、isSorted()和main()方法见“排序类算法模板”
}
$ java sort.SortCompare Shell Insertion 1000 100
For 1000 random Doubles
Shell is 6.1 times faster than Insertion
$ java sort.SortCompare Shell Insertion 10000 100
For 10000 random Doubles
Shell is 70.3 times faster than Insertion
性质E。使用递增序列1,4,13,40,121,364...的希尔排序所需的比较次数不会超出N的若干倍乘以递增序列的长度。