1-16-计数排序-CountingSort

计数排序 (Counting Sort):线性时间非比较排序

摘要:本文从"比较排序的时间下界是 Ω(n log n)"的理论限制出发,详解计数排序如何通过"统计次数而非比较大小"突破这一下界,实现 O(n + k) 的线性时间复杂度。给出了支持升序/降序的 Python 完整实现(标准稳定版、原地版、基数排序专用版),图解了四步核心流程与前缀和的含义,分析了其稳定性保证与空间权衡。最后结合基数排序、桶排序的工程场景讨论其设计哲学与面试高频考点。
本文属于专栏《算法》系列 1 第 16 篇 | 上一篇:块排序 (Block Sort) | 下一篇:(待更新)


文章目录

一、问题引入

前面我们讨论的所有排序算法------冒泡、快排、归并、堆排序、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
  • 输出:按升序(或降序)排列的数组
  • 核心约束:不通过比较元素大小来排序
  • 核心操作:统计每个值的出现次数 → 前缀和定位 → 放置元素

二、算法原理图解

核心思想

计数排序的核心是利用"数据取值范围有限"这个特性,通过统计每个值的出现次数来实现排序。整个算法分为四步:

  1. 确定范围:找出最小值和最大值,确定计数数组的大小
  2. 统计次数:遍历原数组,统计每个值出现多少次
  3. 前缀和:计算前缀和,确定每个值在输出数组中的位置
  4. 放置元素:从后往前遍历原数组,将元素放到正确位置

四步流程文字图解

以数组 [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:计数排序是稳定的吗?如何保证稳定性?

标准实现是稳定的。稳定性的关键有两个:

  1. 前缀和存储右边界count[i] 存的是"小于等于值 i 的元素总数",即右边界 + 1
  2. 从后往前遍历原数组:后出现的元素先被放到靠右的位置,先出现的后放,保持相对顺序

如果从前遍历或者不使用前缀和(直接按计数输出),就不是稳定的。

Q3:计数排序能排浮点数吗?为什么?

直接不行。计数排序的核心假设是"数据可以作为数组索引"------索引必须是整数且范围有限。浮点数无法直接作为数组索引。

但如果浮点数的精度有限(如保留两位小数),可以将浮点数乘以 100 转为整数,然后用计数排序,最后再除以 100 还原。这本质上是离散化的思路。

更通用的方法是用桶排序------将浮点数分到不同的桶里,桶内再排序。

Q4:计数排序和桶排序有什么区别和联系?

维度 计数排序 桶排序
思路 统计每个值的出现次数 将元素分到多个桶中
桶的数量 k(取值范围大小) 通常是 n 或 √n
桶内元素 都是同一个值 值在某个区间内
桶内是否需要排序 不需要(都是同一个值) 需要(区间内有多个不同值)
适用场景 整数、范围小 数据均匀分布
时间复杂度 O(n + k) 平均 O(n),最坏 O(n²)

联系:计数排序可以看作桶排序的特殊情况------每个桶只放一个值的元素,所以桶内不需要排序。当数据取值范围小且为整数时,桶排序退化为计数排序。

Q5:为什么计数排序不常用作通用排序?

主要有三个原因:

  1. 只适用于整数(或可离散化的数据):不能直接排浮点数、字符串等
  2. 取值范围不能太大:k 太大时空间和时间都会爆炸
  3. 通用场景下 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 时浪费空间或越界 始终用偏移量

八、总结

核心要点

  1. 非比较排序------不通过比较大小排序,突破 Ω(n log n) 下界
  2. 线性时间------时间复杂度 O(n + k),k 远小于 n 时接近 O(n)
  3. 四步流程------确定范围 → 统计次数 → 前缀和定位 → 逆序放置
  4. 稳定排序------从后往前遍历 + 前缀和,保持相等元素相对顺序
  5. 适用受限------只适用于整数/可离散化且取值范围有限的数据

适用边界与限制

维度 适用条件 不适用条件
数据类型 整数、枚举、可离散化 浮点数、任意字符串
取值范围 k 远小于 n(如 k ≤ 1000) k 接近或大于 n
稳定性要求 需要稳定性(标准实现) 不需要稳定性(可用原地版)
空间限制 能接受 O(k) 额外空间 空间极度受限(k 又大)
通用性要求 特定业务场景(如年龄、成绩) 通用排序(用 TimSort)

设计哲学

计数排序的设计哲学可以概括为:利用数据特性,绕开理论下界

比较排序有 Ω(n log n) 的理论下界,这个下界不是"不够聪明"导致的------而是"比较"这种操作本身的信息论极限。每次比较只能获得 1 bit 的信息(大或小),要区分 n! 种排列至少需要 log₂(n!) 次比较。

但如果数据本身有额外的结构信息(比如"取值范围有限"),我们就不需要通过比较来获取顺序信息------直接利用这个结构就能更快地排序。计数排序利用"值可以作为索引"的特性,桶排序利用"分布均匀"的特性,基数排序利用"可以按位分解"的特性。

这给我们的启示是:不要被通用解法的边界框住。当你知道数据的具体特征时,往往可以找到比通用解法更高效的针对性方案。 通用算法是"万金油",但在特定场景下,"特种部队"的效率可以高出一个数量级。


📌 专栏导航算法

⬅️ 上一篇块排序 (Block Sort) ➡️ 下一篇:(待更新)

如果这篇文章对你有帮助,欢迎 点赞、收藏、关注,支持专栏持续更新!

相关推荐
疯狂打码的少年1 小时前
【数据结构】排序算法:归并排序与基数排序
数据结构·笔记·算法·排序算法
用户7783366132111 小时前
用搜索数据 API 做一个关键词联想组件(防抖 + 缓存 + 可复用)
python·api
AC赳赳老秦2 小时前
企业标签体系搭建:基于 OpenClaw 采集的多维度公开数据,构建企业画像标签库
java·运维·服务器·python·信息可视化·deepseek·openclaw
吃旺旺雪饼的小男孩2 小时前
U-Net 语义分割详解:原论文、PyTorch 实现与真实消融实
人工智能·pytorch·python·算法
lucas_AI2 小时前
35 种开源文档抽取配置,只有 4 个跨过 F1 0.5
人工智能·算法·llm
练习时长一年的RL研究者2 小时前
与AI对话后对 Actor-Critic 中 TD Target 的 a‘ 来源总结
算法
XZ-0700012 小时前
1-1-可视化-练习
开发语言·python
今天AI了吗2 小时前
AI Agent 在数据分析领域的落地判断:哪些场景真的需要 Agent
java·数据库·人工智能·python·sql·数据分析·copilot
大熊背2 小时前
ISP图像处理中大数乘法溢出处理(二)
算法·大数乘法