计数排序:从 LeetCode 75 到前缀和,理解一种“非比较排序”的思路

0 阅读11分钟

计数排序:从 LeetCode 75 到前缀和,理解一种“非比较排序”的思路

前言

为什么写这篇

一提到排序,很多同学脑子里第一反应往往都是冒泡、选择、插入、归并、快排、堆排这些经典算法。它们有一个共同特点:比较元素大小,再决定元素位置

但排序并不只有这一种思路。

有一类排序方法,并不依赖 compareTo 或者“两个元素谁大谁小”的逐对比较,而是换了一个角度:先统计,再回填。计数排序就是其中最经典的一种。

计数排序本身在工程中并不算常用,原因也很直接:它有明显的使用前提,也有不小的局限性。但是,它非常值得学。因为它能帮助我们建立起一种和“比较排序”完全不同的思维方式,并进一步引出一个非常重要的算法——基数排序

所以这篇文章,不只是讲“计数排序怎么写”,更重要的是讲清楚:

  • 什么叫非比较排序
  • 计数排序的核心思想到底是什么
  • 为什么它只适合“小数据范围”
  • cnt 数组和 index 数组到底在表达什么
  • 前缀和在这里扮演了什么角色

一、什么是非比较排序

不是不能比较,而是不依赖 compareTo

很多人第一次听到“非比较排序”,会误以为它的意思是:元素之间不能比较

其实不是。

所谓非比较排序,准确地说是:

排序过程不依赖元素之间的逐一大小比较,也不借助 compareTo 这类比较接口来完成整体排序。

也就是说,不是元素不能比,而是排序时的主要推进方式,不是“谁和谁比一下”。

像快速排序、归并排序、堆排序,本质上都属于比较排序;而计数排序、基数排序,则属于非比较排序。

这一类方法,通常特别适合处理:

  • 值域有限的整数
  • 固定规则的字符串
  • 可以拆成“位”或“字符”逐层处理的数据

严格来说,非比较排序在很多字符串算法里也会频繁出现。所以从这一章开始,到后面几章,其实都在逐渐进入一类基于字符串/位信息/离散值域的算法思路。


二、为什么要学计数排序

它不一定常用,但它很重要

先说结论:

计数排序更适合整数类问题。

因为它的核心做法就是:
统计每个数字出现了多少次,然后把这些数字按次数重新放回原数组。

这听起来很直接,也很好理解。但问题在于,它的代价并不小:

  • 它需要额外空间
  • 这个额外空间和“数据范围”强相关
  • 数据范围一旦大起来,空间消耗会非常夸张

所以,计数排序虽然思路非常漂亮,但它也有很多劣势和缺点,因此在实际工程中并不总是常见。

不过,它依然值得认真学,原因有两个:

第一,它是非比较排序的入门代表。
第二,它能自然地引出后面的基数排序

换句话说,计数排序的重要性,很多时候不在于“我以后天天用它”,而在于:

它帮我们打开了比较排序之外的另一扇门。


三、从 LeetCode 75 开始理解计数排序

[颜色分类:一个最适合入门的例子]

LeetCode 75 题:颜色分类

题目很简单:

给定一个数组,数组中只包含 0、1、2,请对数组进行排序。

这题很多同学会想到经典解法:三路快排

三路快排的思路是,把数组划分成三段:

  • 小于 v
  • 等于 v
  • 大于 v

对应图示如下:

image.png

三路快排当然可以解决,而且它能做到:

O(n) 时间,原地排序

这一点非常优秀。

但是这题还有一个更直观的思路:

既然数组里只可能出现 0、1、2,那我们为什么不直接数一数:

  • 一共有多少个 0
  • 一共有多少个 1
  • 一共有多少个 2

假设:

  • cnt00
  • cnt11
  • cnt22

那么排完序之后的数组一定长这样:

  • cnt0 个位置全是 0
  • 接下来 cnt1 个位置全是 1
  • 最后 cnt2 个位置全是 2

图示非常直观:

image.png

四、使用计数排序解决 LeetCode 75

[先统计,再回填]

有了前面的理解,这题就非常容易写了。

先开一个长度为 3 的计数数组:

  • cnt[0] 记录 0 出现次数
  • cnt[1] 记录 1 出现次数
  • cnt[2] 记录 2 出现次数

统计完成后,再按次数把原数组重写一遍。

代码如下:

class Solution {
    sortColors(nums: number[]): void {
        const cnt: number[] = new Array(3).fill(0);
        for (const num of nums) {
            cnt[num]++;
        }

        // 填充 0
        for (let i = 0; i < cnt[0]; i++) {
            nums[i] = 0;
        }

        // 填充 1
        for (let i = cnt[0]; i < cnt[0] + cnt[1]; i++) {
            nums[i] = 1;
        }

        // 填充 2
        for (let i = cnt[0] + cnt[1]; i < cnt[0] + cnt[1] + cnt[2]; i++) {
            nums[i] = 2;
        }
    }
}

五、这段代码到底在做什么

[把“值”映射到“区间”]

这段代码表面上是在填数组,本质上其实是在做一件很重要的事情:

把每个值映射到数组中的一个区间。

比如:

  • 0 应该放在区间 [0, cnt[0])
  • 1 应该放在区间 [cnt[0], cnt[0] + cnt[1])
  • 2 应该放在区间 [cnt[0] + cnt[1], cnt[0] + cnt[1] + cnt[2])

这就是计数排序的核心。

也就是说,排序不再是通过“交换两个元素”来实现,而是通过:

  1. 先统计每个值出现多少次
  2. 再计算每个值应占据的区间
  3. 最终把值放回属于它的区间里

这种思路,和比较排序是完全不同的。


六、更一般的计数排序

不只适用于 0、1、2

上面的例子只包含 0、1、2,看起来像是特例。那如果数组里出现的是更一般的整数呢?

答案是:依然可以做。

比如,如果数字可能取值范围是 [0, R),那我们就可以开一个长度为 R 的计数数组:

int[] cnt = new int[R];

遍历数组时:

cnt[num] ++;

这就表示:数字 num 出现了一次。

如果数字范围是 [L, R],那也能处理,只不过数组下标不能直接用 num,而要做一个偏移:

int[] cnt = new int[R - L + 1];
cnt[num - L] ++;

七、计数排序为什么只适合“小数据范围”

不是数据规模小,而是值域小

这是计数排序最容易考、也最容易混淆的点。

很多同学会说:

计数排序适合“小数据”。

这句话不准确。

准确的说法应该是:

计数排序适合数据范围小,不是数据规模小。

这两个概念必须分清:

  • 数据规模小:指元素个数 n
  • 数据范围小:指元素值的取值区间小,比如只在 [0, 100] 之间

计数排序的空间复杂度,不取决于 n,而取决于值域范围 R

所以它适合的场景通常是:

  • 参加考试人数很多,但分数只在 0~100
  • 年龄统计,年龄范围有限
  • 颜色编号、等级编号、状态编号等离散整数数据

如果值域特别大,比如数字在 [0, 10^9],那你再去开一个 cnt 数组,显然就不现实了。

所以计数排序的一个核心限制就是:

空间复杂度通常是 O(R),而不是 O(n)

这也是它在工程里不算特别常用的重要原因。


八、当取值种类变多时,怎么优雅地回填

写 101 个 for 循环显然不现实

0、1、2 的例子里,我们可以很轻松地写出三个 for 循环。

但如果数字范围是 [0, 101) 呢?

总不可能写 101 个 for 吧。

这时候,就需要引入一个非常关键的数组:index

核心思想是:

cnt 记录“每个值出现了多少次”
index 记录“每个值对应区间的起始位置”

比如:

  • 0 对应区间 [0, cnt[0])
  • 1 对应区间 [cnt[0], cnt[0] + cnt[1])
  • 2 对应区间 [cnt[0] + cnt[1], cnt[0] + cnt[1] + cnt[2])

如果把这些区间起点统一存起来,就得到 index 数组。


九、index 数组的本质:前缀和

[它本质上就是累计统计结果]

index 数组的本质就是前缀和。

假设:

cnt[0] = 2
cnt[1] = 3
cnt[2] = 5
cnt[3] = 6

那么 index 数组就是:

index[0] = 0
index[1] = cnt[0]
index[2] = cnt[0] + cnt[1]
index[3] = cnt[0] + cnt[1] + cnt[2]
index[4] = cnt[0] + cnt[1] + cnt[2] + cnt[3]

也就是:

index[0] = 0
index[1] = 2
index[2] = 5
index[3] = 10
index[4] = 16

于是就有了这样的区间含义:

  • 0 放在 [index[0], index[1])
  • 1 放在 [index[1], index[2])
  • 2 放在 [index[2], index[3])
  • 3 放在 [index[3], index[4])

这一点非常关键。

因为它说明:

计数排序并不只是“统计次数”这么简单,
它实际上是在通过前缀和,把“值”转换成“位置区间”。

这也是为什么说,学习计数排序,其实是在为后面的很多算法打基础。因为前缀和这个思想,后面会反复出现。


十、一个更一般的计数排序写法

适用于 [0, R) 的整数范围

下面给出一个更一般的版本。假设数组中的元素都落在 [0, R) 范围内。

class CountingSort {
    static sort(nums: number[], R: number): void {
        const cnt: number[] = new Array(R).fill(0);

        // 1. 统计每个元素出现的次数
        for (const num of nums) {
            cnt[num]++;
        }

        // 2. 计算前缀和数组 index
        const index: number[] = new Array(R + 1).fill(0);
        index[0] = 0;
        for (let i = 0; i < R; i++) {
            index[i + 1] = index[i] + cnt[i];
        }

        // 3. 根据 index 回填
        for (let i = 0; i < R; i++) {
            for (let j = index[i]; j < index[i + 1]; j++) {
                nums[j] = i;
            }
        }
    }
}

这段代码比 0/1/2 的特例更统一,也更容易看出整体结构:

  • cnt:统计频次
  • index:计算区间起点
  • 回填:把每个值写回自己的区间

这里有两个细节特别值得记住。

1)index 数组的长度比 cnt 多 1

这是因为我们要表示的是区间边界

如果 cntR 个值种类,那么区间就有 R 段,而表示 R 段区间,需要 R + 1 个边界点。

所以:

index 数组的元素个数是比 cnt 多一个的。

2)[index[i], index[i + 1]) 区间中的值都是 i

这是整个计数排序回填过程最本质的一句话。


十一、计数排序的优点和缺点

优点很直接,缺点也很明显

优点
  1. 时间复杂度优秀
    在值域不大的前提下,计数排序可以做到线性时间级别。
  2. 思路直接
    不比较元素大小,先统计再回填,逻辑清晰。
  3. 适合某些特定业务场景
    比如成绩排序、年龄统计、等级编号排序等。
缺点
  1. 空间开销依赖值域
    值域一大,cnt 数组就会变得非常夸张。
  2. 只适用于整数或可离散映射的数据
    它不像快排那样通用。
  3. 应用场景受限
    工程里不是所有问题都满足“小值域”这个条件。

所以对计数排序的态度应该是:

它不是“万能排序”,
但它是一种非常有代表性的排序思想。


十二、计数排序和三路快排,该怎么看

一个重在思想,一个重在通用

回到 LeetCode 75 这个问题。

用三路快排,可以做到:

  • O(n) 时间
  • 原地排序
  • 不额外开大空间

从工程角度看,这通常更优。

而用计数排序来做,虽然也很快,但需要额外空间。

那为什么还要讲计数排序?

因为这两者解决问题的方式完全不同:

  • 三路快排:仍然是比较排序思想
  • 计数排序:是统计 + 区间映射思想

前者重在“怎么更高效地交换和划分”,后者重在“怎么把值映射到位置”。

学习算法,不只是为了做一道题,更重要的是积累不同的建模方式。
从这个角度看,LeetCode 75 恰好就是理解计数排序的绝佳入口。


总结

从“统计次数”到“前缀和区间”

这篇文章如果只记一句话,我希望是这一句:

计数排序的本质,不是排序时去比较元素,而是先统计每个值出现的次数,再通过前缀和确定每个值应该落入的区间。

我们可以把全文的重点收拢成下面几点:

  • 计数排序属于非比较排序
  • 它不是元素不能比较,而是排序过程不依赖 compareTo
  • 它尤其适合整数值域较小的问题
  • LeetCode 75 是它最经典的入门案例之一
  • cnt 数组记录的是频次
  • index 数组记录的是区间起点
  • index 的本质就是前缀和
  • index 数组长度会比 cnt1
  • 区间 [index[i], index[i + 1]) 中的值都等于 i

最后再强调一次:

计数排序本身也许不是最常用的排序算法,但它背后的思想非常重要。
真正学会计数排序,往往不是为了停留在计数排序,而是为了走向后面的基数排序