目录
一、插入排序
二、一些简单排序算法的下界
三、希尔排序
四、堆排序
五、归并排序
六、快速排序
七、总结
我们先假设排序工作是在内存中完成的,也就是说数据量小于几百万,这称为内部排序。最后再考虑大量数据时的排序,也就是外部排序。
对内部排序的研究将得出结论:
- 存在几种容易 的算法 以O(N2)完成排序,如插入排序。
- shell排序很简单,以o(N2)(这里是小o)完成排序,在实践中很有效果。
- 有一些复杂的以O(NlogN)完成排序。
- 任何通用的排序算法均要Ω(NlogN)次比较。
一、插入排序
由N-1次排序组成,利用了这样的一个事实:已经位置0到 p-1上的元素已经处理排序的状态。在第p次比较时,我们将位置p上的元素向左移动,直到它在前p+1个元素中找到正确 的位置。位置p上元素保存于
tmp,而前面 的更大的元素向右移动 一个位置。与二叉堆实现时用了相同的技巧。
public static <AnyType extends Comparable<? super AnyType>> void insertationSort(AnyType[]a){ int j; for (int p=1; p<a.length;p++){ AnyType tmp = a[p]; for (j=p; j>0&& tmp.compareTo(a[j-1])<0; j--){ a[j] = a[j-1] ; } a[j] = tmp ; } }
插入排序平均情况为N2。
二、一些简单排序算法的下界
N个互异数组的平均逆序数是 N(N-1)/4.
证明:L 与它的反序 Ly中序偶((x,y),其中y<x)的总个数为N(N-1)/2。
通过交换相邻元素进行排序的任何算法平均都要Ω(N2)时间。
上面的对于一整类只进行相邻元素交换的排序算法都是有效的。也告诉我们,如果想使一个算法以低于O(N2)的时间运行,一定要执行一些比较,特别是
要对相距较远的进行比较。一个排序通过删除逆序而前进,它一定要每次删除 不止一个逆序。
三、希尔排序
希尔排序用一个序列 h1,h2,ht,叫增量序列,其中h1=1, 一个重要的性质是,一个hk排序的文件(然后是hk-1)保持它的hk排序性。一个hk的排序作用就是对hk个
子数组进行一次插入排序 。
目前 一个流行的,但是不是最好的增量序列是 hk=hk-1/2;ht =N/2。如下代码 所示,用和在插入法中一样的方法避免明显的使用交换 。
public static <AnyType extends Comparable<? super AnyType>> void shellSort (AnyType [] a){ int j ; for (int gap =a.length/2; gap>0; gap/=2){ for (int i = gap; i<a.length ; i++){ AnyType tmp = a[i]; for (j =i; j>=gap&& tmp.compareTo(a[j-gap])<0; j-=gap){ a[j]= a[j-gap]; } //同插入排序中,避免使用明显的交换 a[j]= tmp ; } } }
- 希尔排序最坏情况的分析
使用希尔增量时,最坏的情况是=N2.
最坏情况 :一个输入序列,它的偶数位置上有N/2个同为 最大的数,而在奇数位置上有N/2个同为最小的数。记住,除了最后一个增量 ,其它的都是偶数.
希尔增量的问题在于,增量对未必是互素的,hibbard提出了一个不同的增量序列,他的增量形如1,3,7,2k-1..... 使用这种增量序列量坏的运行时间是
=N(3/2)次方。两种增量序列的关键区别在于相邻的增量 没有公因子。
希尔排序在实践中是完全可以接受的,即使是对数以万计的N也是这样,编程也简单,因此它成为对适度大量输入数据的排序。
四、堆排序
优先队列可以用于O(NlogN)的时间排序。这个就是堆排序。我们先建立一个二叉堆,这要O(N)的时间,然后我们执行N次deleteMin(),将删除的元素放到第二个数组中,
要花费O(NlogN)次。这个算法的主要问题是使用了另外的数组资源,存储增加一倍,这个是一个问题。
这个问题可以这样解决:
在每次deleteMin后,堆缩小1。因此,堆最后的单元的位置可以用来存放刚刚删除的元素(不再属于这个堆,堆大小减1)。注意,二叉堆时,数据从1开始,这里从0开始。
- 堆排序的分析
第一步,构建一个堆要用到2N次比较。
第二步,第 i 次deleteMin()最多用到2logi次比较.
因此最坏的情况下,堆排序用到2NlogN-O(N)次比较。堆排序是一个很稳定的算法 。
代码如下
/** * 堆排序 */ private static int leftChild (int i){ return 2*i+1; //这里是从0开始的,和之前的不一样了 } private static <AnyType extends Comparable<? super AnyType>> void percDown(AnyType [] a, int i, int n){ int child ; AnyType tmp; for (tmp=a[i]; leftChild(i) <n; i= child){ child= leftChild(i);//左儿子 //当有右儿子时,选择两个中小的一个 if (child!=n-1&& a[child].compareTo(a[child+1])<0){ child++ ; } //下滤 if (tmp.compareTo(a[child])<0) a[i] = tmp ; else { break; } } a[i] = tmp ; } public static <AnyType extends Comparable<? super AnyType>> void heapSort (AnyType [] a){ //build heap,O(N),这个操作从下而上,不能反 for (int i =a.length/2; i>=0; i--){ percDown(a, i, a.length) ; } for (int i =a.length-1; i>0; i++){ swapRef(a,0,i); //deleteMin percDown(a, 0, i); } } private static <AnyType extends Comparable<? super AnyType>>void swapRef (AnyType [] a, int i,int j){ }
五、归并排序
归并排序以O(NlogN)的最坏情形时间运行,而所使用的比较次数 几乎是最优的,是递归算法的一个很好的例子。这个算法中最基本的操作是合并两个已经排序的表。
算法是经典的分治算法的一个例子,分:将问题分成小的问题然后递归求解,治:将分的阶段的答案修补在一起。
下面是算法的实现,由于 merge是mergeSort的最后一行,因此任何时候,只要有一个临时数组在活动,而且这个临时数组可以在public类型的mergeSort中建立。
不仅如此,我们还可以只用这个临时数组的任意部分,我们将使用与输入数组a相同 的部分。
归并排序实现如下
/** * mergeSort */ //merge two half array private static <AnyType extends Comparable<? super AnyType>> void merge(AnyType []a, AnyType [] tmpArray,int leftPos, int rightPos, int rightEnd){ int leftEnd = rightPos-1 ; int tmpPos= leftPos ; int numElements = rightEnd - leftPos +1; // main loop while (leftPos<= leftEnd && rightPos<= rightEnd){ if (a[leftPos].compareTo(a[rightPos])<=0){ tmpArray[tmpPos++] = a[leftPos++] ;//使用的对应的部分 }else { tmpArray[tmpPos++]= a[rightPos++]; } } //copy rest of first half while (leftPos<= leftEnd){ tmpArray[tmpPos++] = a[leftPos++] ; } // copy rest of second half while (rightEnd<= rightEnd){ tmpArray[tmpPos++] = a[rightPos++] ; } //copy tmpArray back,也可以有别的写法 for (int i =0; i<numElements; i++, rightEnd--){ a[rightEnd] = tmpArray[rightEnd] ; } } private static <AnyType extends Comparable<? super AnyType>> void mergeSort (AnyType []a, AnyType []tmpArray , int left ,int right){ if (left<right){ int center = (left+right)/2 ; mergeSort(a, tmpArray, left, center) ; mergeSort(a, tmpArray, center+1, right) ; //只有一个临时 数组 在活动 merge(a, tmpArray, left, center+1, right) ; } } public static <AnyType extends Comparable<? super AnyType>> void mergeSort (AnyType [] a){ AnyType [] tmpArray = (AnyType [] )new Comparable[a.length] ; mergeSort(a, tmpArray, 0, a.length-1); }
虽然归并排序的运行时间是O(NlogN),但是有一个明显的缺点,即合并两个已经排序的表要用到线性的附加内存。整个算法中还要花费奖数据copy到临时数组再copy回来的这个
额外的工作,明显降低了排序的速度。这个copy的问题可以审慎地交换a和tmpArray的角色以解决。
与其它的O(NlogN)的排序算法 相比,归并排序的消耗严重依赖于比较元素和在数组与临时数组中移动元素的相对开销。这些开销是与语言相关的。
在java中,执行泛型比较时,比较元素是昂贵的,但是移动元素是省时的(因为是引用赋值,而不是对象的copy),归并排序中使用所有流行排序算法最少的比较次数,所以在
java泛型中的比较使用归并。
在C++泛型中,如果对象很大时,则copy对象比较费时,比较对象很快,所以我们使用移动数据次数少而比较多的quickSort。
在java中,快排也用于基本类型的标准库排序 ,这里,比较和数据移动 的开销是差不多的。
六、快速排序
在C++和java的基本类型的排序中很有用,平均运行时间是O(NlogN),这是因为算法的高度优化的内部循环,它的最坏情况是O(N2),我们可以将快速排序与堆排序结合起来,因为堆排序
的最坏情况是O(NlogN),因此我们可以对几乎所有的输入都可以得到很快速的排序 。
与归并排序一样,快速排序也递归地解决两个子问题,并需要线性的附加工作,但与归并排序不同,这两个子问题并不一定一样大,这有一定的隐患。快速排序更快的原因是,它分割成两组
实际上是在更有效的位置进行的,它的高效可以弥补大小不相等的递归调用,还有可能超出。
- 枢纽元的选取
一种错误的方法
如果直接将第一个元素作为枢纽元,除非输入是随机的,否则可能出现很坏的情况 。
一种安全的做法
随机的选取一个元素。但是随机数的生成一般有比较大的开销。
三数中值分割法
枢纽元最是选中值,但是这个很难知道。不过我们可以随机选取三个元素,并用它们的中值作为枢纽元,事实上随机并没有多大的作用,因此我们直接使用最左端,中间,最右端的三个。
- 分割策略
分割要做的就是将大的元素移到右边,小的移到左边,这里的大小都是相对于枢纽元素而说的。
1.将枢纽元与最后的元素交换,使枢纽元离开与要被分割的数据段。
2.当i 在j 的左边时,我们将 i 右移,移过那些 小于枢纽元的元素,同理将 j 右移 。当i 和j 停止时,i 指向一个大的,j 指向一下小的元素,如果i 在j的左边,则将这两个元素交换,效果就是
大元素到了右边,小元素到了左边。
3.重复上面的过程直到 i 和 j 交错为止。
4.将枢纽元素与 i 指向的元素交换 。
我们一定要考虑的一个问题是,当i /j 遇到 一个和枢纽元一样大的元素时的情况时,它们是否应该停止。直观的看,i /j应该做一样的工作,否则分割将偏向于一方。
我们考虑数组中所有的元素都相同的情况
如果i与j都停止 ,那么这种分割建立了几乎相等的子数组,同归并排序,要用的时间是O(NlogN)。(选用这个)
如果 i ,j 都不停止,那么我们要有防止它们越界的情况 ,最坏情况时为O(N2)。
- 小数组
当N<=20时,快速排序不如插入排序,一种好的选择是N=10时换成使用插入排序。
- 下面是快速排序的实现
这里选取枢纽元的方法是,从a[left],a[center],a[right]中选取,最小的放到a[left]位置,最大的在a[right]。我们将枢纽元放在a[right-1]的位置,并在分割时将 i 和 j 初始化为
left+1, right-2,因为a[left] <枢纽元,它可以用作 j 的警戒标记。由于 i 将停在等于枢纽元的地方,故将枢纽元放在a[right-1]提供了一个警戒标记。
/** * quick sort */ public static <AnyType extends Comparable<? super AnyType>> void quickSort (AnyType [] a){ quicksort(a,0, a.length-1) ; } /** * return the median of left center and right the pivot * orders these and hide */ private static <AnyType extends Comparable<? super AnyType>> AnyType median3(AnyType [] a, int left ,int right ){ int center = (left+ right)/2 ; if (a[center].compareTo(a[left])<0){ swapRef(a, left, center) ; } if (a[right].compareTo(a[left])<0){ swapRef(a, left, right) ; } if (a[center].compareTo(a[right])<0){ swapRef(a, center, right) ; } //place pivot at right-1 swapRef(a, center, right-1) ; return a[right-1] ; }
下面是快速排序的核心程序,功能是划分与递归调用
这里我们要注意的几点:
- 将i 和 j 初始,化为比它们正确的值超过1 个位置,使得不存在特殊的情况要考虑。
- swapRef 时,为了加快速度,有时会显式的写出,如果 swapRef 是final 的方法,编译器会自动这样做,但是有的编译器不用,这里对性能的影响会比较大。
- 算法的内部由一个加减1,一个测试,一个转移组成,速度很快。
- 算法很巧妙,如果我们将算法的一主要部分改成另外一种,则这不能正确的运行,因为当 a[i]=a[j]= pivot 时,将进行一个无限循环。
static final int CUTOFF=10 ; private static <AnyType extends Comparable<? super AnyType>> void quicksort(AnyType [] a,int left,int right){ if (left+ CUTOFF<= right){ AnyType pivot =median3(a, left, right) ; //begin partion int i = left, j = right-1 ; for (;;){ while (a[++i].compareTo(pivot)<0) {}; while (a[--j].compareTo(pivot)>0) {}; if (i<j){ swapRef(a, i, j) ; }else { break ; } } swapRef(a, i, right-1) ;//restore pivot quicksort(a, left, i-1) ; //sort small elements quicksort(a, i+1, right) ; //sort large elements }else { //对a[left:right]进行插入排序 //insertationSort(a , left, right); } }
主要部分的一种修改,但是有可以出现无限循环。如下
//begin partion int i = left+1 , j = right ; for (;;){ while (a[i].compareTo(pivot)<0) i++;//这是如果a[i]=a[j]=pivot,可能出现无限循环 while (a[j].compareTo(pivot)>0) j--; if (i<j){ swapRef(a, i, j) ; }else { break ; } }
- 选择问题的线性期望算法
在之前,我们使用优先队列,可以以时间(N+klogN) 找到第k个最大(最小)的元素,对于查找中值时,则是O(NlogN)。
现在我们可以以O(NlogN)的时间排序,因此我们可以期望以更快的时间解决选择问题。下面是快速选择算法。
集合为S, 记|S|为S 的元素个数。
步骤如下
如果|S|=1,那么S就是答案。如果正在使用小数组的cutoff方法且 |S|<CUTOFF,则将S排序返回第k个元素。
- 选取一个枢纽元 v。
- 将集合S- {v}分割成 S1, S2.
- 如果 k<=S, 那么第k 个元素就在 S1中,这时,我们返回 quickselect(S1, k)。
- 如果 k=1+S1, 那么枢纽就是第 k 个元素,我们返回 v作为答案。
- 否则,第k个元素在S2中,它是 S2 的第(k-|S1|-1)个最小的元素,我们调用 quickselect(S2, k-|S1|-1)。
与快速排序相比,quickselect只用进行一次递归调用而不是两次,平均运行时间是O(N)。分析如下
快选,每次选一部分,扔掉另一部分,所以是O(N)
假设每次扔掉一半.
(2^k=N)
T(N) =n +n/2+n/4+n/8+n/2^k = n*(1-2^-k)/(1-2^-1) =2N
快速选择的实际实现比抽象的描述还要简单,当算法结束时,第k个最小的元就在位置 k-1 上。
public static <AnyType extends Comparable<? super AnyType>> void quickSelect (AnyType [] a, int left ,int right, int k){ if (left+ CUTOFF<= right ){ AnyType pivot = median3(a, left, right) ; //begin partion int i = left , j= right -1; for (;;){ while (a[++i].compareTo(pivot)<0){} while(a[--j].compareTo(pivot)>0) {} if (i<j){ swapRef(a, i, j) ; }else { break ; } } swapRef(a, i, right-1) ;// if (k<=i){ quickSelect(a, left, i-1, k); }else if (k>i+1) { quickSelect(a, i+1, right, k); }else { insertationSort(a, left,right) ; } } }
七、总结
对于java 的泛型排序 ,其中对象的类型不知道,归并排序可能是最好的选择,因为它使用的比较的次数最少。对于大部分的情况 ,选用的方法一般是
插入排序,希尔排序,快速排序。它们的选择主要根据输入的大小来决定。
小量数据时,使用插入排序。
大量数据时,使用快速排序,但是不可以轻易将第一个元素作为枢纽元,如果不想过多的考虑这个问题,也可以使用希尔排序。
堆排序的速度比希尔排序慢,主要是为了移动 数据,堆排序要进行两次比较。
我们不提倡归并排序 ,因为它的性能 对基本数据的排序不如快速排序,而且编程也很复杂。
补充:
冒泡与选择
/** * 选择排序 * 第一次从R[0]~R[n-1]中选取最小值,与R[0]交换 * 第二次从R{1}~R[n-1]中选取最小值,与R[1]交换 * @param a */ public static void selectSort(int [] a){ for (int i=0;i<a.length;i++){ for (int j=i+1;j<a.length;j++){ if (a[i]>a[j]){ int tmp = a[i]; a[i] = a[j]; a[j] = tmp ; } } } } /** * 冒泡排序 * 第一趟:首先比较第1个和第2个数,将小数放前,大数放后。 * 然后比较第2个数和第3个数,将小数放前,大数放后,如此继续, * 直至比较最后两个数,将小数放前,大数放后。至此第一趟结束, * 将最大的数放到了最后。在第二趟:仍从第一对数开始比较 * (因为可能由于第2个数和第3个数的交换,使得第1个数不再小于第2个数) * ,将小数放前,大数放后,一直比较到倒数第二个数(倒数第一的位置上已经是最大的), * 第二趟结束,在倒数第二的位置上得到一个新的最大数 * @param a */ public static void bubbleSort(int [] a){ int tmp =0; for (int i=0;i<a.length;i++){ for (int j=0;j< a.length-i-1;j++ ){ //将在数后移 if (a[j]>a[j+1]){ tmp = a[j]; a[j] = a[j+1] ; a[j+1] = tmp ; } } } }