前言
推出一个新系列,《看图轻松理解数据结构和算法》,主要使用图片来描述常见的数据结构和算法,轻松阅读并理解掌握。本系列包括各种堆、各种队列、各种列表、各种树、各种图、各种排序等等几十篇的样子。
计数排序
计数排序(Counting Sort)算法由 Harold H. Seward 在1954年发明,它不是一种基于元素比较的排序算法,而是将待排序数组元素转化为计数数组的索引值,从而间接使待排序数组具有顺序性。
整个过程包含三个数组:待排序数组A、计数数组B和输出数组C。简单来说,就是通过统计待排序数组A中元素不同值的分布直方图,生成计数数组B,然后计算计数数组B的前缀和(此步操作可以看成计算待排序数组A中每个元素的位置信息),最后通过逆序循环将元素对应赋值到输出数组C中,输出数组C即是最终排序结果。
从整个过程也可以看到使用了额外的数组,所以它是一种以空间换时间的做法。
时间复杂度
计数排序的时间复杂度为Ο(n+k),其中n为待排序数组长度,k为计数数组长度(简单情况下可以认为k是待排序数组中最大值)。在整个计数排序过程中涉及到若干个循环操作,其中初始化计数数组与计算计数数组前缀和这两个循环每个最多执行(k+1)次,所以这里时间复杂度为O(k)。而初始化输出数组、统计待排序数组分布直方图、赋值到输出数组这三个循环每个执行n次,所以这里的时间复杂度为O(n)。于是,整个过程所有操作的时间复杂度为Ο(n+k)。
我们知道在所有基于比较的排序算法中,最低的时间复杂度为O(n * logn),所以可以看到计数排序的时间复杂度能够比基于比较的排序算法更优,但当k很大而n又较小时,计数排序的效率反而不如基于比较的排序算法。
执行步骤
设待排序数组为 A,计数数组为 B,输出数组为 C,则计数排序的操作步骤如下:
- 如果计数数组 B 的长度还没有确定,那么就先执行确定操作,其实就是寻找待排序数组中的最小值和最大值,然后用计数数组的所有元素用来表示最小值到最大值之间的所有值,比如最小值和最大值分别为20和30,则计数数组长度为 30-20+1=11,于是数组下标为0到10,分别表示20到30,即需要做一个偏移。假如计数数组长度事先已知道则省略此步。
- 统计待排序数组A中不同元素值的分布直方图,即将不同元素值出现的次数赋值到计数数组B对应的元素上。
- 对计数数组B执行计算前缀和操作,此步操作实际上就是计算小于或等于计数数组索引值的个数,比如 B[4]=5 表示小于等于4的元素有5个。
- 根据计数数组B的位置信息,通过逆序循环将待排序数组中的所有元素赋值到输出数组C中指定位置,最终得到的输出数组C即是最终排序结果。
不考虑稳定性情况
严格的计数排序算法一般认为具有稳定性,既不会打乱待排序数组中值相等的元素的顺序。但有时在不必考虑稳定性的情况下,我们可以简化算法的过程。比如我们在对单纯的整数数组排序时就可以不考虑排序的稳定性,因为一百个整数3中每个3都是相同的,不必区分哪个3要在另一个3的前面。
在不用考虑稳定性的情况下,我们只需要一个计数数组作为辅助即可,直接统计待排序数组的分布直方图,然后根据计数数组依次赋值待排序数组的元素即可完成排序工作。
现在假设我们有10个整数组成一个待排序数组A,元素分别为3,1,4,4,2,0,1,5,0,1
。假设计数数组B长度已经确定为6,则它的索引值为0-5,刚好是待排序数组中元素的取值范围。
从头到尾循环一遍,待排序数组A[0]=3,对应到计数数组B[3],则执行B[3]累加1。
接着A[1]=1,对应到B[1],则B[1]累加1。
继续为A[2]=4,对应到B[4],则B[4]累加1。
再往下为A[3]=4,对应到B[4],则B[4]继续累加1,此时可以看到它的值已经变为2。
类似地,把待排序数组中剩下的其他元素都对应到计数数组中进行累加操作,最终结果如下:
目前为止工作已经完成了一大半了,我们得到了计数数组B,它表示的是什么呢?其实就是待排序数组元素出现的次数,比如B[0]=2表示0出现了2次,B[1]=3表示1出现了三次。所以最后一步就是按出现次数将值赋值回原来的数组中。
B[0]=2,说明有,2个0,分别将其赋值到A[0]和A[1],注意其中赋值一次需要将次数减1。
接着B[1]=3,说明有3个1,以此将其赋值到A[2]、A[3]和A[4]。
同理地,将计数数组B中剩余的其他元素也赋值到待排序数组A中指定的位置,最终结果如下:
此时待排序数组A即是已完成排序的结果。
考虑稳定性情况
前面说到的是不考虑排序稳定性的情况,而我们实际使用计数排序时其实更多是需要考虑计数排序的。比如美国职业篮球联盟(NBA)某赛季西部其中是个球队的胜场数如下:
球队 | 胜场 |
---|---|
火箭 | 65 |
雷霆 | 48 |
勇士 | 58 |
马刺 | 47 |
爵士 | 48 |
开拓者 | 49 |
鹈鹕 | 48 |
森林狼 | 47 |
现在如果要使用计数排序算法根据胜场对球队进行排序,如果还是忽略排序的稳定性的话,那么排序后可能会打乱原来数组中相同值的元素。比如48胜场的有雷霆、爵士和鹈鹕三个球队,排序前的顺序是雷霆-爵士-鹈鹕,但排序后可能是爵士-鹈鹕-雷霆,这就是非稳定性表现。
那么计数排序是如何解决稳定性问题的呢?主要就是对计数排序数组进行前缀和运算,并且引入额外的一个数组来解决。
以上面NBA球队为例,看一个具有稳定性的计数排序过程。一共有8个球队,所以待排序数组长度为8。而计数数组B长度为待排序数组最大值减去最小值再加1,即65-47+1=19。此外要注意到,因为取值范围是47-65,而数组的索引范围是0-18,所以这里其实是要做一个便宜的,其中差值为47。最后再初始化一个输出数组,长度与待排序数组一样。
对待排序数组从头到尾循环一遍,待排序数组A[0]=65,对应到计数数组B[18],则执行B[18]累加1。
接着A[1]=48,对应到B[1],则B[1]累加1。
继续为A[2]=58,对应到B[11],则B[11]累加1。
类似地,把待排序数组中剩下的其他元素都对应到计数数组中进行累加操作,最终结果如下:
接下去是计算计数数组B的前缀和,前面有说过,计算前缀和其实就是计算小于或等于数组索引值的个数。B[1]=B[0]+B[1]=2+3=5,说明小于等于48的个数是5。
接着是B[2]=B[2]+B[1]=5+1=6,说明小于等于49的个数是6。
类似地,计算数组中剩下的前缀和,最终结果如下:
最后是通过逆序循环并根据计数数组的信息将待排序数组中的元素存放到输出数组中,这里计数数组可以看成是一个定位器。
从后往前循环,为什么要逆序循环呢?因为这样可以保证排序的稳定性。因为A[7]=47,所以对应B[0],而B[0]=2,说明“森林狼”及其前面一共有2个球队,那么”森林狼”应该放到C[1]处,此外要将B[0]的值减1。
接着因为A[6]=48,所以对应B[1],而B[1]=5,说明“鹈鹕”及其前面一共有5个球队,那么”鹈鹕”应该放到C[4]处,此外要将B[1]的值减1。
接着因为A[5]=49,所以对应B[2],而B[2]=6,说明“开拓者”及其前面一共有6个球队,那么”开拓者”应该放到C[5]处,此外要将B[2]的值减1。
类似地,将待排序中剩余的元素一个个放到输出数组中,这里得到的输出数据即是最终排好序的数组,最终结果如下。
计数排序的局限
- 计数排序对于有小数的情况比较力不从心,比如数组中的元素包含了3.1415,这种情况下计数数组就不好创建了。
- 对于数组内的最大最小元素差值很大的情况,计数排序的代价将变得很大,同时导致效率很低。比如待排序数组一共有50个元素,其中最大是 10000000,最小是1,那么计数数组长度将是 10000000,这样做明显有问题。
————-推荐阅读————
我的开源项目汇总(机器&深度学习、NLP、网络IO、AIML、mysql协议、chatbot)
跟我交流,向我提问:
欢迎关注: