计数排序:从 LeetCode 75 到前缀和,理解一种“非比较排序”的思路
前言
为什么写这篇
一提到排序,很多同学脑子里第一反应往往都是冒泡、选择、插入、归并、快排、堆排这些经典算法。它们有一个共同特点:比较元素大小,再决定元素位置。
但排序并不只有这一种思路。
有一类排序方法,并不依赖 compareTo 或者“两个元素谁大谁小”的逐对比较,而是换了一个角度:先统计,再回填。计数排序就是其中最经典的一种。
计数排序本身在工程中并不算常用,原因也很直接:它有明显的使用前提,也有不小的局限性。但是,它非常值得学。因为它能帮助我们建立起一种和“比较排序”完全不同的思维方式,并进一步引出一个非常重要的算法——基数排序。
所以这篇文章,不只是讲“计数排序怎么写”,更重要的是讲清楚:
- 什么叫非比较排序
- 计数排序的核心思想到底是什么
- 为什么它只适合“小数据范围”
cnt数组和index数组到底在表达什么- 前缀和在这里扮演了什么角色
一、什么是非比较排序
不是不能比较,而是不依赖 compareTo
很多人第一次听到“非比较排序”,会误以为它的意思是:元素之间不能比较。
其实不是。
所谓非比较排序,准确地说是:
排序过程不依赖元素之间的逐一大小比较,也不借助
compareTo这类比较接口来完成整体排序。
也就是说,不是元素不能比,而是排序时的主要推进方式,不是“谁和谁比一下”。
像快速排序、归并排序、堆排序,本质上都属于比较排序;而计数排序、基数排序,则属于非比较排序。
这一类方法,通常特别适合处理:
- 值域有限的整数
- 固定规则的字符串
- 可以拆成“位”或“字符”逐层处理的数据
严格来说,非比较排序在很多字符串算法里也会频繁出现。所以从这一章开始,到后面几章,其实都在逐渐进入一类基于字符串/位信息/离散值域的算法思路。
二、为什么要学计数排序
它不一定常用,但它很重要
先说结论:
计数排序更适合整数类问题。
因为它的核心做法就是:
统计每个数字出现了多少次,然后把这些数字按次数重新放回原数组。
这听起来很直接,也很好理解。但问题在于,它的代价并不小:
- 它需要额外空间
- 这个额外空间和“数据范围”强相关
- 数据范围一旦大起来,空间消耗会非常夸张
所以,计数排序虽然思路非常漂亮,但它也有很多劣势和缺点,因此在实际工程中并不总是常见。
不过,它依然值得认真学,原因有两个:
第一,它是非比较排序的入门代表。
第二,它能自然地引出后面的基数排序。
换句话说,计数排序的重要性,很多时候不在于“我以后天天用它”,而在于:
它帮我们打开了比较排序之外的另一扇门。
三、从 LeetCode 75 开始理解计数排序
[颜色分类:一个最适合入门的例子]
LeetCode 75 题:颜色分类
题目很简单:
给定一个数组,数组中只包含
0、1、2,请对数组进行排序。
这题很多同学会想到经典解法:三路快排。
三路快排的思路是,把数组划分成三段:
- 小于
v - 等于
v - 大于
v
对应图示如下:
三路快排当然可以解决,而且它能做到:
O(n) 时间,原地排序
这一点非常优秀。
但是这题还有一个更直观的思路:
既然数组里只可能出现 0、1、2,那我们为什么不直接数一数:
- 一共有多少个
0 - 一共有多少个
1 - 一共有多少个
2
假设:
- 有
cnt0个0 - 有
cnt1个1 - 有
cnt2个2
那么排完序之后的数组一定长这样:
- 前
cnt0个位置全是0 - 接下来
cnt1个位置全是1 - 最后
cnt2个位置全是2
图示非常直观:
四、使用计数排序解决 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])
这就是计数排序的核心。
也就是说,排序不再是通过“交换两个元素”来实现,而是通过:
- 先统计每个值出现多少次
- 再计算每个值应占据的区间
- 最终把值放回属于它的区间里
这种思路,和比较排序是完全不同的。
六、更一般的计数排序
不只适用于 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
这是因为我们要表示的是区间边界。
如果 cnt 有 R 个值种类,那么区间就有 R 段,而表示 R 段区间,需要 R + 1 个边界点。
所以:
index数组的元素个数是比cnt多一个的。
2)[index[i], index[i + 1]) 区间中的值都是 i
这是整个计数排序回填过程最本质的一句话。
十一、计数排序的优点和缺点
优点很直接,缺点也很明显
优点
- 时间复杂度优秀
在值域不大的前提下,计数排序可以做到线性时间级别。 - 思路直接
不比较元素大小,先统计再回填,逻辑清晰。 - 适合某些特定业务场景
比如成绩排序、年龄统计、等级编号排序等。
缺点
- 空间开销依赖值域
值域一大,cnt数组就会变得非常夸张。 - 只适用于整数或可离散映射的数据
它不像快排那样通用。 - 应用场景受限
工程里不是所有问题都满足“小值域”这个条件。
所以对计数排序的态度应该是:
它不是“万能排序”,
但它是一种非常有代表性的排序思想。
十二、计数排序和三路快排,该怎么看
一个重在思想,一个重在通用
回到 LeetCode 75 这个问题。
用三路快排,可以做到:
O(n)时间- 原地排序
- 不额外开大空间
从工程角度看,这通常更优。
而用计数排序来做,虽然也很快,但需要额外空间。
那为什么还要讲计数排序?
因为这两者解决问题的方式完全不同:
- 三路快排:仍然是比较排序思想
- 计数排序:是统计 + 区间映射思想
前者重在“怎么更高效地交换和划分”,后者重在“怎么把值映射到位置”。
学习算法,不只是为了做一道题,更重要的是积累不同的建模方式。
从这个角度看,LeetCode 75 恰好就是理解计数排序的绝佳入口。
总结
从“统计次数”到“前缀和区间”
这篇文章如果只记一句话,我希望是这一句:
计数排序的本质,不是排序时去比较元素,而是先统计每个值出现的次数,再通过前缀和确定每个值应该落入的区间。
我们可以把全文的重点收拢成下面几点:
- 计数排序属于非比较排序
- 它不是元素不能比较,而是排序过程不依赖 compareTo
- 它尤其适合整数值域较小的问题
- LeetCode 75 是它最经典的入门案例之一
cnt数组记录的是频次index数组记录的是区间起点index的本质就是前缀和index数组长度会比cnt多1- 区间
[index[i], index[i + 1])中的值都等于i
最后再强调一次:
计数排序本身也许不是最常用的排序算法,但它背后的思想非常重要。
真正学会计数排序,往往不是为了停留在计数排序,而是为了走向后面的基数排序。