对于本文介绍的排序算法,我们假设整个排序工作能够在主存中完成,因此,元素的个数相对来说比较小(小于10的6次方)。当然,不能在主存中完成而必须在磁盘或磁带上的排序叫做外部排序,将在其它博文介绍。
一. 插入排序
- 基本思想:
对于N个元素,插入排序由N-1趟排序组成。对于P=1趟到P=N-1趟,插入排序保证从位置0到位置P上的元素为已排序状态。插入排序利用了这样的事实:位置0到位置P-1上的元素已排过序。 - 排序过程
【示例】:
[初始关键字] 49 38 65 97 76 13 27 49
J=1(49) [49] 38 65 97 76 13 27 49
J=2(38) [38 49] 65 97 76 13 27 49
J=3(65) [38 49 65] 97 76 13 27 49
J=4(97) [38 49 65 97] 76 13 27 49
J=5(76) [38 49 65 76 97] 13 27 49
J=6(13) [13 38 49 65 76 97] 27 49
J=7(27) [13 27 38 49 65 76 97] 49
J=8(49) [13 27 38 49 49 65 76 97] -
源代码
typedef int ElementType; //插入排序,默认从小到大排序 void InsertionSort(ElementType A[], int N) { ElementType Tmp; int i, j; //进行N-1趟遍历 for( i = 1; i < N; ++i ) { Tmp = A[i]; //寻找插入点,数据往后挪 for( j = i; j > 0 && A[j-1] > Tmp; --j) A[j] = A[j-1]; A[j] = Tmp; } }
-
复杂度分析
插入排序平均情况为O(N^2)。如果输入数据已预先排序,那么运行时间为O(N)。
二. 希尔排序
- 基本思想
也称递减增量排序算法。通过比较相距一定间隔的元素来工作,各趟比较所用的距离随着算法的进行而减小,直到只比较元素的最后一趟排序位置。希尔排序使用一个序列h1, h2, ..., ht,叫做增量序列。增量序列的一种流行的选择是使用shell建议的序列:ht=[N/2]和hk=[h(k+1)/2],该方法实质上是一种分组插入方法。 -
源代码
void ShellSort(ElementType A[], int N) { ElementType Tmp; int i, j; int Increment; //Increment表示增量序列的取值,初始值为N/2,每次遍历减半直到1 for( Increment = N/2; Increment > 0; Increment/=2 ) { //每次遍历就是增量为Increment的插入排序 for( i = Increment; i < N; ++i ) { Tmp = A[i]; for( j = i; j >= Increment && A[j-Increment] > Tmp; j -= Increment) A[j] = A[j-Increment]; A[j] = Tmp; } } }
-
复杂度分析
希尔排序的平均情形比O(N^2)好一些,最坏情形为O(N^2)。
Hibbard提出形如1,3,7...,2^k-1的增量系列,平均情形为O(N^(5/4)),最坏情形为O(N^(3/2))。 Sedgewick提出了几种增量序列,其最坏的为O(N^(4/3)),平均运行时间为O(N^(7/6))。
希尔排序的性能在实践中是完全可以接受的,即使是对于数以万计的N仍是如此。编程的简单特点使得它成为对适度地大量的输入数据经常选用的算法。
三. 堆排序
- 基本思想
通过使用max堆(二叉堆),第一步以线性时间建立一个堆,然后通过将堆中的最后元素与第一个元素交换,缩减堆的大小进行下滤,来执行N-1次DeleteMax操作。不像二叉堆,当时数据是在数组下标1处开始,而此处堆排序的数组包含位置0处的数据。 -
源代码
#define LeftChild(i) (2*i+1) //下滤 void PercDown(ElementType A[], int N, int i) { ElementType Tmp; int Child; Tmp = A[i]; for( Tmp = A[i]; LeftChild(i) < N; i = Child ) { Child = LeftChild(i); if( Child < N-1 && A[Child+1] > A[Child]) Child++; if( A[Child] > Tmp) A[i] = A[Child]; else break; } A[i] = Tmp; } void HeapSort(ElementType A[], int N) { ElementType Tmp; int i; //建立一个堆 for( i = N/2; i >= 0; --i) PercDown(A, N, i); for( i = N-1; i > 0; --i) { //交换A[0]和A[i]的值 Tmp = A[0]; A[0] = A[i]; A[i] = Tmp; //重新排序 PercDown(A, i, 0); } }
-
复杂度分析
平均复杂度和最坏情况复杂度都为O(NlogN),然而,在实践中它却慢于使用Sedgewick增量序列的希尔排序。
四. 归并排序
- 基本思想
这个算法中基本的操作是合并两个已排序的表。归并排序算法可以用递归实现,递归地将前半部分数据和后半数据各自归并排序;得到排序后的两部分数据,然后使用上面描述的合并算法再将这两部分合并到一起。 -
源代码
void MSort(ElementType A[], ElementType Tmp[], int N, int Left, int Right); void Merge(ElementType A[], ElementType Tmp[], int N, int LeftStart, int RightEnd); void MergeSort(ElementType A[], int N) { ElementType *Tmp; //申请一个原来数组大小的临时数组 Tmp = (ElementType *)malloc(N * sizeof(ElementType)); if( Tmp != NULL ) { MSort(A, Tmp, N, 0, N-1); free(Tmp); //不要忘了释放内存 } } //递归归并程序 void MSort(ElementType A[], ElementType Tmp[], int N, int Left, int Right) { int Center; if(Left < Right) { Center = (Left + Right) / 2; MSort(A, Tmp, N, Left, Center); MSort(A, Tmp, N, Center + 1, Right); Merge(A, Tmp, N, Left, Right); } } //合并两个数组 void Merge(ElementType A[], ElementType Tmp[], int N, int LeftStart, int RightEnd) { int LeftEnd, RightStart, LeftBound, P; LeftEnd = (LeftStart + RightEnd) / 2; RightStart = LeftEnd + 1; LeftBound = P = LeftStart; while(LeftStart <= LeftEnd && RightStart <= RightEnd) if( A[LeftStart] <= A[RightStart]) Tmp[P++] = A[LeftStart++]; else Tmp[P++] = A[RightStart++]; while(LeftStart <= LeftEnd) Tmp[P++] = A[LeftStart++]; while(RightStart <= RightEnd) Tmp[P++] = A[RightStart++]; while( RightEnd >= LeftBound) { A[RightEnd] = Tmp[RightEnd]; RightEnd--; } }
-
复杂度分析
归并排序以O(NlogN)最坏情形运行时间运行,而所使用的比较次数几乎是最优的。虽然归并排序的运行时间是O(NlogN),但是它很难用于主存排序,主要问题在于合并两个排序的表需求线性附加内存,在整个算法中还要花费将数据拷贝到临时数据再拷贝回来这样一些附加的工作,其结果严重放慢了排序的速度。
五. 快速排序
- 基本思想
不断寻找一个序列的枢纽元,然后对枢纽元左右的序列递归的进行排序,直至全部序列排序完成,使用了分治的思想。
选取枢纽元:一种错误的方法是选择第一个元素,可能输入是预排序或者是反序的。三数中值分割法是常用的,使用左端、右端和中心位置上的三个元素的中值作为枢纽元。对于很小的数组(N远远小于20),快速排序不如插入排序好。一种好的截止范围是N=10。 -
源代码
void QSort(ElementType A[], int Left, int Right); ElementType Median3(ElementType A[], int Left, int Right); void QuickSort(ElementType A[], int N) { QSort(A, 0, N-1); } void Swap(int *a, int *b) { int Tmp; Tmp = *a; *a = *b; *b = Tmp; } ElementType Median3(ElementType A[], int Left, int Right) { int Center; Center = (Left + Right) / 2; if( A[Left] > A[Center] ) Swap(&A[Left], &A[Center]); else if ( A[Left] > A[Right] ) Swap(&A[Left], &A[Right]); else if ( A[Center] > A[Center] ) Swap(&A[Center], &A[Center]); Swap(&A[Center], &A[Right-1]); return A[Right-1]; } #define Cutoff (3) void QSort(ElementType A[], int Left, int Right) { ElementType Pivot; int i, j; if( Left + Cutoff <= Right) { Pivot = Median3(A, Left, Right); i = Left; j = Right -1; while(1) { while( A[++i] < Pivot); while( A[--j] > Pivot); if( i < j ) Swap( &A[i], &A[j]); else break; } Swap(&A[i], &A[Right-1]); QSort(A, Left, i-1); QSort(A, i+1, Right); } //小于三个元素则采用插入排序 else InsertionSort(A + Left, Right - Left + 1); }
-
复杂度分析
快速排序是在实践中最快的已知排序算法,它的平均运行时间是O(NlogN)。它的最坏情形的性能是O(N^2)。
总结
对于最一般的内部排序应用程序,选用的方法不是插入、希尔排序,就是快速排序,它们选用主要是根据输入的大小来决定的。高度优化的快速排序算法即使对于很少的输入数据也能和希尔排序一样快。如果需要对一些大型的文件排序,那么快速排序则是应选用的方法。希尔排序有些小缺陷,但可以接受,特别是需要简单明了的时候,希尔排序最坏的情况也只不过是O(N^(4/3)),平均是O(N^(7/6))。堆排序比希尔排序慢,尽管它是一个带有明显紧凑内循环的O(NlogN)算法,因为堆排序为了移动数据要进行两次比较。插入排序只用在小的或是非常接近排好序的输入数据上。而归并排序性对于主存排序不如快速排序那么好,但合并确是外部排序的中心思想。