排序
在处理数据时,经常需要将一组无序序列变得有序,这里就需要用到排序算法。
排序算法
经过几十年的发展,排序算法也有很多种,这里记录一下入门的排序算法,有:冒泡排序、选择排序、插入排序、希尔排序、快速排序、归并排序、堆排序、桶排序、基数排序等。
时间复杂度(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("\n"); 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\n",(finish - start) / CLOCKS_PER_SEC);//以秒为单位显示之 46 47 return 0; 48 }
以后仅展示算法相关。
冒泡排序
最早学习的一种算法。
思路:
将每两个相邻的元素比较,后面比前面小则交换,否则继续向后比较;每一轮比较后就会使最大的一个元素放至最后,此时将规模-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