计数排序(counting sort)通过统计元素数量来实现排序,通常应用于整数数组。
目录
一.简单实现
先来看一个简单的例子。给定一个长度为 n 的数组 nums
,其中的元素都是“整数”。
- 遍历数组,找出其中的最大数字以及最小数字,分别记录为max和min,然后创建一个长度为 max-min+1 的辅助数组
counter
。 - 借助
counter
统计nums
中各数字的出现次数,其中counter[num-min]
对应数字num
的出现次数。统计方法很简单,只需遍历nums
(设当前数字为num
),每轮将counter[num-min]
增加 1 即可。 - 由于
counter
的各个索引天然有序,因此相当于所有数字已经排序好了。接下来,我们遍历counter
,此时其索引对应的并不是其真实数字,因为当时创建counter数组的时候因为不要出现负索引而减去min,所以此时往真是数组里边填充的时候应该再次加上min,以此来恢复原数据。
public class demo {
public static void main(String[] args) {
int[] nums = {7, 3,-1, 2, 6, 9, 8, -6,4,5};
countingSortNaive(nums);
for (int i = 0; i < nums.length; i++) {
System.out.print(nums[i] + " ");
}
}
/* 桶排序 */
static void countingSortNaive(int[] nums) {
int max=nums[0];
int min=nums[0];
for (int i = 0; i < nums.length; i++) {
max=Math.max(max,nums[i]);
min=Math.min(min,nums[i]);
}
int[] counter=new int[max+1-min];
for(int i:nums){
counter[i-min]++;
}
int k=0;
for(int i=0;i<counter.length;i++){
for(int j=0;j<counter[i];j++){
nums[k++]=i+min;
}
}
}
}
可以将计数排序的每一个索引都当成一个桶,每一个桶内部排序完之后再将桶进行排序,相当于计数排序就是桶排序的一个特例。
二.完整实现
然而可能会发现,如果输入数据是对象,上述步骤 3.
就失效了。假设输入数据是商品对象,我们想按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。
那么如何才能得到原数据的排序结果呢?我们首先计算 counter
的“前缀和”。顾名思义,索引 i
处的前缀和 prefix[i]
等于数组前 i
个元素之和:
prefix[i]=∑j=0icounter[j]
前缀和具有明确的意义,prefix[num] - 1
代表元素 num
在结果数组 res
中最后一次出现的索引。这个信息非常关键,因为它告诉我们各个元素应该出现在结果数组的哪个位置。接下来,我们倒序遍历原数组 nums
的每个元素 num
,在每轮迭代中执行以下两步。
- 将
num
填入数组res
的索引prefix[num] - 1
处。 - 令前缀和
prefix[num]
减小 1 ,从而得到下次放置num
的索引。
遍历完成后,数组 res
中就是排序好的结果,最后使用 res
覆盖原数组 nums
即可。
此时就没有出现第一种情况中先减去min再加上min的情况了,为什么?因为此时counter里边存储的是前缀和,前缀和减一就是该元素最后一次出现的数组位置。
public class demo {
public static void main(String[] args) {
int[] nums = {7, 3, 2,5,-6,-1,4,4,9,8};
countingSort(nums);
for (int i = 0; i < nums.length; i++) {
System.out.print(nums[i] + " ");
}
}
/* 桶排序 */
static void countingSort(int[] nums) {
// 1. 统计数组最大元素 max
int max = 0;
int min=0;
for (int num : nums) {
max = Math.max(max, num);
min = Math.min(min, num);
}
// 2. 统计各数字的出现次数
// counter[num] 代表 num 的出现次数
int[] counter = new int[max + 1-min];
for (int num : nums) {
counter[num-min]++;
}
// 3. 求 counter 的前缀和,将“出现次数”转换为“尾索引”
// 即 counter[num]-1 是 num 在 res 中最后一次出现的索引
for (int i = min; i <max; i++) {
counter[i + 1-min] += counter[i-min];
}
// 4. 倒序遍历 nums ,将各元素填入结果数组 res
// 初始化数组 res 用于记录结果
int n = nums.length;
int[] res = new int[n];
for (int i = n-1; i>=0; i--) {
int num = nums[i];
res[counter[num-min] - 1] = num; // 将 num 放置到对应索引处
counter[num-min]--; // 令前缀和自减 1 ,得到下次放置 num 的索引
}
// 使用结果数组 res 覆盖原数组 nums
for (int i = 0; i < n; i++) {
nums[i] = res[i];
}
}
}
三.算法特性
- 时间复杂度为 O(n+m)、非自适应排序 :涉及遍历
nums
和遍历counter
,都使用线性时间。一般情况下 n≫m ,时间复杂度趋于 O(n) 。 - 空间复杂度为 O(n+m)、非原地排序:借助了长度分别为 n 和 m 的数组
res
和counter
。 - 稳定排序:由于向
res
中填充元素的顺序是“从右向左”的,因此倒序遍历nums
可以避免改变相等元素之间的相对位置,从而实现稳定排序。实际上,正序遍历nums
也可以得到正确的排序结果,但结果是非稳定的。这句话如何理解呢?例如有一个数组[1,1,1,1,6,5,3,2],前面有4个相等的数字,此时用倒序遍历去插入真实数组,而我们依次得到的是一个数最后一次出现的位置然后依次和原顺序一样插入数组当中,而如果是正序的话,所得到的数最后一个出现位置就会被放在数组的第一个位置,也就是说那四个1放反了,虽然不影响结果,但是如果是存储对象的话就会出现错误,并且也不是支持稳定性的。
四.局限性
看到这里,你也许会觉得计数排序非常巧妙,仅通过统计数量就可以实现高效的排序。然而,使用计数排序的前置条件相对较为严格。
计数排序适用于数据量大但数据范围较小的情况。比如,在上述示例中 m 不能太大,否则会占用过多空间。而当 n≪m 时,计数排序使用 O(m) 时间,可能比 O(nlogn) 的排序算法还要慢。