数据结构-排序(五):计数排序与排序专题阶段总结|从外部归并到线性排序,再看算法边界与选型

写在前面

前面的排序专题中,我们已经从冒泡、插入、希尔排序一路学习到堆排序、快速排序和归并排序。上一篇归并排序重点研究了一个非常重要的思想:分治 。递归版本不断把大区间拆成小区间,非递归版本则从 gap = 1 开始,自底向上把小的有序区间不断合并成更大的有序区间。

这一篇可以作为目前排序阶段的一个阶段性收尾。除了新增一种思路完全不同的排序算法------计数排序(Counting Sort),还想顺着前面的内容继续向外延伸几个问题:

  • 如果数据多到内存根本装不下,排序还能不能进行?
  • 为什么计数排序能达到接近线性的速度?
  • 它为什么可以处理负数,却又不能随便用于所有数据?
  • 快速排序已经很快了,工程中还能怎样继续优化?
  • 基数排序、桶排序又处于什么位置?
  • 到目前为止学过的排序算法,时间、空间、稳定性分别有什么区别?

本次代码上单独新增了 CountSort 模块,并把计数排序同步接入原来的 SortComparison 综合测试工程。独立版本的核心接口就是:

复制代码
void CountSort(int* a, int n);

随后在综合测试中作为第 15 组排序算法,继续使用相同的 10 万随机数据进行计时和有序性校验。

当前独立 CountSort 工程中的两个接口也已经这样声明。此外,这两个版本已经同步加入原来的 SortComparison 综合排序工程,继续使用 10 万随机整数 + 相同输入数组 + 运行时间统计 + Check 有序性验证进行测试。

代码仓库 :

数据结构/CountSort · Luminous/Code_2026 - 码云 - 开源中国

SortComparison 排序算法综合工程

代码仓库:

数据结构/SortComparison · Luminous/Code_2026 - 码云 - 开源中国


一、从内部排序到外部排序:归并思想的延伸

1.1 内部排序的前提:数据全部装入内存

前面写的各种排序算法基本都有一个默认前提:数据能够全部放入内存 ,这种排序通常称为内部排序(Internal Sorting)

例如 10000 个 int,一个 int 按 4 字节计算,只需要大约 400 KB,放在内存里毫无压力。但如果数据规模变成 10 亿个 int,仅原始整数本身就大约需要:

假设机器只允许排序程序使用约 1 GB 内存,那么显然不能一次性全部读入内存再调用排序算法。这时就需要进入外部排序(External Sorting)的思路。

1.2 外部排序的核心思路:分块排序 + 多路归并

这正好可以继续使用上一章学习的归并思想。假设待排序数据约 4 GB,可用内存约 1 GB,整体过程可以理解为:

复制代码
4 GB 原始数据
        ↓
拆成多份能够放进内存的数据块
        ↓
每一块单独读入内存
        ↓
使用 QuickSort / HeapSort / MergeSort 等内部排序
        ↓
得到若干有序文件
        ↓
两两归并
        ↓
继续归并
        ↓
最终得到一个完整有序文件

例如最开始有 4 份有序数据 run1~run4,第一轮归并得到 run12、run34,第二轮归并得到最终结果。这实际上就是把归并排序从内存中的数组区间 ,扩展到了磁盘上的有序文件块

1.3 归并排序的工程价值

归并排序一个非常重要的工程价值就是:它天然适合外部排序

真正归并时也不需要把两个完整文件同时读进内存,只需要分别维护输入缓冲区,再维护一个输出缓冲区,就可以边读、边比较、边写入。因此即使数据远大于内存,也依然能够完成排序。


二、计数排序:非比较排序的原理与实现

2.1 比较排序的理论下界

我们前面学习的冒泡、插入、希尔、堆、快速、归并、双向选择排序,本质上都有一个共同点:通过比较两个元素的大小,决定它们的先后顺序 ,这类算法统称为比较排序

对于通用的比较排序,理论上存在 (\Omega(N\log N)) 级别的比较次数下界。也就是说,当我们看到 QuickSort、HeapSort、MergeSort 都做到 (O(N\log N)) 时,其实已经非常接近通用比较排序能够达到的数量级极限。

那么问题来了:有没有可能排序做到 (O(N))?可以,但是必须放弃一部分"通用性",这就引出了本篇的核心------计数排序

2.2 计数排序的核心思想

计数排序完全换了一条路:不比较两个元素谁大,而是直接统计每个数出现了多少次

假设数组:9 2 5 4 7 8 3 2 5 7 普通排序会不断比较元素大小,而计数排序直接统计频次:

  • 2 出现 2 次
  • 3 出现 1 次
  • 4 出现 1 次
  • 5 出现 2 次
  • 7 出现 2 次
  • 8 出现 1 次
  • 9 出现 1 次

接下来从最小数字开始依次回填,得到 2 2 3 4 5 5 7 7 8 9,排序就完成了。

本次独立测试使用的正是对应逻辑:

cpp 复制代码
int arr[] = { 9,2,5,4,7,8,3,2,5,7 };
int n = sizeof(arr) / sizeof(arr[0]);
CountSort(arr, n);
PrintArray(arr, n);

2.3 实现步骤一:扫描最值,确定值域

当前实现并没有默认数据一定从 0 开始,而是先扫描一遍数组得到最小值和最大值:

cpp 复制代码
int min = a[0];
int max = a[0];

for (int i = 0; i < n; i++)
{
    if (a[i] < min)
        min = a[i];
    if (a[i] > max)
        max = a[i];
}

然后计算整个数据值域:

cpp 复制代码
int range = max - min + 1;

例如 min = 2,max = 9,则 range = 9 - 2 + 1 = 8,只需要创建 8 个计数位置,而不是直接从 0 开到 9。当前 CountSort 正是先扫描得到 min/max,再按照 max-min+1 计算计数范围。

2.4 偏移映射:为什么能处理负数

统计阶段的核心代码:

cpp 复制代码
for (int i = 0; i < n; i++)
{
    count[a[i] - min]++;
}

这一行非常重要,它意味着:count 数组保存的并不是原数值本身,而是"相对于最小值的偏移"

如果 min = 2,映射关系为:

  • 原数据 2 → count0
  • 原数据 3 → count1
  • ......
  • 原数据 9 → count7

也就是: index=value-min 回填时则反过来: (value=index+min)

这也是为什么这份代码能够非常自然地处理负数 。例如数据 -3 -1 2 -3 0min = -3range = 6,通过偏移映射后所有下标仍然是 0 ~ range-1 的合法非负整数。

2.5 计数数组的初始化

接下来申请计数数组:

cpp 复制代码
int* count = (int*)calloc(range, sizeof(int));

malloc 不同,calloc 申请完成以后会把这片计数数组初始化为 0,非常适合统计出现次数的场景。

例如 min = 2,max = 9,最开始计数全部为 0;扫描原数组以后,对应位置累加得到各数值的出现次数。

2.6 回填数组完成排序

统计完成之后,按照计数结果从小到大回填:

cpp 复制代码
int j = 0;

for (int i = 0; i < range; i++)
{
    while (count[i]--)
    {
        a[j++] = i + min;
    }
}

从最小偏移开始扫描 count,按照每个数字的出现次数重新写回原数组。由于 i 是从小到大遍历的,最终写回的数组自然就是升序。

2.7 完整代码实现

当前独立版本的核心代码可以整理为:

cpp 复制代码
void CountSort(int* a, int n)
{
    if (n <= 0)
        return;

    // 找最小值和最大值
    int min = a[0];
    int max = a[0];
    for (int i = 0; i < n; i++)
    {
        if (a[i] < min)
            min = a[i];
        if (a[i] > max)
            max = a[i];
    }

    // 数据值域
    int range = max - min + 1;

    // 创建计数数组,并初始化为0
    int* count = (int*)calloc(range, sizeof(int));
    if (count == NULL)
    {
        perror("calloc fail");
        return;
    }

    // 统计每个数据出现次数
    for (int i = 0; i < n; i++)
    {
        count[a[i] - min]++;
    }

    // 按从小到大的顺序重新写回
    int j = 0;
    for (int i = 0; i < range; i++)
    {
        while (count[i]--)
        {
            a[j++] = i + min;
        }
    }

    free(count);
    count = NULL;
}

它和独立 CountSort.c 的主体逻辑完全一致。

2.8 时间与空间复杂度

定义:

cpp 复制代码
N = 数据个数
R = max - min + 1(数据值域)

整个算法主要分为三部分:扫描 N 个元素找 min/max → 扫描 N 个元素统计 → 扫描 R 个计数位置回填。

因此时间复杂度:

空间复杂度:

如果 R 与 N 同数量级甚至 R << N,那么它看起来几乎就是 O(N),这就是计数排序速度非常夸张的原因。它没有突破数学规律,只是它根本不属于通用的比较排序。

2.9 适用边界:值域决定效率

计数排序最大的弱点:不是 N,而是 range。

例如 N = 10000,但所有数字都只分布在 0 ~ 999,R = 1000,计数排序非常合适。但假设数据只有 2 个,分别是 -100000000 和 100000000,虽然 N 极小,但 R 达到两亿,此时为了两个整数去申请巨大的计数数组显然完全不合理。

所以判断计数排序是否适合,不能只看数据有多少个,而必须同时看数据值域有多大。因此计数排序特别适合:数据量很大,但整数值域相对集中,例如成绩、年龄、小范围 ID、类别编号等。

同时它也天然局限于离散整数。核心的下标映射要求数据值可以直接映射成数组下标,对于浮点数、字符串、结构体等无法简单映射到连续整数下标的数据,不能直接使用基础版计数排序。

2.10 稳定性说明:当前为基础不稳定版

经典计数排序可以实现为稳定排序。但是当前版本只记录一个值出现多少次,并没有记录同样值的多个元素原来的先后顺序。

如果排序的是单纯 int,这个问题看不出来;但如果每个数据还有附属信息,例如同分的不同学生,当前这种"直接根据次数重新生成值"的版本不能保留原始相对顺序。因此本文当前实现应视为不稳定版本

如果后续需要稳定计数排序,需要通过统计次数、计算前缀和、建立输出数组、按原数组顺序定位元素的方式实现,会比当前入门版稍复杂一些。

2.11 性能测试的注意事项

当前综合工程统一生成随机数组,所有测试都先 memcpy 复原原始数据再进行排序,保证同一次运行里所有算法面对相同输入。综合测试当前的数据规模也是 10000。

但计数排序和其他算法有一个很大的不同:它的性能不仅取决于 N,还取决于随机数据的值域 R。不同平台的 RAND_MAX 不一定一样,如果随机数值域很小,CountSort 会非常快;如果随机数覆盖范围极大,就可能申请巨大的 count 数组,甚至 calloc 失败。

所以如果以后想专门做更加公平、可控的计数排序性能实验,可以显式限定值域,例如:

cpp 复制代码
arr[i] = rand() % 10000;

这样就可以明确研究固定值域条件下的性能,而不是让平台的 RAND_MAX 决定实验结果。


三、排序算法的更多进阶方向

3.1 快速排序的进阶优化

目前我们已经做过三数取中、小区间插入排序、双指针 Partition、挖坑 Partition、非递归显式栈等优化,当前代码确实已经把多个版本都实现出来。但 QuickSort 还能继续优化,这里先简单介绍两个方向。

3.1.1 三路划分:解决大量重复元素

普通 QuickSort 主要划分成 < keykey> key 三段。但当数组有大量重复数据时,传统二路 Partition 的效率会受到影响。

三路划分则直接把区间分成 < key= key> key,等于 key 的整段区域不再参与后续递归。对于重复元素很多的数据,三路快排通常会明显更合适。

3.1.2 Introsort(内省排序):快排与堆排的混合

QuickSort 的优势是平均性能极好,但理论最坏情况为 O(N^2);HeapSort 的优势是最坏情况仍然保持 O(N logN)。

于是工程中自然产生一种思路:能不能平时用 QuickSort,发现递归已经异常深时,再切换 HeapSort?这就是 Introsort(内省排序) 思想之一。

简单理解:先用快速排序,不断统计递归深度;划分正常就继续 QuickSort,递归深度超过阈值时,当前区间改用 HeapSort。同时特别小的区间仍然可以使用 InsertSort。因此最终就形成 QuickSort + HeapSort + InsertSort 的混合排序。

这也是为什么工程级排序函数往往不是教材里某一个算法的"纯版本",而是根据不同数据状态自动切换策略。

3.2 基数排序:按"位"来排

除了计数排序之外,还有一种经典的非比较排序:基数排序(Radix Sort)

例如十进制整数,可以先按个位排序,再按十位排序,再按百位排序。如果每一轮使用稳定排序,最终就可以得到整体有序结果。它的思想不是比较 A 和 B 谁大,而是先按某一位分类,然后一位一位处理。

基础教材里的 LSD/MSD 基数排序通常主要针对非负整数或能够拆分成固定"位"的离散关键字。对于负数、浮点数并不能直接把最基础的版本原样套上去。当然工程上可以通过正负拆分、编码映射、IEEE 浮点位模式变换等方法继续扩展,所以更准确地说:基础基数排序并不直接适用于普通负整数和浮点数,而不是理论上永远无法处理。

3.3 桶排序:分桶策略与分布假设

桶排序(Bucket Sort)的思想更加直接。假设已知数据 0 ~ 999,可以提前划分多个桶,每个元素先进入自己的桶,每个桶内部再排序,最后按桶顺序拼接。

如果数据分布非常均匀,并且桶划分得合理,性能可以非常优秀。但它对数据的先验条件依赖也更强:值域是多少、数据分布均不均匀、建多少个桶、每个桶范围多大、桶内部采用什么算法,都会影响最终表现。一旦数据严重集中,绝大多数元素都掉进同一个桶,性能就会明显退化。

因此桶排序可以理解为:用更强的数据分布假设换取更高的潜在性能。


四、排序算法全景总览

4.1 核心算法指标对比表

到这里,可以对当前排序阶段做一次比较完整的总结。设 (N) 为元素个数,(R) 为数据值域大小(max-min+1):

排序算法 最好时间 平均时间 最坏时间 额外空间 稳定性
冒泡排序(带 flag) O(N) O(N^2) O(N^2) O(1) 稳定
直接插入排序 O(N) O(N^2) O(N^2) O(1) 稳定
双向选择排序 O(N^2) O(N^2) O(N^2) O(1) 不稳定
希尔排序 与增量序列有关 与增量序列有关 与增量序列有关 O(1) 不稳定
堆排序 O(N logN) O(N logN) O(N logN) (O(1)) 不稳定
快速排序 O(N logN) O(N logN) O(N^2) 平均 O(log N))递归栈 不稳定
归并排序 O(N logN) O(N logN) O(N logN) O(N) 可稳定
计数排序(当前实现) O(N+R) O(N+R) O(N+R) O(R) 当前实现不稳定

这里有几个值得特别注意的地方:

  • 希尔排序的复杂度不能简单写成一个固定值。它高度依赖 gap 序列;一些增量策略在实际测试中通常明显优于 O(N^2),但不能把某个经验上的 (N^{1.3}) 当成所有 ShellSort 的统一严格结论。
  • 归并排序理论上可以稳定,但当前代码需要一个小修改 。如果要真正保持稳定性,合并时相等元素应该优先取左区间,也就是使用 <= 而非 <
  • 计数排序的复杂度里一定不能漏掉 R。它不是无条件的 O(N),而是 O(N+R),只有值域合适时才真正强大。

4.2 算法选型:没有最好,只有最合适

学完这么多算法以后,最容易出现的一个误区是:"到底哪个排序最好?"其实没有绝对的答案。

  • 如果数据很少或者基本有序,InsertSort 可能反而非常合适。
  • 如果希望通用数组排序 + 实际速度高,QuickSort 及其混合优化仍然非常重要。
  • 如果要求最坏情况也必须保持 O(N logN) 且额外空间尽量小,HeapSort 有自己的价值。
  • 如果需要稳定且天然适合外部排序,MergeSort 非常重要。
  • 如果数据量巨大且整数值域很小,CountSort 甚至可以直接把 (N logN) 的比较排序甩开。

所以学习排序算法真正重要的并不是背出一个"最快算法",而是理解:不同算法到底利用了什么条件,又付出了什么代价。


五、阶段总结:代码工程与知识体系

5.1 SortComparison 工程现状

目前 SortComparison 中已经统一加入了全部实现:

复制代码
冒泡排序
直接插入排序
堆排序

希尔排序:
    三重分组
    二重交叉
    Knuth 三重
    Knuth 二重

双向选择排序

快速排序:
    基础优化版
    挖坑法
    双指针独立版
    显式栈非递归版

归并排序:
    递归版
    非递归版

计数排序

从测试文件可以看到,所有算法都继续采用相同的 origin 数据,测试前重新 memcpy,再通过 Check() 验证是否升序,避免"速度很快但排序结果错误"的情况。目前这套工程已经足以作为后续复习排序算法的统一实验平台。

5.2 知识脉络梳理

回头看整个排序专题,其实已经不只是学了十几个函数。

  • 从冒泡和插入开始,最早只是思考怎么交换数据、怎么让局部逐渐有序。
  • 进入希尔排序以后,开始看到先让数据局部接近有序,再最终完成排序的思路。
  • 学习堆以后,开始利用完全二叉树 + 极值维护的结构。
  • 快速排序带来了 Partition、分治、递归、基准选择、小区间混合优化。
  • 归并排序又进一步理解:分治并不一定都是"先处理再递归",也可以先递归、再合并。
  • 到了计数排序,又第一次真正离开"元素之间必须互相比较"这条道路,变成直接利用数据本身的值域信息完成排序。

这其实就是排序学习最有价值的地方:从一种固定写法,逐渐看到算法对数据特征、空间、硬件和应用场景的利用。


写在最后

到计数排序这里,我准备先给这一阶段的排序学习做一个阶段性收尾。

从最基础的 O(N^2),到 O(N logN),再到满足特殊条件时可以做到 O(N+R),真正变化的不只是时间复杂度。每一次性能提升背后,几乎都意味着算法利用了更多信息:

  • 插入排序利用已有局部有序性
  • 希尔排序通过 gap 提前减少逆序
  • 堆排序利用完全二叉树维护极值
  • 快速排序利用 Partition 将问题分治
  • 归并排序利用两个有序区间可以线性合并
  • 计数排序直接利用整数值域

因此不存在一个算法能够在所有情况下同时拥有最快、最省空间、稳定、通用、实现简单、最坏性能优秀。真正的工程算法往往是在不同目标之间做取舍,甚至像 Introsort 一样,把多种算法组合起来使用。

后续如果继续深入排序,还可以再研究三路快速排序、Introsort、稳定计数排序、基数排序、桶排序以及真正的大文件外部排序。不过到这里,比较排序、分治排序、堆排序以及非比较排序的主干已经基本建立起来,可以先把这一阶段的知识整理、复盘,再继续往更深处扩展。

相关推荐
流云枫1 小时前
003-上下文窗口:物理限制、KV Cache 与工程应对策略
算法
山下梅子酒2251 小时前
洛谷-入门-B2046
c语言
盟道科技1 小时前
小程序订阅消息大规模投递实践:频控令牌池、Redis限流与失败补偿设计
分布式·算法·小程序
工具派1 小时前
视频场景检测是怎么找到切镜头位置的?三种思路与实测
算法·计算机视觉·视频
钓鱼的肝1 小时前
csp-j-s总结(1)
c++·笔记·算法·青少年编程
流云枫1 小时前
001-Transformer架构与Self-Attention机制深度解析
算法
李永奉1 小时前
中科蓝讯SDK开发-耳机项目功耗问题排查教程
c语言·开发语言·嵌入式硬件·物联网·智能手机
The Chosen One9851 小时前
OS第二章随手记(2.1)
linux·运维·服务器·笔记
志尊宝1 小时前
Vue3 零基础每日笔记(030):动态组件与 keep-alive——切换组件与页面缓存
笔记·vue·html·前端开发·软件开发