数据结构-排序

目录

排序

排序的概念

常见的排序算法

常见排序算法的实现

插入排序

直接插入排序

[希尔排序( 缩小增量排序 )](#希尔排序( 缩小增量排序 ))

选择排序

直接选择排序:

堆排序

交换排序

冒泡排序

快速排序

快速排序优化

[1. 三数取中法选 key(基准)](#1. 三数取中法选 key(基准))

[2. 递归到小的子区间时,可以考虑使用插入排序](#2. 递归到小的子区间时,可以考虑使用插入排序)

快速排序非递归

归并排序

非比较排序

排序算法复杂度及稳定性分析​编辑

选择题


排序

排序的概念

排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次 序保持不变,即在原序列中,ri=rj,且ri在rj之前,而在排序后的序列中,ri仍在rj之前,则称这种排 序算法是稳定的;否则称为不稳定的。

内部排序:数据元素全部放在内存中的排序。

外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

常见的排序算法

常见排序算法的实现

插入排序

直接插入排序

直接插入排序是一种简单的插入排序法,其基本思想是:

把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为 止,得到一个新的有序序列 。

直接插入排序:

当插入第i(i>=1)个元素时,前面的array0,array1,...,arrayi-1已经排好序,此时用arrayi的排序码与 arrayi-1,arrayi-2,...的排序码顺序进行比较,找到插入位置将arrayi插入,原来位置上的元素顺序后移

简单来说就是把数组分成两部分:左边是已排序好的右边是待插入的。每次从右边拿一个数,插入到左边正确的位置。

直接插入排序的特性总结:

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高

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

  3. 空间复杂度:O(1),它是一种稳定的排序算法

  4. 稳定性:稳定

希尔排序( 缩小增量排序 )

希尔排序法又称缩小增量法。希尔排序法的基本思想是:

先介绍一下:增量(gap)指的是下标之间的距离,不是两个数字的差值。

先选定一个整数(称为增量 gap ),把待排序文件中所有记录分成 gap 个组 ,所有距离为 gap 的记录分在同一组内,并对每一组内的记录进行直接插入排序。

然后,取下一个更小的 gap (例如按照 Knuth 提出的方式:gap = gap/3 + 1 或初始 gap = n/2 并逐步折半),重复上述分组和排序的工作。当 gap = 1 时,所有记录在统一组内排好序,此时整个文件成为有序序列。

实际上希尔排序就是直接插入排序的改进版

  • 直接插入排序每次只移动相邻元素(增量1),效率低。

  • 希尔排序先按大间隔分组,对每组做直接插入排序,让数组宏观上基本有序;最后再用直接插入排序(gap=1)收尾,这样整体速度大大提升。

初始数组(下标和值)

下标: 0 1 2 3 4 5 6 7 8 9

值: 9 1 2 5 7 4 8 6 3 5

第一趟:gap = 5。分组(根据下标差5):

组0:下标0,5 → 值 9, 4

组1:下标1,6 → 值 1, 8

组2:下标2,7 → 值 2, 6

组3:下标3,8 → 值 5, 3

组4:下标4,9 → 值 7, 5

每组内部排序(每组只有两个元素,直接比较交换就行):

组0:4 比 9 小 → 交换位置 → 下标0变成4,下标5变成9

组1:1 比 8 小,不动 → 1, 8 保持

组2:2 比 6 小,不动

组3:3 比 5 小 → 交换 → 下标3变成3,下标8变成5

组4:5 比 7 小 → 交换 → 下标4变成5,下标9变成7

结果(第一趟排序后):

下标: 0 1 2 3 4 5 6 7 8 9

值: 4 1 2 3 5 9 8 6 5 7

第二趟:gap = 2

基于上面结果:

4 1 2 3 5 9 8 6 5 7

分组(下标差2):

组0:下标0,2,4,6,8 → 值 4, 2, 5, 8, 5

组1:下标1,3,5,7,9 → 值 1, 3, 9, 6, 7

组0内部排序(直接插入排序):

初始 4, 2, 5, 8, 5

插入 2:2 比 4 小 → 2, 4, 5, 8, 5

插入 5:比 4 大,不动 → 2, 4, 5, 8, 5

插入 8:不动

插入 5:比 8 小,比 5 小?比较:5比5相等不动?不,应该插入到 5 和 8 之间 → 2, 4, 5, 5, 8

所以组0最终: 2, 4, 5, 5, 8

对应回下标 (0,2,4,6,8):

下标0=2, 下标2=4, 下标4=5, 下标6=5, 下标8=8

组1内部排序:

初始 1, 3, 9, 6, 7

已有序前两个1,3

插入 9:不动

插入 6:比 9 小,比 3 大 → 1, 3, 6, 9, 7

插入 7:比 9 小,比 6 大 → 1, 3, 6, 7, 9

组1最终: 1, 3, 6, 7, 9

对应下标 (1,3,5,7,9):

下标1=1, 下标3=3, 下标5=6, 下标7=7, 下标9=9

合并后结果(第二趟排序后):

下标: 0 1 2 3 4 5 6 7 8 9

值: 2 1 4 3 5 6 5 7 8 9

第三趟:gap = 1

这就是一次普通的直接插入排序,对整个数组排序,最终得到:

1 2 3 4 5 5 6 7 8 9

规则

分组:按"下标差为 gap"分成多个子序列。

排序 :每个子序列内部独立进行直接插入排序(不是整体交换)。

不断缩小 gap,最后 gap=1 就是全局插入排序。

希尔排序的特性总结:

  1. 希尔排序是对直接插入排序的优化。

  2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就 会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。

  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,希尔排序的时间复杂度都不固定

  4. 稳定性:不稳定

关于时间复杂度,每个教材都给出了相关解释:

比如《数据结构(C语言版)》--- 严蔚敏

《数据结构-用面相对象方法与C++描述》--- 殷人昆

选择排序

基本思想: 每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的 数据元素排完 。即:

每次从待排序的区间中选出最小(或最大) 的元素,放到区间的起始位置(或末尾位置),然后缩小待排序区间,重复这个过程。

直接选择排序:

在元素集合arrayi--arrayn-1中选择关键码最大(小)的数据元素

若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换

在剩余的arrayi--arrayn-2(arrayi+1--arrayn-1)集合中,重复上述步骤,直到集合剩余1个元素

特性:

  1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用

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

  3. 空间复杂度:O(1)

  4. 稳定性:不稳定

堆排序

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是 通过堆来进行选择数据 。需要注意的是排升序要建大堆,排降序建小堆。

特性:

  1. 堆排序使用堆来选数,效率就高了很多。

  2. 时间复杂度:O(N*logN)

  3. 空间复杂度:O(1)

  4. 稳定性:不稳定

交换排序

基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排 序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。

冒泡排序

特性:

  1. 冒泡排序是一种非常容易理解的排序

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

  3. 空间复杂度:O(1)

  4. 稳定性:稳定

快速排序

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中 的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右 子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

cpp 复制代码
// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
 if(right - left <= 1)
 return;
 
 // 按照基准值对array数组的 [left, right)区间中的元素进行划分
 int div = partion(array, left, right);
 
 // 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right)
 // 递归排[left, div)
 QuickSort(array, left, div);
 
 // 递归排[div+1, right)
 QuickSort(array, div+1, right);
}

上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,在写递归框架时可想想二叉 树前序遍历规则即可快速写出来,后序只需分析如何按照基准值来对区间中数据进行划分的方式即可。 将区间按照基准值划分为左右两半部分的常见方式有hoare,挖坑法,前后指针这三种:

这三种都是快速排序中实现分区(partition) 的不同方法。它们的目标一样:选一个基准值(pivot),把数组分成左边 ≤ pivot、右边 ≥ pivot 的两部分,并返回基准的最终位置。

下面用数组 [6, 1, 2, 7, 9, 3, 4, 5, 10, 8] 为例,假设选第一个元素 6 作为基准。

  1. Hoare 版本(原始版本)

由快速排序发明者 Hoare 提出,使用左右指针向中间扫描,交换逆序对。

左指针 L 从左边找大于等于 pivot 的元素

右指针 R 从右边找小于等于 pivot 的元素

交换 L 和 R 指向的元素

继续直到 L ≥ R

最后将 pivot 与 R 指向的元素交换

特点:

效率高,交换次数少

基准最终位置不一定是分界点,分界点在左右指针相遇处

边界条件容易错

  1. 挖坑法

坑位的概念代替交换,更直观。

保存 pivot 值,原位置成为"坑"

右指针找比 pivot 小的数,填入左边坑位,右指针处变新坑

左指针找比 pivot 大的数,填入右边坑位,左指针处变新坑

交替进行,直到左右指针相遇

最后把 pivot 填入最后的坑位

特点:

逻辑清晰,不需要交换元素,只是搬运

容易理解和实现

相对于 Hoare,多了一次赋值操作

过程示意(pivot=6):

6, 1, 2, 7, 9, 3, 4, 5, 10, 8

坑位0

右找小:5 → 填入坑位0 → 5, 1, 2, 7, 9, 3, 4, _, 10, 8 坑位7

左找大:7 → 填入坑位7 → 5, 1, 2, _, 9, 3, 4, 7, 10, 8 坑位3

右找小:4 → 填入坑位3 → 5, 1, 2, 4, 9, 3, _, 7, 10, 8 坑位6

左找大:9 → 填入坑位6 → 5, 1, 2, 4, _, 3, 9, 7, 10, 8 坑位4

右找小:3 → 填入坑位4 → 5, 1, 2, 4, 3, _, 9, 7, 10, 8 坑位5

左右相遇,填入 pivot 6 → 5, 1, 2, 4, 3, 6, 9, 7, 10, 8

  1. 前后指针版本

使用两个指针(前指针 i,后指针 j)同向移动,维护一个"小于等于 pivot 的区域"。

选择 pivot(通常最右或最左)

指针 i 指向分区边界左侧

指针 j 遍历数组,遇到 ≤ pivot 的元素就与 i+1 交换,然后 i 前进

最后将 pivot 与 i+1 交换

特点:

代码最简洁,不容易出错

单次遍历,只需一个循环

交换次数较多,但现代计算机上性能差异不大

过程示意(pivot=6,基准选最右或最左稍有不同):

若选最左为 pivot,则从第二个元素开始用 j 扫描,小于 6 的依次交换到前面。

遍历后得到 [1, 2, 3, 4, 5, 6, 9, 7, 10, 8] 这样的效果。

理解原理推荐挖坑法前后指针,逻辑直观

追求极致性能可以考虑 Hoare 版(交换次数更少)

实际写代码时前后指针最简洁、最不容易写错

快速排序优化

三数取中 → 让分区更平衡,避免最坏情况。

小数组插入排序 → 降低递归深度,提升小规模数据排序速度。

两者结合后,快速排序的实际性能 接近最优,被广泛应用于标准库(如 C 的 qsort、C++ 的 std::sort 的内省排序中也包含类似思想)。

1. 三数取中法选 key(基准)

问题背景

普通快排通常选第一个或最后一个元素作为基准(key)。

如果数组已经有序或接近有序,每次选到的 key 都是最小或最大值,导致分区极度不平衡。

结果:递归深度 = n,时间复杂度退化为 O(n²)

优化思想

不固定选端点,而是从当前子数组的左端、中间、右端 三个位置取元素,选择它们中大小居中的那个作为 key。

具体做法(以选左端为 key 的场景为例)

  1. 计算中间位置:mid = left + (right - left) / 2

  2. 比较 arr[left]arr[mid]arr[right]

  3. 将三个值中中间大小 的元素与 arr[left] 交换,使其成为新的 key。

  4. 之后正常执行分区算法(挖坑/Hoare/前后指针)。

举例

子数组:[8, 3, 6, 1, 9]

  • left=8, mid=6, right=9

  • 排序三个数:[6,8,9],中值是 8

  • 所以还是选 8 作为 key(如果原数组是 [1,2,3,4,5],则左=1,中=3,右=5 → 选 3 作为 key,比选 1 更平衡)

效果

  • 极大降低最坏情况发生的概率。

  • 对于基本有序的数据,性能接近 O(n log n)。

  • 额外代价:仅增加常数次比较和一次交换。

2. 递归到小的子区间时,可以考虑使用插入排序

问题背景

  • 快速排序是分治算法,会一直递归直到子数组长度为 1。

  • 当子数组长度很小(比如 < 10)时,递归调用的函数开销(压栈、调用)已经大于排序本身的工作量。

  • 插入排序在小规模数据上常数因子小、对缓存友好,实际速度比快排更快。

优化思想

在递归函数中增加一个判断:如果当前子数组长度 ≤ 某个阈值(通常取 10~20),则不再继续递归,而是直接调用插入排序对该子数组排序。

具体做法(伪代码)

cpp 复制代码
void quickSort(arr, left, right) {
    if (left >= right) return;
    // 小数组优化
    if (right - left + 1 <= 10) {
        insertionSort(arr, left, right);
        return;
    }
    // 否则正常分区 + 递归左右子数组
    int p = partition(arr, left, right);
    quickSort(arr, left, p-1);
    quickSort(arr, p+1, right);
}

插入排序实现

cpp 复制代码
void insertionSort(int arr[], int left, int right) {
    for (int i = left+1; i <= right; i++) {
        int key = arr[i];
        int j = i-1;
        while (j >= left && arr[j] > key) {
            arr[j+1] = arr[j];
            j--;
        }
        arr[j+1] = key;
    }
}

阈值选择

  • 通常取 10 ~ 20,具体可通过测试调整。

  • 太大会削弱快排优势,太小优化不明显。

效果

  • 减少递归调用次数(递归树底部的许多小节点被剪枝)。

  • 利用插入排序在小数组上的高效稳定性

快速排序非递归

快速排序通常用递归实现,但递归太深可能导致栈溢出。非递归版本用自己管理的栈保存待排序的区间,避免了系统调用栈的开销。执行流程:初始将整个区间入栈,循环弹出区间进行分区,再将得到的左右子区间入栈,直到栈空。

非递归快排的核心思想

递归快排的工作流程:

cpp 复制代码
quickSort(arr, left, right):
    if (left >= right) return
    pivot = partition(arr, left, right)   // 分区,得到基准下标
    quickSort(arr, left, pivot-1)         // 递归处理左半区
    quickSort(arr, pivot+1, right)        // 递归处理右半区

非递归模拟

我们用栈来保存 "每一次递归调用需要处理的区间边界"

  • 初始将 [left, right] 压栈。

  • 循环:弹出栈顶区间 [L, R],如果区间有效(L < R),则进行分区得到基准位置 div

  • 然后将左子区间 [L, div-1] 和右子区间 [div+1, R] 分别压栈。

  • 重复直到栈空。

栈的 LIFO 特性决定了处理顺序:先压入右区间再压入左区间,则左区间会先被处理(类似递归中的先左后右)。

cpp 复制代码
void QuickSortNonR(int* a, int left, int right)
{
    Stack st;
    StackInit(&st);
    // 初始将整个区间 [left, right] 压栈(先压左边界,后压右边界)
    //注意顺序:先左后右 → 弹栈时会先得到右边界。
    StackPush(&st, left);
    StackPush(&st, right);

    while (StackEmpty(&st) != 0)   // 栈非空则继续处理
    {
        // 因为压栈顺序是先左后右,所以先弹出的是右边界
        right = StackTop(&st);
        StackPop(&st);
        left = StackTop(&st);
        StackPop(&st);

        // 区间内元素个数 < 2 则不需要排序(注意:这里是 left >= right)
        if (left >= right)
            continue;

        // 分区,返回基准元素的最终下标(基准已经归位)
        int div = PartSort1(a, left, right);

        // 将右子区间 [div+1, right] 压栈
        StackPush(&st, div + 1);
        StackPush(&st, right);

        // 将左子区间 [left, div-1] 压栈
        StackPush(&st, left);
        StackPush(&st, div - 1);
    }

    StackDestroy(&st);
}

例子:

初始数组:[6, 1, 2, 7, 9, 3, 4, 5, 10, 8],下标 0~9。

假设分区函数 PartSort1 返回基准最终位置(例如挖坑法,pivot=6 最后放到下标 5)。

栈初始[0, 9] (先左后右)

第一轮循环

弹出 left=0, right=9 → 分区得 div=5

右子区间 [6,9] 压栈:StackPush(6); StackPush(9);

左子区间 [0,4] 压栈:StackPush(0); StackPush(4);

此时栈内容(从底到顶):[6,9] , [0,4](栈顶是 0,4

第二轮循环 (弹出栈顶 [0,4]):

弹出 left=0, right=4 → 分区得 div=3(假设)

压右区间 [4,4] → 压左区间 [0,2]

栈内容:[6,9] , [4,4] , [0,2]

第三轮循环 (弹出 [0,2]):

分区得 div=1

压右区间 [2,2] → 压左区间 [0,0]

栈内容:[6,9] , [4,4] , [2,2] , [0,0]

第四轮循环 (弹出 [0,0]):

left=0, right=0left >= right,跳过

栈内容:[6,9] , [4,4] , [2,2]

第五轮循环 (弹出 [2,2]):

跳过

栈内容:[6,9] , [4,4]

第六轮循环 (弹出 [4,4]):

跳过

栈内容:[6,9]

第七轮循环 (弹出 [6,9]):

继续分区,以此类推,直到栈空。

最终数组排序完成。

特性总结:

  1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序

  2. 时间复杂度:O(N*logN)

  3. 空间复杂度:O(logN)

  4. 稳定性:不稳定

归并排序

基本思想:

归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。

将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有 序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:

第一阶段:分解 (Divide)

分解的过程就像是一棵树的生长过程(自顶向下),不断地将数组从中间劈成两半,直到每一个节点只剩下两个元素或一个元素。

  1. 第一层分解:

    ◦ 将 {10, 6, 7, 1, 3, 9, 4, 2} 分成左半部分 {10, 6, 7, 1} 和右半部分 {3, 9, 4, 2}。

  2. 第二层分解:

    ◦ 左半部分继续分:变成 {10, 6} 和 {7, 1}。

    ◦ 右半部分继续分:变成 {3, 9} 和 {4, 2}。

  3. 第三层分解(叶子节点):

    ◦ 直到每个子数组只有两个元素(图中绿色底线部分):{10, 6}, {7, 1}, {3, 9}, {4, 2}。

    ◦ (注:在计算机实现中,通常会继续分解到单个元素,因为单个元素默认是有序的)。

第二阶段:合并 (Merge)

这是算法真正干活的地方(自底向上)。我们需要从下往上看,将两个已经有序的子数组合并成一个更大的有序数组。

合并的基本规则(双指针法):比较两个数组当前的第一个元素,谁小就把谁拿出来放到新数组里,然后移动那个数组的指针。

  1. 第一层合并(两两合并):

    ◦ 合并 {10, 6}:

    ▪ 比较 10 和 6,6 小,先放 6。

    ▪ 剩下 10,放 10。

    ▪ 结果:{6, 10} (变成了有序的蓝框)。

    ◦ 合并 {7, 1}:

    ▪ 比较 7 和 1,1 小,先放 1。

    ▪ 剩下 7,放 7。

    ▪ 结果:{1, 7}。

    ◦ 右侧同理: 合并得到 {3, 9} 和 {2, 4}。

  2. 第二层合并(四个四个合并):

    ◦ 合并左边 {6, 10} 和 {1, 7}:

    ▪ 新数组空,比较 6 和 1 -> 取出 1。

    ▪ 比较 6 和 7 -> 取出 6。

    ▪ 比较 10 和 7 -> 取出 7。

    ▪ 取出剩下的 10。

    ▪ 最终结果:{1, 6, 7, 10}。

    ◦ 合并右边 {3, 9} 和 {2, 4}:

    ▪ 比较 3 和 2 -> 取出 2。

    ▪ 比较 3 和 4 -> 取出 3。

    ▪ 比较 9 和 4 -> 取出 4。

    ▪ 取出剩下的 9。

    ▪ 最终结果:{2, 3, 4, 9}。

  3. 第三层合并(终极合并):

    ◦ 现在有两个大数组:{1, 6, 7, 10} 和 {2, 3, 4, 9}。

    ◦ 这是一个典型的"两个有序链表/数组合并"问题:

    ▪ 1 < 2,取 1

    ▪ 6 > 2,取 2

    ▪ 6 > 3,取 3

    ▪ 6 > 4,取 4

    ▪ 6 < 9,取 6

    ▪ 7 < 9,取 7

    ▪ 10 > 9,取 9

    ▪ 剩下 10,取 10

    ◦ 最终结果:{1, 2, 3, 4, 6, 7, 9, 10}。

总结与特性

性质:

  1. 稳定性: 归并排序是稳定的排序算法。这意味着在排序过程中,相等元素的相对位置不会改变

  2. 时间复杂度: O(n log n)。

    ◦ log n 来自于"分"的过程(树的高度)。

    ◦ n 来自于"合"的过程(每一层合并都需要遍历所有元素)。

    ◦ 这是所有比较排序算法中能达到的最优平均时间复杂度。

  3. 空间复杂度: O(n)。

    ◦ 归并排序在合并的过程中,需要借助额外的辅助数组来存放临时数据,因此它不是原地排序算法。

  4. 适用场景:

    ◦ 数据量较大时。

    ◦ 对稳定性有要求时。

    ◦ 链表排序(不需要连续内存空间,只需改变指针指向)。

非比较排序

计数排序(Counting Sort)是一种非常巧妙的非比较排序 算法。与我们之前学的归并排序或快速排序不同,它不通过比较元素的大小 来确定顺序,而是利用数据的范围来进行排序。它特别适合处理数据范围集中且数据量大的整数数组。

核心思想:鸽巢原理与哈希定址

计数排序的基本思想是,如果我们知道每个数值在数组中出现了多少次,我们只需要按顺序把这些数值"填"回去,就能得到一个有序的数组。这其实就是哈希表的直接定址法的一个变形:用数组下标代表数值,用数组的值代表该数值出现的次数。

操作步骤:

  1. 统计相同元素出现次数

  2. 根据统计的结果将序列回收到原来的序列中

详细解释:

第一步:找出最大值和最小值,确定数据范围

遍历 A,找到 Min = 0,Max = 5。范围 K = Max - Min + 1 = 6。所以我们建立计数数组 C0..5,初值为全 0。

第二步:统计相同元素出现的频率(对应图 a)

遍历原数组 A 的每个元素,CA\[i]++:

A0=2 → C2++ → C2=1

A1=5 → C5++ → C5=1

A2=3 → C3++ → C3=1

A3=0 → C0++ → C0=1

A4=2 → C2++ → C2=2

A5=3 → C3++ → C3=2

A6=0 → C0++ → C0=2

A7=3 → C3++ → C3=3

统计完之后,图 a 中 C 的状态是:C = 2, 0, 2, 3, 0, 1

含义:0 出现 2 次,1 出现 0 次,2 出现 2 次,3 出现 3 次,4 出现 0 次,5 出现 1 次。

第三步:对计数数组求前缀和(对应图 b)

这一步是计数排序的精髓所在。我们把 C 从前往后累加:

C0 = 2

C1 = 2 + 0 = 2

C2 = 2 + 2 = 4

C3 = 4 + 3 = 7

C4 = 7 + 0 = 7

C5 = 7 + 1 = 8

图 b 中 C 变成了:C = 2, 2, 4, 7, 7, 8

现在 Ci 的含义变了------它不再表示"i 出现的次数",而是表示"≤ i 的元素一共有多少个 "。换句话说,数值 i 在最终有序数组中的最后一个合法位置是 Ci(这里图里位置编号是从 1 开始的,即 B 的下标 1~8)。

这样做的目的有两个:一是让我们知道每个值该放哪里,二是为接下来的"逆向回填"保证稳定性打基础。

第四步:根据统计结果将序列回收到原来的序列中(对应图 c → 图 d)

这是最关键的一步。从原数组 A 的末尾向前遍历(逆向),对每一个元素 Ai

  1. 查 CA\[i],得到这个值应该放的新位置 pos = CA\[i]

  2. 把 Ai 放到结果数组 B 的第 pos 个位置(下标 pos-1)

  3. CA\[i]--

跟着图 c 走一遍,A = {2, 5, 3, 0, 2, 3, 0, 3}。最终 B 如图 d 所示:B = {0, 0, 2, 2, 3, 3, 3, 5}

再把 B 抄回原数组 A,排序完成。

**注意为什么从后往前:**​ 图中两个 3(A2 和 A7),A7 在原数组中靠后,它先被处理,占住了位置 7;A2 靠前,后被处理,占住了位置 6。所以输出后两个 3 的相对顺序还是"原来的前 3 还在前,原来的后 3 还在后"------这就是稳定性的保证。

计数排序的特性总结:

1. 高效但受限于场景

计数排序的致命短版是:它需要开的额外数组大小由"值域范围"决定。如果数据是 {1, 999999},范围接近百万但只有两个数,C 数组就得开百万级------纯属浪费。所以计数排序只适合数据范围集中且较小的整数场景(比如成绩排序 0~100、年龄排序 0~150 等)。

2. 时间复杂度:O(N + K)

N 是数组长度,K 是值域范围(Max - Min + 1)。遍历 A 统计频次花 O(N),前缀和遍历 C 花 O(K),逆向回填再花 O(N),合起来就是 O(N + K)。图上 K=6、N=8,整个过程确实是线性时间,远快于任何基于比较的 O(N log N) 排序------但前提是 K 不能太大。

3. 空间复杂度:O(K)

需要额外开一个大小为 K 的计数数组 C,以及通常还需要一个大小为 N 的结果数组 B(有些写法可以优化成一个 B 然后拷回 A),所以额外空间 O(K + N),但主导项就是 O(K)。

4. 稳定性:稳定

关键在于第三步的前缀和变换 + 第四步的从后往前逆向回填。正着填会破坏相等元素的先后顺序,逆着填就能保住。图 c 的箭头方向(从下往上扫描 A)直观地体现了这一点。

排序算法复杂度及稳定性分析

选择题

  1. 快速排序算法是基于( )的一个排序算法。

A分治法

B贪心法

C递归法

D动态规划法

2.对记录(54,38,96,23,15,72,60,45,83)进行从小到大的直接插入排序时,当把第8个记录45插入到有序

表时,为找到插入位置需比较( )次?(采用从后往前比较)

A 3

B 4

C 5

D 6

3.以下排序方式中占用O(n)辅助存储空间的是

A 简单排序

B 快速排序

C 堆排序

D 归并排序

4.下列排序算法中稳定且时间复杂度为O(n2)的是( )

A 快速排序

B 冒泡排序

C 直接选择排序

D 归并排序

5.关于排序,下面说法不正确的是

A 快排时间复杂度为O(N*logN),空间复杂度为O(logN)

B 归并排序是一种稳定的排序,堆排序和快排均不稳定

C 序列基本有序时,快排退化成冒泡排序,直接插入排序最快

D 归并排序空间复杂度为O(N), 堆排序空间复杂度的为O(logN)

6.下列排序法中,最坏情况下时间复杂度最小的是( )

A 堆排序

B 快速排序

C 希尔排序

D 冒泡排序

7.设一组初始记录关键字序列为(65,56,72,99,86,25,34,66),则以第一个关键字65为基准而得到的一趟快

速排序结果是()

A 34,56,25,65,86,99,72,66

B 25,34,56,65,99,86,72,66

C 34,56,25,65,66,99,86,72

D 34,56,25,65,99,86,72,66

答案: 1.A 2.C 3.D 4.B 5.D 6.A 7.A

相关推荐
小O的算法实验室1 小时前
2025年IEEE TITS,基于动态聚类粒子群算法的无人机任务分配与路径规划
算法
Tairitsu_H1 小时前
[LC优选算法#5] 分治:快排 | 颜色分类 | 排序数组 | 第K大元素
c++·算法·leetcode·排序算法·快速排序
青山木1 小时前
Hot 100 --- 滑动窗口最大值
java·数据结构·算法·leetcode·动态规划
青山木1 小时前
Hot 100 --- 除自身以外数组的乘积
java·数据结构·算法
Frank学习路上1 小时前
【C++】面试:STL容器与算法
c++·算法·面试
10岁的博客1 小时前
NOIP2010普及组「接水问题」详解:模拟算法与优先队列解法
开发语言·c++·算法
彼岸星光ぐ>1 小时前
排序算法对比
数据结构·算法·排序算法
YHHLAI2 小时前
LeetCode 1.两数之和 | 从暴力枚举到线性优化
算法·leetcode·职场和发展