zoukankan      html  css  js  c++  java
  • 排序

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

    排序算法

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

    时间复杂度(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

  • 相关阅读:
    SSRF——和远程文件包含的区别在于远程文件包含多是包含攻击者的主机文件,SSRF是内网主机的文件
    SSRF中的绕过手段——字节总结得比较好,如何绕过SSRF的保护机制,DNS欺骗,使用IPV6地址,十六进制编码、八进制编码、双字编码、URL编码和混合编码等
    SSRF——服务端请求伪造,根因是file_get_contents,fsockopen,curl_exec函数调用,类似远程文件包含,不过是内网机器
    文件包含——本地文件包含和远程文件包含
    文件包含和目录遍历区别——目标都是信息泄露,但手段一个是利用函数来包含web目录以外的文件,另外一个是对web路径访问权限设置不严格导致
    DFS——单词分割,原来还是要使用cached dp才能避免不超时
    模块module间引用和使用本地maven库的jar包
    机器学习西瓜书白话解读笔记---0401-0404、信息和熵的度量
    机器学习实战笔记---0、读大纲
    心得体悟帖---201129(【社会规律】)
  • 原文地址:https://www.cnblogs.com/EasonDongH/p/9573116.html
Copyright © 2011-2022 走看看