十大排序算法

本文详细介绍了冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序和堆排序的原理、时间复杂度分析及应用场景,对比了它们的优缺点。此外,还涵盖了计数排序、桶排序和基数排序,展示了在不同场景下的适用性和效率提升策略。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

先用两张图概括:
在这里插入图片描述
在这里插入图片描述

冒泡排序

重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。
在这里插入图片描述
什么时候最快: 当输入的数据已经是正序时

什么时候最慢: 当输入的数据是反序时

冒泡排序最好情况是数组已经有序的情况,此时只需要遍历一次数据,没有交换发生,结束排序,时间复杂度为O(n)。

那最坏情况下的冒泡就是逆序,此时你需要遍历n-1次数据,(数据为 3 2 1,一次遍历为2 1 3 ,二次遍历 1 2 3 结束 ),此时的时间复杂度为O(n^2) 。
平均情况下也为O(n^2) 需要注意的是平均情况并不是与最坏情况下的时间复杂度相等,
平均的时间复杂度=sum(Pi*f(n));Pi为每种情况出现的概率,计算起来有些困难。
空间复杂度:只需要一个temp临时变量来交换数据,所以O(1)

#include <iostream>
using namespace std;
template<typename T> //整数或浮点数皆可使用,若要使用类(class)或结构体(struct)时必须重载大于(>)运算符
void bubble_sort(T arr[], int len) {
        int i, j;
        for (i = 0; i < len - 1; i++)
                for (j = 0; j < len - 1 - i; j++)
                        if (arr[j] > arr[j + 1])
                                swap(arr[j], arr[j + 1]);
}
int main() {
        int arr[] = { 61, 17, 29, 22, 34, 60, 72, 21, 50, 1, 62 };
        int len = (int) sizeof(arr) / sizeof(*arr);
        bubble_sort(arr, len);
        for (int i = 0; i < len; i++)
                cout << arr[i] << ' ';
        cout << endl;
        float arrf[] = { 17.5, 19.1, 0.6, 1.9, 10.5, 12.4, 3.8, 19.7, 1.5, 25.4, 28.6, 4.4, 23.8, 5.4 };
        len = (float) sizeof(arrf) / sizeof(*arrf);
        bubble_sort(arrf, len);
        for (int i = 0; i < len; i++)
                cout << arrf[i] << ' '<<endl;
        return 0;
}

选择排序

无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。

  • 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
  • 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
  • 重复第二步,直到所有元素均排序完毕。

在这里插入图片描述

template<typename T> //整數或浮點數皆可使用,若要使用物件(class)時必須設定大於(>)的運算子功能
void selection_sort(std::vector<T>& arr) {
        for (int i = 0; i < arr.size() - 1; i++) {
                int min = i;
                for (int j = i + 1; j < arr.size(); j++)
                        if (arr[j] < arr[min])
                                min = j;
                std::swap(arr[i], arr[min]);
        }
}

插入排序

  • 将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。

  • 从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
    在这里插入图片描述

public static void InsertSort(int[] array)
{
    for(int i = 1;i < array.length;i++)
    {
        int temp = array[i];
        for(int j = i - 1;j >= 0;j--)
        {
            if(array[j] > temp)
            {
                array[j + 1] = array[j];
                array[j] = temp;
            }
            else
                break;
        }
    }
}

希尔排序

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

  • 选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;

  • 按增量序列个数 k,对序列进行 k 趟排序;

  • 每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

归并排序

和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 O(nlogn) 的时间复杂度。代价是需要额外的内存空间。

归并排序是用分治思想,分治模式在每一层递归上有三个步骤:

  • 分解(Divide):将n个元素分成个含n/2个元素的子序列。
  • 解决(Conquer):用合并排序法对两个子序列递归的排序。
  • 合并(Combine):合并两个已排序的子序列已得到排序结果。
    void merge(vector<int>& a, int L1, int R1, int L2, int R2){
        int i = L1, j = L2;
        vector<int> temp;
        while(i <= R1 && j <= R2){
            if(a[i] < a[j])
                temp.push_back(a[i++]);
            else
                temp.push_back(a[j++]);
        }
        while(i <= R1)
            temp.push_back(a[i++]);
        while(j <= R2)
            temp.push_back(a[j++]);
        for(int i = L1; i <= R2; i++)
            a[i] = temp[i - L1];
    }
    
    void MergeSort(vector<int> &a, int left, int right){
        if(left < right){
            int mid = (right - left) / 2 + left;
            MergeSort(a, left, mid);
            MergeSort(a, mid + 1, right);
            merge(a, left, mid, mid + 1, right);
        }
    }

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

快速排序

通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序

快速排序使用分治法(Divide and conquer)策略来把一个序列(list)分为两个子序列(sub-lists)。

  • 从数列中挑出一个元素,称为 “基准”(pivot),
  • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。

时间复杂度:

快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排,就是每一次取到的元素就是数组中最小/最大的,这种情况其实就是冒泡排序了(每一次都排好一个元素的顺序)。 这种情况时间复杂度就好计算了,就是冒泡排序的时间复杂度:T[n] = n * (n-1) = n^2 + n;

快速排序最优的情况就是每一次取到的元素都刚好平分整个数组。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。此时的时间复杂度公式则为:T[n] = 2T[n/2] + f(n);T[n/2]为平分后的子数组的时间复杂度,f[n] 为平分这个数组时所花的时间。

巧解快排时间复杂度:
快速排序我们可以近似的想成一个二叉树
在这里插入图片描述
节点是所有元素的不重复的划分,每次划分时轴元素与所有元素都要比较一次,因此每层的节点排序的合计时间复杂度可以看做O(n),每次划分确定一个元素的位置,因此节点数=划分次数=元素个数,二叉树深度乘以O(n)就是快速排序的时间复杂度。
二叉树的深度的计算可以参照:2^(深度-1)=最大节点数,则深度与节点的关系有:深度=lgn
最终快排的时间复杂度:nO(lgn)

空间复杂度:
分析下就地快速排序的空间复杂度:
首先就地快速排序使用的空间是O(1)的,也就是个常数级;而真正消耗空间的就是递归调用了,因为每次递归就要保持一些数据;

  • 最优的情况下空间复杂度为:O(logn) ;每一次都平分数组的情况
  • 最差的情况下空间复杂度为:O( n ) ;退化为冒泡排序的情况

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

    int randPartition(vector<int>& a, int left, int right){
        int p = (round(1.0 * rand()/RAND_MAX * (right - left))) + left;
        swap(a[p], a[left]);
        int temp = a[left];
        while(left < right){
            if(left < right && a[right] > temp)
                right--;
            a[left] = a[right];
            if(left < right && a[left] <= temp)
                left++;
            a[right] = a[left];
        }
        a[left] = temp;
        return left;
    }
     
    void QuickSort(vector<int> &a, int left, int right){
        if(left < right){
            int p = randPartition(a, left, right);
            QuickSort(a, left, p - 1);
            QuickSort(a, p + 1, right);
        }
    }

堆排序

堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。

  • 创建一个堆 heap[0……n-1];

  • 把堆首(最大值)和堆尾互换;

  • 把堆的尺寸缩小 1,并调用 downAdjust(1),目的是把新的数组顶端数据调整到相应位置;

  • 重复步骤 2,直到堆的尺寸为 1。

int heap[maxn], n = 10;

void Swap(int &a, int &b){
    int temp;
    temp = a;
    a = b;
    b = temp;
}

void downAdjust(int low, int high){
    if(low == high)
        return;
    int i = low, j = 2 * i;
    while(j <= high){
        if(j + 1 <= high && heap[j] < heap[j + 1])
            j++;
        if(heap[i] < heap[j]){
            Swap(heap[i], heap[j]);
            i = j;
            j = 2 * i;
        }
        else
            break;
    }
}

void createHeap(){
    for(int i = n/2; i >= 1; i--){
        downAdjust(i, n);
    }
}

void deleteTop(){
    heap[1] = heap[n--];
    downAdjust(1, n);
}

计数排序

文章https://blog.youkuaiyun.com/csdnnews/article/details/83005778中写的很详细。

计数排序需要根据原始数列的取值范围,创建一个统计数组,用来统计原始数列中每一个可能的整数值所出现的次数。
原始数列中的整数值,和统计数组的下标是一一对应的,以数列的最小值作为偏移量。比如原始数列的最小值是90, 那么整数95对应的统计数组下标就是 95-90 = 5。
在这里插入图片描述
数组每一个下标位置的值,代表了数列中对应整数出现的次数。

有了这个“统计结果”,排序就很简单了。直接遍历数组,输出数组元素的下标值,元素的值是几,就输出几次。

桶排序

每一个桶(bucket)代表一个区间范围,里面可以承载一个或多个元素。桶排序的第一步,就是创建这些桶,确定每一个桶的区间范围:
在这里插入图片描述
具体建立多少个桶,如何确定桶的区间范围,有很多不同的方式。我们这里创建的桶数量等于原始数列的元素数量,除了最后一个桶只包含数列最大值,前面各个桶的区间按照比例确定。

区间跨度 = (最大值-最小值)/ (桶的数量 - 1)

第二步,遍历原始数列,把元素对号入座放入各个桶中:

在这里插入图片描述
第三步,每个桶内部的元素分别排序(显然,只有第一个桶需要排序):

在这里插入图片描述
第四步,遍历所有的桶,输出所有元素:0.5,0.84,2.18,3.25,4.5
到此为止,排序结束。

每一个桶被定义成一个链表(LinkedList),这样便于在尾部插入元素。

假设原始数列有n个元素,分成m个桶(我们采用的分桶方式 m=n),平均每个桶的元素个数为n/m。

下面我们来逐步分析算法复杂度:

第一步求数列最大最小值,运算量为n。

第二步创建空桶,运算量为m。

第三步遍历原始数列,运算量为n。

第四步在每个桶内部做排序,由于使用了O(nlogn)的排序算法,所以运算量为 n/m * log(n/m ) * m。

第五步输出排序数列,运算量为n。

加起来,总的运算量为 3n+m+ n/m * log(n/m ) * m = 3n+m+n(logn-logm) 。

去掉系数,时间复杂度为:

O(n+m+n(logn-logm))

至于空间复杂度就很明显了:

空桶占用的空间 + 数列在桶中占用的空间 = O(m+n)。

为了使桶排序更加高效,我们需要做到这两点:

  1. 在额外空间充足的情况下,尽量增大桶的数量
  2. 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中

同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。

什么时候最快:当输入的数据可以均匀的分配到每一个桶中。

什么时候最慢:当输入的数据被分配到了同一个桶中。

基数排序

把排序工作拆分成多个阶段,每一个阶段只根据一个字符进行计数排序,一共排序k轮(k是元素长度)。

基数排序 vs 计数排序 vs 桶排序
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

基数排序:根据键值的每位数字来分配桶;
计数排序:每个桶只存储单一键值;
桶排序:每个桶存储一定范围的数值;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值