数据结构之排序

304 阅读2分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第26天,点击查看活动详情

数据结构之排序

排序的定义

       排序,就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。为了查找方便,通常希望计算机中的表是按关键字有序的。排序的确切定义如下:

       输入:n个记录Rt, R…,Rn,对应的关键字为k, k,…",kn

       输出:输入序列的一个重排 R',R2'…",R',使得k'≤k'<…≤k(其中“≤”可以换成其他的比较大小的符号)。

       在排序过程中,根据数据元素是否完全在内存中,可将排序算法分为两类:①内部排序,是指在排序期间元素全部存放在内存中的排序:②外部排序,是指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动的排序。

       一般情况下,内部排序算法在执行过程中都要进行两种操作:比较和移动。通过比较两个关键字的大小,确定对应元素的前后关系,然后通过移动元素以达到有序。当然,并非所有的内部排序算法都要基于比较操作,事实上,基数排序就不基于比较。

       每种排序算法都有各自的优缺点,适合在不同的环境下使用,就其全面性能而言,很难提出一种被认为是最好的算法。通常可以将排序算法分为插入排序、交换排序、选择排序、归并排序和基数排序五大类,后面几节会分别进行详细介绍。内部排序算法的性能取决于算法的时间复杂度和空间复杂度,而时间复杂度一般是由比较和移动的次数决定的。

1.插入排序

       插入排序是一种简单直观的排序方法,其基本思想是每次将一个待排序的记录按其关键字大小插入前面已排好序的子序列,直到全部记录插入完成。由插入排序的思想可以引申出三个重要的排序算法:直接插入排序、折半插入排序和希尔排序。

1.1 直接插入排序

       根据上面的插入排序思想,不难得出一种最简单也最直观的直接插入排序算法。假设在排序过程中,待排序表L[1..n]在某次排序过程中的某一时刻状态如下:

有序序列L[ 1...i-1]L(i)无序序列L[i+1...n]

       要将元素L(i)插入已有序的子序列L[ 1...i-1],需要执行以下操作(为避免混淆,下面用L[]表示一个表,而用L()表示一个元素):

       1)查找出L(i)在L[ 1...i-1]中的插入位置k。

       2)将L[ k...i-1]中的所有元素依次后移一个位置。

       3)将L(i)复制到L(k)。

       为了实现对L[ 1...n ]的排序,可以将L(2)~(n)依次插入前面已排好序的子序列,初始工[1]可以视为是一个已排好序的子序列。上述操作执行n-1次就能得到一个有序的表。插入排序在实现上通常采用就地排序(空间复杂度为O(1)),因而在从后向前的比较过程中,需要反复把已排序元素逐步向后挪位,为新元素提供插入空间。 下面是直接插入排序的代码。

void InsertSort(ElemType A[],int n){
    int i,j;
    for(i=2;i<=n;i++){
        if(A[i]<A[i-1]){
            A[0]=A[i];
            for(j=i-1;A[0]<A[j];j--){
                A[j+1]=A[j];
            }
            A[j+1]=A[0];
        }
    }
}

直接插入排序算法的性能分析如下:

       空间效率:仅使用了常数个辅助单元,因而空间复杂度为O(1)。

       时间效率:在排序过程中,向有序子表中逐个地插入元素的操作进行了n-1趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态。

       在最好情况下,表中元素已经有序,此时每插入一个元素,都只需比较一次而不用移动元素,因而时间复杂度为O(n)。

       在最坏情况下,表中元素顺序刚好与排序结果中的元素顺序相反(逆序)·总的比较次数达到最大,为 i=1ni\sum_{i=1}^{n}i,总的移动次数也达到最大,为i=1n(i+1)\sum_{i=1}^{n}(i+1)

       平均情况下,考虑待排序表中元素是随机的,此时可以取上述最好与最坏情况的平均值作为平均情况下的时间复杂度,总的比较次数与总的移动次数均约为n^2/4。

       因此,直接插入排序算法的时间复杂度为O(n2)。

       稳定性:由于每次插入元素时总是从后向前先比较再移动,所以不会出现相同元素相对位置发生变化的情况,即直接插入排序是一个稳定的排序方法。

       适用性:直接插入排序算法适用于顺序存储和链式存储的线性表。为链式存储时,可以从前往后查找指定元素的位置。

1.2 折半插入排序

       从直接插入排序算法中,不难看出每趟插入的过程中都进行了两项工作:①从前面的有序子表中查找出待插入元素应该被插入的位置;②给插入位置腾出空间,将待插入元素复制到表中的插入位置。注意到在该算法中,总是边比较边移动元素。下面将比较和移动操作分离,即先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素。当排序表为顺序表时,可以对直接插入排序算法做如下改进:由于是顺序存储的线性表,所以查找有序子表时可以用折半查找来实现。确定待插入位置后,就可统一地向后移动元素。算法代码如下:

void InsertSort(ElemType A[],int n){
    int i,j,low.high,mid;
    for(i=2;i<=n;i++){
        A[0]=A[i];
        low=1;high=i-1;
        while(low<=high){
            mid=(low+high)/2;
            if(A[mid]>A[0]) high=mid-1;
            else low=mid+1;
        }
        for(j=i-1;j>=high+1;j--){
            A[j+1]=A[j];
        }
        A[high+1]=A[0];
    }
}

       从上述算法中,不难看出折半插入排序仅减少了比较元素的次数,约为O(nlogan),该比较次数与待排序表的初始状态无关,仅取决于表中的元素个数n;而元素的移动次数并未改变,它依赖于待排序表的初始状态。因此,折半插入排序的时间复杂度仍为O(n),但对于数据量不很大的排序表,折半插入排序往往能表现出很好的性能。折半插入排序是一种稳定的排序方法。

1.3希尔排序

       希尔排序的基本思想是:先将待排序表分割成若干形如L[i,i+d,i+2d,i+kd]的“特殊”子表,即把相隔某个“增量”的记录组成一个子表,对各个子表分别进行直接插入排序,当整个表中的元素已呈“基本有序”时,再对全体记录进行一次直接插入排序。

       希尔排序的过程如下:先取一个小于n的步长d1,把表中的全部记录分成d1组,所有距离为d1的倍数的记录放在同一组,在各组内进行直接插入排序;然后取第二个步长d2<d1,重复上述过程,直到所取到的dt=1,即所有记录已放在同一组中,再进行直接插入排序,由于此时已经具有较好的局部有序性,故可以很快得到最终结果。

希尔排序算法的代码如下:

void shellSort (ElemType A[],int n){
    for(dk=n/2;dk>-1;dk=dk/2)
        for(i=dk+l;i<=n;++i)
            if(A[i]<A[i-dk]){
                A[0]=A[i];
            for(j=i-dk;j>0&&A[0]<A[j];j-=dk)
                A[j+dk] =A[j];
            A[j+dk]=A[0];
}

希尔排序算法的性能分析如下:

       空间效率:仅使用了常数个辅助单元,因而空间复杂度为O(1)。

       时间效率:由于希尔排序的时间复杂度依赖于增量序列的函数,这涉及数学上尚未解决的难题,所以其时间复杂度分析比较困难。当n在某个特定范围时,希尔排序的时间复杂度约为O(n1.3)。在最坏情况下希尔排序的时间复杂度为O(n2)。

       稳定性:当相同关键字的记录被划分到不同的子表时,可能会改变它们之间的相互次序,因此希尔排序是一种不稳定的排序方法。

       适用性:希尔排序算法仅适用于线性表为顺序存储的情况。