计数排序真的那么牛批吗?

345 阅读3分钟

这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战

先说答案,假的,为什么?请往下看.....

计数排序

介绍

在前面几篇文章中,我们介绍了好几种排序算法,它们的时间复杂度不是为O(n^2),就是O(nlogn),那么有没有一种线性时间复杂度的算法呢?别说,还真有,它就是计数排序。

贴一段百科百科对于计数排序的介绍:

计数排序是一个非基于比较稳定的排序算法,该算法于1954年由 Harold H. Seward 提出。它的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k)(其中k是整数的范围),快于任何比较排序算法。当然这是一种牺牲空间换取时间的做法,而且当O(k)>O(n*log(n))的时候其效率反而不如基于比较的排序

从上面我们可以看出,这是一种非基于比较的排序算法,那就是不用比较,不用比较就能排序?还快于任何比较排序算法?这么神奇?让我们来看看这是怎么回事。

算法思路

我们先来了解最朴素的计数排序,给定下列20个无序整数

image.png

如何给这些无序的随机整数排序呢?

非常简单,可以看出上面最大数为9,那我们就申明一个大小为10的数组,然后遍历这个无序的随机数列,每一个整数按照其值对应数组下标的元素进行加1操作,数组每一个下标位置的值,代表了数列中对应整数出现的次数。

比如第一个整数是9,那么数组下标为9的元素加1:

image.png

以此类推,最终,数列遍历完毕时,数组的状态如下:

image.png

有了这个“统计结果”,排序就很简单了。直接遍历数组,输出数组元素的下标值,元素的值是几,就输出几次:

image.png

到此,就排好序了,是不是very简单。

代码贴贴:

public void sort(int[] nums){
    int max = Integer.MIN_VALUE;
    //求出数组中最大的数
    for(int i=0;i<nums.length;++i){
        if(max < nums[i]){
            max = nums[i];
        }
    }
    //以最大数+1为长度申明数组
    int[] arrays = new int[max+1];
    //统计数组中每个数的个数
    for(int i=0;i<nums.length;++i){
        arrays[nums[i]]++;
    }
    //遍历“统计数组”给原数组重新赋值
    int cur = 0;
    for(int i=0;i<arrays.length;++i){
        for(int j=0;j<arrays[i];++j){
            nums[cur++] = i;
        }
    }
}

可以看出,现在朴素版的计数排序他是不稳定的,只是统计个数,没有顾虑谁前谁后

完善一

大家看完之后,应该也看出这个计数排序的一些问题了,例如:如果给你的数字是90-99,那你要申明一个长度为100的数组吗?可以意识到,这会造成巨大空间的浪费,那该怎么优化才行呢?

很简单,我们不再以数组的最大值+1作为统计数组的长度,而是以数组最大值和最小值的差+1作为统计数组的长度。

同时,数组的最小值作为一个偏移量,用于统计数组的对号入座,举个例子,最小值为90,那么95这个数应该放在索引为95-90=5的位置。

下面给定10个随机数:

image.png

这些随机数的最大值为99,最小值为90,那么数组的长度为99-90+1=10。

对于第一个整数95,对应的统计数组下标是 95-90 = 5,如图所示:

image.png

最终统计完如下图:

image.png

遍历数组,输出排序好的数值即可,输出的时候记得加上偏移量

代码贴贴:

public void sort(int[] nums){
    int max = Integer.MIN_VALUE,min = Integer.MAX_VALUE;
    //求出数组中最大值和最小值
    for(int i=0;i<nums.length;++i){
        if(max < nums[i]) max = nums[i];
        if(min > nums[i]) min = nums[i];
    }
    //以最大值-最小值+1申明数组
    int[] arrays = new int[max-min+1];
    //统计数组中每个数的个数
    for(int i=0;i<nums.length;++i){
        //偏移量为最小值
        arrays[nums[i]-min]++;
    }
    //遍历“统计数组”给原数组重新赋值
    int cur = 0;
    for(int i=0;i<arrays.length;++i){
        for(int j=0;j<arrays[i];++j){
            //记得输出的时候要加上偏移量
            nums[cur++] = min + i;
        }
    }
}

完善二

到这里,你觉得现在的计数排序能用于现实的业务中吗?如果只是给整数排序且不考虑稳定性的话,那问题不大,但是如果给你下面这张学生成绩表,要你按照成绩高低从大到小进行排序,成绩相同的,则按原来在数组中的排序输出,这就要求我们要考虑到这个稳定性了,退一步说,如果不考虑稳定性,单单知道成绩如何知道这个成绩是谁的?是不是就没办法了,所以接下来我们思考如何解决这个问题。

image.png

接下来的思路可能比较绕,看不懂建议多看几遍:

我们从统计数组(图一)的第二个元素(下标为1)开始,将其值加上前一个元素的值,这有什么含义?其实就相当于将当前下标 i 的元素值转换为小于等于 i 的元素个数,看不懂没关系,我们用图理解:

image.png

举个例子,图二中下标为8的值代表小于等于97的数有4个,即89、89、91、95。

然后我们再申明一个存储排序结果的数组,长度跟原数组的长度一样,最后对原数组从后到前进行遍历,如图:

image.png

钱七的的成绩98对应下标为9,其值为5,代表现在成绩小于等于98的有5人,那么钱七就是那第五人,钱七排完序后的下标应为4(下标0开始),将钱七放在输出数组下标为4的位置,并且将统计数组下标为4的值减1,即5-1=4,表示下次遇到跟钱七一样成绩的人的话,他就是成绩小于等于98的学生中的第4人,他排完序后的下标应为3。

再举两个例子帮助大家理解:

image.png

赵六的成绩89对应下标0,值为2,代表现在成绩小于等于赵六的有2人,赵六是第2人,排完序后下标应为1,将赵六放在输出数组下标为1的位置,并且将统计数组下标为0的值减1。

省略王五,我们看李四:

image.png

李四的成绩89对应下标0,值为1,代表现在成绩小于等于李四的有1人,李四是第1人,排完序后下标应为0,将李四放在输出数组下标为0的位置,并且将统计数组下标为0的值减1。

代码贴贴:

public int[] sort(int[] nums){
    int max = Integer.MIN_VALUE,min = Integer.MAX_VALUE;
    //求出数组中最大值和最小值
    for(int i=0;i<nums.length;++i){
        if(max < nums[i]) max = nums[i];
        if(min > nums[i]) min = nums[i];
    }
    //以最大值-最小值+1申明数组
    int[] arrays = new int[max-min+1];
    //统计数组中每个数的个数
    for(int i=0;i<nums.length;++i){
        //偏移量为最小值
        arrays[nums[i]-min]++;
    }
    //将当前下标 i 的元素值转换为小于等于 i 的元素个数
    for(int i=1;i<arrays.length;++i){
        arrays[i] += arrays[i-1];
    }
    //存放排完序的结果
    int[] res = new int[nums.length];
    //从后向前遍历原数组
    for(int i=nums.length-1;i>=0;i--){
        int index = arrays[nums[i] - min]--;
        res[index-1] = nums[i];
    }
    return res;
}

局限性

通过上面的介绍,相信大家应该对计数排序有所了解,其实我们日常的开发中,计数排序很少用到,为什么呢,原因主要有两点:

1.当数列最大最小值差距过大时,并不适用计数排序。

2.当数列元素不是整数,并不适用计数排序。

结尾

本文通过图文结合的方式从朴素版的计数排序逐渐完善到成熟的计数排序,细嚼慢咽,帮助大家理解,计数排序是非比较排序算法,时间复杂度为O(n+k),是稳定的。最后,留下一道思考题:为什么我们在最后遍历原数组的时候,要从后向前遍历?从前向后会怎么样? 知道的小伙伴可以在评论区回复下。