经典排序算法详解

因不同平台格式不同出现乱码,如有需要可以直接访问作者notion博客

https://www.notion.so/b8c30991dc014ce089b0864cfade5dce

排序算法

  • 注意:表格中插入排序类缺少折半插入排序

插入排序

  • 插入排序的基本思想是:每次选择待排序的记录序列的第1个记录,按照排序码的大小将其插入已排序的序列中的适当位,直到所有的记录全部排序完成。

直接插入排序

基本思想

  • 直接插入排序是一种最简单的排序方法。排序过程是:先将第1个记录视为一个有序的记录序列,然后从第2个记录开始,一次将未排序的记录插入这个有序的记录序列中,直到整个文件中的全部记录排序完毕

  • 在排序过程中,前面的记录序列是已经排好序的,而后面的记录序列是有待排序的

  • 流程示例

代码示例

typedef int elementType;
typedef struct
{
    elementType key;
}sortElement;

void directsort(sortElement A[], int n)
{
    int i, j;
    sortElenment temp;
    for (i = 1; i < n; i++)
    {
        j = i;
        temp = A[i];
        while (j > 0 && temp.key < A[i-1].key)
        {
            A[j] = A[j-1]; //排序码后移
            j--;
        }
        A[j] = temp;
    }
}

时间复杂度分析

  1. 从A[0],A[1],...,A[n-1]采用直接插入排序,需要进行i-1趟扫描(代码中的i)。一般地,在第 i 趟,插入发生在A[0]到A[i],平均大约需要i/2次比较。总的比较次数是:

    1/2+2/2+...+(n-1)/2=n(n-1)/2 

    按照比较次数衡量,算法的时间复杂度为:O(n^{2})

  2. 考虑最好和最坏的情况:

    1. 最好情况:待排序文件本身就是排好序了的,即在第i趟,插入发生在A[i]处,于是每趟只需要进行1次比较,从而总的比较次数是n-1次,算法的时间复杂度为:O(n)
    2. 最坏情况:待排序文件恰好是逆序的,则在第i趟,插入总是发生在A[0]处,于是第i趟需要进行i次比较,总的比较次数是:n(n-1)/2,此时时间复杂度为:O(n^{2})

稳定性

  • 直接插入排序是稳定的

折半插入排序

基本思想

  • 将直接插入排序中寻找A[i]的插入位置的方法改为采用折半比较(类似二分查找),便得到折半插入排序算法
  • 折半比较查找(或者二分查找)是在已经排好序的排序码序列中进行查找,而在处理A[i]时,A[0],A[1],....,A[i-1]是恰好已经排好序了的
  • 具体过程:在插入A[i]时,取A[(i-1)/2]的排序码与A[i]的排序码进行比较,如果A[i]的排序码小于A[(i-1)/2]的排序码,说明A[i]只能插入A[0]到A[(i-1)/2]之间,然后可以在A[0]到A[(i-1)/2-1]之间继续使用折半比较;若A[i]的排序码大于A[(i-1)/2]的排序码,则说明A[i]只能插入A[(i-1)/2]到A[i]之间,同理可以在A[(i-1)/2]到A[i]之间进行折半比较。直到找到最终插入位置
  • 一般地,在A[k]到A[r]之间采用折半,中间结点为A[(k+r)/2]
  • 经过一次比较,可以排除一半的记录,把可能插入的区间减少了一半,故称折半。执行折半插入排序的前提是文件记录必须是顺序存储

代码示例

void BinaryInsertionSort(sortElement A[], int n)
{
    int i, k, r;
    sortElement temp;
    for (i = 1; i < n; i++)
    {
        temp = A[i];
        k = 0;  
        r = i-1;
        //查找插入位置
        while (k <= r)
        {
            int m = (k+r)/2;
            if (temp.key < A[m].key)
                r = m-1; //在前半部分继续查找
            else k = m+1; //在后半部分继续查找
        }
        //找到插入位置k后,先将A[k]~A[i-1]右移一个位置
        for (r = i; r>k; r--)
            A[r] = A[r-1];
        A[k] = temp;
    }
}

时间复杂度分析

  • 使用折半插入排序时,需要进行的比较次数和记录的初始状态无关,仅依赖于记录的个数。在插入第i个记录时没如果,则无论排序码取什么值,都需要恰好经过j = log_{2}i$次比较才能确定应该插入的位置;如果$2^{j} < i \leq 2^{j+1}$,则需要的比较次数大约为$j+1$。因此将n个记录用折半插入排序所要进行的总的比较次数约为:$nlog_{2}n$

  • 但是,查找到插入位置后的记录的移动次数与直接插入排序相同(相当于只是优化了查找过程)

    • 最坏情况:待排序文件中的记录恰好是逆序的,此时总的移动次数是$n(n-1)/2$
    • 最好情况:待排序文件中的记录恰好是排好序的,此时总的移动次数是$2(n-1)$

    平均时间复杂度仍为:$O(n^{2})$

稳定性

  • 折半插入排序是稳定的

shell排序

基本思想

  • 先选定一个整数$s_{1}<n$,把待排序文件中的所有记录分成$s_{1}$个组,所有距离为$s_{1}$的记录分在同一组内,并对每一组内的记录进行排序。然后取$s_{2} < s_{1}$,重复上述分组和排序的工作,当到达$s_{i} = 1$时,所有记录在同一个组内排好序(故称shell排序为缩小增量排序)
  • 各小组中,各组内的排序通常采用直接插入法。由于开始时$s$的取值较大,没组内记录数较少,所以排序较快。随着$s$不断缩小,每组内的记录数逐步增多,但由于已经按$s_{i-1}$排好序,因此排序速度也较快。

代码示例

void ShellSort(sortElement A[], int n, int s)
{
    int i, j, k;
    sortElement temp;
    //分组排序,初增量为s,每循环一次增量减半,直到增量为0时结束
    for (k = s; k > 0; k >>= 1) //k >>= 1,二进制右移,相当于除2
    {
				//从每组的第2个元素开始进行排序
        for (i = k; i < n; i++) //分组排序
        {
            temp = A[i];
            j = i-k;

            while (j >= 0 && temp.key < A[j].key)
            {
                A[j+k] = A[j];
                j -= k;
            }
            A[j+k] = temp;
        }
    }
}
  • 算法执行流程图示

时间复杂度分析

  • shell排序算法在一般情况下速度要快于直接插入排序。具体分析较为复杂,本文不作证明
  • 平均情况下,时间复杂度为:$O(n^{1.3})$
  • 最好情况下,时间复杂度为:$O(n)$
  • 最坏情况下,时间复杂度为:$O(n^{2})$

稳定性

  • shell排序算法是不稳定的

选择排序

  • 选择排序的基本思想:每次从待排序的记录中选出排序码最小的记录,再在剩下的记录中选出次最小的记录,重复这个选择过程,知道完成全部排序

直接选择排序

基本思想

  • 每次从待排序的记录中选出排序码最小的记录,顺序放在已排序的记录序列的最后,直到完成全部排序

代码示例

void DirectSelectSort(sortElement A[], int n)
{
    int i, j, k;
    sortElement temp;

    for (i = 0; i < n-1; i++)
    {
        k = i;
        for (j = i+1; j < n; j++)
            if (A[j].key < A[k].key)
                k = j;
        if (i != k)
        {
            temp = A[k];
            A[k] = A[i];
            A[i] = temp;
        }
    }
}

时间复杂度分析

  • 直接选择排序的比较次数与排序码的初始顺序无关,总的比较次数为:

    $\displaystyle\sum_{i=1}^{n-1}(n-i)= \displaystyle\sum_{i=2}^n(n-i)=\dfrac{n(n-1)}{2}$

    当初始文件已经排好序时,移动次数最少为0次,最多为$3(n-1)$次(temp = a; a = b; b = temp)

    时间复杂度用选取最小排序码记录的比较次数来衡量,则无论是最好情况(待排序文件已经排好序)还是最坏情况(排序文件恰好是逆序),选取出最小排序码的记录的比较次数都是$O(n^{2})$

    即:平均时间复杂度、最好情况时间复杂度及最坏情况时间复杂度均为$O(n^{2})$

稳定性

  • 直接选择排序是不稳定的

优点

  • 直接选择排序在排序过程中会提供一个有效的中间值(如待排序列中的最值)

树形选择排序

树状结构排序算法本文不作讨论,后续会补充

基本思想

  • 把$n$个排序码两两进行比较,取出$\lceil \dfrac{n}{2} \rceil$个较小的排序码作为第1步比较的结果保存下来,再把$\lceil \dfrac{n}{2} \rceil$个排序码两两进行比较,重复上述过程,一直比较出最小的排序码为止

时间复杂度分析

  • 移动次数不超过比较次数,总的时间开销为:$O(nlog_{2}n)$

交换排序

  • 基本思想:每次将待排序文件中两个记录的排序码进行比较,如果不满足排序要求,则交换这两个记录在文件中的顺序,直到文件中任意两个记录之间都满足排序要求为止。

冒泡排序(起泡排序)

基本思想

  • 起泡排序是最简单的交换排序。排序过程为:首先比较第1个记录和第2个记录的排序码,如果不满足排序要求,则交换第1个记录和第2个记录的顺序;然后对第2个记录(可能是新交换过来的最初的第1个记录)和第3个记录进行同样的处理;重复此过程直到处理完第$n-1$个记录和第$n$个记录为止。上述过程称为一次起泡过程,这个过程的处理结果就是将排序码最大或者最小的那个记录交换到最后一个记录位置,到达这个记录在最后排序结果中的正确位置。然后从,重复上述起泡过程,但每次只对前面的未排好序的记录进行处理,直到所有的记录均排好序为止
  • 在每次冒泡过程中,可以设立一个标志位,用以标示每次冒泡过程中是否进行过记录交换,。如果某次冒泡过程中未发生记录交换,则表明整个记录已经达到了排序要求(也就没有后续重复冒泡过程的必要)

代码示例

void BubbleSort(sortElement A[], int n)
{
    int i, j;
    Bool flag;
    sortElement temp;

    for (i = n-1, flag = true; i > 0 && flag; i--)
    {
        flag = false;//设置未交换标志
        for (j = 0; j < i; j++)
            if (A[j+1].key < A[j].key)
            {
                flag = true;
                temp = A[j+1];
                A[j+1] = A[j];
                A[j] = temp;
            }
    }
}

时间复杂度分析

  1. 最好的情况,即待排序文件是已经排好序了的,于是只需要1次冒泡过程即可,此时比较次数和移动次数均为最小,比较次数为$n-1$次,移动次数为0,于是时间复杂度为$O(n)$

  2. 最坏的情况,即待排序文件恰好是逆序的,则需要进行$n-1$次排序,此时比较次数和移动次数均达到最大,比较次数为:$\displaystyle\sum_{k=1}^{n-1}i = \dfrac{n(n-1)}{2}$

    每次比较完后要进行交换,每次交换需要3次移动,移动次数为:$3\displaystyle\sum_{k=1}^{n-1}i = \dfrac{3n(n-1)}{2}$,于是时间复杂度为$O(n^{2})$

快速排序

基本思想

  • 从待排序记录中任选一个记录,以这个记录的排序码作为中心值,将其他所有记录划分为两个部分,第1个部分包括所有排序码小于中心值的记录,第2部分包括所有排序码大于中心值的记录,而排序码作为中心值的这个记录,在排序后必然处于这两部分的中间位置;对于上述两部分继续采用同样的方法进行排序处理,直到每个部分为空或者只含有一个记录为止。至此,待排序文件中的每个记录都被放置到正确的排序位置

代码示例

void QuickSort(sortElement A[], int low, int high) 
//low和high分别为数组A的起始位置和结束位置
{
    int i, j;
    sortElement temp;
    
    if (low >= high) return ;

    i = low;
    j = high;
    temp = A[i];
    while (i < j)
    {
        while (i < j && temp.key < A[j].key) j--;
        if (i < j)
            A[i++] = A[j];//注意是前自加
            
        while (i < j && temp.key >= A[i].key) i++;
        if (i < j) 
            A[j--] = A[i];//注意是前自减
    }

    A[i] = temp;
    QuickSort(A, low, --j);
    QuickSort(A, ++i, high);
}

时间复杂度分析

  • 快速排序最好的情况是,每次选取的中心值记录恰好能将其他记录分成大小相等(最多相差一个记录)的两部分(为了后续两个递归过程的递归深度大致相等,“木桶效应”)。第1遍扫描时,经过大约$n$次(实际上是$n-1$次)比较,产生2个大小约为$\dfrac{n}{2}$的子文件。第2遍扫描时,对每个子文件经过大约$\dfrac{n}{2}$次比较,产生4个大小约$\dfrac{n}{4}$的子文件这一阶段总的比较次数为$2*\dfrac{n}{2}$次。第3遍扫描时,处理4个大小约为$\dfrac{n}{4}$的子文件,需要大约$4*\dfrac{n}{4}$次比较。以此类推,在经过$k=log_{2}n$遍扫描后,所得到的的子文件大小约为1,算法终止,排序结束。因此总的比较次数约为:

    $1(n/1)+2(n/2)+4(n/4)+,...,+n(n/n) = n+n+,...,+n=nk=nlog_{2}n$

    于是最好情况下,时间复杂度为$O(nlog_{2}n)$

  • 最坏的情况是,所选取的中心值的总是最大或者最小的排序码,即当排序文件中的记录已经符合排序要求的记录顺序时。第1遍扫描时经过$n-1$次比较,得到一个大小为$n-1$的子文件。同样第2遍扫描的到一个大小为$n-2$的子文件。以此类推(思路和最好的情况类似),总的比较次数为:

    $(n-1)+(n-2)+,...,+1 = n(n-1)/2$

    于是最坏情况下,时间复杂度为$O(n^{2})$

  • 平均时间复杂度为$O(nlog_{2}n)$

稳定性

  • 快速排序是不稳定的

分配排序

基数排序

基本思想

  • 基数排序的基本思想是把每个排序码看成是一个d元组
  • $K_{i}=(K_{i}^{0},K_{i}^{1},...,K_{i}^{d-1})$
  • 其中每个$K_{i}$有r种可能取值:$c_{0},c_{1},c_{2},...,c_{r-1}$
  • 基数r的选择和排序码的类型有关,当排序码是十进制时,最自然的取值是$r =10$,此时$c_{0}=0,c_{1} = 1,...,c_{9} = 9$,d为排序码的最大位数。当排序码是大写的英文字符串时,$r = 26$,$c_{0}='A',c_{1}='B',...,c_{25} = 'Z'$,d为排序码字符串的最大长度。排序时,先按最低位$K_{i}^{d-1}$的值从小到大将待排序的记录分配到$r$个盒子中,然后再收集这些记录,再按照$K_{i}^{d-2}$的值从小到大将全部记录重新分配到$r$个盒子中,并注意保持每个盒子中记录之间在分配前的相对先后关系,再重新收集起来……如此反复,直到对最高位$K_{i}^{0}$分配后,收集起来的序列就是排序后的有序序列,至此基数排序完成

代码示例(略)

时间复杂度分析

  • 基数排序算法的时间复杂度取决于基数和排序码的长度
  • 每执行一次分配和手机,队列初始化需要$O(r)$的时间,分配工作需要$O(n)$的时间,收集工作需要$O(r)$的时间,即每一趟需要$O(n+2r)$的时间,总共要执行$d$趟,共需要$O[d(n+2r)]$的时间,排序过程不涉及记录的移动。
  • 基数排序适用于记录较多、排序码分布比较均匀(d较小)的情况,特别是排序过程中不需要移动记录,因而当记录的数据信息较大时执行效率很高

稳定性

  • 基数排序是稳定的

归并排序

基本思想

  • 当待排序文件已经是部分排序时,可以采用将已排序的部分进行合并的方式,将部分排序的记录归并成一个完全有序的文件,这就是将要讨论的归并排序。所谓部分排序,就是指一个文件划分成若干个子文件,整个文件是未排序的,但每个子文件内是已经排好序了的
  • 基本思想是:将已经排好序了的子文件进行合并,得到完全排序的文件。合并时只要比较个子文件的第1个记录的排序码,排序码最小的那个记录就是排序后的第1个记录,取出这个记录,然后继续比较子文件的第1个记录,便可找到排序后的第2个记录。如此反复,对每个子文件经过一遍扫描,便可得到最终的结果
  • 对于任意的待排序文件,可以把每个记录看成一个子文件,显然这样的子文件是已经排好序了的(只有一个记录,当然是排好序的),因而可以采用归并排序进行排序,但是,要经过一趟扫描将n个子文件全部归并显然是困难的。通常采用两两归并的方法,即每次将两个子文件归并成一个大的子文件。第1趟归并后,得到$n/2$个长度为2的子文件;第2趟归并后得到$n/4$个长度为4的子文件。如此反复,直到最后将两个长度为$n/2$的子文件经过一趟归并,即可完成对文件的排序
  • 上述归并过程中,每次都是将两个子文件归并成一个较大的子文件,这种归并方法称为“二路归并排序”,此外,也可以采用“三路归并排序”或“多路归并排序”

代码示例

void MergeSort(sortElement A[], int n)
{
    int k;
    sortElement* B = (sortElement*)malloc(n*sizeof(sortElement));
    /*初始子文件的长度为1*/
    k = 1;
    while (k<n)
    {
        /*将A中的子文件经过一趟归并存储在数组B中*/
        OnePassMerge(B, A, k, n);
        /*归并后子文件的长度增加一倍*/
        k <<= 1;
        
        if (k >= n)
        /*已归并排序完成,但结果保存在B中,调用标准函数mencpy()将其赋值到A中*/
            memcpy(A, B, n*sizeof(sortElement));
        else 
        {
            /*将B中的子文件经过一趟归并存储到A*/
            OnePassMerge(A, B, k, n);
            /*归并后长度增加一倍*/
            k <<= 1;
        }
    }
}

void OnePassMerge(sortElement Dst[], sortElement Src[], int len, int n)
{
    int i;

    for (i = 0; i < n-2*len; i += 2*len)
    /*执行两两归并,将Src中长度为len的子文件归并成长度为2*len的子文件,结果存放在Dst中*/
        TwoWayMerge(Dst, Src, i, i+len-1, i+2*len-1);
    
    if (i < n-len)
        /*尾部至多还有两个子文件*/
        TwoWayMerge(Dst, Src, i, i+len-1, n-1);
    else 
        /*尾部可能还有一个子文件,直接复制到Dst中*/
        memcpy(&Dst[i], &Src[i], (n-i)*sizeof(sortElement));
}

void TwoWayMerge(sortElement Dst[], sortElement Src[], int s, int e1, int e2)
{
    int s1, s2;
    for (s1 = s, s2 = e1+1; s1 <= e1 && s2 <= e2; )
        if (Src[s1].key <= Src[s2].key)
            /*第一个子文件最前面记录其排序码小,将其归并到 Dst*/
            Dst[s++] = Src[s1++];
        else 
            /*第二个子文件最前面记录其排序码小,将其归并到 Dst*/
            Dst[s++] = Src[s2++];
        
    if (s1 <= s2)
        /*第一个子文件未归并完,将其直接复制到 Dst中*/
        memcpy(&Dst[s], &Src[s1], (e1-s1+1)*sizeof(sortElement));
    else 
        /*第二个子文件未归并完,将其直接复制到 Dst中*/
        memcpy(&Dst[s], &Src[s2], (e2-s2+1)*sizeof(sortElement));
}

时间复杂度分析

  • 归并排序算法对n个记录排序,需要调用函数 OnePassMerge 约$log_{2}n$次,而每次调用函数OnePassMerge的时间复杂度为$O(n)$,此外,归并排序算法在最后可能执行n次移动,所以总的时间复杂度为$O(nlog_{2}n)$

稳定性

  • 归并排序算法是稳定的

参考书籍:《数据结构与算法(第二版)》熊岳山

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

满天星..

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值