← 返回题目列表

计数排序、桶排序、基数排序是怎么做到 O(n) 的?它们有什么限制?

高频 中等 第 6 / 26 题 更新于 2026/08/03
排序计数排序桶排序基数排序

简化版

它们是非比较排序,不靠元素间比较,而是利用元素的值本身当索引或分组依据,所以能突破比较排序 O(n log n) 的下界、做到 O(n) 级。计数排序:统计每个值出现次数,按值域顺序输出;桶排序:按值分到若干桶、桶内排序再拼接;基数排序:按位(个、十、百…)逐位做稳定排序。限制:都要求元素能映射到有限、可控的值域(通常是整数或定长串),值域太大就不划算。

详细版

计数排序(Counting Sort)

int[] countingSort(int[] a, int maxVal) {
    int[] count = new int[maxVal + 1];
    for (int x : a) count[x]++;              // 统计每个值出现次数
    int[] res = new int[a.length]; int idx = 0;
    for (int v = 0; v <= maxVal; v++)        // 按值从小到大输出
        while (count[v]-- > 0) res[idx++] = v;
    return res;
}

O(n + k),k 是值域大小。适合值域小的非负整数(年龄、分数)。

桶排序(Bucket Sort)

把数据按值范围分到若干个「桶」,每个桶内部再排序(常用插入排序),最后按桶顺序拼接。数据均匀分布时接近 O(n),分布不均时退化。

基数排序(Radix Sort)

把整数按处理:先按个位用稳定排序(通常计数排序),再按十位、百位……从低位到高位逐位排完,整体就有序了。O(d·(n+k)),d 是最大位数。适合整数或定长字符串

完整版教学

一、为什么非比较排序能突破 O(n log n)

比较排序的下界是 Ω(n log n),因为它只能通过「谁比谁大」获取信息,n! 种排列至少需要 log(n!) = Ω(n log n) 次比较。非比较排序换了个信息来源:它不问「a 比 b 大吗」,而是直接利用元素的值——把值当数组下标(计数)、当分组依据(桶)、当逐位关键字(基数)。因为绕开了「比较」这个瓶颈,它们不受 Ω(n log n) 约束,能做到线性。代价是通用性下降:必须能把元素映射到有限值域。

二、计数排序:用值当下标

计数排序的核心是「开一个以值为下标的计数数组」:

  1. 遍历原数组,count[值]++,统计每个值出现多少次。
  2. 从小到大遍历 count,把每个值按出现次数依次写回。

因为值直接当下标,不需要任何比较,时间 O(n + k)(n 遍历原数组、k 遍历计数数组)。

  • 适合:值域 k 不大的非负整数(如 0~100 的分数、年龄)。
  • 不适合:值域巨大(如存 int 全范围,要开 40 亿的数组)——空间会爆炸。所以计数排序的适用前提是「k 和 n 同量级或更小」。
  • 稳定版:用「前缀和」确定每个值的输出位置,从后往前填,可保证稳定。

三、桶排序:分而治之的值域划分

桶排序把值域划分成若干区间,每个区间一个「桶」:

  1. 遍历数据,按值把每个元素分到对应的桶
  2. 对每个桶内部排序(桶内元素少,用插入排序等)。
  3. 按桶的顺序依次拼接,就是有序结果。

如果数据均匀分布,每个桶只有常数个元素,桶内排序 O(1),整体接近 O(n)。但如果数据都挤在一个桶里,就退化成桶内排序的复杂度(最坏 O(n²) 或 O(n log n))。所以桶排序的效率依赖数据分布是否均匀。它常用于浮点数排序(如 [0,1) 均匀分布的小数)。

四、基数排序:逐位稳定排序

基数排序处理整数(或定长字符串),按「位」来排:

  • 先按最低位(个位) 排序,再按十位、百位……一直到最高位。
  • 每一位的排序必须用稳定排序(通常是计数排序)——这样高位相同的元素,能保持低位已经排好的顺序。

这叫 LSD(最低位优先)。因为每一位用 O(n+k) 的计数排序,共 d 位,总时间 O(d·(n+k))

为什么从低位到高位、且每位必须稳定?因为「先排低位」的结果要靠「后排高位时的稳定性」来保留。如果高位排序不稳定,低位排好的顺序就被打乱了,结果就错。这是基数排序对稳定性依赖的关键。

五、三者的限制与选择

它们都很快,但都有使用前提,不是万能的:

  • 计数排序:值域 k 不能太大(空间 O(k));只适合整数/可离散化的键。
  • 桶排序:依赖数据均匀分布,分布差会退化;需要额外空间放桶。
  • 基数排序:适合整数或定长串;位数 d 大时优势减弱;每位排序要稳定、需额外空间。

怎么选:值域小的整数用计数;均匀分布的浮点数用;位数不多的大整数/定长字符串用基数。一旦数据是任意可比较对象、或值域巨大,还是得回到比较排序(快排/归并)。

六、把不变量、推演与工程边界落到代码上

算法正确性的核心不是记住某个 while,而是始终维护这个不变量:每个阶段按键的可枚举结构分配元素,并保留此前已经建立的次序。

对应的状态推进是:计数按值映射,桶排序按值域分段,LSD 基数排序从低位做稳定排序。每次推进前都应能解释“为什么被排除的部分不可能再包含答案”,否则只是碰巧通过样例。

初始化边界与状态
while 尚未结束:
    根据当前状态作出唯一可证明安全的选择
    更新边界、计数或局部结构
    断言不变量仍然成立
返回不变量在终止状态下推出的答案

复杂度不能只背一个符号。计数 O(n+k);基数 O(d(n+r));桶排序复杂度取决于分布和桶内算法。

带数字走一遍:100 万个 0–120 的年龄只需 121 个计数槽,32 位全值域则不可行。手算时要同时记录下标、区间含义和本轮排除的候选,才能暴露等号与边界错误。

核对项结论
前提仅在值域、位数或分布满足约束时才能优于比较排序
时间复杂度不是无条件 O(n),必须带上 k、d、r 等参数
额外空间通常 O(n+k) 或 O(n+r)
关键边界负数需做偏移;稳定计数要反向回填;LSD 每一趟必须稳定
替代方案无可利用键结构时回到比较排序

易错点:模板只有在前提和不变量一致时才成立;换了区间定义、重复值规则或输出语义,等号与推进方式就必须重新推导。

实现完成后至少检查五类用例:

  • 空输入或题目允许的最小规模,验证初始化不会越界。
  • 单元素与两个元素,验证循环条件和最后一次推进。
  • 大量重复值,验证相等分支、稳定性或去重语义。
  • 已满足目标性质与极端逆序/偏斜分布,观察最好和退化路径。
  • 上面的数字样例逐轮手算,并与程序记录的状态轨迹逐项对照。

七、常见误区与追问

  • 误区:只要记住模板就适用于所有输入。 本题成立的前提是“仅在值域、位数或分布满足约束时才能优于比较排序”,前提被破坏后必须换算法或重新证明。
  • 误区:复杂度只写 不是无条件 O(n),必须带上 k、d、r 等参数 就算完整。 还要说明输入参数、预处理、递归栈以及最坏退化条件;本题的推导是“计数 O(n+k);基数 O(d(n+r));桶排序复杂度取决于分布和桶内算法”。
  • 误区:重复值和边界值不会改变代码。 负数需做偏移;稳定计数要反向回填;LSD 每一趟必须稳定。
  • 追问:为什么每次推进不会漏掉答案? 因为始终维护“每个阶段按键的可枚举结构分配元素,并保留此前已经建立的次序”,被舍弃区域已由顺序或状态关系证明不可能更优。
  • 追问:用一个数字例子怎么讲? 可以从“100 万个 0–120 的年龄只需 121 个计数槽,32 位全值域则不可行”开始,逐轮写出状态与被排除区间。
  • 追问:什么时候不该使用这个方案? 当前提不满足或退化代价不可接受时,应考虑“无可利用键结构时回到比较排序”。
  • 追问:如何设计自测避免只过样例? 同时覆盖最小规模、重复值、极端分布、无解/边界解,并对关键状态加断言。

八、加强记忆

计数/桶/基数是非比较排序,靠利用值本身(当下标/分组/逐位键)而非比较,突破 Ω(n log n) 到 O(n) 级。计数:以值为下标统计次数,O(n+k),适合小值域整数;:按值分桶、桶内排序再拼,均匀分布时 O(n);基数:从低位到高位逐位用稳定排序(计数),O(d(n+k)),适合整数/定长串。三者都稳定,但都要求值能映射到有限值域——值域大或任意对象就用比较排序。