一种最简单的排序算法是这样的:首先,找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置(如果第一个元素就是最小元素那么它就和自己交换)。再次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此往复,直到将整个数组排序。这种方法叫做选择排序,因为它在不断地选择剩余元素之中的最小者。
如下列算法所示,选择排序的内循环只是在比较当前元素与目前已知的最小元素(以及将当前索引加1和检查是否代码越界) ,这已经简单到了极点。交换元素的代码写在内循环之外,每次交换都能排定一个元素, 因此交换的总次数是N。所以算法的时间效率取决于比较的次数。
总的来说,选择排序是一种很容易理解和实现的简单排序算法,它有两个很鲜明的特点。运行时间和输入无关。为了找出最小的元素而扫描——包数组并不能为下一遍扫描提供什么信息。这种性质在某些情况下是缺点,因为使用选择排序的人可能会惊讶地发现,一个已经有序的数组或是主键全部相等的数组和一个元素随机排列的数组所用的排序时间竟然一样长! 我们将会看到,其他算法会更善于利用输入的初始状态。
数据移动是最少的。每次交换都会改变两个数组元素的值,因此选择排序用了N次交换一交换次数和数组的大小是线性关系。我们将研究的其他任何算法都不具备这个特征(大部分的增长数量级都是线性对数或是平方级别)。
public class Selection{
//将a[]按升序排列
publicstatic void sort(Comparable[]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);
}
}
}
}
通常人们整理桥牌的方法是一张一张的来, 将每一张牌插人到其他已经有序的牌中的适当位置。在计算机的实现中,为了给要插入的元素腾出空间,我们需要将其余所有元素在插人之前都向右移动一位。这种算法叫做插入排序。
与选择排序一样, 当前索引左边的所有元素都是有序的,但它们的最终位置还不确定,为了给更小的元素腾出空间,它们可能会被移动。但是当索引到达数组的右端时,数组排序就完成了。和选择排序不同的是,插人排序所需的时间取决于输人中元素的初始顺序。例如,对一个很大且其中的元素已经有序(或接近有序)的数组进行排序将会比对随机顺序的数组或是逆序数组进行排序要快得多。
命题B。对于随机排列的长度为N且主键不重复的数组,平均情况下插入排序需要~ N/4次比较以及~ N/4次交换。最坏情况下需要~ N/2次比较和~ N/2次交换,最好情况下需要N-1次比较和0次交换。
证明:通过一个N*N的轨迹表可以很容易就得到交换和比较的次数。最坏情况下对角线之下所有的元素都需要移动位置,最好情况下都不需要。对于随机排列的数组,在平均情况下每个元素都可能向后移动半个数组的长度,因此交换总数是对角线之下的元素总数的二分之一。
比较的总次数是交换的次数加上一个额外的项,该项为N减去被插入的元素正好是已知的最小元素的次数。在最坏情况下(逆序数组),这一项相对于总数可以忽略不计:在最好情况下(数组已经有序),这一项等于N-1。
插入排序对于实际应用中常见的某些类型的非随机数组很有效。例如,正如刚才所提到的,想想当你用插入排序对一个有序数组进行排序时会发生什么。插入排序能够立即发现每个元素都已经在合适的位置之上,它的运行时间也是线性的(对于这种数组,选择排序的运行时间是平方级别的)。对于所有主键都相同的数组也会出现相同的情况(因此命题B的条件之一就是主键不重复)。
public class Insertion{
publicstatic void sort(Comparable[] a){
//将a[i]控升库排列
int N=a.length;
for(int i = 1; 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);
}
}
}
对于0到N-1之间的每一个 i,将a[i]与a[0]到a[i-1]中比它小的所有元素依次有序地交换。在索引i由左向右变化的过程中,它左侧的元素总是有序的,所以当i到达数组的右端时排序就完成了。
我们要考虑的更般的情况是部分有序的数组。倒置指的是数组中的两个顺序颠倒的元素。如果数组中倒置的数量小于数组大小的某个倍数,那么我们说这个数组是部分有序的。下面是几种典型的部分有序的数组:
1.数组中每个元素距离它的最终位置都不远;
2.一个有序的大数组接一个小数组;
3.数组中只有几个元素的位置不正确。
命题C。插入排序需要的交换操作和数组中倒置的数量相同,需要的比较次数大于等于倒置的数量,小于等于倒置的数量加上数组的大小再减一。4
证明:每次交换都改变了两个顺序颠倒的元素的位置,相当于减少了一对倒置,当倒置数量为0时,排序就完成了。每次交换都对应着一次比较,且1到N-1之间的每个1都可能需要一次额外的比较(在a[i]没有达到数组的左端时)。
要大幅提高插入排序的速度并不难,只需要在内循环中将较大的元素都向右移动而不总是交换。
总的来说,插入排序对于部分有序的数组十分高效,也很适合小规模数组。这很重要,因为这些类型的数组在实际应用中经常出现,而且它们也是高级排序算法的中间过程。
现在我们已经实现了两种排序算法,我们想知道选择排序和插入排序哪种更快。 我 这里我们第一次用实践说明我们解决这个问题的办法。一般来说,我们将通过以下步骤比较两个算法:
1.实现并调试它们;
2.分析它们的基本性质;
3.对它们的相对性能作出猜想;
4.用实验验证我们的猜想。
这些步骤都是经过时间检验的科学方法,只是现在是运用在算法研究之上。
现在,上面的算法表示已经实现了第一步,命题A、命题B和命题C组成了第二步,下面的性质D将是第三步,之后“比较两种排序算法"的SortCompare类将会完成第四步。这些行为都是紧密相关的。
实现了算法之后, 下一步我们需要确定一个适当的输入模型。 对于排序,命题A、命题B和命题C用到的自然输人模型假设数组中的元素随机排序,且主键值不会重复。对于有很多重复主键的应用来说,我们需要一个更加复杂的模型。
如何估计插入排序和选择排序在随机排序数组下的性能呢?通过上面的算法以及命题A、命题B和命题C可以发现,对于随机排序数组,两者的运行时间都是平方级别的。也就是说,在这种输入下插人排序的运行时间和N乘以一个小常数成正比,选择排序的运行时间和N乘以另一个小常数成比例。这两个常数的值取决于所使用的计算机中比较和交换元素的成本。对于许多数据类型和一般的计算机,可以假设这些成本是相近的(但我们也会看到一些大不相同的例外)。
性质D:对于随机排序的无重复主键的数组,插入排序和选择排序的运行时间是平方级别的,两者之比应该是一个较小的常数。
例证:可以通过SortCompare类来检测。它会使用由命令行参数指定的排序算法名称所对应的sort()方法进行指定次数的实验(将指定大小的数组排序),并打印出所观察到的各种算法的运行时间的比例。
为了证明这一点,我们用SortCompare来做几次实验。我们使用Stopwatch来计时,右侧的time()函数的任务是调用几种简单排序算法。
public static double time(String alg, Comparable[] a){
Stopwatch timer = new Stopwatch();
if (alg.equalsC"Insertion")) Insertion.sort(a);
if (alg.equalsC"Selection")) Selection.sort(a);
if (alg. equals("Shell")) Shell. sort(a);
if (alg. equals("Querk")) Querk. sort(a);
if (alg. equals("Merge")) Merge. sort(a);
return timer. elapsedTime();
}
随机数组的输入模型由SortCompare 类中的timeRandom-Input方法实现。这个方法会生成随机的Double值,将它们排序,并返回指定次测试的总时间。使用0.0至1.0之间的随机Double值比使用类似于StdRandom.shuffle()的库丽数更简单有效,因为这样几乎不可能产生相等的主键值。用命令行参数指定重复次数的好处是能够运行大量的测试(测试次数越多,每遍测试所需的平均时间就越接近于真实的平均数据)并且能够减小系统本身的影响。你应该在自己的计算机上用SortCompare进行实验,来了解关于插入排序和选择排序的结论是否成立。
public Class SortCompare{
public static double time(String alg, Double[] a){
//见上面的内容
}
public static double timeRandomInput(String alg, int N, intT){
//使用算法1将T个长度为N的数组排序
double total = 0.0;
Double[] a = new Double [N];
for (int t=0; t<T; t++){
//进行一次测试(生成一个数组并排序)
a[i] = StdRandom. uniform();
}
return total;
}
public static void main(String[] args){
String alg1 = arg[0];
String alg2 = arg[1];
int N = Integer . parseInt(args[2]);
int T = Integer. parseInt(args[3]);
double t1 = timeRandomInput(a1g1, N, D); //算法1的总时间
double t2 = timeRandomInput(alg2, N, TD; //算法2的总时间
StdOut.printf( "For %d random Doubles\n %s is",N, alg1);
StdOut.printf(" %.1f times faster than %s\n",t2/t1, alg2);
}
}
这个用例会运行由前两个命令行参数指定的排序算法,对长度为N (由第三个参数指定)的Double型随机数组进行排序,元素值均在0.0到1.0之间,重复T次(由第四个参数指定),然后输出总运行时间的比例。