经典排序算法总结(C实现)

一 排序算法介绍

1.0 排序的概述

在计算机计算和处理加工数据时,经常会直接或间接地涉及到数据的排序问题。可以简单地将排序操作理解为:将一个按值无序的数据序列转换成为一个按值有序的数据序列的过程。例如,将一个无序的数组 A[5] = {7, 5, 8, 2, 1} 排列成有序的数组 A[5] = {1, 2, 5, 7, 8} 或是 A[5] = {8, 7, 5, 2, 1}。

对于文件而言,排序可以理解为:根据文件记录的关键字值的递增或递减关系将文件记录的次序重新排列的过程。排序后的文件记录一定是按关键字值有序排列的。例如,最开始从磁盘中读出的文件,如下图所示:

无序的文件

我们将文件读入内存中后,将文件记录按关键字值的顺序进行调整,然后将排序后的文件重新再写回到磁盘,那么以后该文件就是有序的了。按关键字值递增的顺序排列后的文件内容,如下图所示:

有序的文件

常见的排序算法总结:

冒泡排序(BubbleSort)

快速排序(QuickSort)

选择排序(SelectSort)

插入排序(InsertSort):直接插入排序(DirectInsertSort)、折半插入排序(BinaryInsertSort)、希尔排序(ShellSort)

堆排序(HeapSort)

归并排序(MergeSort)

基数排序(RadixSort)

  • 常见排序算法归类,如下图所示:

<Tips> 我们只要掌握了每一种排序算法的基本思想,就很容易将它应用到实际的工作中。

1.1 冒泡排序(bubbleSort)

冒泡排序顾名思义就是整个过程像水中的气泡一样往上升,水底的气泡都是很小的,随着气泡不断往上升,气泡就会越来越大。它是一类具有“交换”性质的排序算法。

算法特性:stable sort(稳定排序)、In-place sort(就地排序)。

稳定性的理解:是指所有相等的元素经过某种排序算法后,仍能保持它们之前的相对次序不变,不会改变相同元素的相对顺序。

就地排序的理解:就是在待排序序列的内存空间中直接进行排序过程,不需要借助额外的辅助空间,一般其空间复杂度为O(1)。

算法思想:通过相邻元素之间两两比较大小并交换,使较小的元素逐步从序列的后端移到序列的前端,使较大的元素从序列的前端移到后端。这就像水底的气泡不断往上“冒”一样,因此人们形象地称这种排序算法为“冒泡排序”法。

排序过程:以数组序列 {3, 6, 4, 2, 11, 10, 6} 为例,分析冒泡排序的过程,如下图所示:

冒泡排序过程

算法分析:一个包含 n 个元素的序列要进行 n-1 趟冒泡排序,第 i 趟冒泡排序,需要两两比较(n-i)次,将第(1~n-i+1)个元素中最大的元素交换到第(n-i+1)个位置上。

算法实现:冒泡排序算法的代码描述如下:

//冒泡排序(升序)
void bubbleSort(int a[],int n)
{
    int i,j;  //i表示趟数,j表示第i趟两两比较的次数
    int tmp; //临时变量
    for(i=0;i<n-1;i++)           //一共需要执行(n-1)趟冒泡排序
        for(j=0;j<n-(i+1);j++)   //第i趟需要两两比较(n-i)次,这里的i是从1开始算起
        {
            if(a[j] > a[j+1])    //数据交换
            {
                tmp=a[j];
                a[j]=a[j+1];
                a[j+1]=tmp;
            }
        }
}

<说明> 因为数组的下标是从0开始的,所以我们的累加变量 i, j 也是从0开始计算次数的,所以两个for循环的条件表达式是没有等号的,这点需要特别注意一下。

算法改进:上图的冒泡排序过程中,可以发现从第4趟排序开始,序列本身就已经是有序的了,那么后面几趟的冒泡排序过程完全可以不用进行的。不难理解,如果某一趟排序过程中只有元素之间的比较操作,而没有发生元素的位置交换,那就说明到本趟排序为止,序列中的元素已经是按值有序的了,因此不需要再进行下一趟的排序了,排序可以提前结束了。认识到这一点,就可以将前面的冒泡排序算法加以改进,使它的排序效率更高。

在代码中,可以设置一个标志变量flag。规定当flag=1时,说明本趟排序中仍有元素交换的动作,因此还需要进行下一趟的冒泡排序。当flag=0时,说明本趟排序中已经没有了元素交换,只有元素的比较,因此表明该序列已经按值有序,排序过程可以提前停止。

改进后的冒泡排序算法,代码描述如下:

//改进后的冒泡排序(升序)
void bubbleSort2(int a[],int n)
{
    int i,j;  //i表示趟数,j表示第i趟两两比较的次数
    int tmp; //临时变量
    int flag=1;  //元素交换标志变量
    for(i=0; i<n-1 && flag==1; i++)    //一共需要执行(n-1)趟冒泡排序
    {
        flag = 0;
        for(j=0;j<n-(i+1);j++)   //第i趟需要两两比较(n-i)次,这里的i是从1开始算起
        {
            if(a[j] > a[j+1])    //数据交换
            {
                tmp=a[j];
                a[j]=a[j+1];
                a[j+1]=tmp;
                flag = 1;        //发生数据交换flag置1
            }
        }
    }
}

复杂度分析

  • 时间复杂度

最好的情况,也就是待排序的序列本身就已经是有序的了,那么只需要一趟冒泡排序,进行(n-1)次比较,没有数据交换动作,时间复杂度为O(n)

最坏的情况,即待排序的序列是逆序的情况,此时需要(n-1)趟冒泡排序,需要进行比较的次数为:(n-1)+(n-2)+...+2+1=n(n-1)/2次,并做等数量级的数据交换动作,因此总的时间复杂度为 O(n^2)

  • 空间复杂度:O(1)

1.2 快速排序(quickSort)

快速排序(quick sort)是由 C.A.R Horse(中文名:东尼·霍尔) 提出的一种排序算法,它是冒泡排序的一种改进算法。由于快速排序算法元素之间的比较次数较少,速度较快,因而得名快速排序。在各种内部排序算法中,快速排序算法被认为是目前最好的一种排序算法。

算法特性:unstable sort(不稳定排序)、In-place sort(就地排序)。

算法思想:在待排序序列(k1, k2, ... kn)中任意选取一个元素,把该元素作为基准元素,把<=基准元素的所有元素都移到其左边,把>=基准元素的所有元素都移到其右边,这样使得基准元素所处的位置恰好就是排序后的最终位置。并且把当前待排序的序列划分成了前后两个子序列。其中,左边的子序列中的元素都<=基准元素,右边的子序列中的元素都>=基准元素,这样就完成了一次快速排序过程,或称为快速排序的一次划分。

接下来分别对这两个子序列重复上述的排序操作(如果子序列的长度大于1的话),直到所有元素都被移动到排序后它们应处的最终位置上。

快速排序算法之所以效率高,是因为每一次元素的移动都是跳跃式的,不像冒泡排序那样只能在相邻元素之间进行交换,元素移动的间隔距离较大,因此总的比较和移动次数就减少了,排序的速度自然也就提高了。

在排序的过程中,每次按照基准元素将原序列划分为前后两个子序列的过程称为一次划分操作。

排序过程:一次划分操作的过程如下:

假设原序列为:{5, 7, 4, 2, 11, 10, 6}。

首先设置两个游标变量 i 和 j。i=1, 指向元素5;j=7, 指向元素6。设定基准元素为 i 指向的元素5,如下图所示:

序列的初始状态

(1)反复执行 i=i+1 操作,直到 i 指向的元素 >= 基准元素5,或者 i 指向序列尾部,即 i=7为止。然后反复执行 j=j-1 操作,直到 j 指向的元素 <= 基准元素5,或者 j 指向序列的首部,即 j=1为止。按上述操作执行完毕后,序列的状态如下图所示:

步骤(1)后序列的状态

(2)若此时 i<j, 则将 i 与 j 指向的元素进行交换,如下图所示:

实现第1次元素交换

完成了第1次交换后,然后重复执行步骤(1)、(2),直到 i >=j 为止,再执行步骤(3)。上述过程执行完毕,序列的状态如下图所示:

执行步骤(2)后序列的状态

(3)此时,i>=j, 然后将基准元素与 j 指向的元素交换位置,如下图所示:

完成序列的第一次划分

至此完成了原序列的第1次划分,此时序列中基准元素5已移至排序的最终位置,基准元素的前半序列都小于5,后半序列都大于5。

接下来分别对基站元素5前后两部分子序列(长度大于1的子序列)重复执行上述划分操作,直到整个序列排列有序。

算法分析:序列的每一次划分操作都是按照:步骤1—>步骤2—>步骤1—>步骤2......步骤2—>步骤1—>步骤3—>结束,这样的过程进行的。也就是说,只有当 i>=j 时,才将本次划分的基准元素放置到它最终的位置上。对于本例来说,本次划分的基准元素5,在它进行一次划分完成后,最终位置就是位于序列的第3个位置上。

进行完序列的一次划分后,再对基准元素5前后的两个子序列分别重复进行上述操作。对于每个子序列的操作又是一次划分的过程,因此这个算法具有递归的特性。每次划分过程的基准元素仍可设定为该子序列的第一个元素。

算法实现:快速排序的递归算法,代码描述如下:

void swap(int *a, int *b)
{
    int tmp;
    tmp = *a;
    *a = *b;
    *b = tmp;
}

//快速排序(升序)
void quickSort(int a[],int left,int right)
{
    int low,high;   //low,high分别为左右端游标指针
    if(left<right)
    {
        low=left;
        high=right+1;
        while(1)
        {
            //将序列左端点的元素a[left]设定为基准元素
            do{
                low++;
            }
            while(!(a[low]>=a[left] || low==right)); //如果<=基准数,右移,否则停止移动
            
            do{
                high--;
            }
            while(!(a[high]<=a[left] || high==left)); //如果>=基准数,左移,否则停止移动
            
            if(low < high)  //若此时low<high,则交换两游标指向的元素
                swap(&a[low], &a[high]);
            else            //若low>=high,则退出循环
                break;
        }
        //如果low>=high,将基准元素与high指向的元素交换位置
        swap(&a[left], &a[high]);
        //基准数左边子序列进行递归
        quickSort(a,left,high-1);
        //基准数右边子序列进行递归
        quickSort(a,high+1,right);
    }
}

测试用例

int main()
{
    int i,n;
    int a[30];
    printf("输入n= ");
    scanf("%d",&n);
    printf("输入数组元素: ");
    for(i=0;i<n;i++)
        scanf("%d",&a[i]);
    printf("排序前数组: ");
    for(i=0;i<n;i++)
        printf("%-4d",a[i]);
    quickSort(a,0,n-1);
    printf("\n排序后数组: ");
    for(i=0;i<n;i++)
        printf("%-4d",a[i]);
    return 0;
}

示例运行结果:

>a.exe
输入n= 10
输入数组元素: 2 5 6 3 7 8 0 9 12 1
排序前数组: 2   5   6   3   7   8   0   9   12  1
排序后数组: 0   1   2   3   5   6   7   8   9   12

适用场景:由于快速排序算法特性的约束,快速排序算法一般只适用于顺序表线性结构或数组序列的排序,它并不适合于在链表结构上实现排序。

复杂度分析

  • 时间复杂度

(1)最坏时间复杂度:每次区间划分的结果都是基准元素的左边(或右边)为空,而另一边区间中的元素仅比排序前序列少了一项,即说明选择的基准元素是待排序序列中的最小值或是最大值。最坏情况下快速排序算法的时间复杂度为 O(n^2)

(2)最好时间复杂度:每次区间划分的结果都是基准元素的左右两边的子序列长度相等或者相差为1,即选择的基准元素是待排序序列的中间值。此时进行比较的次数总共为 nlog2^n,所以最好的情况下快速排序算法的时间复杂度为 O(nlog2^n)

(3)平均时间复杂度:快速排序算法的平均时间复杂度为 O(nlog2^n)。其在所有平均时间复杂度为O(nlog2^n)的排序算法中,快速排序算法的平均性能是最好的。

  • 空间复杂度

快速排序的过程需要一个栈空间来实现递归。最好情况下,递归的深度为 log2^n,其空间复杂度也就是 O(nlog2^n);最坏情况下,需要进行(n-1)次递归,其空间复杂度为O(n);平均情况下,空间复杂度为 O(nlog2^n)

基准元素的选取:基准元素的选取是决定快速排序算法的关键,常用的基准元素的选取方式如下:

(1)三者取中。将序列的首尾和中间位置上元素进行比较,选择三者中值元素作为基准元素。m(left < m < right)

(2)取left~right 之间的一个随机元素,用 A[m] 作为基准元素。上面的快速排序算法的递归实现代码中,采用的是将待排序序列的第1个元素作为基准元素进行快速排序的。

1.3 选择排序(selectSort)

选择排序(select sort)是一种简单直观的排序算法。

算法特性:unstable sort,In-place sort。

算法思想:首先从待排序序列中找到最小元素,存放到序列的起始位置,然后再从剩余的待排序元素中继续寻找最小元素,然后放到未排序序列的的起始位置,以此类推,直到所有元素均排序完毕。

假设有n个元素的序列,

第1趟排序,需要从n个元素中找到一个最小的元素存放到序列的第1个位置上。

第2趟排序,需要从(n-1)个元素中找到一个最小的元素存放到序列的第2个位置上。

第i趟排序,需要从(n-i+1)个元素中找到一个最小的元素存放到序列的第i个位置上。

排序过程:假设有一个数据元素序列为 {3, 6, 4, 2, 11, 10, 6}。该序列的选择排序过程如下图所示:

选择排序过程

算法分析:n 个元素的序列,需要进行(n-1)趟选择排序过程,初始状态假定首元素就是我们要找的最小元素,然后通过打擂法从余下的元素中找到真正的最小元素,并将其移到已排好序的子序列中。选择排序算法的每一趟都是按照“选择-交换”方法进行的。不难看出,选择排序的第 i 趟排序就是从序列的后(n-i+1)(i = 1,2,3,...,n-1)个元素中选择一个最小的元素,并与第 i 个位置上的元素进行交换的过程。

算法实现:选择排序算法的代码描述如下:

//选择排序算法(升序)
void selectSort(int *arr,int n)
{
    int i,j,min;
    int tmp;

    for(i=0;i<n-1;i++)
    {
        min=i;  //开始一趟选择排序,假定第i个元素是后面(n-i+1)个未排序的元素中最小的元素
        for(j=i+1;j<n;j++)
        {
            if(arr[min] > arr[j]) //如果发现比当前最小元素还小的元素,则更新记录最小元素的下标
                min=j;
        }
        //如果最小元素的下标不是后面(n-i+1)的未排序序列的第一个元素,则需要交换第i个元素和后面找到的最小元素的位置
        if(min != i)
        {
            tmp=arr[min];
            arr[min]=arr[i];
            arr[i]=tmp;
        }
    }
}

复杂度分析

  • 时间复杂度

选择排序算法最大的特点是交换移动次数相当少,这样就节约了时间。分析它的时间复杂度可以发现,无论是最好最差情况,其比较次数都是一样多,第 i 趟排序需要进行(n-i)次大小比较,因此总共需要比较的次数为: (n-1)+(n-2)+...+2+1=n(n-1)/2。对于交换次数而言,最好的情况下,交换0次,即序列本身就是有序的;最差的情况下,交换次数为(n-1)次,即序列本身是逆序的。基于最终的比较次数与交换次数总和,总的时间复杂度依然为 O(n^2),尽管选择排序的时间复杂度与冒泡排序的时间复杂度同为 O(n^2),当选择排序的性能要优于冒泡排序。

  • 空间复杂度

选择排序的空间复杂度和冒泡排序是一样的,都是 O(1)

1.4 插入排序(InsertSort)

插入排序算法有好几种,这里主要讲3种插入排序算法:直接插入排序(direct insert sort)、折半插入排序(binary insert sort) 和 希尔排序(shell sort)。

1.4.1 直接插入排序(directInsertSort)

算法特性:stable sort、In-place sort

算法思想:每一趟将待排序序列中的元素插入到前面已经排好序的序列中的合适位置,使前面的序列依然有序,直到待排序列的元素全部插入完为止。

算法过程:有一个元素序列 {3, 6, 4, 2, 11, 10, 6`},其中6` 表示该元素与本序列中其他元素有重复,在这里加以区分。直接插入排序过程,如下图所示:

直接插入排序过程

算法分析:一个包含有 n 个元素的序列,需要 (n-1) 趟的直接插入排序就可以将原序列排列有序。

第 i 趟插入排序,将原序列中的第(i+1)个元素 A[i+1] 插入到一个已经按值有序的子序列(A[1], A[2], ..., A[k])中的合适位置,使得插入后的序列仍然是按值有序的。

算法实现:直接插入算法的代码描述如下:

//直接插入排序(升序)
void directInsertSort(int a[], int n)
{
    int i,j,tmp;
    //初始将数组第1个元素a[0]作为有序序列的第1个元素,从第2个元素开始往该有序序列中插入待排序元素
    for(i=1;i<n;i++){
        tmp = a[i];         //将待插入元素a[i]保存到临时变量tmp中
        for(j=i-1; j>=0 && tmp<a[j]; j--)
            a[j+1]=a[j];   //如果待排序元素小于有序序列中的元素,将当前元素后移一位
        a[j+1]=tmp;        //找到插入位置,将待排序数插入指定位置
    }
}

 适用场景:直接插入排序比较适合用于只有“少量元素”的序列排序。

复杂度分析

  • 时间复杂度

最好时间复杂度:当序列本身是有序的时候,时间复杂度为O(n),此时不需要移动有序序列中的元素位置,只会进行(n-1)次元素比较。

最坏时间复杂度:当序列本身是逆序的时候,时间复杂读为O(n^2),比较次数:n(n-1)/2;移动次数:n(n-1)/2。

平均时间复杂度:O(n^2)

  • 空间复杂度:只需要一个记录的辅助空间,因此其空间复杂度为 O(1)

1.4.2 折半插入排序(binaryInsertSort)

折半插入排序(binary insert sort)是对直接插入排序的一种改进排序算法。改进的部分则是利用“折半查找”来加快寻找插入点的速度,而最终移动元素的次数还是一样的。

算法特性:stable sort、In-place sort

算法思想:折半插入排序原理与直接插入排序是一样的,区别就在于将待排元素插入到已排好序的子序列时,采用的是折半查找(也叫二分查找)的方法,找到待排元素插入的位置,然后移动有序子序列的元素,最后将待排元素插入到有序子序列的正确位置,保持子序列依然按值有序。

取有序子序列的中间元素作为监视哨,将待插入元素与监视哨作大小比较,如果比监视哨的值小,则插入位置在监视哨的前半部分,否则插入位置在后半部分,依次不断缩小范围,直到找到要插入的准确位置。

算法过程:先将序列中的第1个元素看成是一个有序的子序列,然后从第2个元素起逐个进行查找插入,直至整个序列变成按值有序的序列为止。

假设对有 n 个元素的序列进行折半插入排序,前 i 个元素已经有序,现在要将第 (i+1) 个元素插入到有序序列中。折半插入排序需要做两步工作:找到待插入元素的位置、执行插入操作。

插入排序示意图

第1步:首先,定义两个游标 low 和 high,用于寻找 a[i] 的插入位置。low 初始指向有序序列的a[0],high 指向有序序列的a[i-1],中点游标 mid = (low + high) / 2,指向有序序列的a[mid]元素。

“折半”示意图

比较a[i] 与 a[mid] 的大小,若 a[i] > a[mid],说明 a[i] 的位置应该在 mid~high之间,将查找区间[low, high] 缩短为 [mid+1, high],令 low=mid+1; 若 a[i] <= a[mid],说明a[i]的位置在 low~mid 之间,将查找区间缩短为[low, mid-1],令 high=mid-1。每次折半之后,a[i]的插入位置都应该在 [low, high]之间,如此循环,直到 low>high 时跳出循环,此时,a[i]的插入位置找到,游标low指向的位置即为 a[i] 应该插入的位置。

第2步:找到了元素a[i]的插入位置为low后,将有序序列[low, i-1] 区间内的元素整体后移一位,最后将元素 a[i] 插入到 游标low 指向的位置。

完成以上两步,就完成了一趟折半插入排序过程,如此循环,直到所有元素都插入到有序序列后,整个排序过程就结束了。

示例图解:有一个待排序列 {5, 2, 6, 0, 9},折半插入排序过程如下图所示:

折半插入排序过程

算法分析:n 个元素的序列,需要进行(n-1)趟折半插入排序的操作。

算法实现:折半插入排序算法,代码描述如下:

//折半插入排序(升序)
void binaryInsertSort(int L[],int n)
{
    int i,j;
    int low,high,mid;
    //L[0..i-1]为有序序列
    for(i=1;i<n;i++)
    {
        low=0;                 //low初始指向有序子序列的首元素
        high=i-1;              //high初始指向有序子序列的尾元素
        while(low<=high)       //当low>high时,跳出while循环
        {
            mid=(low+high)/2;  //获取有序序列的折半位置,a[mid]为监视哨
            if(L[i]<=L[mid])    //如果待插入元素L[i]<=L[mid],插入低半区
                high=mid-1;
            else               //如果待插入元素L[i]>L[mid],插入高半区
                low=mid+1;
        }                      //while循环结束,low就是L[i]应该放置的位置
        
        int tmp = L[i];        //待排序元素暂存到临时变量
        for(j=i-1;j>low;j--)   //将[low,i-1]区间元素整体向后平移一位
            L[j+1]=L[j];
        L[low]=tmp;           //插入待排序元素L[i]到有序序列中
    }
}

<特别提醒> 由于C语言数组的下标是从0开始的,因此当我们说序列的第i个元素的时候,其对应的元素为a[i-1],而不是a[i],所以不要弄混淆了。

复杂度分析

  • 时间复杂度:折半插入排序相较于直接插入排序,仅减少了元素间的比较次数,而元素的移动次数不变。因此,折半插入排序的时间复杂度仍为 O(n^2)
  • 空间复杂度:同直接插入排序一样,也是 O(1)

1.4.3 希尔排序(ShellSort)

希尔排序(Shell Sort)又称为“缩小增量排序”(Diminishing Increment Sort),是一个叫希尔的人于1959年提出的一种排序算法。希尔排序同样是对直接插入排序进行改进的一种插入排序算法,在时间效率上较前面两种插入排序算法有较大的改进。

算法特性:unstable sort、In-place sort

算法思想:首先设定一个元素间隔增量gap,将整个待排序列按这个增量数gap分割成若干个子序列,然后分别对子序列进行直接插入排序,然后依次缩减增量大小,再进行排序,直到增量为1时,再对序列的全体元素进行一次直接插入排序,排序结束。

**增量gap的范围:1<= gap< 待排序数组的长度 (gap需为 int 值)
**增量的取值:一般初始取序列(数组)的一半为增量,以后每次减半,直到增量变为1。
第一个增量=数组的长度/2,
第二个增量= 第一个增量/2,
第三个增量=第二个增量/2,
以此类推,最后一个增量=1。

算法过程:假设某个待排序列有 n 个元素,首先令间隔增量gap=n / 2,那么这个序列就被划分为gap个子序列,然后对每个子序列进行直接插入排序,排序完成后,缩小间隔增量值,令gap=gap / 2,继续对重新划分的子序列进行直接插入排序,当间隔增量gap=1时,此时待排序列已经“基本有序”,最后对待排序列的全体元素进行一次直接插入排序,排序完成。

示例图解:有一个待排序列 {8, 9, 1, 7, 2, 3, 5, 4, 6, 0},希尔排序过程如下图所示:

待排序列初始状态
希尔排序过程

算法分析:希尔排序算法对直接插入排序的改进,主要是从两个方面进行的:一方面,通过划分多个子序列,减少待排序列的长度,然后再对子序列进行直接插入排序,这是因为在n值很小的时候,直接插入排序算法的效率会比较高;另一方面,当待排序列为正序时,直接插入排序的时间复杂度可以提高到O(n),所以应该先尽可能让待排序列变为有序,然后再进行一次直接插入排序,就可以大大提高排序效率。

算法实现:希尔排序算法,代码描述如下:

//希尔排序(升序)
void shellSort(int s[], int n)
{
    int i,j,d;
    d = n/2;      //确定间隔增量值,初始增量值=n/2(n为元素个数)
    while(d>=1){
        for(i=d; i<n; i++)  //数组下标从(d+1)开始进行直接插入排序
        {
            int tmp = s[i];   //将待排元素暂存临时变量
            j = i-d;          //在当前有序子序列中找到最右边的元素位置
            while(j>0 && tmp<s[j]){
                s[j+d] = s[j];  //元素后移
                j-=d;          //游标向左移d个位置
            }
            s[j+d]=tmp;        //在确定的位置插入s[i]
        }
        d = d / 2;              //间隔增量变为原来的一半
    }
}

复杂度分析

  • 时间复杂度:希尔算法的性能与所选取的间隔增量(分组长度)序列有很大关系。只对特定的待排序记录序列,可以准确地估算比较次数和移动次数。想要弄清比较次数和记录移动次数与增量选取之间的数学关系,并给出完整的数学分析,至今仍然是数学难题。上面的代码实现中的增量序列是按折半方式处理的,也许不是最佳的选取方法。

最好时间复杂度:待排序列是正序,在这种情况下,需要进行比较操作需要(n-1)次,移动操作为0次,时间复杂度为O(n)。

最坏时间复杂度:待排序列是逆序,在这种情况下,时间复杂度为 O(n^2)。

平均时间复杂度:O(nlog2^n)

  • 空间复杂度:排序过程需要用到一个临时变量用作辅助空间,因此其空间复杂度为 O(1)

1.4.4 三种插入排序算法的比较

  • 排序性能比较(时间复杂度):希尔排序 > 折半插入排序 > 直接插入排序。
  • 排序空间复杂度:三种插入排序算法的空间复杂度均为 O(1)。
  • 排序稳定性:直接插入排序和折半插入排序都是稳定的,而希尔排序是不稳定的。
  • 三种排序算法使用场景:三种插入排序算法适合用在数据量比较小的排序场合下。

1.5 堆排序(heapSort)

算法特性:unstable sort,In-place sort。

堆排序(heap sort)是一种特殊形式的选择排序,它是简单选择排序的一种改进排序算法。首先我们需要了解一下什么是堆(Heap),进而再介绍堆排序算法。

堆的定义

 满足条件1的堆称为小顶堆,满足条件2的堆称为大顶堆。下面讨论的堆排序全是基于大顶堆的。

如果将堆序列中的元素存放在一棵完全二叉树中,数据从上至下,从左至右地按层来存放,那么堆可以与一棵完全二叉树相对应。例如,一个堆序列{49, 22, 40, 20, 18, 36, 6, 12, 17},其对应的完全二叉树如下图所示:

堆序列的完全二叉树

 上图为一个大顶堆的完全二叉树表示,满足堆定义中的条件2。二叉树的根节点为大顶堆的第1个元素,因此该值最大。同时我们也可以看到在大顶堆对应的完全二叉树中,每个分支结点的值均大于或等于其左右子树(如果存在的话)中所有结点的值。

算法思想:基于大顶堆的完全二叉树表示,堆排序的核心思想可描述如下:

(1)将原始序列构造成一个堆序列。(建立初始堆)

(2)交换堆的第一个元素和堆的最后一个元素的位置。即,将堆顶元素(最大值元素)置于堆的最末位置。

(3)将移走最大元素之后剩余元素所构成的序列再转换为一个堆。

(4)重复上述(2)、(3)步骤 (n-1) 次。

经过上述操作,就可将一个无序的序列按升序方式进行排序,这个排序方法称为堆排序法。

算法过程:堆排序的实现就集中在两个方面:(1)如何将原始序列构建成一个堆?(2)如何将移走最大值元素之后的剩余元素所构成的序列再转换为一个堆?只要解决了这两个关键问题,就可以很容易实现堆排序的操作。

我们先来讨论第2个关键问题,在此基础之上再来讨论第1个关键问题。

假设原始序列恰好就是一个堆序列 {49, 22, 40, 20, 18, 36, 6, 12, 17},就是上图中的完全二叉树结构(如何将一个一般序列初始化为一个堆序列的过程后面再讲)。进行堆排序时,先执行第(2)步操作:交换堆顶元素和堆的最后一个元素的位置。交换后的堆如下图所示:

交换堆顶元素和最后一个元素

接下来执行第(3)步操作:将移走最大值元素(也就是堆顶元素)之后剩余的元素所构成的序列再转换为一个堆。也就是说将完全二叉树中除去结点49的其他二叉树结点。将如下图所示的完全二叉树,再重新构建成一个堆。

移走最大元素之后的剩余元素所构成的序列(二叉树表示)

不难发现,上图所示的二叉树虽然不是一个堆,但是除了根节点,其余的任何一棵子树仍满足堆的特性。因此,我们通常采用自上而下的调整方法将该二叉树转换成为一个堆。即,将序号为 i 的结点与其左右孩子结点(序号分别为2i 和 2i+1)这3个结点中的最大值,替换到序号为 i 的结点的位置上。只要彻底地完成一次自上而下的调整,该二叉树就会变成一个堆。二叉树调整为一个堆的过程,如下图所示:

完全二叉树调整为一个堆的过程

这里需要说明一下的是,其实这个过程并不一定真的是在二叉树结构上进行的,这里只是用二叉树的形式进行描述会比较清晰明了,实际上它是将一个普通序列调整为一个堆序列的过程,而序列是用一维数组作为存储结构的。这个调整过程的代码描述如下:

//参数a[]:表示存储序列的一维数组
//参数i:表示序列a中的第(i+1)个元素对应的数组下标
//参数n:表示序列a的元素个数
//函数功能:将下标为i的元素作为根节点的二叉树调整为一个新的堆序列
void heapAdjust(int a[], int i, int n)
{
    int j;
    int tmp = a[i];  //将下标为i作为根结点的元素暂存临时变量
    j = 2 * i +1;    //获得左孩子结点元素的下标
    while(j < n){
        //如果有右孩子结点,并且右孩子结点元素值>左孩子结点元素值,则选取右孩子结点元素
        if((j+1)<n && a[j]<a[j+1])
            j++;                   //j为i的左右孩子中元素值较大孩子的元素下标
        
        //如果父结点的值已经大于孩子结点的值,则直接跳出循环
        if(tmp >= a[j])
            break;
        
        //将孩子结点中较大结点的元素值赋值给父结点
        a[i] = a[j];
        
        //将下标为j的结点元素作为新的父结点,继续向下层调整
        i = j;
        j = j * 2 + 1;
    }
    a[i] = tmp;  //将初始的二叉树根节点元素插入到指定位置上
}

这里需要注意的是,调用 heapAdjust()函数的前提是该二叉树中除了根结点外其余的任何一棵子二叉树仍然满足堆的特性。如果该二叉树除了根结点外其子树也不完全是堆结构的话,则不能仅通过调用一次 heapAdjust()函数 就将将其调整为堆。

下面我们回过头来考虑第一个问题——如何将一个序列初始化为一个堆序列?假设一个序列对应的完全二叉树有 n 个结点,那么可按照下面的步骤将该序列调整为一个堆。

(1)初始时,令序列号 i = n/2,它对应二叉树中第 i 个结点(二叉树中结点按层编号,从1开始,从左到右,从上到下编号)。

(2)调用调整函数 heapAdjust()。

(3)每执行完一次调整,都执行一次 i=i-1 的操作。

(4)重复执行步骤(2)、(3),直到 i 等于 1 时执行步骤(5)。

(5)最后再调用一次 heapAdjust()函数。

这样,就可以把一个序列调整为一个堆了。

下面我们来举例说明。假如有一个原始序列为 {23, 6, 77, 2, 60, 10, 58, 16, 48, 20},其对应的完全二叉树如下图(a)所示,将其调整为一个堆的过程,如下图的 图(b)、图(c)、图(d) 、图(e) 和 图(f) 所示,得到的堆序列为 {77, 60, 58, 48, 20, 10, 23, 16, 2, 6}。

上图中虚线方框所包含的范围是本次调整动作(heapAdjust)的调整范围。通过上图图示的调整过程,可将一个无序的序列构建成为一个初始堆序列(这里指的是大顶堆)。

将一个无序序列初始化为一个堆序列后,接下来就回到我们上面刚开始时讨论的第一个问题了。交换堆顶元素与堆的最后一个元素的位置,也就是将堆序列的最大元素移到堆的最后位置;然后调用 heapAdjust() 函数,将除去了最后一个元素的其他元素调整为一个新的堆(这里是大顶堆);重复执行上述操作 n-1 次(假设原序列中有n个元素),就可将一个无序的序列堆排序为一个有序的序列(升序排序)。

算法实现:堆排序算法,代码描述如下:

//两数交换函数
void swap(int *a, int *b)
{
    int tmp;
    tmp = *a;
    *a = *b;
    *b = tmp;
}

//堆排序(升序)
void heapSort(int a[], int n)
{
    int i;
    
    //将原始序列初始化为一个堆序列
    for(i=n/2; i>=0; i--){
        heapAdjust(a, i, n);
    }
    
    //进行n-1次调整,完成排序
    for(i=n-1; i>0; i--){
        //交换堆顶元素和堆中最后一个元素
        swap(&a[0], &a[i]);
        
        //将移走最大元素之后剩余元素所构成的序列再转换为一个堆
        heapAdjust(a, 0, i);
    }
}

<注意>上面代码描述了堆排序过程。这里需要注意的是,待排序的序列是存放在数组a中的,而数组a的下标是从0开始的,序列的第1个元素对应的数组元素为a[0],而不是a[1],这里的a[0]作为序列元素是参与了排序的。

在理解堆排序的内容时,要把握住一下几点:

(1)堆排序是针对线性序列的排序,之所以采用完全二叉树的形式解释堆排序的过程,是出于方便解释的需要。

(2)堆排序的第一步是将原序列(无序序列)变成一个堆序列(即满足堆的特性的序列,我们上面讲的堆是构建的大顶堆)。这个过程是通过下面的代码实现的:

//将原始序列初始化为一个堆序列
for(i=n/2; i>=0; i--){
    heapAdjust(a, i, n);
}

(3)接下来就是一系列“交换-调整”的动作。所谓“交换”就是将堆中第1个元素(堆顶元素)和本次调整范围内的堆中的最后一个元素交换位置,使得较大的元素能够置于序列的后面。所谓“调整”就是将交换后的剩余元素从上至下调整为一个新堆的过程。这个过程是通过下面的代码实现的:

//进行n-1次调整,完成排序
for(i=n-1; i>0; i--){
    //交换堆顶元素和堆中最后一个元素(交换操作)
    swap(&a[0], &a[i]);
    
    //将移走最大元素之后剩余元素所构成的序列再转换为一个堆(调整操作)
    heapAdjust(a, 0, i);
}

(4)通过(2)、(3)的操作可将一个无序序列从小到大进行排序。

(5)如果基于大顶堆进行堆排序,则排序后序列按升序排列;相反,如果基于小顶堆进行堆排序,则排序后的序列按降序排列

测试用例:

//堆排序测试用例
int main(char *argv[], int argc)
{
    int a[20]={0};
    int n, i;
    
    printf("Please input n: ");
    scanf("%d", &n);
    printf("Please input element: ");
    for(i=0; i<n; i++)
        scanf("%d", a+i);
    printf("Before sort:\n");
    for(i=0; i<n; i++)
        printf("%d, ", a[i]);
    heapSort(a, n);
    printf("\nAfter sort:\n");
    for(i=0; i<n; i++)
        printf("%d, ", a[i]);
    return 0;
}

编译命令:gcc heapSort.c -std=c99

示例运行结果:>a.exe
Please input n: 10
Please input element: 23 6 77 2 60 10 58 16 48 20
Before sort:
23, 6, 77, 2, 60, 10, 58, 16, 48, 20,
After sort:
2, 6, 10, 16, 20, 23, 48, 58, 60, 77,

复杂度分析

  • 时间复杂度

堆排序的执行时间主要耗费在初始构建堆和在构建新堆时反复“交换-调整”操作上。在初始构建堆的过程中,因为是从完全二叉树的最下层最右边的非叶子结点开始构建的,将它与其左右孩子结点进行比较和若有必要的交换,对每个非叶子结点来说,其实最多进行两次比较和一次交换操作,因此初始构建堆的时间复杂度为 O(n)。

在正式排序时,第 i 次取堆顶元素重建堆的时间复杂度为O(log2^i),并且总共需要取(n-1)次堆顶元素,因此,重建堆的时间复杂度为 O(nlog2^n)

由于堆排序对原始序列的状态并不敏感,因此,它无论是最好、最坏和平均时间复杂度均为O(nlog2^n)。这在性能上显然要远远优于冒泡、简单选择、直接插入排序算法的时间复杂度。

  • 空间复杂度:堆排序仅需一个用于交换的辅助存储空间,因此其空间复杂度为 O(1)

适用场景:堆排序适合于数据量非常大(百万级数据)的场合,由于初始构建堆需要比较的次数比较多,因此它反而不适合数据量较少的情况。

1.6 归并排序(MergeSort)

算法特性:stable sort、Out-place sort。

归并排序(Merge Sort) 采用的是分治法思想解决排序问题。“归并”的含义是将两个或两个以上的有序序列合并成一个新的有序序列。它的实现方法,无论是顺序存储结构还是链式存储结构,都可在 O(m+n) 的时间量级上实现。归并排序就是利用归并的这个思想实现排序过程的。

假设两个有序表的长度分别为 m 和 n。

算法思想:假设待排序列中有 n 个元素,则可以看成是 n 个有序的子序列,每个子序列的的长度为1,然后两两归并,得到 (n/2 向上取整)个长度为 2 或 1 的有序子序列;再两两归并,如此重复,直至得到一个长度为 n 的有序序列为止,这种排序方法称为 2-路归并排序。

算法过程:假设有一个待排序列 {49, 38, 65, 97, 76, 13, 27},执行2-路归并排序的过程如下图所示:

2-路归并排序过程

将归并排序过程分解来看的话,其实就是做两件事情:

(1)“划分”——将序列每次折半划分(可以通过递归实现)

(2)“合并”——将划分后的子序列两两归并

2-路归并排序中的核心操作是将一维数组中前后相邻的l两个有序序列归并为一个有序序列。

归并两个有序序列为一个有序序列的操作原理如下:

(1)申请辅助存储空间,该辅助空间用来存放合并后的有序序列。

(2)设置两个游标初始分别指向两个有序序列的起始位置。

(3)比较两个游标所指向的序列元素,选择较小的元素存放到合并空间,并移动游标到下一个位置。

(4)重复步骤3,直至遍历完其中一个子序列中的全部元素。

(5)将另一子序列中剩余的所有元素复制到合并序列的尾部。

该归并操作的代码描述如下:

//将两个有序子序列a[low..mid]和a[mid+1..high]归并成一个有序序列a[low..high]
void merge(int a[], int low, int mid, int high)
{
    //动态申请存储空间用来临时存放两个有序序列合并成一个有序序列
    int *pTmp = (int*)malloc((high-low+1) * sizeof(int));
    
    int i = low;
    int j = mid + 1;
    int k = 0;
    
    while(i<=mid && j<=high){
        if(a[i] <= a[j])
            pTmp[k++] = a[i++];
        else
            pTmp[k++] = a[j++];
    }
    
    //把左边剩余的数据移入归并后的数组
    while(i<=mid)
        pTmp[k++] = a[i++];
    
    //把右边剩余的数据移入归并后的数组
    while(j<=high)
        pTmp[k++] = a[j++];
    
    //将临时区域中排序后的元素,整合到原数组a中
    for(i=0; i<k; i++)
        a[low+i] = pTmp[i];
    
    free(pTmp);
}

算法实现:归并排序算法的递归实现方法,代码描述如下:

//归并排序(升序)-递归方法实现
void mergeSort(int a[], int low, int high)
{
    //用分治法对a[low..high]序列进行2-路归并排序
    if(low < high){                  //子序列的区间长度>1
        int mid = (low+high) / 2;
        //递归地对a[low..mid]进行排序
        mergeSort(a, low, mid);
        //递归地对a[mid+1..high]进行排序
        mergeSort(a, mid+1, high);
        //归并,将两个有序区归并为一个有序区
        merge(a, low, mid, high);
        
        //printf("\nmergeSort(), low=%d, mid=%d, high=%d\n", low, mid, high);
    }
}

测试用例

int main(char *argv[], int argc)
{
    int a[20]={0};
    int n, i;
    
    printf("Please input n: ");
    scanf("%d", &n);
    printf("Please input element: ");
    for(i=0; i<n; i++)
        scanf("%d", a+i);
    printf("Before sort:\n");
    for(i=0; i<n; i++)
        printf("%d, ", a[i]);
    mergeSort(a, 0, n-1);
    printf("\nAfter sort:\n");
    for(i=0; i<n; i++)
        printf("%d, ", a[i]);
    return 0;
}

编译命令:gcc mergeSort.c -std=c99

示例运行结果:>a.exe
Please input n: 10
Please input element: 51 46 20 18 65 97 82 30 77 50
Before sort:
51, 46, 20, 18, 65, 97, 82, 30, 77, 50,
After sort:
18, 20, 30, 46, 50, 51, 65, 77, 82, 97,

归并排序的非递归方式实现

使用非递归的方式实现归并排序的思路如下:

(1)初始将一个有n个元素的待排序列,划分成n个有序子序列,此时子序列的长度len=1。

(2)将相邻的两个子序列进行两两归并操作,当归并操作完成后,就是完成了一次归并排序过程。此时,新的子序列长度翻倍(len = 2 * len)。一次归并排序过程可以通过for循环来实现。

(3)重复步骤(2)的操作,直至子序列的长度大于等于待排序列的长度n为止,跳出循环,此时,整个归并排序过程结束,原序列也就变成了有序序列。

归并排序算法的非递归实现,代码描述如下:

/**************************************
归并排序算法的非递归实现(升序)
**************************************/
//一趟归并排序过程实现
void mergePass(int a[], int N, int len)
{
    int i;
    for(i=0; i+2*len<=N; i+=2*len){       //每次移动两个子序列长度的距离
        merge(a, i, i+len-1, i+2*len-1);  //归并相邻两组子序列
    }
    if(i + len < N)  //i+len小于n,意味着有两个长度不等的子序列,其中一个为len,后一个小于len
        merge(a, i, i+len-1, N-1);  //归并最后两组子序列
}

//参数a用于存储待排序列
//参数N表示待排序列的长度
void mergeSort2(int a[], int N)
{
    int subLen = 1;  //初始化子序列长度为1
    while(subLen < N){
        mergePass(a, N, subLen);
        subLen *= 2;
    }
}

<说明> merge()函数已经在归并排序算法—递归方法实现中写了,这里就不重复写了。

测试用例:

int main(char *argv[], int argc)
{
    int a[20]={0};
    int n, i;
    
    printf("Please input n: ");
    scanf("%d", &n);
    printf("Please input element: ");
    for(i=0; i<n; i++)
        scanf("%d", a+i);
    printf("Before sort:\n");
    for(i=0; i<n; i++)
        printf("%d, ", a[i]);
    // mergeSort(a, 0, n-1);
    mergeSort2(a, n);
    printf("\nAfter sort:\n");
    for(i=0; i<n; i++)
        printf("%d, ", a[i]);
    return 0;
}

示例运行结果:

>a.exe
Please input n: 10
Please input element: 51 46 20 18 65 97 82 30 77 50
Before sort:
51, 46, 20, 18, 65, 97, 82, 30, 77, 50,
After sort:
18, 20, 30, 46, 50, 51, 65, 77, 82, 97,

>a.exe
Please input n: 9
Please input element: 8 4 5 2 6 7 1 10 3
Before sort:
8, 4, 5, 2, 6, 7, 1, 10, 3,
After sort:
1, 2, 3, 4, 5, 6, 7, 8, 10,

 参考连接归并排序(非递归实现)

复杂度分析

  • 时间复杂度:

一趟归并操作需要将数组 a[] 中相邻长度为len的有序子序列进行两两归并,并将结果放到暂存到临时数组 tmp[] 中,这需要将待排序列的所有元素遍历一遍,因此耗费 O(n),又有完全二叉树的深度可知,整个归并排序需要进行 log2^n 次。因此,总的时间复杂度为O(nlogn),而且这是归并排序算法中最好、最坏、平均的时间性能。

综上分析可知,归并排序算法的时间复杂度为 O(nlog2^n)

  • 空间复杂度:

由于归并排序在归并过程中需要和原序列相同数量的辅助存储空间用于存放两两归并的结果以及递归时深度为 log2^n 的栈空间,因此其空间复杂度为 O(n + log2^n),综合而言,归并排序算法的空间复杂度为 O(n)

由此可见,归并排序是一种比较占内存,但是效率却比较高,同时也是一种稳定的排序算法。

1.7 基数排序(RadixSort)

算法特性:stable sort、Out-place sort。

基数排序(Radix Sort) 是和前面介绍的各类排序算法完全不相同的一种排序方法。前面介绍的排序主要是通过关键字间的比较和移动元素这两种操作,而实现基数排序不需要进行元素关键字间的比较。基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序的方法。

对于基数排序的详细分析,后续我会单独写一篇博文,这里就不详加展开了。

二 各种排序算法性能比较

比较各种排序算法的性能,一般从其时间复杂度和空间复杂度两个角度进行分析。第一部分中对于各个排序算法的时间/空间复杂度也进行了详细的解释说明,下面通过图表的形式给出各个排序算法的复杂度比较,如下图所示:

各种排序算法性能比较

<说明> nlogn 是 nlog2^n的简写形式,是以2为底n的对数。

  • 在平均时间复杂度的情况下,希尔排序、快速排序、堆排序和归并排序的时间复杂度量级是一致的,都能达到较快的排序速度。但是相对而言,快速排序算法是最快的(只要不是最坏的情况),而希尔排序和堆排序的空间消耗要比快速排序的小,归并排序的空间消耗是最大的。
  • 冒泡排序和直接插入排序的排序速度较慢,都是 O(n^2)的时间复杂度,但是如果参加排序的序列最开始就是基本有序的或是局部有序的,使用这两种排序算法会得到十分满意的效果,排序速度会较快。在最好的情况下(原序列按值有序),使用冒泡排序和直接插入排序的时间复杂度降到了 O(n)。
  • 从参加排序的序列规模来看,序列中的元素个数越小,采用冒泡排序、直接插入排序或者简单选择排序算法最合适。序列的规模较大时,采用希尔排序、快速排序和堆排序、归并排序比较适合。这是因为当序列的规模(待排序列的元素个数)n 越小,O(n^2) 与 O(nlog2^n) 的差距就越小。同时,使用复杂的排序算法也会带来额外的系统开销。这样看来,对小规模的序列进行排序使用相对简单的冒泡排序、直接插入排序 或者 简单选择排序算法是最划算的。
  • 从算法实现的角度来看,冒泡排序、直接插入排序、简单选择排序实现起来简单直接。其他的排序算法都可以看做是对上述某一种排序算法的改进和提供,因此实现起来比较复杂。
  • 从算法的稳定性方面考虑,冒泡排序、直接插入排序、折半插入排序、归并排序 是稳定排序算法。简单选择排序、快速排序、希尔排序和堆排序是不稳定排序算法。

综上分析可知,排序算法的好坏是相对的而非绝对的,没有一种绝对优秀的排序算法适合于任意一种环境。每一种排序算法都有其优点和不足,每一种排序算法都有其适用的场景。我们在选取一种排序算法时要综合考虑各种情况,在本身所处的应用环境下选择最适合自己的排序算法。

参考

《妙趣横生的算法(C语言实现-第2版)》

《数据结构(严蔚敏-C语言版)》

常用排序算法

排序算法总结

排序算法(七大经典排序算法)

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值