目录

假设你要给一个班级的考试成绩排名,分数范围是 0 到 100 分。

你不需要两两比较谁高谁低,只需要数一下:有几个 0 分、几个 1 分……几个 100 分,然后按顺序把分数”倒出来”就行了。

这就是计数排序(Counting Sort)的思路:不通过元素之间的比较来确定顺序,而是统计每个值出现的次数,再根据计数结果直接输出有序序列。它是非比较排序的代表,当数据范围有限时可以做到真正的 O(n) 时间复杂度。


它是什么

核心思想 先扫描一遍找出数据范围,开一个计数数组统计每个值出现的次数,然后通过前缀和计算每个元素的最终位置,反向遍历原数组把元素放到正确位置。

“数数就行了,不用比。” , 计数排序完全跳出了”比较-交换”的框架,用空间换时间。

为什么能突破 O(n log n)

基于比较的排序算法有一个理论下界:O(n log n)。

这是因为 n 个元素有 n! 种排列,每次比较只能排除一半的可能性,至少需要 log₂(n!) = O(n log n) 次比较。

但计数排序不做比较,所以不受这个下界约束。

代价是需要额外的计数数组,且数据范围不能太大。

伪代码

// 仅为示意
function countingSort(arr):
    // Step 1: 找范围
    minVal = min(arr), maxVal = max(arr)
    k = maxVal - minVal + 1

    // Step 2: 计数
    count[k] = {0}
    for each x in arr:
        count[x - minVal]++

    // Step 3: 前缀和
    for i = 1 to k-1:
        count[i] += count[i - 1]

    // Step 4: 反向放置(保证稳定)
    for i = n-1 downto 0:
        idx = arr[i] - minVal
        output[count[idx] - 1] = arr[i]
        count[idx]--

    return output

下面用流程图拆解这四步的核心流程。

工作原理流程图

对应伪代码的整体流程:

flowchart LR
    A(["输入数组 arr"]) --> B["找 min 和 max<br/>确定范围"]
    B --> C["开辟 count[max-min+1]<br/>初始化为 0"]
    C --> D["遍历 arr 统计每个值出现次数"]
    D --> E["对 count 做前缀和<br/>计算 <= 每个值的元素个数"]
    E --> F["反向遍历 arr<br/>按前缀和确定位置放入 output"]
    F --> G(["输出有序数组"])

前缀和与稳定放置:

flowchart LR
    A(["count: 各值出现次数"]) --> B["prefix[i] = prefix[i-1] + count[i]<br/>prefix[i] = <=值i 的元素总数"]
    B --> C["反向遍历原数组 i = n-1 .. 0"]
    C --> D["idx = arr[i] - min"]
    D --> E["output[prefix[idx]-1] = arr[i]"]
    E --> F["prefix[idx]--"]
    F --> G{"i > 0 ?"}
    G -->|是| C
    G -->|否| H(["完成"])

逐步演示

用 [4, 2, 2, 8, 3, 3, 1] 跑一遍:

初始数组: [4, 2, 2, 8, 3, 3, 1]
数据范围: [1, 8] -> 计数数组大小 = 8

Step 1 - 计数数组 (下标+1 为实际值):
  [1]:1  [2]:2  [3]:2  [4]:1  [8]:1

Step 2 - 前缀和数组 (表示 <= 该值的元素个数):
  [1]:1  [2]:3  [3]:5  [4]:6  [8]:7

Step 3 - 输出数组: [1, 2, 2, 3, 3, 4, 8]

前缀和的含义:prefix[2]=3 表示值 <= 2 的元素有 3 个,所以最后一个 2 应该放在位置 2(下标 2)。

反向遍历保证了相同值的元素保持原始相对顺序。


能解决什么问题

适用场景

场景为什么适合
整数排序且范围小如年龄(1-100)、成绩(0-100)、考试编号等
作为基数排序的子过程基数排序每一位就是用计数排序实现的
需要稳定线性时间排序某些实时系统对延迟敏感

局限性

局限说明
只适用于整数(或可映射到整数)浮点数/字符串无法直接使用
数据范围不能太大如果 max-min >> n,空间浪费严重
不是原地排序需要额外 O(k) 的计数数组(k=数据范围)
不适合通用场景范围未知或范围很大时不如快排

稳定性

计数排序是稳定的。

关键在于:

  1. 使用前缀和确定位置(而非简单重复输出)
  2. 反向遍历原数组(从右往左)

程序验证:

原始: (2,a) (3,x) (2,b) (1,y) (3,z)
排序后: (1,y) (2,a) (2,b) (3,x) (3,z)

(2,a) 在 (2,b) 前,(3,x) 在 (3,z) 前 , 相等值的相对顺序完美保持。


C++ 实现与复杂度分析

通用 C++ 模板实现 计数排序依赖数值到索引的映射,适用于整数类型(int / long long / size_t 等):

#include <algorithm>  // std::minmax_element
#include <vector>

template <typename T>
void countingSort(std::vector<T>& arr) {
    int n = static_cast<int>(arr.size());
    if (n <= 1) return;

    auto [minIt, maxIt] = std::minmax_element(arr.begin(), arr.end());
    T minVal = *minIt, maxVal = *maxIt;
    int range = static_cast<int>(maxVal - minVal + 1);

    // 计数
    std::vector<int> count(range, 0);
    for (const T& v : arr)
        count[static_cast<int>(v - minVal)]++;

    // 前缀和
    for (int i = 1; i < range; ++i)
        count[i] += count[i - 1];

    // 反向构建输出(保证稳定性)
    std::vector<T> output(n);
    for (int i = n - 1; i >= 0; --i) {
        int idx = static_cast<int>(arr[i] - minVal);
        output[count[idx] - 1] = arr[i];
        count[idx]--;
    }
    arr = std::move(output);
}

时间复杂度怎么算

按以下步骤进行分析与计算:

步骤操作复杂度
找到 min/max遍历一次O(n)
计数遍历一次O(n)
前缀和遍历计数数组O(k)
反向放置遍历一次O(n)

总计 = O(n + k),其中 n 是元素个数,k 是数据范围。

情况时间复杂度条件
一般O(n + k)总是如此
当 k = O(n) 时O(n)数据范围与规模同阶
当 k >> n 时O(k)被 range 主导,退化为低效

这就是为什么说计数排序适合”范围小的整数”。

如果数据是 [1, 1000000],要开一个百万大小的数组就不划算了。

空间复杂度

计数数组 size = k,输出数组 size = n → O(n + k)

小结算法:时间 O(n+k)、空间 O(n+k)、稳定、非比较类、仅适用于范围有限的整数。


总结

  • 是什么:统计每个值出现的次数,通过前缀和计算位置,直接输出有序序列。完全不依赖元素间的比较。
  • 解决什么 / 用在哪:小范围整数排序;基数排序的子过程;需要稳定线性时间的场景。
  • 局限:只适用于整数;数据范围大时空间浪费严重;不适合通用排序。
  • 复杂度怎么算:四步各 O(n) 或 O(k),总计 O(n+k)。当 k=O(n) 时达到线性时间 O(n),突破了比较排序 O(n log n) 的下界。空间 O(n+k)。