数据结构之八大排序算法(C语言实现)

Posted 小赵小赵福星高照~

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构之八大排序算法(C语言实现)相关的知识,希望对你有一定的参考价值。

排序

排序的概念及其应用

排序的概念

排序的定义

数据结构必学的结构之一,在现实生活中应用多,所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

排序的稳定性

如果在序列中有两个数据元素 r[i] 和 r[j],它们的关键字 k[i] == k[j],且在排序之前,对象 r[i] 排在 r[j] 前面;如果在排序之后,对象 r[i] 仍在对象 r[j] 的前面,则称这个排序方法是稳定的,否则称这个排序方法是不稳定的

排序在现实生活中的应用

下图是中国网站排行榜:

我们打开淘宝,在首页搜索你想要买的东西,上面就会有按综合排序,按销量排序,按信用排序,按价格排序,如下图:


常见的排序算法


我们这里主要讲解八个排序算法:直接插入排序、希尔排序、选择排序、堆排序、冒泡排序、快速排序、计数排序、归并排序。

常见排序算法的实现

直接插入排序

直接插入排序:它的原理是,我们假设前n-1个数是有序的,我们将第n个数与前面的数进行比较,将它插入到合适的位置

直接插入排序与我们平时打扑克牌时是一样的,假设我们手里的牌是有序的,我们新拿到一张牌时,习惯插到适当的位置(后面比它大,前面比它小),其实插入排序也就是这样的。具体看下面的动图演示,助于理解。

动图演示:


代码如下:

//插入排序
// 2 1 4 3 6
//时间复杂度:O(N^2) 逆序
//最好:O(N)  接近顺序有序
void InsertSort(int*a,int n)
{
    for(int i=0;i<n-1;i++)
    {
        //单趟插入
        int end=i;
        int temp = a[end+1];//新的元素
        while(end>=0)
        {
            if(temp<a[end])//新元素小于前面的数时
            {
                a[end+1]=a[end];//end后移
                end--;//更新end,end>=0时继续比较
            }
            else
            {
                break;
            }
        }
        //循环结束有两种情况:1、end减为-1  放在end的后面一个位置
        //2、temp>a[end]  放在end的后一个位置
        a[end+1]=temp;
     } 
}

时间复杂度:O(n^2),直接插入排序的最好情况是什么呢?最好情况是数据接近有序的时候,只需比较n-1次,即为O(N)

空间复杂度:O(1)


希尔排序

在直接插入排序中,我们发现它的时间复杂度为O(N^2),最好情况为O(N),但是此时是需要数据接近有序,这时一位我们的前辈希尔就提出了希尔排序的思想。

希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。

希尔排序是基于插入排序的以下两点性质而提出改进方法的:

  • 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
  • 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;

希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。希尔排序实际上是直接插入排序的优化版本。

希尔的思路:

1、预排序(接近升序)

2、直接插入排序

那么怎么预排序呢?

比如我们有这样的一组序列:9 8 7 6 5 4 3 2 1 0

我们按照希尔的思路走,首先我们进行预排序:对间隔为gap,分成一组,分好组之后每组进行插入排序,假设gap=3

第一组:9 6 3 0 第二组:8 5 2 第三组:7 4 1

按分组,对这gap组数据插入排序:

最后我们再将预排序的结果进行一次直接插入排序,就变成了升序。

希尔排序的整体过程:

由上图可知一个数据一次挪动,不是走一步,而是走gap步,这样数据挪动更快,gap越小,越接近有序,gap越大,越不接近有序,但是gap越小挪动樾慢,gap越大挪动越快,gap==1时,其实就是直接插入排序

代码如下:

void ShellSort(int* a,int n)
{
    //gap>1 预排序--接近有序
    //gap==1 直接插入排序
    int gap = n;
    while(gap>1)//while循环的时间复杂度:log以3为底N的对数
    {
        gap=gap/3+1;//最后一次一定是1
        //gap = gap/2;//预排会多一些
        
        //间隔为gap的多组并排
        for(int i=0;i<n-gap;i++)//同时走这个gap组数据的间隔为gap插入排序
        {//对于时间复杂度,我们考虑边界的情况:如果gap很大时,基本无序,挪动数据快,for循环里面的代				码几乎可以忽略不记 O(N)
         //gap很小时,接近有序,gap是1时,看起来是O(N^2),但是这里gap是1时,接近有序,故为O(N)
            int end = i;
            int temp=a[end+gap];//保存后一个数
            while(end>=0)
            {
                if(temp<a[end])
                {
                    a[end+gap]=a[end];
                    end-=gap;
                }
                else
                {
                    break;
                }

            }
            //出来要么end=-gap,要么a[end]<temp(前一个数小于后一个数)
            a[end+gap]=temp;//当end=-gap时要把temp赋给第一个元素位置,是另一种情况时,将									temp(较大者)赋给后一个数也是没问题的
        }
    }
}

时间复杂度:

while循环的时间复杂度:log以3为底N的对数,对于while循环里面的for循环的时间复杂度,我们考虑边界的情况:如果gap很大时,基本无序,挪动数据快,for循环里面的代码几乎可以忽略不记 O(N),当gap很小时,接近有序,gap是1时,看起来是O(N^2),但是这里gap是1时,接近有序,故为O(N),故时间复杂度为O(N*log以3为底N的对数),官方给的时间复杂度为O(N^1.3)。

空间复杂度:

O(1)


选择排序

第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾。 以此类推,直到全部待排序的数据元素的个数为零。

动图演示:

代码如下:

void Swap(int *p1,int *p2)
{
    int temp=*p1;
    *p1=*p2;
    *p2=temp;
}
void SelectSort(int *a,int n)
{
    int i=0;
    int j=0;
    for(j=0;j<n-1;j++)
    {
        //单趟
        int min=j;//每趟先将第一个假定为最小的
        for(i=j;i<n;i++)
    	{
            if(a[i]<a[min])
            {
                min=i;//更新min
            }
    	}
        Swap(&a[j],&a[min]);//交换
    }  
}

这时一次选出一个最大的或者最小的,时间复杂度为O(N^2),那么可不可以优化一下呢?答案是可以的。

选择排序的优化版本:

我们可以定义一个begin变量,一个end变量,用来记录数据首和尾的下标,我们一个可以找出两个值,一个最大值,一个最小值,最小值放在a[begin]中,最大值放在a[end]中,这样我们就比上面的快多了

//时间复杂度O(N^2)
//直接选择排序
void Swap(int *p1,int *p2)
{
    int temp=*p1;
    *p1=*p2;
    *p2=temp;
}
void SelectSort(int *a,int n)
{
    int begin = 0;
    int end = n-1;
    while(begin<end)
    {
        int mini=begin;
        int maxi=end;
        int i=0;
        for(i=begin;i<=end;i++)
        {
            //选出[begin,end]中最大和最小的
            if(a[i]<a[mini])
            {
                mini=i;
            }
            if(a[i]>a[maxi])
            {
                maxi=i;
            }
        }
        Swap(&a[begin],&a[mini]);
        //这里需要考虑第一个值放最大值的情况,如果第一个值为最大值,此时最大值位置被移动
        if(begin==maxi)
        {
            maxi=mini;//最大的值被换到了mini的位置,更新最大值的位置
        }
        Swap(&a[end]&a[maxi]);
        begin++;
        end--;
    }
}

时间复杂度:O(n^2)
空间复杂度:O(1)


堆排序

堆排序分两个步骤:

1、建堆

2、排序

那么我们排升序建大堆还是建小堆呢?答案是建大堆。

升序为什么不能建小堆呢?

建堆选出最小的数,花了O(N)的时间复杂度,紧接着如何选次小的数呢?剩下的数父子关系全乱了,向下调整需要满足左右子树都是堆,但是关系都乱了,左右子树可能都不满足向下调整的条件了,故剩下的N-1个数只能重新建堆,效率太低了

升序建大堆

1、选出了最大的数,把最大的数与最后的数交换

2、紧接着选出次大的数,与倒数第二个位置的数交换…

因为堆结构没有破坏,最后一个数不看作堆里面,左右子树依旧是大堆,向下调整即可,选出第二大

建大堆完成后,排序的步骤如图:

堆排序代码如下:

#include<stdio.h>
void swap(int *p1,int *p2)
{
    int temp=*p1;
    *p1=*p2;
    *p2=temp;
}
//左右子树都是小堆或者大堆
void AdjustDown(int *a,int n,int parent)
{
   	int child=parent*2+1; //左孩子,左孩子+1即为右孩子
    while(child<n)
    {
        //选择出左右孩子中较小/大的那个
        //小堆
        if(child+1<n && a[child+1]>a[child])//右孩子存在(防止越界)且如果右孩子比左孩子小
        {
            child++;//那就下标来到右孩子
        }
        
        if(a[child]<a[parent])//大的孩子大于父亲就交换,继续调整
        {
            swap(&a[parent],&a[child]);
            parent=child;
            child=parent*2+1;
        }
        else//大的孩子比父亲小或相等,则不处理,调整结束
        {
            break;
        }
    }
}
//排序(升序),排升序要建大堆,排降序要建小堆
void HeapSort(int *a,int n)
{
    //1、建堆
    int i=0;
    //为了满足向下调整条件,从最后一个非叶子结点开始调整,从下往上调整
    for(i=(n-1-1)/2;i>=0;--i)//最后一个结点的父亲是最后一个非叶子结点
    {
        AdjustDown(a,n,i);//O(N)
    }
    //2、排序
    int end=n-1;
    while(end>0)
    {
        swap(&a[0],&a[end]);
        AdjustDown(a,end,0);//O(nlogn),一次向下调整为层数次 即logn次,while循环一共调整n次,故是nlogn
        end--;
    }
}

堆排序时间复杂度O(N*logN),一次向下调整为层数次 即logn次,while循环一共调整n次,故是nlogn

堆排序空间复杂度O(1)


冒泡排序

算法思想:从左到右,相邻元素进行比较。每次比较一轮,就会找到序列中最大的一个或最小的一个。这个数就会从序列的最右边冒出来。以从小到大排序为例,第一轮比较后,所有数中最大的那个数就会浮到最右边;第二轮比较后,所有数中第二大的那个数就会浮到倒数第二个位置……就这样一轮一轮地比较,最后实现从小到大排序。

冒泡排序动图演示:

void BubbleSort(int* a,int n)
{
    int i=0;
    int j=0;
    for(i=0;i<n-1;i++)
    {
        for(j=0;j<n-1-i;j++)
        {
            if(a[j]>a[j+1])
            {
                Swap(&a[j],&a[j+1]);
            }
        }
    }
}

时间复杂度:O(N^2)

空间复杂度:O(1)

冒泡排序的优化

冒泡排序有时候在我们已经有序的情况下,内部的循环还是会进去,这样影响了效率,故我们设置一个flag,当有一趟没有发生交换时,flag没有发生变化,此时就是有序了,此时直接结束循环。

void BubbleSort(int* a,int n)
{
    int i=0;
    int j=0;
    for(i=0;i<n-1;i++)
    {
        int flag = 0;
        for(j=0;j<n-1-i;j++)
        {
            if(a[j]>a[j+1])
            {
                Swap(&a[j],&a[j+1]);
                flag=1;
            }
        }
        if(flag==0)
        {
            break;
        }
    }
}

时间复杂度最好O(N),最坏O(N^2)

空间复杂度O(1)


快速排序

快速排序是我们这里的高手,高手要登场了,快速排序其实就是冒泡排序的升级,它们都属于交换排序类,它也是通过不断比较和移动来实现排序的,只不过它的实现,增大了记录的比较和移动的距离,较大的记录从前面直接移动到后面,较小的记录从后面直接移动到前面,减少了比较次数和移动次数。

Hoare法

Hoare法快速排序的思想

我们有一组待排序的数据,在其中选一个关键字key出来,一般是选头或者尾

快速排序的单趟:key放到它正确的位置上(整体排完序后最终放的位置),key的左边的值比key小,key右边值比它大

单趟排完,再想办法让key左边区间有序,key的右边区间有序,整体就有序了

Hoare法快速排序单趟动图演示:

Hoare法快排单趟代码:

//单趟
int PartSort(int *a,int begin,int end)
{
    int keyi=begin;//选定一个关键字的下标
    while(begin<end)
    {
        //右边先走 右边先找比keyi小的
        while(begin<end && a[end]>a[keyi])
        {
            end--;
        }
        //此时end是比keyi小的那个数的下标
        
        //左边找比keyi大的
        while(begin<end && a[begin]<a[keyi])
        {
            begin++;
        }
        //此时begin是比keyi大的那个数的下标
        Swap(&a[begin],&a[end]);//交换
        //这里的目的是让左边是比关键字小的,右边是比关键字大的
    }
    //此时begin和end相遇了
    Swap(&a[begin],&a[keyi]);//交换相遇的地方和关键字的位置
    return begin;//返回关键字的位置
}

有的人可能会迷惑,万一相遇点比关键字大呢?

我们假设关键字是头,那么是如何保证begin和end相遇的那个位置的数一定比关键字小呢?

当关键字是头时,我们让end先走,这其实就保证了那个位置的数一定比关键字小,为什么呢?

因为相遇有两种情况:begin遇end;end遇begin

  • 当begin遇end情况下

end先走,end停在了比关键字小的位置上,如果(begin<end是前提)此时begin后面的都比关键字小,那么begin就来到了end的位置停止了,而end就在比关键字小的位置上

  • 当end遇begin情况下

end先走,end停在了比关键字小的位置上,begin再走,begin停在了比关键字大的位置上,将begin和end处的值交换(注意这个交换很重要),end再走,假设((begin<end是前提)end前面的都比关键字小,那么end来到了begin的位置就停止了,此时相遇,此时相遇点处的值比关键字小了,为什么呢?因为前面begin处的值与end处的值发生了交换,而end处的值是小于关键字的。

综上所述,相遇的位置处的值一定比关键字小

同理,关键字是尾时,我们让左边先走,那么就会保证相遇的位置处的值一定比关键字大

单趟的时间复杂度是多少呢?最坏情况比较N次,故时间复杂度为O(N)


那么单趟排完,怎么让整体有序呢?再想办法让key左边区间有序,key的右边区间有序,整体就有序了,那么怎么让左边区间有序,右边区间有序呢?

这是不是又是一个子问题了呢?故可以用递归解决

快速排序代码:

//单趟
int PartSort(int *a,int begin,int end)
{
    int keyi=begin;//选定一个关键字的下标
    while(begin<end)
    {
        //右边先走 右边先找比keyi小的
        while(begin<end && a[end]>a[keyi])
        {
            end--;
        }
        //此时end是比keyi小的那个数的下标
        
        //左边找比keyi大的
        while(begin<end && a[begin]<a[keyi])
        {
            begin++;
        }
        //此时begin是比keyi大的那个数的下标
        Swap(&a[begin],&a[end]);//交换
        //这里的目的是让左边是比关键字小的,右边是比关键字大的
    }
    //此时begin和end相遇了
    Swap(&a[begin],&a[keyi]);//交换相遇的地方和关键字的位置
    return begin;//返回关键字的位置
}
//快速排序
void QuickSort(int *a,int begin,int end)
{
    if(begin>=end)//如果区间不存在
    {
        return;
    }
    int keyi = PartSort(a,begin,end);
    QuickSort(a,begin,keyi-1);
    QuickSort(a,keyi+1,end);
}

这样就完成了快速排序,但是时间复杂度是多少呢?


快速排序时间复杂度

理想情况下(单趟后关键字处于中间),递归深度为层数次,即log(N),每一层的递归时间复杂度加起来都是N,故理想情况下时间复杂度为N*logN

但是在有序的情况下,时间复杂度为O(N^2):

那么有没有什么优化的方法呢?答案是有的


快速排序的优化

优化方法:

  • 随机选key
  • 三数取中选key

这里我们讲解第二种:三数取中。

我们取下标为begin,mid,end的数当中大小处于中间的作为key,这样就解决了上面的最坏的情况

三数取中优化快排代码:

int GetMidIndex(int* a, int begin, int end)
{
    int mid = (begin + end) / 2;
    if (a[begin] < a[mid])
    {
        if (a[mid] < a[end])
        {
            return mid;
        }
        else if (a[begin] < a[end])
        {
            return end;
        }
        以上是关于数据结构之八大排序算法(C语言实现)的主要内容,如果未能解决你的问题,请参考以下文章

C语言编程学习:八大排序之基数排序

八大排序算法C语言过程图解+代码实现(插入,希尔,选择,堆排,冒泡,快排,归并,计数)

八大排序算法(C语言实现)

八大排序算法(C语言实现)

数据结构c语言版八大算法(上)图文详解带你快速掌握——希尔排序,堆排序,插入排序,选择排序,冒泡排序!

[数据结构]八大排序算法(C语言)