十大排序算法详细分析总结+图解及个人思考(下篇)-C++实现

强烈建议先阅读上篇:十大排序算法详细分析总结+图解及个人思考(上篇)
本篇为上篇的其余内容,介绍剩余的五个排序算法。

排序概述

排序算法在学校考试,企业面试中都是高频高点,所以今天就针对这一知识点好好整理整理,也方便以后回顾复习!

排序前言

对于排序算法,不仅仅要了解算法原理,背下代码模板,更要理解该如何分析和评价一个排序算法!

所有代码均为C++,部分涉及C++11语法,均在win10平台下,VScode编译运行无误。

总览

image

十大排序算法分析:
image

基于分治思想的排序

希尔排序

也称 递减增量排序算法,是 插入排序 的一种更加高效的改进版本,也是第一个 突破 O ( n 2 ) O(n^2) O(n2) 的排序。

首先还记得 插入排序的基本思想:通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

在 插入排序中,我们每次只交换两个 相邻元素,当一个元素需要向前移动到它正确的位置时,只能一步一步的移动,因此插入插入排序的平均时间复杂度为: O ( n 2 ) O(n^2) O(n2)

而希尔排序的想法是 实现具有 一定间隔 的元素之间的交换,即首先排序有一定间隔的元素,同时按顺序依次减小间隔,这样就可以让一个元素一次性地向目标位置前进一大步,当间隔为1时,就是 插入排序了。

  1. 计算步长间隔值 gap
  2. 将数组划分为子数组
  3. 按照 插入排序思想,进行排序
  4. 重复这个过程,直到间隔为1,进行普通的插入排序

shellSort

#include <iostream>
#include <vector>

using namespace std;

void shellSort(vector<int> & arr)
{
    //最初步长设置为 n/2, 并不断步长取半
    for(int gap=arr.size()/2;gap>0;gap/=2)
    {
        //插入排序
        for(int i=gap;i<arr.size();i++)
        {
            int temp=arr[i];
            int j;
            for(j=i;j>=gap&&arr[j-gap]>temp;j-=gap)
            {
                arr[j]=arr[j-gap];
            }

            arr[j]=temp;
        }
    }
}
int main()
{
    vector<int> test={3,5,1,0,45,2,8,9,4,10,3};
    shellSort(test);
    for(auto x:test)
    {
        cout<<x<<" ";
    }
    cout<<"\n";
    system("pause");
    return 0;
}

在希尔排序中,步长选择 尤为重要,上面是取 n / 2 n/2 n/2, 并且不断取半,直到最后为1,虽然这样可以比普通的插入排序O(n^2)更好,但是根据步长的选择不同,希尔排序的平均时间复杂度还可以更优。下图为不同步长序列选择下的最坏时间复杂度
image

代码分析

  • 希尔排序的运行时间依赖于增量序列的选择 ,而证明很复杂【有兴趣可查看其他资料】,这里就不再多言了。
  • 使用 希尔增量 时希尔排序 最坏时间复杂度 是: O ( n 2 ) O(n^2) O(n2)
  • 使用 Hibbard增量 的希尔排序 最坏时间复杂度 是: O ( n 3 / 2 ) O(n^{3/2}) O(n3/2) ; 最优时间复杂度是 O ( n 5 / 4 ) O(n^{5/4}) O(n5/4)
  • 使用 Sedgewick 增量 序列,排序 最坏时间复杂度 是: O ( n 4 / 3 ) O(n^{4/3}) O(n4/3) ; 平均时间复杂度是 O ( n 7 / 6 ) O(n^{7/6}) O(n7/6) 。最好的序列是 1 , 5 , 19 , 41 , 109 … … {1,5,19,41,109……} 1,5,19,41,109。该序列中的项或者是 9 ∗ 4 i − 9 ∗ 2 i + 1 9 * 4 i - 9 * 2 i +1 94i92i+1的形式,或者是 4 i − 3 ∗ 2 i + 1 4 i - 3* 2 i +1 4i32i+1的形式。

是原地排序吗?
没有使用额外内存空间,希尔排序是 原地排序算法

是稳定排序吗?
由于存在希尔排序将数组分组并存在元素的交换,所以 希尔排序不是稳定的排序算法。

基于计数的排序

基于比较次数的,前提是要知道这些数的范围,以便于给数组开空间,下面会详细介绍:计数排序、桶排序和基数排序。

*计数排序

计数排序的核心在于 将 输入的数据值 转化为 键 ,存储在 额外开辟的数组空间中,就是 用额外的数组记录输入数据中各种数据出现的次数,然后将数据按出现频数取出
countingSort

先找到数组中元素最大值max,额外分配一个大小为max+1的数组用于计算元素出现次数。最后从小到大按元素个数更新原数组。

#include <iostream>
#include <vector>

using namespace std;

void countSort(vector<int>& arr)
{
    int max=arr[0];
    for(auto x:arr)
    {
        if(x>max)
        {
            max=x;
        }
    }
    
    //需要分配数组大小,节省 数组空间
    vector<int> count(max+1,0);
    //计数
    for(int i=0;i<arr.size();i++)
    {
        count[arr[i]]++;
    }

    int index=0;
    for(int k=0;k<count.size();k++)
    {
        for(int cnt=0;cnt<count[k];cnt++)
        {
            arr[index++]=k;
        }
    }
}
int main()
{
    vector<int> test={3,5,1,0,45,2,8,9,4,10,3};
    countSort(test);
    for(auto x:test)
    {
        cout<<x<<" ";
    }
    cout<<"\n";
    system("pause");
    return 0;
}

上面的算法是可以进行优化的,我们在计数时分配了 计数数组count,但是 count数组的两个位置根没有计入任何数字,因为该数组中 最小的元素为 2,根本就不会 用到 0 和1,所以我们可以进行改进,找到数组中的最大元素和最小元素,然后分配: m a x − m i n + 1 max-min+1 maxmin+1 空间的数组即可,改进和如下:

void countSort(vector<int>& arr)
{
    int max=arr[0];
    int min=arr[0];
    for(auto x:arr)
    {
        if(x>max)
        {
            max=x;
        }
        if(x<min)
        {
            min=x;
        }
    }
    
    //需要分配数组大小,节省 数组空间
    vector<int> count(max-min+1,0);
    //计数
    for(int i=0;i<arr.size();i++)
    {
        count[arr[i]-min]++;
    }

    int index=0;
    for(int k=0;k<count.size();k++)
    {
        for(int cnt=0;cnt<count[k];cnt++)
        {
            arr[index++]=k;
        }
    }
}

这样改进后的算法,确实节省了一定的空间,但是也出现了一个问题,就是每次排序的结果是根据 count 数组中的元素出现次数直接对原数组进行更新,这样 原数组中的元素会被 覆盖,算法的稳定性 也会受到影响,那么是否可以 保证排序前后的数组的数据 前后一致 并且 保持稳定呢?

是可以的,我们首先需要对之前的count数组进行变形,将数组中的每一个元素进行累加:从第二个元素开始,每个元素都加上前面的元素的和。
由于我们要保证原数组数据的 前后一致性,所以接下来我们需要分配一个 新的数组,用于拷贝 array 中的元素,最终达到有序。

这里我们使用 反向填充数组 实现:
建立好count数组后,我们用k遍历原数组array.

例如当k=5时,我们在count数组中找到对应下标(5-2=3)的位置,count[3]=5.也就是说,我们的元素5最终会在有序数组中的第5位。由于数组下标从0开始,所以我们需要将5保存至Sorted_array下标为4的地方,即Sorted_array[4]=array[5]。同时存入一个数据后,我们需要将count数组中该元素对应值减1,表示下一个相等元素位置向前移动,直到我们遍历完整个数组array.
image

上面就可以简单描述为:
k=5 -> array[k]=5 -> array[k]-min=3 count[3]=5 -> sorted_array[4] = array[5]

由于最后我们将原数组反向填充到新数组中,同时指向位置的指针不断向前移动,这样,我们就保证了我们计数排序算法的稳定性
image

改进后代码如下:

#include <iostream>
#include <vector>

using namespace std;

vector<int> countSort(vector<int>& arr)
{
    int max=arr[0];
    int min=arr[0];
    for(auto x:arr)
    {
        if(x>max)
        {
            max=x;
        }
        if(x<min)
        {
            min=x;
        }
    }
    
    //需要分配数组大小,节省 数组空间
    vector<int> count(max-min+1,0);
    vector<int> sortedarray(arr.size(),0);

    //计数
    for(int i=0;i<arr.size();i++)
    {
        count[arr[i]-min]++;
    }

    for(int j=1;j<count.size();j++)
    {
        count[j]=count[j-1]+count[j];
    }

    for(int k=arr.size()-1;k>=0;k--)
    {
        sortedarray[count[arr[k]-min]-1]=arr[k];
        count[arr[k]-min]--;
    }

    return sortedarray;

}

int main()
{
    vector<int> test={3,5,1,0,45,2,8,9,4,10,3};
    vector<int> newArray=countSort(test);
    for(auto x:newArray)
    {
        cout<<x<<" ";
    }
    cout<<"\n";
    system("pause");
    return 0;
}

算法分析

计数排序的时间复杂度为: O ( n + k ) O(n+k) O(n+k),由于需要分配额外的数组空间,空间复杂度也为: O ( n + k ) O(n+k) O(n+k)

是原地排序吗?
计数排序 不是原地排序算法

是稳定排序吗?
通过反向填充数组,可以是实现 稳定的计数排序

由于用来计数的数组count的长度取决于待排序数组中数据的范围,这使得对于数据范围很大的数组,计数排序需要消耗大量额外的内存。也就是说计数排序具有一定的局限性,虽然作为一种线性时间复杂度的排序,计数排序要求输入必须是确定范围的整数。如果数据范围太大,意味着我们需要额外消耗的内存也就更大,所以计数排序也不是那么万能的。

桶排序

桶排序中的桶其实有点类似于计数排序中的“键”,不过这里的桶代表的是一个区间范围。桶排序算法的实现就是将数组分配到有限量的桶里,然后对每个桶分别进行排序(有可能用到其他排序算法),排序完后再将桶里的数据依次拿出,即可得到排序后的数

  1. 设定一定数量的空桶。
  2. 遍历数组,将元素放入特定范围的桶中。
  3. 对每个不为空的桶内数据进行排序。

image

#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>

using namespace std;

void bucketSort(vector<int>& arr)
{
    queue<int> buckets[10];
    for (int digit = 1; digit <= 1e9; digit *= 10)
    {
        for (int elem : arr)
        {
            buckets[(elem / digit) % 10].push(elem);
        }

        int idx = 0;
        for (queue<int>& bucket : buckets)
        {
            while (!bucket.empty())
            {
                arr[idx++] = bucket.front();
                bucket.pop();
            }
        }
    }
    
}

int main()
{
    vector<int> test = {3, 5, 1, 0, 45, 2, 8, 9, 4, 10, 3};
    bucketSort(test);
    for (auto x : test)
    {
        cout << x << " ";
    }
    cout << "\n";
    system("pause");
    return 0;
}

如果桶的数量等于数组元素的数量,那么桶排序就变成了计数排序。所以在代码中可以看到与计数排序相似的地方

代码分析

如果我们需要排序的数组元素有 n 个,需要使用 m 个桶来存储数据,那么平均每个桶的元素个数为: k = n / m k=n/m k=n/m 个,如果在桶内使用快速排序,则时间复杂度为: k l o g k klogk klogk,总的时间复杂度为 : n l o g ( n / m ) nlog(n/m) nlog(n/m), 如果 桶的数量接近元素数量,桶排序的时间复杂度为: O ( n ) O(n) O(n), 但是如果运气不好,所有的元素都到了一个桶了,那么它的时间复杂度就退化成 O ( n l o g n ) O(nlogn) O(nlogn)

基数排序

基数排序是 非比较型的 整数排序算法,原理是:将整数按位切割成不同的数字,然后按每个位数分别比较。

由此可见,基数排序是基于位数的比较,所以再处理一些位数较多的数字时基数排序就有明显的优势了。例如在给手机号排序,或者给一些较长的英语专业名词排序等。

主要步骤:

  1. 扎到数组总的最大值并求其 位数bit
  2. 初始化一个数组 count[],长度与当前数组所包含数字的进制相同,比如整数排序,数组长度为10
  3. 运用 计数排序的思想对数组进行排序,循环 bit 次

radixsort

#include <iostream>
#include <vector>

using namespace std;

int maxbit(vector<int> arr)
{
    //d保存最大位数
    int bit=1,p=10;
    for(int i=0;i<arr.size();i++)
    {
        while(arr[i]>=p)
        {
            p*=10;
            bit++;
        }
    }

    return bit;
}

void radixSort(vector<int>& arr)
{
    int bit=maxbit(arr);
    vector<int> tmp(arr.size());
    //计数器0-9
    vector<int> count(10);

    int i,j,k;
    int radix=1;
    //进行bit次排序
    for(int i=1;i<=bit;i++)
    {
        for(j=0;j<10;j++)
        {
            //清空分配前的计数器
            count[j]=0;
        }
        for(j=0;j<arr.size();j++)
        {
            k=(arr[j]/radix)%10;
            count[k]++;
        }
        for(int j=1;j<10;j++)
        {
            count[j]=count[j-1]+count[j];
        }

        for(j=arr.size()-1;j>=0;j--)
        {
            k=(arr[j]/radix)%10;
            tmp[count[k]-1]=arr[j];
            count[k]--;
        }

        for(j=0;j<arr.size();j++)
        {
            arr[j]=tmp[j];
        }
        radix=radix*10;
    }
}
int main()
{
    vector<int> test={3,5,1,0,45,2,8,9,4,10,3};
    radixSort(test);
    for(auto x:test)
    {
        cout<<x<<" ";
    }
    cout<<"\n";
    system("pause");
    return 0;
}

算法分析

基数排序的时间复杂度是: O ( k ∗ n ) O(k*n) O(kn), 其中 n 个排序的元素个数,k是元素中最大元素的位数,因此,基数算法也是线性的时间复杂度,但是由于k取决于数字的位数,所以在某些情况下该算法不一定优于 O ( n l o g n ) O(nlogn) O(nlogn)
空间复杂度为: O ( n + k ) O(n+k) O(n+k)

是原地排序吗?
当然不是,基数排序需要暂存一个最大位数大小的数组,不是原地排序

是稳定排序吗?
基数排序是稳定排序

数据结构

堆是一种数据结构,通过 构建堆,也可以实现排序,C++ 中有可以直接调用的库函数:优先队列,可以直接代替堆(大根堆)的使用。

堆排序

利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆积的性质:即 子结点的键值或索引总是小于(或者大于)它的父节点

堆:一棵完全二叉树(完全二叉树是指即除了最底层未被填满,其他层的节点都必须被元素填满,同时最底层的叶子节点必须全部在左侧且未中间未有空缺)

将每个节点的值都大于等于其子节点值的堆称为“大顶堆”(max heap)
将每个节点的值小于等于子节点值的堆称为“小顶堆”(min heap).
image

一般用一个数组来存储完全二叉树,所以也可以使用 数组来存储堆,如果输入如下,则构建的 堆如图:
image

如果堆中任意一个元素的下标为 i i i , 则如果该节点有 左右节点 和 前驱结点,则他们的下标如下:
左子节点: 2 ∗ i − 1 2*i-1 2i1
右子节点: 2 ∗ i + 2 2*i+2 2i+2
前驱结点: i / 2 i/2 i/2

对于堆,我们还有如下操作:

  • 维护堆 heapify
    对于“大顶堆”,它的每个节点的值都必须大于等于它的子节点的值。所以说为了维护一个“大顶堆”,我们需要设计一个算法将不满足“大顶堆”性质的节点进行修改。修改思路是将其与子节点相比较,如果它的值小于它的子节点,就将其与子节点中最大值发生交换,然后继续对该点进行判断,直到到达合适的地方
  • 建堆 build heap
    当我们需要维护一个大小为n的堆时,一般从下标n/2的位置不断向前移动。如下图,我们首先判断下标为4的元素,其满足堆的定义。然后我们判断下标为3的元素,使其与值为29的子节点发生交换。依次循环,当下标为1时,值为2的节点需要不断与子节点发生交换,直到叶子节点的位置。具体实现如下图(上图):
    image

有了这两个操作,我们就可以利用 堆 实现排序操作了:
根据“大顶堆”的定义,堆顶元素即为整个数据中的最大值。于是当我们建好堆后,我们将堆顶元素与堆中最后一个元素交换,即将堆中最大元素放在了数组中的最后一个位置。此时,因为我们将较小的元素放在了堆顶,所以我们需要对其进行堆维护(heapify).维护完成后,堆顶元素为此时堆中的最大元素,然后继续重复上面的操作,反向填充数组,直到最后堆中剩下一个元素,即在数组的首位置
heapSort

#include <iostream>
#include <vector>

using namespace std;

void down(vector<int>& arr,int n, int u)
{
    //记录最小值
    int t=u;
    //左孩子
    if(2*u+1<n&&arr[2*u+1]>arr[t])
    {
        t=2*u+1;
    }
    if(2*u+2<n&&arr[2*u+2]>arr[t])
    {
        t=2*u+2;
    }

    //需要调整
    if(t!=u)
    {
        swap(arr[t],arr[u]);
        //递归这个过程
        down(arr,n,t);
    }
}
void heapSort(vector<int>& arr)
{
    int n=arr.size();
    for(int i=n/2-1;i>=0;i--)
    {
        //初始化堆
        down(arr,n,i);
    }

    for(int i=n-1;i>=0;i--)
    {
        swap(arr[0],arr[i]);
        //堆的数量减少一个
        down(arr,i,0);
    }

}
int main()
{
    vector<int> test={3,5,1,0,45,2,8,9,4,10,3};
    heapSort(test);
    for(auto x:test)
    {
        cout<<x<<" ";
    }
    cout<<"\n";
    system("pause");
    return 0;
}

因为我们维护的是一个逐渐减小的堆,所以要把 数组的大小作为参数传递。

在C++ 中,直接使用 优先队列,实现堆排序 会更加方便:

#include <iostream>
#include <vector>
#include <queue>

using namespace std;

int main()
{
    vector<int> test={3,5,1,0,45,2,8,9,4,10,3};

    //小根堆-优先队列
    priority_queue<int,vector<int>,greater<int>> heap;
    for(auto x:test)
    {
        heap.push(x);
    }

    //输出
    while(!heap.empty())
    {
        cout<<heap.top()<<" ";
        heap.pop();
    }
    cout<<"\n";
    system("pause");
    return 0;
}

算法分析

我们已经知道,包含n个元素的完整二叉树的高度为 l o g n logn logn

我们使用 heapify 函数,对某个元素进行维护时,我们需要继续将元素与其左右 子元素进行比较,并向下推移,直到两个子元素均小于其大小,最坏情况下要移动到叶子节点,而且在建堆时,还需要对 n / 2 n/2 n/2 个元素执行这样的操作,所以最坏时间复杂度为: n / 2 ∗ l o g ( n )   −   n l o g n n/2*log(n) ~-~ nlogn n/2log(n)  nlogn

在排序步骤中,我们将根元素与最后一个元素交换,并堆放根元素。对于每个元素,这又需要花费 l o g n logn logn最长时间,因为我们可能需要将元素从根一直带到最远的叶子上。由于我们重复了 n n n次,因此 h e a p S o r t heapSort heapSort步骤也是 n l o g n nlogn nlogn。由于 建堆 和 排序 的步骤是一个接一个执行的,所以算法复杂度不会增加,并且保持为: n l o n g nlong nlong.
即 堆排序在所有情况下,时间复杂度均为: O ( n l o g n ) O(nlogn) O(nlogn)

是原地排序吗?
需要进行多次交换,不使用额外空间(仅堆空间),堆排序是原地排序

是稳定排序吗?
多次进行交换元素,建堆和调整,堆排序不是稳定排序。

最后总结

算法分析

我们讲解的十大排序:
image

十大排序算法分析:
image

最后

感谢观赏,一起提高,慢慢变强

参考

站在巨人的肩膀上

ACwing-Coderoger:超详细的十大排序算法总结

博客园-一像素:十大经典排序算法

ACwing-封禁用户:C++排序算法整理

排序面试问答 v1.0.pdf

其他内容来自网络

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值