排序

在处理数据时,经常需要将一组无序序列变得有序,这里就需要用到排序算法。

排序算法

经过几十年的发展,排序算法也有很多种,这里记录一下入门的排序算法,有:冒泡排序、选择排序、插入排序、希尔排序、快速排序、归并排序、堆排序、桶排序、基数排序等。

时间复杂度(T(n)):指算法执行时耗费的时间长度。

空间复杂度(S(n)):   指算法运行时额外占用的空间。一般不算输入,有时也不算必然需要的输出(如排序算法中数组参数被指定const)。

稳定性:当i,j ∈[ 0, n),i<j,有A[i] = A[j],排序之后,A[i]还在A[j]前面,则算法稳定;否则就不稳定。

在分析复杂度时,使用渐进法去分析,并需要得到精确值,只要得到相同数量级的一个值即可。也称大O记法。

若无额外说明,默认使用数组,非降序排序;且无额外说明,使用10万的随机数据做测试。


统一使用的框架

 1 #include <stdio.h>
 2 #include <time.h>
 3 
 4 void Swap(int *a, int *b){
 5     int tmp = *a;
 6     *a = *b;
 7     *b = tmp;
 8 }
 9 
10 int RandomNumber(int max){
11     
12     return rand() % max;
13 }
14 
15 void GetRandom(int* A, int N){
16     int i;
17     
18     for( i=0; i<N; i++)
19         A[i] = RandomNumber(N*10);
20 }
21 
22 void Print(int* A, int N){
23     int i;
24     
25     for( i=0; i<N; i++)
26       printf("%d ", A[i]);
27     printf("
");
28 }
29 
30 int main(void){
31     int N = 10000;
32     int A[N];
33     double start, finish;
34     
35     srand((int)time(NULL));
36     GetRandom( A, N);
37     Print( A, N);
38     
39     start = clock();//取开始时间
40     //某个排序算法
41     finish = clock();//取结束时间
42     
43     Print( A, N);
44     
45     printf( "%f seconds
",(finish - start) / CLOCKS_PER_SEC);//以秒为单位显示之
46     
47     return 0;
48 }
C语言实现

以后仅展示算法相关。


 冒泡排序

最早学习的一种算法。

思路:

将每两个相邻的元素比较,后面比前面小则交换,否则继续向后比较;每一轮比较后就会使最大的一个元素放至最后,此时将规模-1,再次循环比较。

版本A:

1 void bubbleSort_A(int* A, int N){
2     int i, j;
3     
4     for( i=0; i<N; i++)
5       for( j=0; j<N-i-1; j++)
6         if( A[j+1] < A[j] )
7             Swap( &A[j+1], &A[j]);
8 }

这里的复杂度很好分析,两个嵌套的循环,且无任何提前退出,所有最好、最坏都是O(n2)。

我这里测试10万数量级的排序平均耗时:38.144s

反思:

我刚刚提到了提前退出,那什么时候能提前退出呢?比如,如果进来的就是一个有序的序列,版本A还是要扫描n2。我们可以意识到,当扫描一轮之后,如果未做过交换操作,那么其实该序列已经有序且可以退出了。

版本B:

 1 void bubbleSort_B(int* A, int N){
 2     int i, j, sorted=1;
 3     
 4     for( i=0; i<N; i++){
 5       sorted = 1;
 6       for( j=0; j<N-i-1; j++)
 7         if( A[j+1] < A[j] ){
 8             Swap( &A[j+1], &A[j]);
 9             sorted = 0;
10         }
11       if( sorted )
12         break;    
13     }
14 }

可以写得更简洁,但这样写一目了然。理论上说是T(n)应该是得到提高的,但实测O(n)还是在35~40s之间,为什么?因为这样写其实提高也只是比较好(某个时刻,前缀序列已有序)的情况。

版本C:

版本B改进的是整个前缀已有序的情况,还有一种情况,即序列部分有序,如10,9,1,2,3,4,7,6中1~4已有序,再去扫描是浪费时间,如何改进?看版本C。

 1 void bubbleSort_C(int* A, int N){
 2     int i, j, last;
 3     
 4     last = N-1;
 5     for( i=0; i<N; i++){
 6       for( j=0; j<last; j++)
 7         if( A[j+1] < A[j] ){
 8             Swap( &A[j+1], &A[j]);
 9             last = j;
10         }   
11     }
12 }

改进办法就是用一个指针last指向最后一次发生交换的地方,last后面的序列必然已有序。但整体时间复杂度还是O(n2)。

总结:

冒泡排序几十年前已被发明,简单有效。


选择排序

思路:

在未排序的序列中选择一个最大值,放至已排序序列的最前方。

 1 void selectionSort(int* A, int N){
 2     int i, j, max;
 3     
 4     for( i=N-1; i>=0; i--){
 5         max = i;
 6         for( j=0; j<=i; j++)
 7           if( A[max] < A[j] ) 
 8             max = j;
 9         Swap( &A[max], &A[i]);
10     }
11 }

总结:

选择排序依然是O(n2),但是比冒泡排序改进的是,每次循环只要做一次交换,这种交换操作还是比较耗时的,比如我这边选择排序10万的随机数据耗时仅11.952s,但选择排序不管好、坏,都是O(n2)。


 插入排序

思路:

插入排序就像生活中抓拍的过程:一开始手上没牌,直接插入;手上有牌后再抓牌,可以从后向前找一个合适的地方插入该牌。

插入排序就是这样,有序序列规模为k,无序序列规模为n-k,为第k+1个元素寻找位置时,从k到0依次扫描,不合适依次向后依,合适中断并插入。

 1 void insertionSort(int* A, int N){
 2     int i, j, X;
 3     
 4     for( i=0; i<N; i++){
 5         X = A[i];
 6         for( j=i; j>0; j--)
 7           if( X < A[j-1] )
 8             A[j] = A[j-1];
 9           else
10             break;
11         A[j] = X;
12     }
13 }

总结:

理论时间复杂度O(n2),但每次仅需移动元素而不需要进行交换。10万随机数据排序耗时7.711s


希尔排序:

说到希尔排序就要先说明一个概念:逆序对,当i,j ∈[ 0, n),i<j,有A[i] > A[j],则称i、j是一对逆序对。

思想:

冒泡排序因为每次只比较相邻的两个元素,因此一次最多消除一对逆序对。而希尔排序是通过先消除间隔较远的两个逆序对,而此时可能也会同时消除更多的逆序对,通过不断缩小间隔,使得序列逐渐有序。

 1 void shellSort(int* A, int N){
 2     int i, j, D, si, X;
 3     int Sedgewick[] = {929, 505, 209, 109, 41, 19, 5, 1, 0};
 4     
 5     for( si=0; Sedgewick[si]>=N; si++);
 6     for( D=Sedgewick[si]; D>0; D=Sedgewick[++si]){
 7         for( i=D; i<N; i+=D){
 8             X = A[i];
 9             for( j=i; j>=D&&A[j-D]>X; j-=D)
10                 A[j] = A[j-D];
11             A[j] = X;
12         }
13     }
14 }

总结:

希尔排序算法的整体时间复杂度与增量序列的选取有关,目前没有最优的增量序列。在代码中我使用的叫Sedgewick增量序列,形式如4i-3*2i+1,对其复杂度的猜想为平均O(N7/6),最差O(N4/3)。我这里10万的随机数据平均耗时6.127s


快速排序:

真正突破O(n2)的算法,使用了递归的思想。

思想:

先选择一个基准,然后开始扫描序列,比基准大的数放在序列右边,比基准小的值放在基准左边,然后再递归解决左右两个子序列。

版本A:

根据该思想,可以写出第一个版本:左右指针法

 1 int PartSort1(int* A, int left, int right){
 2     int pivot = right;
 3     
 4     while( left < right ){
 5         while( left<right && A[left] <= A[pivot] )
 6           left++;
 7         while( left<right && A[pivot] <= A[right] )
 8           right--;
 9         Swap( &A[left], &A[right]);
10     } 
11     Swap( &A[left], &A[pivot]);
12     return left;
13 }
14 
15 void QSort(int* A, int left, int right){
16     if( right <= left )
17       return;
18     
19     int pos = PartSort1( A, left, right);
20     QSort( A, left, pos-1);
21     QSort( A, pos+1, right);
22 }

使用两个指针,分别指向最左边、最右边,当左边向右边线性扫描,遇到大于基准的值停止;右边指针向左边线性扫描,遇到基于基准的值停止;然后交换两个值的位置,循环做这件事,直到左右指针不合法:左指针到了右指针右边。

版本B:

挖坑法:先保存基准值,然后由左指针向右扫描,遇到大于基准值后停止,将该左指针值放进右指针位置;同理,右指针向左扫描,遇到小于基准值后停止,将该值放进左指针位置;最后将保存的基准值放进左指针位置。

 1 //挖坑法
 2 int PartSort2(int* A, int left, int right){
 3     int pivot = A[right];
 4     while( left < right ){
 5         while( left<right && A[left]<=pivot )
 6           left++;
 7         A[right] = A[left];
 8         while( left<right && pivot<=A[right] )
 9           right--;
10         A[left] = A[right];
11     }
12     A[left] = pivot;
13     
14     return left;
15 } 

版本C:

递归比较消耗内存---栈帧,因此可以设定一个阀值,小于该阀值时调用简单排序。

且基准的选择也可以优化快速排序,其中一种选择方式就是在左、中、右三个数中选一个中位数做基准。

 1 int FindPivot(int* A, int lo, int hi){
 2     int mi = (lo+hi)/2;
 3     if( A[mi] < A[lo] )
 4       Swap( &A[lo], &A[mi]);
 5     if( A[hi] < A[lo] )
 6       Swap( &A[hi], &A[lo]);
 7     if( A[hi] < A[mi] )
 8       Swap( &A[hi], &A[mi]);
 9     Swap( &A[mi], &A[hi-1]);
10     
11     return A[hi-1]; 
12 }
13 
14 //挖坑法
15 int PartSort2(int* A, int left, int right){
16 //    int pivot = A[right];
17     int pivot = FindPivot( A, left, right);
18     right--;
19     while( left < right ){
20         while( left<right && A[left]<=pivot )
21           left++;
22         A[right] = A[left];
23         while( left<right && pivot<=A[right] )
24           right--;
25         A[left] = A[right];
26     }
27     A[left] = pivot;
28     
29     return left;
30 } 
31 
32 void QSort(int* A, int left, int right){
33 //    if( right <= left )
34 //      return;
35     
36     if( 1000 < (right-left+1) ){
37         int pos = PartSort1( A, left, right);
38         QSort( A, left, pos-1);
39         QSort( A, pos+1, right);
40     }
41     else
42         insertionSort( A+left, right-left+1);
43 }

总结:

快速排序时间复杂度仅为O(NlogN),对快速排序进行10万随机数据测试,仅需不到0.01s时间。但快排比较占用空间,可以简单看做空间复杂度为O(logN)。


归并排序:

归并排序也是使用递归实现的,但与快速排序相反,快速排序是选基准、划分左右子序列,归并是一步步分解,分解到最小规模时,再依次向上合并,最后形成有序序列。

思路:

当序列规模较大时,以中间点将序列一分为二,重复此步骤,直到序列可以直接比较得出有序序列(规模为2的时候),然后再逐步归并,将两个规模为2的子序列依序归并为规模为4的更大子序列,最后形成一个规模为n的有序序列。

 1 void merge(int* E, int lo, int mi, int hi){
 2     int LLen, RLen, i, j, k;
 3     LLen = mi - lo + 1; RLen = hi - mi;
 4     int *left, *right = E + mi + 1, *A = E + lo;
 5     left = (int *)malloc(sizeof(int)*LLen);
 6     
 7     for( i=0; i<LLen; i++)
 8       left[i] = A[i];
 9     for( i=0, j=0, k=0; j<LLen && k<RLen; ){
10         if( left[j] <= right[k] )
11           A[i++] = left[j++];
12         else
13           A[i++] = right[k++];
14     }
15     while( j < LLen )
16       A[i++] = left[j++];
17     free(left);
18 }
19 
20 void merge_Sort(int* A, int lo, int hi){
21     if( hi <= lo )
22       return;
23     
24     int mi = (lo+hi) >> 1;
25     merge_Sort( A, lo, mi);
26     merge_Sort( A, mi+1, hi);
27     merge( A, lo, mi, hi);
28 }
29 
30 void mergeSort(int* A, int N){
31     merge_Sort( A, 0, N-1);
32 }

总结:

归并排序带来的时间复杂度也是O(NlogN),但是归并需要一个O(N)的空间复杂度,当数据量极大的时候不能被接受,所以一般内部排序不会使用归并,但是归并在外部排序场合使用很多。10万随机数据排序平均为0.03s


推荐一个带有动画演示的排序算法传送门:https://www.cnblogs.com/onepixel/articles/7674659.html

原文地址:https://www.cnblogs.com/EasonDongH/p/9573116.html