数据结构:计数排序 (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
,统计每个元素出现的次数。
-
遇到
1
,count[1]
就加一。 -
遇到
4
,count[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
。
我们来模拟这个过程:
-
从
arr
的末尾开始,取到元素2
。 -
查找累加数组
count[2]
,值为4
。这告诉我们,2
应该放在输出数组的第4个位置,也就是output[3]
。 -
将
2
放入output[3]
。 -
非常重要❗:将
count[2]
的值减一,变为3
。这意味下一个2
如果出现,应该被放在第3个位置 (output[2]
),从而保证了稳定性。 -
继续向前,取到元素
5
。count[5]
的值是6
,所以5
放在output[5]
。然后count[5]
减一。 -
...
-
取到
arr[2]
的元素1
。count[1]
的值是2
,所以1
放在output[1]
。然后count[1]
减一,变为1
。 -
取到
arr[0]
的元素1
。count[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)。
计数排序是一种非常特殊且高效的排序算法,它牺牲空间换取时间,并绕开了“比较排序”的限制。它不是一种通用的排序算法,但在特定场景下(整数、范围不大)是无敌的存在。它也是更复杂算法如基数排序的基石。