本篇文章参考借鉴了叶老师的笔记和极客时间的数据结构与算法一文,若涉及侵权,请联系我删除。
前言
上几篇博客我们一块学习了几种常用的排序算法,最低的时间复杂度是 O(nlogn) 如果我们还想提升一下速度,怎么办,这就需要我们改变一下思路,不基于比较来进行排序,本篇文章,我们一块来学习非比较的 线性排序 算法。桶排序,计数排序,基数排序。时间复杂度为 O(n) 。
没看过之前文章的小伙伴可以先了解一下
核心思想
桶排序,计数排序,基数排序的核心思想是 非比较 。
带着问题学习
如何根据年龄,给100万用户排序?
这几个问题当然也可以用前几篇文章提到的排序算法,但是时间消耗还是比较多,有没有更快的算法,同时这个数据结构比较特殊,这么多用户当中,年龄相同的会有很多,这个特点就很符合接下来要学习的算法,桶排序。
桶排序原理
桶排序,顾名思义需要用到桶,核心思想就是把要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序,桶内排完顺序之后,再把桶里的数据,按顺序依次取出,组成的序列就是有序的了。
桶排序的时间复杂度为什么是 O(n) ?我们一起分析下,如果要排序的数组有 n 个,我们把它们均匀的划分到 m 个桶里,每个桶里就有 k = n/m 个元素,每个桶内部使用快速排序,时间复杂度为 O(klogk) 。 m 个桶的排序时间就是 O(mklogk),因为 k = n/m 所以整个桶的排序时间复杂度就是 O(nlog(n/m)) ,当桶的个数 m 接近数据个数 n 时, log(n/m) 就是个非常小的量,这个时候桶排序的时间复杂度接近 O(n)。
桶排序看起来很优秀,那它可以代替之前学习的排序吗?
目前看来是不行的,因为上面我们做了很多条件的限制,因为桶排序对数据要求非常苛刻。
首先,要排序的数据需要很容易的就划分成 m 个桶,并且桶和桶之间有着天然的大小顺序。这样每个桶排序完之后,桶与桶之间的数据才不需要排序。
&emsp其次,数据在各个桶之间的分布是比较均匀的,如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据的排序时间复杂度就不是常量级别的了,在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(n*log(n/m)) 的排序算法了。
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
比如说现在有 10 GB的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是内存有限,只有几百 MB ,没办法一次性把所有的数据都加载到内存中,怎么办?
这个时候就需要借助桶排序。
先扫描一遍文件,查看一下订单金额范围。假设扫描后,订单金额最小是 1 元,最大是 10 万元。我们将所有订单,根据金额划分到 100 个桶里, 第一个桶 1-1000,第二个 1001-2000 ,以此类推,每一个桶对应一个文件,并且按照金额范围的大小顺序进行编号命名(00,01,02,03…99)。
理想状态下,如果订单在 1 到 10 万元之间均匀分布,那订单会被均匀的划分到 100 个桶里,每个文件存储大小为 100MB 的数据,我们可以把这 100 个文件一次放入到内存中,通过快排来进行排序,等都排好之后,我们只需要按照文件编号,一个一个读取到同一个文件中,那数据就完成排序了。
不过有个问题,订单数据很难做到均匀分布,如果有的文件超过 100 MB 无法放入内存怎么办?这种情况我们可以单独对数据较多的桶继续划分,然后重复依次排序的过程。直到所有文件都能读入内存为止。
计数排序原理
计数排序比较类似桶排序的特殊情况,当所排序的数据范围不大的时候,比如说最大值是 K ,我们就可以划分成 K 个桶。这样就可以省掉桶内排序的时间。
举个例子,高考分数排序,全国几百万考生,数据量很大,但是高考分数的范围很小,满分 750,最低分 0 份,我们就可以划分成 751 个桶,然后把学生们按成绩放入到每个桶里,然后再从桶里一次取出数据,就是排列好的情况了,而且时间复杂度是 O(n)。
计数排序思想很简单,只是把桶排序的桶最小化,不过为什么要叫 计数排序 ?而不是最小桶排序,小桶排序,迷你桶排序呢? 计数的含义是什么?
想弄懂“计数”的含义是什么,那就需要来看计数排序的实现了。简化一下刚才的例子,有 8 个考生,成绩在 0-5 之间,数据放在一个数组 arr[8] 中。
考生成绩 0-5 ,那我们使用大小为 6 的数组 C[6] 表示桶,其中下标对应分数。不过 C[6] 内存储的不是考生,而是考生的个数。上面的例子,只要遍历一遍考生分数,就可以得到 C[6] 的值。
从上图可以看出,分数为 3 的考生有 3 个,分数小于 3 的考生有 4 个,成绩为 3 的考生在排序之后的有序数组 R[8] 中,会保存下标4,5,6的位置。
现在关键点是如何快速计算每个分数的考生在有序数组中对应的存储位置呢?这个处理方法非常巧妙。思路如下:
我们对 C[k] 数组改变成,C[k] = C[0] + C[1] + … + C[k] 的形式。这样 C[k] 就变成了存储小于等于分数 K 的考生个数。
计数排序核心
有了上面的准备,我们就要讲最难理解的一部分了,大家仔细阅读。
从后到前依次扫描数组 A 。比如当扫描到 3 时,我们可以从数组 C 中取出下标为 3 的值 7 ,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,那 3 就是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。当 3 放入到数组 R 后,小于等于 3 的元素就只剩下 6 个了,所以响应的 C[3] 就要 -1 变成 6 。
以此类推,当扫描到第二个分数为 3 的考生的时候,就把它让入到数组 R 中的第 6 个元素的位置(也就是下标为 5 的位置)。当我们扫描完完整的数组 A 后,数组 R 中的数据,就按照分数从小到大排序好了。
上面过程有点复杂,需要结合代码来看
function countingSort(arr) {
let n = arr.length;
if (n <= 1) return;
//查找数组中的数据范围
let max = arr[0];
for (let i = 1; i < n; ++i) {
if (max < arr[i]) {
max = arr[i];
}
}
let c = [];
for (let i = 0; i <= max; ++i) {
c[i] = 0;
}
//计算每个元素的个数,放入c中
for (let i = 0; i < n; ++i) {
c[arr[i]]++;
}
//依次累加
for (let i = 1; i <= max; ++i) {
c[i] = c[i - 1] + c[i];
}
//临时数组r,存储排序之后的结果
let r = [];
//计算排序的关键步骤
for (let i = n - 1; i >= 0; --i) {
let index = c[arr[i]] - 1;
r[index] = arr[i];
c[arr[i]]--;
}
//将结果拷贝给arr数组
for (let i = 0; i < n; ++i) {
arr[i] = r[i];
}
}
let abc = [9, 7, 8, 5, 6, 1, 3, 2];
countingSort(abc);
console.log(abc);//[1, 2, 3, 5, 6, 7, 8, 9]
看完上面代码是不是明白为什么叫计数排序了吧,是利用另一个数组来计数,完成排序实现。上面代码不必死记硬背,重要的是理解和会用。
计数排序总结
计数排序只能用在数据范围不大的场景当中,类似高考按分数排名这样的例子,如果数据范围 K 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
举个例子,高考分数可能存在小数,比如 620.5 分,这个时候就需要把所有数据乘 10 之后再进行排序。
基数排序
现在有一个新问题,如果有 10 万个手机号,希望这 10 万个手机号从小到大排序,有什么比较快速的排序方法?
我们之前学习过快排,时间复杂度能做到 O(nlogn) 还有没有更高效的算法?桶排序,计数排序也不能用,因为手机号的范围太大了,有 11 位数。针对这种排序,我们需要一种时间复杂度为 O(n) 的排序算法。
这时候就需要用到我们接下来要学习的排序算法了, 基数排序 。
上面的问题有一个特性,存在一个隐性的规律:假设要比较两个手机号码 a ,b 的大小,如果在前几位中,a 已经比 b 大了,那么后面的位数就不用比了,a 肯定比 b 大。
借助稳定排序算法,这里有一个巧妙的实现思路。我们之前在讲稳定算法的时候举过一个订单排序的例子,不记得的同学,可以看下面的文章,在排序算法稳定性那一小节。
我们可以借助那个订单排序的思路,先按照最后一位来排序手机号码,然后按照倒数第二位,然后按照导数第三位,以此类推,排序11次,手机号码就有序了。
凭什么?凭什么倒着排就行了?,这个排序效果之前讲稳定性的时候就解释过了,因为使用的是稳定的排序算法那,那么相同位置的手机号,在排序之后,前后顺序不会变,这样后面的排序不会影响到前面排序的结果,就可以完成排序了。
结合一下图形可以更清楚的认识,手机号有点长,我们用字符串代替一下。
注意,这里按照每位来排序的算法,必须要稳定排序算法,不然上面思路就是错误的。
根据每一位来排序,我们可以使用桶排序或者计数排序,他们的时间复杂度都可以做到 O(n) 。如果要排序的相机有 K 位,那我们就需要 K 次桶排序,总的时间复杂度是 O(k*n) ,不过由于 K 比较小,最大也就是 11 ,所以基数排序的时间复杂度近似等于 O(n) 。
当有时候我们要排序的数据不是等长的时候怎么办?比如排序牛津字典中的 20 万个英文单词,最短的只有一个字母,最长的有 45 个字母,中文翻译是尘肺病。对于这种不等长的数据,基数排序还适用吗?
实际上, 我们可以把所有单词补全到相同长度的,位数不够的可以补“0”,根据 ASCII 码,所有字母都大于“0”,这样就可以继续用基数排序了。
总结,基数排序要对排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且“位”之间有递进关系,如果 a 数据的高位比 b 数据大,那么后面的数据就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则基数排序的时间复杂度就不能做到 O(n) 了。
解答开篇
还记得我们开篇提出的一个问题吗?如何根据年龄给100万用户排序?现在思考起来是不是很简单了,用一个桶排序,设置120个桶,分别表示年龄从1岁到120岁,然后把用户都放入到对应的桶中,再一次取出,就完成了。
内容小结
本篇文章介绍了三种应用不是很广的排序算法,对数据要求比较高,但是如果数据的某些特征符合算法特点,那就可以极大的节省排序时间。会非常高效。