cv.cm

编码 · 算法

计数排序详解:原理、代码与时间复杂度

阅读约 6 分钟

简短回答

计数排序是一种非比较排序算法,适用于范围已知且较小(大小为 k)的整数(或可映射为整数的键)。它统计每个值出现的次数,用累加和把计数变成起始位置,再把每个元素直接放到它的位置上。时间 O(n + k),额外空间 O(n + k)。使用前缀和的版本是稳定的,这使它成为基数排序的基础组件。

打开行排序

计数排序解决什么问题

任何只比较元素对的排序,最坏情况下都需要 Ω(n log n) 次比较。计数排序完全不比较,从而突破了这个限制:它把键本身用作数组下标。当键是小整数时,例如 0 到 100 的考试分数、年龄、0 到 255 的字节或星级评分,它比任何比较排序都快。

代价是内存与键的取值范围成正比。如果要对少量分布在整个 64 位范围内的整数排序,需要的计数数组会大到不可能。

思路与示例

对 [4, 2, 2, 8, 3, 3, 1] 排序,其值落在 0 到 8 的范围内(k = 9)。

  1. 统计每个值:count[v] 是 v 出现的次数。
  2. 前缀和:把每个计数换成小于或等于该值的元素个数。此时 count[v] 就是 v 应放位置的最后一格再往后一位。
  3. 放置元素:从右向左遍历输入;对每个值 v,先把 count[v] 减 1,再把 v 写到该位置。从右向左遍历能让相等的值保持原有顺序。
值012345678
计数012210001
前缀和013566667

于是 1 放到下标 0,两个 2 放到下标 1 和 2,两个 3 放到下标 3 和 4,4 放到下标 5,8 放到下标 6。结果:[1, 2, 2, 3, 3, 4, 8]。

Python 实现

这个稳定的版本可以按 0 到 k - 1 之间的整数键,对任意对象排序。

def counting_sort(items, k, key=lambda x: x):
    """Stable sort of items by integer key in range(k)."""
    count = [0] * k
    for x in items:
        count[key(x)] += 1
    for v in range(1, k):                # prefix sums
        count[v] += count[v - 1]
    out = [None] * len(items)
    for x in reversed(items):            # right to left: stable
        count[key(x)] -= 1
        out[count[key(x)]] = x
    return out

print(counting_sort([4, 2, 2, 8, 3, 3, 1], k=9))
# [1, 2, 2, 3, 3, 4, 8]
people = [("Ana", 3), ("Bo", 1), ("Cy", 3), ("Di", 2)]
print(counting_sort(people, k=4, key=lambda p: p[1]))
# [('Bo', 1), ('Di', 2), ('Ana', 3), ('Cy', 3)]

如果只对普通整数排序,又不要求稳定,可以用更简单的版本,根据计数重建输出。这个变体还通过减去最小值进行平移,来处理负数:

def counting_sort_ints(nums):
    if not nums:
        return []
    lo, hi = min(nums), max(nums)
    count = [0] * (hi - lo + 1)
    for x in nums:
        count[x - lo] += 1
    out = []
    for offset, c in enumerate(count):
        out.extend([offset + lo] * c)
    return out

print(counting_sort_ints([3, -1, 2, -1, 0]))   # [-1, -1, 0, 2, 3]

时间与空间复杂度

指标值原因
时间(任何情况)O(n + k)遍历一遍输入,遍历一遍计数数组,再放置一遍
额外空间O(n + k)大小为 k 的计数数组,加上大小为 n 的输出数组
比较次数0键被当作下标使用,而不是互相比较

当 k 是 O(n) 时,计数排序是线性的。如果 k 远大于 n,例如在 0 到 10⁹ 的范围内只有 1,000 个值,计数数组就会成为主要开销,此时比较排序或基数排序更好。

计数排序与其他排序算法的比较

算法最好平均最坏额外空间稳定
计数排序O(n + k)O(n + k)O(n + k)O(n + k)是
基数排序(LSD)O(d · (n + b))O(d · (n + b))O(d · (n + b))O(n + b)是
归并排序O(n log n)O(n log n)O(n log n)O(n)是
快速排序O(n log n)O(n log n)O(n²)O(log n)否
堆排序O(n log n)O(n log n)O(n log n)O(1)否
插入排序O(n)O(n²)O(n²)O(1)是
Timsort(Python 的 sorted)O(n)O(n log n)O(n log n)O(n)是

这里 k 是键范围的大小,d 是位数,b 是基数排序所用的基数。当用前缀和并从右向左放置元素时,计数排序是稳定的。简单的由计数重建的版本,对普通整数能得到同样的结果,但无法让其他数据跟随键一起移动。

变体与常见错误

  • 基数排序对每一位运行一次稳定的计数排序,把这一思路推广到大整数和定长字符串。
  • 桶排序按范围把值分散到若干桶中,再对每个桶排序;当值均匀分布时很快。
  • 只计数不排序:很多时候你只需要计数本身,例如绘制直方图或求众数。collections.Counter 可以直接做到。

何时使用计数排序

当键是整数,且其范围相对于元素个数较小时使用:成绩、年龄、月份中的日期、字节、0 到 255 的颜色通道值,或小的枚举值。它也是基数排序和后缀数组构造算法的内部步骤。面试中,它出现在颜色分类(值为 0、1、2)、按频率排序、H 指数等题目中,也出现在「如何突破比较排序 O(n log n) 下界」这个经典问题中。

常见问题

计数排序稳定吗?

稳定,使用前缀和并从右向左放置元素的标准版本是稳定的。正是这个性质,让基数排序能对每一位都使用它。

计数排序的时间复杂度是多少?

O(n + k),其中 n 是元素个数,k 是键范围的大小。最好、平均和最坏情况下都相同。

计数排序为什么能比 O(n log n) 更快?

Ω(n log n) 的下界只适用于比较排序。计数排序从不比较元素;它把每个键用作数组下标,因此不受该下界约束。

计数排序能处理负数吗?

能。把每个键都减去最小值,使最小的键对应下标 0,写入输出时再加回去。

什么时候不该用计数排序?

当键范围 k 远大于 n、键是浮点数或任意字符串,或者内存紧张时。这时更好的选择是 sorted() 这类比较排序。

全部编码教程