文章来源于极客时间前google工程师−王争专栏。
桶排序、计数排序、基数排序时间复杂度是O(n),所以这类排序算法叫作线性排序。
线性的原因:三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。
三种排序对排序的数据要求苛刻,重点要掌握这些排序算法的适用场景。
问题:如何根据年龄给100万用户排序?有没有更快的排序方法?
核心思想:将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序的时间复杂度为什么是O(n)呢?
如果要排序的数据有n个,我们把他们均匀地划分到m个桶内,每个桶里就有k=n/m个元素。每个桶里使用快速排序,时间复杂度为O(klogk)。m个桶的排序的时间复杂度就是O(mklogk),k=n/m,所以整个桶排序的时间复杂度就是O(nlong(n/m))。当桶的个数m接近数据个数n时,log(n/m)就是一个非常小的常量,这个时候时间复杂度就接近o(n)。
排序数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不均匀,极端情况下,数据全部划分到一个桶里,就退化为O(nlogn)的排序算法了。
外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
问题:有10GB的订单数据,我们希望按订单金额(假设订单金额都是正整数)进行排序,但是我们的内存有限,只有几百MB,没办法一次性把10GB的数据都加载到内存中,这个时候该怎么办呢?
我们可以借助桶排序的处理思想来解决这个问题。
我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是1元,最大是10万元。我们将所有订单根据金额划分到100个桶里,第一个桶我们存储金额在1元到1000元之内的订单,第二桶存储金额在1001到2000元之内的订单,以此类推。每个桶对应一个文件,按照金额范围大小顺序编号。
理想情况下,如果订单金额在1到10万之间均匀分布,那么订单会被均匀划分到100个文件中,每个小文件中存储大约100MB的订单数据,可以放到内存中用快排来排序。
如果某个区间数据比较多,大小超过100MB,那么可以继续划分,直到所有的文件都能读入内存中为止。
**计数排序其实是桶排序的一种特殊情况。**当要排序的n个数据,所处范围并不大的时候,比如最大值是k,我们就可以把数据划分成k个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
高考分数查询名次系统。考生满分900分,最小0分,分成901个桶。每个桶都是分数相同的考生。依次扫描每个桶。将桶内考生依次输出到一个数组中,就实现了50万考生的排序。只涉及扫描遍历操作,所以时间复杂度是O(n)。
计数排序只不过是桶的大小粒度不同。为什么这个排序算法叫“计数”排序呢?“计数”的含义来自哪里呢?
假设有8个考生,分数分别为2,5,3,0,2,3,0,3。分数在0~5分之间。放在一个A[8]的数组中。
我们使用大小为6的数组,下标表示分数,数组中的数值代表考生个数。
如何计算出每个分数的考生在有序数组中对应的存储位置呢?处理方法非常巧妙。
思路:对c[6]数组顺序求和,c[k]里存储小于等于分数k的考生个数。
步骤:依次扫描数组A。比如扫描到3,去C数组取出下标为3的值7,也就是到目前为止,包括自己在内,分数小于等于3的考生有7个,然后把3放到R中的第7个元素,下标为6。当3放入数组R中,小于等于3的元素就只剩下6个了,所以相应C[3]要减1,变成6。
代码实现如下:
// 计数排序,a是数组,n是数组大小。假设数组中存储的都是非负整数
public static void countingSort(int[] a) {
if (a == null) {
return;
}
int n = a.length;
if (n <= 1) {
return;
}
// 统计a数组中的最大值
int max = a[0];
for (int i = 1; i < n; ++i) {
if (a[i] > max) {
max = a[i];
}
}
// 初始化c数组 下标[0,max]
int[] c = new int[max + 1];
for (int i = 0; i <= max; ++i) {
c[i] = 0;
}
// 统计数组a中,元素个数
for (int i = 0; i < n; ++i) {
c[a[i]]++;
}
// 数组c统计
for (int i = 1; i <= max; ++i) {
c[i] = c[i-1] + c[i];
}
// 构造临时数组r
int[] r = new int[n];
// 计数排序核心逻辑 遍历a数组
for (int i = n - 1; i >= 0; --i) {
r[c[a[i]] - 1] = a[i];
c[a[i]]--;
}
// 将结果拷贝给a数组
for (int i = 0; i < n; ++i) {
a[i] = r[i];
}
}
总结:计数排序只能用在数据范围不大的场景中,如果数据范围k比要排序的数据n大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
比如,还是考生的例子,如果考生成绩精确到小数后一位,我们就需要将所有分数先乘以10,转化成整数,然后再放到9010个桶内。如果要排序的数据中有负数,数据的范围是[-1000,1000],那我们就需要先对每个数据都加1000,转化成非负整数。
假设我们有10万个手机号码,希望将这10万个手机号码从小到大排序,你有什么比较快速的排序方法呢?
快排,时间复杂度nlogn,还有更高效的算法吗?桶排序、计数排序能派上用场吗?手机号码11位,范围太大,显然不适合用这两种排序算法。有没有时间复杂度是O(n)的算法呢?我们来看基数排序。
规律:两个号码,前面几位中较大的,后面几位就可以不看。
借助稳定排序算法。先按照最后一位来排序手机号码,然后再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过11次排序之后,手机号码就都有序了。
注意:如果是非稳定排序算法,最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。
根据每一位排序,桶排序或者计数排序,时间复杂度可以做到n,排序数据有k位,需要k次桶排序或者计数排序,总的时间复杂度为O(k*n)。当k不大,手机号11位,复杂度接近O(n)。
排序数据不等长怎么办?可以把所有单词补齐到相同长度,位数不够的可以在后面补0,根据ASCII值,所有字母都大于0,所以补0没影响。
总结:基数排序对排序数据有要求,需要分割成独立的“位”来比较,而且位之间有递进的关系,如果a数据的高位比b数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则基数排序的时间复杂度就无法做到O(n)了。
如何根据年龄给100万用户排序?
思路:类似按照成绩给50万考生排序。我们假设年龄范围最小1岁,最大不超过120岁。我们遍历这100万用户,根据年龄将其划分到这120个桶里,然后依次顺序遍历这120个桶中的元素。完成排序。
学习了三种线性时间复杂度排序算法,桶排序、计数排序、基数排序。他们对排序的数据都有比较苛刻的要求,应用不是很广泛。但是如果数据特征比较符合这些排序算法的要求,应用这些算法,会非常高效,线性时间复杂度可以达到O(n)。
桶排序和计数排序的排序思想非常相似,都是针对范围不大的数据,将数据划分成不同的桶来实现排序。
基数排序要求数据可以划分成高低位,位之间有递进关系。比较两个数,我们只需要比较高位,高位相同再比较低位。而且每一位的数据范围不能太大,因为基数排序算法需要借助桶排序或者计数排序来完成每一位的排序工作。
假设我们现在需要对D,a,F,B,c,A,z这个字符串进行排序,将所有小写排在大写前面。小写和大写内部不要求有序。如何实现?如果还有数字,怎么解决?
思路:用两个指针a、b:a指针从头开始往后遍历,遇到大写字母就停下,b从后往前遍历,遇到小写字母就停下,交换a、b指针对应的元素;重复如上过程,直到a、b指针相交。
对于小写字母放前面,数字放中间,大写字母放后面,可以先将数据分为小写字母和非小写字母两大类,进行如上交换后再在非小写字母区间内分为数字和大写字母做同样处理