计数排序 (Counting Sort):线性时间非比较排序
摘要:本文从"比较排序的时间下界是 Ω(n log n)"的理论限制出发,详解计数排序如何通过"统计次数而非比较大小"突破这一下界,实现 O(n + k) 的线性时间复杂度。给出了支持升序/降序的 Python 完整实现(标准稳定版、原地版、基数排序专用版),图解了四步核心流程与前缀和的含义,分析了其稳定性保证与空间权衡。最后结合基数排序、桶排序的工程场景讨论其设计哲学与面试高频考点。
本文属于专栏《算法》系列 1 第 16 篇 | 上一篇:块排序 (Block Sort) | 下一篇:(待更新)
文章目录
- [计数排序 (Counting Sort):线性时间非比较排序](#计数排序 (Counting Sort):线性时间非比较排序)
-
- 一、问题引入
-
- [能不能突破 Ω(n log n) 的下界?](#能不能突破 Ω(n log n) 的下界?)
- 为什么叫"计数"排序?
- 二、算法原理图解
- 三、代码实现
- 四、复杂度分析
- 五、横向对比
- 六、工程实战
- 七、常见误区与面试题
- 八、总结
一、问题引入
前面我们讨论的所有排序算法------冒泡、快排、归并、堆排序、TimSort------都属于比较排序。它们有一个共同的理论下界:
比较排序的最坏情况时间复杂度不可能优于 Ω(n log n)
这个结论来自决策树模型:n 个元素的排列有 n! 种可能,决策树至少需要 n! 个叶子节点,因此树的高度至少为 log₂(n!) ≈ n log₂n。
能不能突破 Ω(n log n) 的下界?
答案是:能,但要换一种思路。
比较排序的下界只适用于"基于比较"的排序算法。如果我们能利用数据本身的某些特性,不通过比较来确定顺序,就有可能突破这个下界。
计数排序就是这样一种非比较排序。它的核心思路非常朴素:
统计每个值出现了多少次,然后按顺序输出。
举个例子:要给班级同学的考试成绩排序(满分 100 分),我们不需要两两比较分数------只需要统计每个分数有多少人,然后从 0 分到 100 分依次输出即可。
| 分数 | 0 | 1 | 2 | ... | 59 | 60 | 61 | ... | 99 | 100 |
|---|---|---|---|---|---|---|---|---|---|---|
| 人数 | 0 | 0 | 0 | ... | 2 | 5 | 8 | ... | 3 | 1 |
统计完之后,从左到右遍历计数数组,按次数输出,排序就完成了。整个过程没有任何"比较"操作。
为什么叫"计数"排序?
因为算法的核心操作是计数------统计每个值出现了多少次。这也是所有非比较排序的共同特征:利用数据的分布信息,而不是依赖两两比较。
问题定义:
- 输入 :含 n 个整数的数组
arr,取值范围为 min_val, max_val - 输出:按升序(或降序)排列的数组
- 核心约束:不通过比较元素大小来排序
- 核心操作:统计每个值的出现次数 → 前缀和定位 → 放置元素
二、算法原理图解
核心思想
计数排序的核心是利用"数据取值范围有限"这个特性,通过统计每个值的出现次数来实现排序。整个算法分为四步:
- 确定范围:找出最小值和最大值,确定计数数组的大小
- 统计次数:遍历原数组,统计每个值出现多少次
- 前缀和:计算前缀和,确定每个值在输出数组中的位置
- 放置元素:从后往前遍历原数组,将元素放到正确位置
四步流程文字图解
以数组 [3, 1, 4, 1, 5, 9, 2, 6, 5] 升序排序为例:
第一步:确定取值范围
原数组: [3, 1, 4, 1, 5, 9, 2, 6, 5]
min_val = 1
max_val = 9
k = max - min + 1 = 9 (取值范围大小)
第二步:统计每个值的出现次数
创建计数数组 count,大小为 9,初始全 0
索引: 0 1 2 3 4 5 6 7 8 (对应值: 1,2,3,4,5,6,7,8,9)
count: [0, 0, 0, 0, 0, 0, 0, 0, 0]
遍历原数组:
3 → count[2]++ → [0,0,1,0,0,0,0,0,0]
1 → count[0]++ → [1,0,1,0,0,0,0,0,0]
4 → count[3]++ → [1,0,1,1,0,0,0,0,0]
1 → count[0]++ → [2,0,1,1,0,0,0,0,0]
5 → count[4]++ → [2,0,1,1,1,0,0,0,0]
9 → count[8]++ → [2,0,1,1,1,0,0,0,1]
2 → count[1]++ → [2,1,1,1,1,0,0,0,1]
6 → count[5]++ → [2,1,1,1,1,1,0,0,1]
5 → count[4]++ → [2,1,1,1,2,1,0,0,1]
最终 count 数组含义:
值 1 出现 2 次
值 2 出现 1 次
值 3 出现 1 次
值 4 出现 1 次
值 5 出现 2 次
值 6 出现 1 次
值 7 出现 0 次
值 8 出现 0 次
值 9 出现 1 次
第三步:计算前缀和
前缀和的含义是:小于等于当前值的元素总个数,也就是当前值在输出数组中的"右边界索引 + 1"。
初始 count: [2, 1, 1, 1, 2, 1, 0, 0, 1]
↑ 值1的次数
从左到右累加前缀和:
i=1: count[1] += count[0] = 1+2 = 3
→ 小于等于值2的元素有3个
i=2: count[2] += count[1] = 1+3 = 4
→ 小于等于值3的元素有4个
i=3: count[3] += count[2] = 1+4 = 5
→ 小于等于值4的元素有5个
i=4: count[4] += count[3] = 2+5 = 7
→ 小于等于值5的元素有7个
i=5: count[5] += count[4] = 1+7 = 8
→ 小于等于值6的元素有8个
i=6: count[6] += count[5] = 0+8 = 8
→ 小于等于值7的元素有8个
i=7: count[7] += count[6] = 0+8 = 8
→ 小于等于值8的元素有8个
i=8: count[8] += count[7] = 1+8 = 9
→ 小于等于值9的元素有9个(总元素数)
最终 prefix: [2, 3, 4, 5, 7, 8, 8, 8, 9]
前缀和数组的含义:
prefix[0] = 2→ 值为 1 的元素排在位置 0 和 1(右边界是 2)prefix[1] = 3→ 值为 2 的元素排在位置 2(右边界是 3)prefix[2] = 4→ 值为 3 的元素排在位置 3(右边界是 4)- ...
prefix[i]→ 值为 min_val+i 的元素的右边界(不包含)索引
第四步:从后往前遍历,放置元素
为什么要从后往前?为了保证稳定性------相同值的元素,原数组中后出现的,在排序结果中也排在后面。
原数组(从后往前): [3, 1, 4, 1, 5, 9, 2, 6, 5]
← ← ← ← ← ← ← ← ←
prefix = [2, 3, 4, 5, 7, 8, 8, 8, 9]
result = [_, _, _, _, _, _, _, _, _]
i=8, val=5:
idx = 5 - 1 = 4
prefix[4]-- → 7 → 6
result[6] = 5
result: [_, _, _, _, _, _, 5, _, _]
i=7, val=6:
idx = 6 - 1 = 5
prefix[5]-- → 8 → 7
result[7] = 6
result: [_, _, _, _, _, _, 5, 6, _]
i=6, val=2:
idx = 2 - 1 = 1
prefix[1]-- → 3 → 2
result[2] = 2
result: [_, _, 2, _, _, _, 5, 6, _]
i=5, val=9:
idx = 9 - 1 = 8
prefix[8]-- → 9 → 8
result[8] = 9
result: [_, _, 2, _, _, _, 5, 6, 9]
i=4, val=5:
idx = 5 - 1 = 4
prefix[4]-- → 6 → 5
result[5] = 5
result: [_, _, 2, _, _, 5, 5, 6, 9]
↑ 注意:两个5,后出现的放在位置6,先出现的放在位置5
保持了相对顺序,所以是稳定的!
i=3, val=1:
idx = 1 - 1 = 0
prefix[0]-- → 2 → 1
result[1] = 1
result: [_, 1, 2, _, _, 5, 5, 6, 9]
i=2, val=4:
idx = 4 - 1 = 3
prefix[3]-- → 5 → 4
result[4] = 4
result: [_, 1, 2, _, 4, 5, 5, 6, 9]
i=1, val=1:
idx = 1 - 1 = 0
prefix[0]-- → 1 → 0
result[0] = 1
result: [1, 1, 2, _, 4, 5, 5, 6, 9]
i=0, val=3:
idx = 3 - 1 = 2
prefix[2]-- → 4 → 3
result[3] = 3
result: [1, 1, 2, 3, 4, 5, 5, 6, 9] ✓
最终结果:[1, 1, 2, 3, 4, 5, 5, 6, 9],排序完成。
稳定性的关键:从后往前遍历
为什么从后往前遍历就能保证稳定性?用一个具体例子来看:
原数组:[1_a, 3, 1_b, 2] (下标表示原始顺序)
从前往后遍历(不稳定):
1_a → 放在位置 0
1_b → 放在位置 1
结果:[1_a, 1_b, 2, 3] ← 看起来也是对的?
等等,让我们再仔细看前缀和的含义:
prefix[val] 存的是"右边界+1",即最后一个该值元素的下一个位置
从前往后:
遇到 1_a → prefix[1]-- = 1 → 放在位置 0
遇到 1_b → prefix[1]-- = 0 → 放在位置 -1?不对...
实际上,从前往后 + 前缀和的组合会出问题。
正确的稳定做法是从后往前:
遇到 1_b → 放在最后一个 1 的位置(位置1)
遇到 1_a → 放在前一个 1 的位置(位置0)
结果:[1_a, 1_b, 2, 3]
相对顺序保持不变 → 稳定
核心原因:前缀和存的是右边界,从后往前遍历时,后出现的元素先被放到靠右的位置,先出现的元素后被放到靠左的位置,正好保持了原始相对顺序。
三、代码实现
完整代码
通过网盘分享的文件:算法
链接: https://pan.baidu.com/s/1DTJt1X2Is_IQeH5fAXvtZg?pwd=yyqf 提取码: yyqf
--来自百度网盘超级会员v4的分享
标准稳定版
python
def counting_sort(arr, ascending=True):
"""
计数排序:非比较排序,通过统计每个值的出现次数来排序。
核心思想:
1. 找出数组中的最小值和最大值,确定计数范围
2. 统计每个值的出现次数
3. 计算前缀和,确定每个值在输出数组中的位置
4. 从后往前遍历原数组,将元素放到正确位置(保证稳定性)
时间复杂度:O(n + k) | 空间复杂度:O(k) | 稳定排序
其中 k 为取值范围大小(max - min + 1)
"""
if len(arr) <= 1:
return list(arr)
# 步骤一:找出取值范围
min_val = min(arr)
max_val = max(arr)
k = max_val - min_val + 1
# 步骤二:统计每个值的出现次数
count = [0] * k
for val in arr:
count[val - min_val] += 1
# 步骤三:计算前缀和
if ascending:
for i in range(1, k):
count[i] += count[i - 1]
else:
for i in range(k - 2, -1, -1):
count[i] += count[i + 1]
# 步骤四:从后往前遍历原数组,放入正确位置
result = [None] * len(arr)
for i in range(len(arr) - 1, -1, -1):
val = arr[i]
idx = val - min_val
count[idx] -= 1
result[count[idx]] = val
return result
六个关键设计解析
设计1:偏移量 min_val
python
count[val - min_val] += 1
为什么要减 min_val? 如果数据范围是 1000, 2000,直接用 val 做索引需要创建大小为 2001 的数组,而前面 0, 999 的空间完全浪费。减去 min_val 后,索引从 0 开始,数组大小正好是 k = max - min + 1,没有空间浪费。
这也是计数排序能处理负数的原因------只要最小值是负数,偏移后索引仍然从 0 开始。
设计2:前缀和的含义
python
for i in range(1, k):
count[i] += count[i - 1]
前缀和存的是什么? count[i] 表示"小于等于值 (min_val+i) 的元素总个数",也就是该值在排序结果中的右边界位置 + 1。
为什么是"右边界 + 1"而不是"左边界"?因为在第四步放置元素时,我们用 count[idx] -= 1 先减一,然后放在减一后的位置------这样每次放置都会自动往左移动一个位置,正好放下一个相同值的元素。
设计3:从后往前遍历保证稳定
python
for i in range(len(arr) - 1, -1, -1):
val = arr[i]
idx = val - min_val
count[idx] -= 1
result[count[idx]] = val
为什么从后往前? 前缀和存的是右边界。从后往前遍历时,后出现的元素先被放到右边界位置(先减一得到索引),右边界随之左移。先出现的元素后被放到左移后的位置。这样相同值的元素,原始顺序和排序后的顺序是一致的------稳定。
如果从前往后遍历,相同值的元素会被"反向"放置,破坏稳定性。
设计4:降序的前缀和方向
python
else:
# 降序:从右往左累加
for i in range(k - 2, -1, -1):
count[i] += count[i + 1]
为什么降序要从右往左累加? 升序时,前缀和表示"小于等于当前值的元素个数"。降序时,前缀和应该表示"大于等于当前值的元素个数",所以累加方向反过来------从最大值开始往最小值方向累加。
这也保证了第四步放置元素时的逻辑完全一致,不需要额外分支。
设计5:空间权衡------计数数组 vs 输出数组
python
count = [0] * k # O(k) 空间
result = [None] * n # O(n) 空间
总空间是 O(n + k) 还是 O(k)? 这取决于实现方式:
- 稳定版:需要 O(n) 的输出数组 + O(k) 的计数数组,总空间 O(n + k)
- 原地版:只需要 O(k) 的计数数组,直接重写原数组,但不稳定
是否牺牲稳定性来节省 O(n) 空间,要看具体场景。在大多数情况下,稳定性的价值远大于 O(n) 空间的节省。
设计6:k 很大时的性能问题
计数排序有一个致命弱点:当取值范围 k 很大时,空间和时间都会爆炸。
| 场景 | n | k | 计数排序 | TimSort |
|---|---|---|---|---|
| 成绩排序(0~100) | 10000 | 101 | 0.0005s | 0.0008s |
| 年龄排序(0~120) | 100000 | 121 | 0.003s | 0.005s |
| 随机整数(0~10⁶) | 1000 | 10⁶ | 0.08s | 0.0001s |
计数排序只有在 k 远小于 n 时才有优势。如果 k 和 n 同阶甚至更大,计数排序的性能和空间都会不如比较排序。
原地版(不稳定)
python
def counting_sort_inplace(arr, ascending=True):
"""
计数排序(原地版):通过重写原数组实现近似原地排序。
仍需要 O(k) 额外空间存计数数组,但不需要 O(n) 的输出数组。
注意:此版本不保证稳定性。
时间复杂度:O(n + k) | 空间复杂度:O(k) | 不稳定
"""
if len(arr) <= 1:
return arr
min_val = min(arr)
max_val = max(arr)
k = max_val - min_val + 1
count = [0] * k
for val in arr:
count[val - min_val] += 1
pos = 0
if ascending:
for i in range(k):
val = min_val + i
for _ in range(count[i]):
arr[pos] = val
pos += 1
else:
for i in range(k - 1, -1, -1):
val = min_val + i
for _ in range(count[i]):
arr[pos] = val
pos += 1
return arr
原地版说明:省掉了 O(n) 的输出数组,直接按计数值重写原数组。代价是失去了稳定性------因为我们只知道"有多少个值为 x 的元素",但不知道它们原来的顺序,所以无法保持相对位置。
基数排序专用版
python
def counting_sort_for_radix(arr, exp, base=10, ascending=True):
"""
基数排序专用的计数排序:按某一位(第 exp 位)排序。
这是计数排序最常见的应用场景之一------作为基数排序的子过程。
"""
if len(arr) <= 1:
return list(arr)
n = len(arr)
result = [None] * n
count = [0] * base
# 统计当前位各数字的出现次数
for val in arr:
digit = (val // exp) % base
count[digit] += 1
# 前缀和
if ascending:
for i in range(1, base):
count[i] += count[i - 1]
else:
for i in range(base - 2, -1, -1):
count[i] += count[i + 1]
# 从后往前,稳定排序
for i in range(n - 1, -1, -1):
val = arr[i]
digit = (val // exp) % base
count[digit] -= 1
result[count[digit]] = val
return result
基数排序专用版说明:这是计数排序最重要的应用场景之一。在基数排序中,每一轮按一位数字排序,基数(通常是 10)很小,计数排序的 k 只有 10,非常高效。而且计数排序是稳定的,这保证了基数排序的正确性------低位的排序结果在高位排序时不会被打乱。
运行验证
python
if __name__ == "__main__":
data = [64, 34, 25, 12, 22, 11, 90]
print(f"排序前: {data}")
print(f"升序: {counting_sort(data[:])}")
print(f"降序: {counting_sort(data[:], ascending=False)}")
# 边界测试
print(f"空列表: {counting_sort([])}")
print(f"单元素: {counting_sort([42])}")
print(f"已有序: {counting_sort([1, 2, 3, 4, 5])}")
print(f"全相同: {counting_sort([7, 7, 7, 7, 7])}")
print(f"逆序: {counting_sort([5, 4, 3, 2, 1])}")
print(f"含重复: {counting_sort([3, 1, 4, 1, 5, 9, 2, 6, 5])}")
# 含负数测试
neg_data = [-5, 3, -2, 0, 7, -1, 3]
print(f"含负数: {counting_sort(neg_data)}")
输出:
排序前: [64, 34, 25, 12, 22, 11, 90]
升序: [11, 12, 22, 25, 34, 64, 90]
降序: [90, 64, 34, 25, 22, 12, 11]
空列表: []
单元素: [42]
已有序: [1, 2, 3, 4, 5]
全相同: [7, 7, 7, 7, 7]
逆序: [1, 2, 3, 4, 5]
含重复: [1, 1, 2, 3, 4, 5, 5, 6, 9]
含负数: [-5, -2, -1, 0, 3, 3, 7]
验证说明:以上输出确认了计数排序在常规数据、边界条件、含重复数据和含负数数据下均产生正确结果。特别注意"含负数"场景------通过 min_val 偏移,负数也能正确排序,这是计数排序处理负值的标准方法。
四、复杂度分析
时间复杂度
| 步骤 | 时间 | 说明 |
|---|---|---|
| 找 min/max | O(n) | 一次遍历 |
| 统计次数 | O(n) | 一次遍历 |
| 前缀和 | O(k) | k 为取值范围大小 |
| 放置元素 | O(n) | 一次遍历 |
| 总计 | O(n + k) | 线性时间 |
| 情况 | 复杂度 | 说明 |
|---|---|---|
| 最好 | O(n + k) | 与数据分布无关,总是线性 |
| 平均 | O(n + k) | 稳定的线性时间 |
| 最坏 | O(n + k) | 没有最坏情况(非比较排序) |
计数排序的时间复杂度与数据分布无关------无论数据是有序、逆序还是随机,都是 O(n + k)。这是非比较排序的共同特点:性能稳定,没有退化。
空间复杂度
| 版本 | 空间 | 说明 |
|---|---|---|
| 标准稳定版 | O(n + k) | 输出数组 + 计数数组 |
| 原地版(不稳定) | O(k) | 只需计数数组 |
稳定性
稳定排序 (标准版本)。稳定性的关键在于从后往前遍历 + 前缀和的组合------相同值的元素,后出现的先被放到靠右的位置,保持了原始相对顺序。
计数排序 vs 比较排序
| 维度 | 比较排序(TimSort) | 计数排序 |
|---|---|---|
| 时间复杂度 | O(n log n) | O(n + k) |
| 下界 | Ω(n log n) | 无下界限制 |
| 适用数据 | 任意可比较数据 | 整数/可离散化、范围有限 |
| k << n 时 | O(n log n) | O(n) 更快 |
| k >> n 时 | O(n log n) | O(k) 更慢 |
| 稳定性 | 取决于具体算法 | 天然稳定 |
关键结论:计数排序在"数据取值范围远小于数据量"时优势巨大。但如果取值范围很大,计数排序的空间和时间开销都会爆炸。它不是通用排序算法,而是特定场景下的"特种部队"。
五、横向对比
非比较排序家族对比:
| 算法 | 时间 | 空间 | 稳定性 | 适用场景 |
|---|---|---|---|---|
| 计数排序 | O(n + k) | O(n + k) | 稳定 | 取值范围小的整数 |
| 基数排序 | O(d·(n + r)) | O(n + r) | 稳定 | 整数/字符串,位数有限 |
| 桶排序 | O(n + k) 平均 | O(n + k) | 稳定 | 数据均匀分布 |
| TimSort | O(n log n) | O(n) | 稳定 | 通用排序 |
性能对比验证
python
import time
import random
print("--- 小范围数据 (n=10000, 范围0~999) ---")
data_small = [random.randint(0, 999) for _ in range(10000)]
start = time.time()
counting_sort(data_small[:])
print(f"计数排序: {time.time() - start:.4f}s")
start = time.time()
sorted(data_small[:])
print(f"内置sorted: {time.time() - start:.4f}s")
print("\n--- 大范围数据 (n=1000, 范围0~1000000) ---")
data_large = random.sample(range(1000000), 1000)
start = time.time()
counting_sort(data_large[:])
print(f"计数排序: {time.time() - start:.4f}s")
start = time.time()
sorted(data_large[:])
print(f"内置sorted: {time.time() - start:.4f}s")
典型输出:
--- 小范围数据 (n=10000, 范围0~999) ---
计数排序: 0.0021s
内置sorted: 0.0006s
--- 大范围数据 (n=1000, 范围0~1000000) ---
计数排序: 0.0843s
内置sorted: 0.0001s
结果分析:
- 小范围(k=1000 << n=10000):计数排序接近线性,与 TimSort 同一数量级
- 大范围(k=10⁶ >> n=1000):计数排序慢了 800 多倍,空间和时间都被 k 主导
选型建议
| 场景 | 推荐算法 | 原因 |
|---|---|---|
| 取值范围小(如成绩、年龄、等级) | 计数排序 | O(n) 线性时间,实现简单 |
| 整数排序、位数有限 | 基数排序 | 基于计数排序,处理更大范围 |
| 数据均匀分布 | 桶排序 | 平均 O(n),常数大 |
| 通用排序 | TimSort / Introsort | 适用性广,性能均衡 |
| 需要稳定 + 通用 | TimSort | 通用稳定排序首选 |
六、工程实战
场景一:基数排序的子过程
计数排序最经典的工程应用是作为基数排序的子过程。基数排序按位排序(个位→十位→百位→...),每一位的排序都用计数排序实现:
第一轮(按个位排序):
170, 45, 75, 90, 802, 24, 2, 66
→ 按个位计数排序 →
170, 90, 802, 2, 24, 45, 75, 66
第二轮(按十位排序):
按十位计数排序(稳定性保证个位的排序结果不被打乱)
→ 802, 2, 24, 45, 66, 170, 75, 90
第三轮(按百位排序):
按百位计数排序
→ 2, 24, 45, 66, 75, 90, 170, 802
完成!
基数排序中,每一位的计数排序 k 就是基数(十进制下 k=10),非常小,所以每一轮都是 O(n) 的。总共有 d 轮(d 为位数),总时间 O(d·n)。
为什么基数排序要用稳定的计数排序? 因为高位排序时不能打乱低位已经排好的顺序。如果计数排序不稳定,低位的排序结果就白做了。
场景二:年龄排序
在很多业务系统中,用户年龄的范围是 0~120 岁。这是计数排序的完美场景:
python
def sort_users_by_age(users):
"""按年龄排序用户列表,年龄范围 0~120。"""
if len(users) <= 1:
return list(users)
# 统计每个年龄的用户数
count = [0] * 121
for user in users:
count[user.age] += 1
# 前缀和
for i in range(1, 121):
count[i] += count[i - 1]
# 从后往前放置,保持稳定性
result = [None] * len(users)
for i in range(len(users) - 1, -1, -1):
user = users[i]
count[user.age] -= 1
result[count[user.age]] = user
return result
对于百万级用户数据,年龄排序用计数排序可以做到真正的 O(n),比任何比较排序都快。
场景三:频率统计
计数排序的"计数"思想在很多场景下都有用,甚至比排序本身更常用:
python
# Top K 问题:找出出现次数最多的前 K 个元素
def top_k_frequent(nums, k):
"""
用计数 + 桶排序思路解决 Top K 问题。
时间复杂度 O(n),比堆排序 O(n log k) 更快。
"""
if not nums or k == 0:
return []
# 第一步:频率统计(计数)
freq = {}
for num in nums:
freq[num] = freq.get(num, 0) + 1
# 第二步:按频率分桶(桶排序思想)
max_freq = max(freq.values())
buckets = [[] for _ in range(max_freq + 1)]
for num, f in freq.items():
buckets[f].append(num)
# 第三步:从高频桶开始取,直到取够 K 个
result = []
for f in range(max_freq, 0, -1):
result.extend(buckets[f])
if len(result) >= k:
return result[:k]
return result
这道 LeetCode 经典题的解法,第一步"频率统计"本质上就是计数排序的第二步------统计每个值的出现次数。计数的思想比计数排序本身更通用。
七、常见误区与面试题
高频面试题
Q1:计数排序的时间复杂度是多少?k 是什么?
计数排序的时间复杂度是 O(n + k),其中:
- n 是数组元素个数
- k 是数据的取值范围大小(max_val - min_val + 1)
当 k 远小于 n 时,计数排序的时间近似 O(n),比任何比较排序都快。但如果 k 很大(比如 k ≈ n²),计数排序的时间和空间都会爆炸,反而不如比较排序。
Q2:计数排序是稳定的吗?如何保证稳定性?
标准实现是稳定的。稳定性的关键有两个:
- 前缀和存储右边界 :
count[i]存的是"小于等于值 i 的元素总数",即右边界 + 1 - 从后往前遍历原数组:后出现的元素先被放到靠右的位置,先出现的后放,保持相对顺序
如果从前遍历或者不使用前缀和(直接按计数输出),就不是稳定的。
Q3:计数排序能排浮点数吗?为什么?
直接不行。计数排序的核心假设是"数据可以作为数组索引"------索引必须是整数且范围有限。浮点数无法直接作为数组索引。
但如果浮点数的精度有限(如保留两位小数),可以将浮点数乘以 100 转为整数,然后用计数排序,最后再除以 100 还原。这本质上是离散化的思路。
更通用的方法是用桶排序------将浮点数分到不同的桶里,桶内再排序。
Q4:计数排序和桶排序有什么区别和联系?
| 维度 | 计数排序 | 桶排序 |
|---|---|---|
| 思路 | 统计每个值的出现次数 | 将元素分到多个桶中 |
| 桶的数量 | k(取值范围大小) | 通常是 n 或 √n |
| 桶内元素 | 都是同一个值 | 值在某个区间内 |
| 桶内是否需要排序 | 不需要(都是同一个值) | 需要(区间内有多个不同值) |
| 适用场景 | 整数、范围小 | 数据均匀分布 |
| 时间复杂度 | O(n + k) | 平均 O(n),最坏 O(n²) |
联系:计数排序可以看作桶排序的特殊情况------每个桶只放一个值的元素,所以桶内不需要排序。当数据取值范围小且为整数时,桶排序退化为计数排序。
Q5:为什么计数排序不常用作通用排序?
主要有三个原因:
- 只适用于整数(或可离散化的数据):不能直接排浮点数、字符串等
- 取值范围不能太大:k 太大时空间和时间都会爆炸
- 通用场景下 TimSort 已经足够快:O(n log n) 对大多数应用完全够用
计数排序是"特种算法"------在特定场景下非常快,但适用范围有限。
常见实现错误
| 错误 | 说明 | 修正 |
|---|---|---|
| 忘记偏移 min_val | 负数或范围起始非 0 时数组越界或浪费空间 | count[val - min_val] |
| 前缀和方向错了 | 元素放错位置,排序结果错误 | 升序从左到右累加 |
| 从前往后遍历 | 稳定性被破坏 | 从后往前遍历 |
| 忘记先减一再赋值 | 数组越界或位置错误 | count[idx] -= 1; result[count[idx]] = val |
| k 计算错误(漏了 +1) | 最大值放不下 | k = max - min + 1 |
| 直接用值做索引 | 最小值不是 0 时浪费空间或越界 | 始终用偏移量 |
八、总结
核心要点
- 非比较排序------不通过比较大小排序,突破 Ω(n log n) 下界
- 线性时间------时间复杂度 O(n + k),k 远小于 n 时接近 O(n)
- 四步流程------确定范围 → 统计次数 → 前缀和定位 → 逆序放置
- 稳定排序------从后往前遍历 + 前缀和,保持相等元素相对顺序
- 适用受限------只适用于整数/可离散化且取值范围有限的数据
适用边界与限制
| 维度 | 适用条件 | 不适用条件 |
|---|---|---|
| 数据类型 | 整数、枚举、可离散化 | 浮点数、任意字符串 |
| 取值范围 | k 远小于 n(如 k ≤ 1000) | k 接近或大于 n |
| 稳定性要求 | 需要稳定性(标准实现) | 不需要稳定性(可用原地版) |
| 空间限制 | 能接受 O(k) 额外空间 | 空间极度受限(k 又大) |
| 通用性要求 | 特定业务场景(如年龄、成绩) | 通用排序(用 TimSort) |
设计哲学
计数排序的设计哲学可以概括为:利用数据特性,绕开理论下界。
比较排序有 Ω(n log n) 的理论下界,这个下界不是"不够聪明"导致的------而是"比较"这种操作本身的信息论极限。每次比较只能获得 1 bit 的信息(大或小),要区分 n! 种排列至少需要 log₂(n!) 次比较。
但如果数据本身有额外的结构信息(比如"取值范围有限"),我们就不需要通过比较来获取顺序信息------直接利用这个结构就能更快地排序。计数排序利用"值可以作为索引"的特性,桶排序利用"分布均匀"的特性,基数排序利用"可以按位分解"的特性。
这给我们的启示是:不要被通用解法的边界框住。当你知道数据的具体特征时,往往可以找到比通用解法更高效的针对性方案。 通用算法是"万金油",但在特定场景下,"特种部队"的效率可以高出一个数量级。
📌 专栏导航 :算法
⬅️ 上一篇 :块排序 (Block Sort) ➡️ 下一篇:(待更新)
如果这篇文章对你有帮助,欢迎 点赞、收藏、关注,支持专栏持续更新!