当前位置: 首页 > news >正文

数据结构:计数排序 (Counting Sort)

目录

从“比较”的思维定势中解放出来

为什么计数排序只能处理非负整数?

一个简单但有缺陷的实现

一个更精妙的稳定实现

第一步:计数(和之前一样)

第二步:将 “频率”转换为“最终位置”

第三步:根据位置信息,构建输出数组

代码的逐步完善

step1: 找到范围,寻找最大值

step2:创建“草稿纸”(辅助数组)

step3:统计频率

step4:拷贝回原数组并释放内存

复杂度与特性分析

时间复杂度 (Time Complexity)

空间复杂度 (Space Complexity)

稳定性 (Stability)


我们来探讨一个完全不同维度的排序算法——计数排序 (Counting Sort)。它颠覆了我们之前所有算法的基础。

从“比较”的思维定势中解放出来

到目前为止,我们学过的所有排序算法(冒泡、插入、选择、快速、归并)都有一个共同的、底层的“灵魂”:

它们都依赖于元素之间的比较 (if (a < b)) 来确定顺序。这类算法被称为“基于比较的排序”。理论已经证明,任何基于比较的排序算法,其平均时间复杂度的理论下限是 O(nlogn)。

要想突破这个“音障”,我们就必须提出一个颠覆性的问题:“我们能否不通过比较元素,就完成排序?”

这听起来似乎不可能。不比较,我怎么知道 7 应该在 5 的后面?

计数排序给出的答案是:我们可以不关心元素之间的相对关系,而是去关心每个元素最终应该在哪个位置上。

怎么知道一个元素最终的位置?💡一个绝妙的想法是:如果我知道有多少个元素比我小,我的位置不就确定了吗?

例如,在数组 [3, 1, 4, 1, 5] 中,对于元素 4,比它小的元素有 3, 1, 1,共3个。那么在排好序的数组里,4 的位置就应该是第4个(索引为3)。

这个“统计小于某元素的数量来确定其位置”的思想,就是计数排序的第一性原理。它利用元素的数值本身作为信息,而不是通过比较来获取信息。

❗重要前提:这种思想有一个严格的限制,它通常只适用于非负整数,并且这些整数的范围(最大值和最小值的差)不应该过大。

为什么计数排序只能处理非负整数?

这个问题可以从计数排序最核心的“第一性原理”中找到答案。

计数排序的核心魔法是:它巧妙地使用“元素的值”作为“count 数组的索引”。

count[arr[i]]++;

这一行代码就是整个算法的基石。这个用法直接导致了两个限制:

1. 数组索引不能是负数

如果你要排序的数组包含负数,例如 arr = [-2, 1, -5]。当代码尝试执行 count[-2]count[-5] 时,程序会立即出错。

因为在 C++ 和几乎所有主流编程语言中,数组的索引都必须是从0开始的非负整数。你无法访问一个数组的“负二号”位置。

2. 数组索引不能是小数

如果你要排序的数组包含小数,例如 arr = [1.5, 2.7, 0.8]。当代码尝试执行 count[1.5]count[2.7] 时,同样会编译失败。

数组的索引必须是整数,你无法访问一个数组的“1.5号”位置。

结论就是: 这个算法之所以能实现 O(n+k) 的惊人速度,正是因为它利用了“值->索引”这个直接映射的快捷方式,绕开了元素间的比较。

但这种快捷方式的代价就是,它必须依赖于数组索引的两个基本规则:非负整数

(虽然可以通过偏移量等技巧来支持负数(例如所有数都加上一个最小值的绝对值),但这会增加实现的复杂性,并且仍然无法解决小数的问题。因此,我们通常说标准、基础的计数排序只适用于非负整数。)


一个简单但有缺陷的实现

我们先用最简单的方式来实现这个思想。

目标:对数组 arr = [1, 4, 1, 2, 7, 5, 2] 排序。

已知:数组元素都是非负整数,最大值 max = 7

1️⃣ 创建一个辅助数组,我们称之为 count 数组。它的大小应该能覆盖从0到 max 的所有值,所以大小是 max + 1

count 数组的索引将代表原始数组中的元素值。

2️⃣计数:遍历原始数组 arr,统计每个元素出现的次数。

  • 遇到 1count[1] 就加一。

  • 遇到 4count[4] 就加一。

  • ...

  • 遍历结束后,count 数组就成了每个元素值的“频率统计表”。

  • count 数组的状态会是: [0, 2, 2, 0, 1, 1, 0, 1]

  • (表示值为0的出现0次,值为1的出现2次,值为2的出现2次...)

3️⃣输出:现在我们有了频率表,可以很简单地重构出排好序的数组。

  • count[0] 是0,跳过。

  • count[1] 是2,所以在结果数组里放两个 1

  • count[2] 是2,所以在结果数组里接着放两个 2

  • ...以此类推,直到遍历完 count 数组。

  • 最终得到结果 [1, 1, 2, 2, 4, 5, 7]

这个实现有什么问题

它能正确地对数字排序。但是,如果我们要排序的对象更复杂,比如学生的成绩 (90分, 张三)(85分, 李四)(90分, 王五)。用这个方法排序后,我们只能得到 85, 90, 90

我们无法知道那个排在前面的90分到底是张三还是王五,也就是说,它不稳定 (Unstable)


一个更精妙的稳定实现

为了解决稳定性问题,我们需要对“计数”的含义进行升级。

第一步:计数(和之前一样)

  • arr = [1, 4, 1, 2, 7, 5, 2] 计数。

  • 得到 count 数组:[0, 2, 2, 0, 1, 1, 0, 1]

第二步:将 “频率”转换为“最终位置”

  • 这是整个算法最精妙的一步。我们对 count 数组进行一次变形。

  • count[i] 存储的不再是 i 出现的次数,而是小于等于 i 的元素总共有多少个

  • 这可以通过一个简单的累加实现:

    • count[1] = count[1] + count[0] = 2 + 0 = 2

    • count[2] = count[2] + count[1] = 2 + 2 = 4

    • count[3] = count[3] + count[2] = 0 + 4 = 4

    • count[4] = count[4] + count[3] = 1 + 4 = 5

    • ...

变形后的 count 数组(我们称之为“累加数组”)为: [0, 2, 4, 4, 5, 6, 6, 7]

📌如何解读这个新数组?

count[4] = 5 的意思是:在原数组中,小于等于4的元素一共有5个。

这也就意味着,在最终排好序的数组里,最后一个 4 应该被放在第5个位置(也就是索引为 5-1=4 的位置)。我们通过这种方式,直接计算出了每个元素在排序后所处区间的右边界


第三步:根据位置信息,构建输出数组

  • 现在我们有了每个元素的位置信息,我们可以创建一个新的 output 数组来存放排序结果。

  • 为了保证稳定性,这里有一个关键技巧👉:我们必须从后往前遍历原始数组 arr

我们来模拟这个过程:

  1. arr 的末尾开始,取到元素 2

  2. 查找累加数组 count[2],值为 4。这告诉我们,2 应该放在输出数组的第4个位置,也就是 output[3]

  3. 2 放入 output[3]

  4. 非常重要❗:将 count[2] 的值减一,变为3。这意味下一个 2 如果出现,应该被放在第3个位置 (output[2]),从而保证了稳定性。

  5. 继续向前,取到元素 5count[5] 的值是 6,所以 5 放在 output[5]。然后 count[5] 减一。

  6. ...

  7. 取到 arr[2] 的元素 1count[1] 的值是 2,所以 1 放在 output[1]。然后 count[1] 减一,变为1

  8. 取到 arr[0] 的元素 1count[1] 的值现在是 1,所以这个 1 放在 output[0]count[1] 减一。

因为我们是从后往前遍历原数组的,所以原数组中靠后的相同元素会先被放入 output 数组中靠后的位置。✅这样就保证了稳定性。


代码的逐步完善

step1: 找到范围,寻找最大值

为了让讲解更清晰,我们全程使用一个固定的例子。

待排序数组:int arr[] = {4, 2, 2, 8, 3, 3, 1};

数组大小:n = 7

你在笔算时,第一步会做什么?你会先看一眼数字,找到最大的那个,好准备一张足够大的草稿纸来计数。代码也需要做同样的事情。

我们先搭好函数的架子,然后实现寻找最大值的功能。

#include <iostream>void countSort(int arr[], int n) {// 对于长度为0或1的数组,无需排序if (n <= 1) {return;}// 笔算步骤: "看一眼数字,找到最大的那个"// 代码翻译: 遍历数组,用一个变量来记录最大值int max = arr[0];for (int i = 1; i < n; i++) {if (arr[i] > max) {max = arr[i];}}// 对于我们的例子 arr = {4, 2, 2, 8, 3, 3, 1}// 循环结束后, max 的值会是 8// ... 后续步骤将在这里展开 ...
}

step2:创建“草稿纸”(辅助数组)

笔算时,你会准备一张草稿纸(count数组)和一个最终写答案的纸(output数组)。

  • count 数组:这张纸需要多大?如果最大数是8,你需要有能代表0, 1, 2...直到8的所有格子,所以你需要 8 + 1 = 9 个格子。代码中就是 max + 1

  • output 数组:这张纸大小和原数组一样大,n

void countSort(int arr[], int n) {if (n <= 1) return;int max = arr[0];for (int i = 1; i < n; i++) {if (arr[i] > max) {max = arr[i];}}// 笔算步骤: "准备两张草稿纸"// 代码翻译: 创建两个辅助数组// 1. 创建 count 数组,大小为 max + 1//    因为数组大小是变量,需要动态分配内存int* count = new int[max + 1];// 2. 创建最终输出用的 output 数组int* output = new int[n];// 笔算步骤: "确保计数用的草稿纸是干净的"// 代码翻译: 将 count 数组的所有元素初始化为 0for (int i = 0; i <= max; i++) {count[i] = 0;}// ... 核心逻辑将在这里展开 ...// 重要:用完后需要释放内存delete[] count;delete[] output;
}

step3:统计频率

这是最直观的一步。遍历原数组,在 count 数组上对应位置“画正字”。

// (接上,在初始化 count 数组之后)// 笔算步骤: "遍历原始数字,在草稿纸上画正字"
// 代码翻译: 遍历 arr,以 arr[i] 的值作为 count 数组的索引,并增加其值
for (int i = 0; i < n; i++) {// 比如第一个数是 4,那么 count[4]++// 第二个数是 2,那么 count[2]++count[arr[i]]++; 
}
// 循环结束后,对于我们的例子,count 数组的状态会是:
// 索引: 0  1  2  3  4  5  6  7  8
// 值:   0  1  2  2  1  0  0  0  1

计算累加和

这是将“频率”转换为“位置”的关键一步。count[i] 将记录小于等于 i 的元素个数。

// (接上,在统计频率之后)// 笔算步骤: "将草稿纸上的'正字'个数,转换为'排名'"
// 代码翻译: 遍历 count 数组,将当前值与前一个值相加
for (int i = 1; i <= max; i++) {// count[i] 现在存储的是 <= i 的元素个数count[i] += count[i - 1];
}
// 循环结束后,对于我们的例子,count 数组的状态会是:
// 索引: 0  1  2  3  4  5  6  7  8
// 值:   0  1  3  5  6  6  6  6  7
// 解读: count[3] = 5 的意思是,小于等于3的数字共有5个。
//       所以在排好序的数组里,最后一个3应该放在第5个位置(索引为4)。

放置元素到输出数组

这是最精妙的一步。为了保证稳定性,我们从后往前遍历原数组。

// (接上,在计算累加和之后)// 笔算步骤: "从后往前看原数组,根据'排名'草稿纸,把数字填到新纸上"
// 代码翻译: 从 i = n-1 开始循环
for (int i = n - 1; i >= 0; i--) {// 例: 第一次循环,i=6, arr[6] = 1// 1. 找到 arr[i] 在 count 数组中的值。count[1] 的值是 1// 2. 这个值告诉我们,1 应该放在第1个位置,也就是索引 1-1 = 0 的位置// 3. 将 arr[i] 放入 output 数组的正确位置output[count[arr[i]] - 1] = arr[i];// 4. 将 count 数组中这个值减一。//    表示这个位置已经被占了,下一个同样的数(如果有)应该往前放count[arr[i]]--;
}
// 循环结束后,output 数组就是 [1, 2, 2, 3, 3, 4, 8],已经完全排好序

step4:拷贝回原数组并释放内存

我们最终的目的是让原数组 arr 有序,所以需要把 output 的内容复制回去。

// (接上,在构建 output 数组之后)// 笔算步骤: "把新纸上的答案抄回原来的卷子"
// 代码翻译: 遍历 output 数组,将其值一一赋给 arr
for (int i = 0; i < n; i++) {arr[i] = output[i];
}// 最后,不要忘记释放我们动态申请的内存
delete[] count;
delete[] output;

复杂度与特性分析

时间复杂度 (Time Complexity)

  • n 是输入数组的元素个数,k 是元素的范围(即 max 值)。

  • 寻找最大值:O(n)

  • 初始化count数组:O(k)

  • 统计频率:O(n)

  • 计算累加值:O(k)

  • 构建输出数组:O(n)

  • 拷贝回原数组:O(n)

  • 总时间复杂度是这些步骤的总和:O(n+k)。

📍关键点:这是一个线性时间的排序算法!当 k 的规模与 n 差不多或者比 n 小的时候 (k = O(n)),它的时间复杂度就是 O(n),比任何基于比较的排序 O(nlogn) 都要快。

但如果 k 非常大(比如 k=n^2),它的性能就会急剧下降。


空间复杂度 (Space Complexity)

  • 我们创建了两个主要的辅助数组:

    • count 数组,大小为 k+1

    • output 数组,大小为 n

  • 因此,总的额外空间复杂度是:O(n+k)


稳定性 (Stability)

  • 正如我们第二步推导中详细分析的,通过计算累加位置从后往前遍历输入数组,我们能确保相同元素的原始相对顺序得以保留。

  • 因此,计数排序是稳定的排序算法 (Stable Sort)


计数排序是一种非常特殊且高效的排序算法,它牺牲空间换取时间,并绕开了“比较排序”的限制。它不是一种通用的排序算法,但在特定场景下(整数、范围不大)是无敌的存在。它也是更复杂算法如基数排序的基石。

http://www.dtcms.com/a/361747.html

相关文章:

  • 逻辑门编程(一)——与或非门
  • 接口响应慢 问题排查指南
  • MongoDB 内存管理:WiredTiger 引擎原理与配置优化
  • GraalVM Native Image:让 Java 程序秒启动
  • 植物中lncRNA鉴定和注释流程,代码(包含Identified,Classification,WGCNA.....)
  • shell编程 函数、数组与正则表达式
  • 预处理——嵌入式学习笔记
  • day06——类型转换、赋值、深浅拷贝、可变和不可变类型
  • 009=基于YOLO12与PaddleOCR的车牌识别系统(Python+PySide6界面+训练代码)
  • C++运行时类型识别
  • k8s知识点汇总2
  • Java 加载自定义字体失败?从系统 fontconfig 到 Maven 损坏的全链路排查指南
  • 基于 C 语言的网络单词查询系统设计与实现(客户端 + 服务器端)
  • 适合工程软件使用的python画图插件对比
  • Maven - Nexus搭建maven私有仓库;上传jar包
  • 20250829的学习笔记
  • OPENCV 基于旋转矩阵 旋转Point2f
  • 代码随想录二刷之“回溯”~GO
  • 机器翻译:python库translatepy的详细使用(集成了多种翻译服务)
  • Spring框架入门:从IoC到AOP
  • 爬虫实战练习
  • 如何在Github中创建仓库?如何将本地项目上传到GitHub中?
  • IDEA Spring属性注解依赖注入的警告 Field injection is not recommended 异常解决方案
  • Python绘制多彩多角星实战
  • MyBatis 性能优化最佳实践:从 SQL 到连接池的全面调优指南
  • 链表相关OJ题
  • MongoDB 备份与恢复:mongodump 和 mongorestore 实战
  • NestJS 3 分钟搭好 MySQL + MongoDB,CRUD 复制粘贴直接运行
  • Flutter Container 阴影设置指南 2025版
  • Flutter 完全组件化的项目结构设计实践