排序
排序是将一组对象按照某种逻辑顺序重新排列的过程。在计算机早期,大家普遍认为30%的计算周期都用在排序上。如今这个比例下降,可能原因之一是如今的排序算法更高效了,而不是说排序的重要性降低了。
既然可以使用标准库中的排序算法,大家为什么还要研究排序呢?
- 理解算法有助于解决类似的其他问题
- 这些算法很经典,优雅,值得去看。
- 应用于事务处理,组合优化,天体物理学,分子动力学,语言学,基因组学,天气预报等众多领域。其中,快速排序被誉为20世纪科学和工程领域的十大算法之一。
如何来判断算法的成本
- 计算比较和交换的数量。对于不交换元素的算法,则计算访问数组的次数。
- 排序算法的额外开销和运行时间同等重要。
public static boolean less(Comparable v, Comparable w){
return v.comparableTo(w) < 0;
}
public static void exch(Comparable[] a, int i, int j){
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
选择排序
原理: 找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置。再次,在剩下的元素中找到最小的元素,和数组的第二个元素交换位置,直到最后一个元素为止。
public class Selection{
public static void sort(Comparable[] a){
int N = a.length();
for(int i = 0; i < N; i ++){
int min = i; //最小元素的索引
for(int j = i+1; j < N; j++){
if(less(a[j], a[min])){
min = j;
}
}
exch(a, i, min);
}
}
}
交换总次数:N
比较总次数:N^2 / 2
因此这个算法效率取决于比较的次数
运行时间与输入无关。
插入排序
为了给插入的元素腾出空间,我们需要将数组的其他元素在插入之前都往右移一位。当索引到达数组的最右端的时候,数组排序就完成了。
与选择排序不同的是,插入排序取决于输入元素的初始顺序。
public class Insertion{
public static void sort(Comparable[] a){
int N = a.length;
for(int i = 1; i < N; i++){
//将a[i]插入到a[i-1], a[i-2],...
for(int j = i; j > 0 && less(a[j], a[j-1]); j--){
exch(a, a[j], a[j-1]);
}
}
}
}
交换总次数:最好的情况需要0次交换,最坏的情况需要N^2 / 2次交换
比较总次数:最好的情况需要N-1次比较,最坏的情况需要N^2 / 2次比较
运行时间与输入有关。
当对一个很大且已经有序,或者接近有序的数组进行排序会比随机顺序或者逆序数组排序快的多。
希尔排序
希尔排序的思想是数组中任意间隔为h的元素都是有序的,这样的数组被称为h有序数组。它是基于插入排序的一种算法。
当较大的数替换到后面去,就可以减少比较的次数。
public class Shell{
public static void sort(Comparable[] a){
int N = a.length;
int h = 1;
while (h < N /3 ) {
//1,4,13,40,121,...
h = h * 3 + 1;
}
while(h >= 1){
for(int i = h; h < N; i++){
//将a[j] 插入到a[i-h], a[i - 2*h], a[i - 3*h],...
for(int j = i; j >= h && less(a[j], a[j-h]); j -= h){
exch(a, j, j-h);
}
}
h = h / 3;
}
}
}
希尔排序不需要额外的空间,而且代码量很小,当没有系统的排序函数可用时,值得优先考虑。
归并排序
即两个有序的数组归并成为一个更大的有序数组,这样的递归排序算法成为归并排序。
有序容易,难在归并,所以是如何归并的呢?
原地归并的抽象方法:
public static void merge(Comparable[] a, int lo, int mid, int hi){
//将数组[lo..mid]和[mid+1..hi]归并
int i = lo, j = mid+1;
for(int k = lo; k <= hi; k++){
aux[k] = a[k]; //将a[lo..hi]复制到aux[lo..hi]中
}
for(int k = lo; k <= hi; k++){
if(i > mid) a[k] = aux[j++]; //左半部分用尽了
if(j > hi) a[k] = aux[i++]; //右半部分用尽
if(less(aux[j], aux[i])) a[k] = aux[j++]; //右半部分小于左半部分,取右半部分
else a[k] = aux[i++]; //取左半部分
}
}
自顶向下的归并排序实现
基于原地归并的抽象实现了另一种递归归并,这也是分治思想的一个典型的例子。
public class Merge{
public static Comparable[] aux;
public static void sort(Comparable[] a){
aux = new Comparable[a.length];
sort(a, 0, a.length-1);
}
public static void sort(Comparable[] a, int lo, int hi){
if(hi <= lo) return; //结束的标志位
int mid = lo + (hi - lo) / 2;
sort(a, lo, mid); //将左半部分排序
sort(a, mid+1, hi); //将右半部分排序
merge(a, lo, mid, hi); //归并
}
}
假设用一颗树来表示,n表示树的层数,k 表示0到k-1层, 因此,第k层有 2^k 个数组,每个数组中有 2^(n-k)个元素,
所以每层比较的次数就是 2^k * 2^(n-k) = 2n,n层的比较次数就是n*2n = NlgN。
对于长度N的任意数组,自顶向下的归并排序需要1/2NlgN至NlgN次比较
证明: 令C(N)表示长度为N的数组需要比较的次数,我们有C(0)=C(1)=0,对于N>1,我们有以下公式:
C(N) = C(N/2) + C(N/2) + N //左半部分比较次数,右半部分比较次数,N表示归并需要比较的次数,最少归并比较的次数为N/2
假设N为2的幂,即N=2^n,可以得到
C(2^n) = 2C(2^(n-1)) + 2^n, 同时除以 2^n 有,
C(2n)/2n = C(2(n-1))/2(n-1) + 1, 将这个公式代入 C(N/2),有
C(2(n-1))/2(n-1) = C(2(n-2))/2(n-2) + 1,所以
C(2n)/2n = C(2(n-2))/2(n-2) + 1 + 1,所以重复n-1遍,便有
C(2n)/2n = C(20)/20) + n, 同时乘以 2^n, 有
C(2^n) = n*2^n = NlgN。