oi 常用排序算法简单讲解及模板总结

主要内容

本文主要介绍了五种在 oi 中常用的排序方式,包括以下内容:

  1. 快速排序
  2. 归并排序
  3. 计数排序
  4. 桶排序
  5. 基数排序

接下来我们会一一介绍它们。

快速排序

快速排序,简称快排。是一种常用的排序方式,虽然一般不会考代码实现,但是其思想比较精妙。

思想

快排应用了分治的思想,分治的一般思想是:

  1. 将大问题划分成许多很小的子问题
  2. 递归子问题解决
  3. 通过对子问题的答案合并得到大问题的答案

其中快速排序不涉及第三点,但是第三点将在归并排序中发挥大作用。

快排的主要思想是这样的:

  1. 随便找一个值,一般会找数组中其中一个值。
  2. 将小于当前值的所有数放在数组左侧,大于等于当前值的所有数放在右侧。
  3. 将这两个部分的所有数继续通过上面的步骤递归,直到递归到区间内只有一个数。

代码实现

虽然没啥用(没考过),但还是把代码放在下面供有需求的人学习。


int n,a[N];
void quick_sort(int a[],int l,int r)
{
    if(l>=r)return;//判断递归终止的条件
    int i=l-1,j=r+1,x=a[l+r>>1];//划分成子问题
    while(i<j)
    {
        do i++;while(a[i]<x);
        do j--;while(a[j]>x);
        if(i<j)swap(a[i],a[j]);
    }
    quick_sort(a,l,j),quick_sort(a,j+1,r);//递归子问题
    return;
}//将 a[1] ~a[n] 进行排序: quick_sort(a,1,n)

复杂度

最优时间复杂度和平均时间复杂度都为 \(O(n\log n)\),最坏时间复杂度为 \(O(n^2)\)。稳定性不太好,但是有很多优化手段,可以参考 oi-wiki

归并排序

归并排序基于分治的思想,是相较于快排来说更稳定的算法。

思想

归并排序的主要思想:

  1. 将大区间分成两个小区间。
  2. 将两个小区间进行排序。
  3. 将排序完的两个小区间合并成大区间。

代码


int n,a[N],tmp[N];
void merge_sort(int a[],int l,int r)
{
    if(l>=r)return;//判断边界条件
    int mid=l+r>>1;
    merge_sort(a,l,mid),merge_sort(a,mid+1,r);//划分成两个小区间
    int k=0,i=l,j=mid+1;
    //将两个排序完的小区间合并
    while(i<=mid&&j<=r)
        if(a[i]<=a[j])tmp[++k]=a[i++];
        else tmp[++k]=a[j++];
    while(i<=mid)tmp[++k]=a[i++];
    while(j<=r)tmp[++k]=a[j++];

    for(i=l,j=1;i<=r;i++,j++)a[i]=tmp[j];//赋值给原数组
    return;
}//将 a[1] ~a[n] 进行排序: merge_sort(a,1,n)

复杂度

递归式为\(T(n)=2T(\frac{n}{2})+f(n)\)

\(2T(\frac{n}{2})\) 为子问题的时间复杂度,\(f(n)\) 为合并子问题的时间复杂度

可以算出 \(f(n)=O(n)\),根据递归式的计算方法得出 \(T(n)=O(n\log n)\),因此时间复杂度为 \(O(n\log n)\),空间复杂度为 \(O(n)\),稳定性较好。

计数排序

计数排序是一种十分精妙的线性排序方法,在数据大但是值域小时效率较高。

思想

计数排序的思想是将原数组所有数直接存到一个额外数组中,其中数值对应新数组的下标,根据新数组的值将原数组排序。

具体来说大致分为三步:

  1. 计算所有数的出现次数
  2. 求每个数出现次数的前缀和
  3. 利用前缀和,倒序计算出每个数的排名

其中计算前缀和是方便直接统计出当前数的名次。

代码

int n,w,a[N],cnt[N],b[N];//w表示值域(所有数的最大值)
void counting_sort()
{
    memset(cnt,0,sizeof cnt);//初始化计数数组
    for(int i=1;i<=n;i++)cnt[a[i]]++;//统计每个数的出现次数
    for(int i=1;i<=w;i++)cnt[i]+=cnt[i-1];//计算每个数出现次数的前缀和
    for(int i=n;i;i--)b[cnt[a[i]]--]=a[i];//倒序将所有数排序,注意将出现次数-1
}

其实还有一处优化,如果数值的最小值偏大,我们就可以建立一个类似于映射的东东,将原来的数值相对偏移到最小值比较小的值,在最后排完序后再还原,在某些卡空间的题目可以很大程度上减少空间消耗。

这个优化比较简单,代码就不贴了。

复杂度

时间复杂度为 \(O(n+w)\),其中 \(w\) 表示值域大小,空间复杂度为 \(O(w)\),稳定性较好。

桶排序

桶排序是一种由计数排序延伸出的算法,可以更加适用于数据值域大但是分布比较均匀的情况。

思想

桶排序中也有分治思想的影子,其主要思想是:

  1. 将值域分块,每一块当成一个桶。
  2. 将所有数放入所对应的桶中,并将每个桶中的数进行排序。
  3. 将所有桶合并,得出排序后的答案。

代码

int n,w,a[N];//w为值域,即最大值
vector<int>bucket[N];
void insertion_sort(vector<int>&A)//插入排序
{
    for(int i=1;i<A.size();i++)
    {
        int key=A[i],j=i-1;
        while(~j&&A[j]>key)A[j--+1]=A[j];
        A[j+1]=key;
    }
}
void bucket_sort() 
{
    int s=w/n+1;//定义每个桶的大小,+1避免边界问题
    for(int i=0;i<n;i++)bucket[i].clear();//清空所有需要用到的桶
    for(int i=1;i<=n;i++)bucket[a[i]/s].push_back(a[i]);//将所有元素放入对应的桶中
    for(int i=0,p=0;i<n;i++)
    {
        insertion_sort(bucket[i]);//使用了插入排序,也可以使用其他排序方式或 sort
        for(int j:bucket[i])a[++p]=j;//将排完序的元素依次放回原数组中
    }
}

同样,桶排序也有与计数排序相同的优化手段,这里不过多叙述。

复杂度

平均时间复杂度为 \(O(n+\frac{n^2}{k}+k)\)(值域分 \(n\) 块 + 每一块排序 + 合并答案),当 \(k=n\) 是时间复杂度为 \(O(n)\),最坏时间复杂度为 \(O(n^2)\)。空间复杂度较高,为 \(O(nk)\)。稳定性较好。

基数排序

思想

基数排序也用了桶的思想,但是与计数排序和桶排序的划分方式不大相同:

  • 计数排序中每个桶的大小为 \(1\),存储的是对应数的数量。
  • 桶排序中每个桶的大小不定,存储的是对应值域的所有数。
  • 基数排序中根据每位数字划分桶,存储的是对应关键字的所有数。

基数排序一般分为两种:MSD(Most Significant Digit first)基数排序和 LSD(Most Significant Digit first)基数排序。
MSD 基数排序:从第 \(1\) 关键字到第 \(k\) 关键字顺序比较。
LSD 基数排序:从第 \(k\) 关键字到第 \(1\) 关键字顺序比较。

在自然数排序中,关键字也就是数字的个位数字、十位数字...

可以来这里模拟一下,就理解大体的过程了。

代码

const int N=1e5+10,S=(1<<8)-1;
int n,a[N],b[N],cnt[S+1];
void radix_sort()
{
    int*x=a,*y=b;
    for(int i=0;i<32;i+=8)
    {
        memset(cnt,0,sizeof cnt);
        for(int j=0;j<n;j++)cnt[x[j]>>i&S]++;
        for(int sum=0,j=0;j<=S;j++)
        {
            sum+=cnt[j];
            cnt[j]=sum-cnt[j];
        }
        for(int j=0;j<n;j++)y[cnt[x[j]>>i&S]++]=x[j];
        swap(x,y);
    }
}

这篇代码运用了 \(256\) 进制数的优化,速度比 sort 还要快。具体内容在这里不过多赘述,感觉自己没有能力讲明其中的奥秘,大家可以参考这篇很好的博客,可以加深理解。

复杂度

基数排序算法的运行时间很容易计算,对于 \(n\)\(k\) 进制 \(d\) 位数,假如每一位的排序使用计数排序算法,则该位排序用时为 \(O(n+k)\),于是 \(d\) 位数的总排序用时为 \(O(d(n+k))\)。当 \(d\) 为常数且 \(k=O(n)\) 时,总排序时间为 \(O(n)\)

虽然我们举了一个三位十进制数的例子,而且计算时间复杂度的时候也强调了所谓的“\(k\) 进制 \(d\) 位数”,但实际上基数排序并非局限于此。数字的进制只在其书写状态下存在,而在计算机中是不存在进制这一概念的。所以当我们拿到一组数,完全不需要关心它是几进制数,而是可以按照我们的需要选择合适的 \(k\)\(d\),选择的标准如下。

先比较数据的上限与 \(n\) 的大小,如果接近或小于 \(n\),直接使用计数排序即可。如果远大于 \(n\),那么我们可以将其表示为一个 \(n\) 进制数,即相当于令 \(k=n\),并由此计算出该数的位数。按照此方式调用基数排序,可以确保时间复杂度为 \(O(n)\)

以上选自计数排序、基数排序和桶排序

空间复杂度为 \(O(n)\),较稳定。

参考资料:

归并排序的证明与边界分析

oi-wiki

排序算法之桶排序

基数排序

posted @ 2023-08-26 17:05  week_end  阅读(92)  评论(0编辑  收藏  举报