Java 排序算法 - 线性排序:桶排序和基数排序
Java 排序算法 - 线性排序:桶排序和基数排序
数据结构与算法目录(https://www.cnblogs.com/binarylei/p/10115867.html)
前面我们介绍了时间复杂度分为 O(n2) 和 O(nlogn) 的排序算法,本文介绍的则是复杂度分为 O(n) 的排序算法:桶排序和基数排序。线性排序时间、空间复杂度分析起来也很简单,但是对要排序的数据要求很苛刻,所以我们重点要掌握这些排序算法的适用场景。
1. 桶排序
1.1 桶排序原理分析
桶排序核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序的时间复杂度为什么是 O(n) 呢?理想情况下,我们可以把 n 个数据均匀地划分到 m 个桶内,每个桶里就有 k = n / m 个元素。每个桶内部使用快速排序,时间复杂度为 O(klogk)。m 个桶排序的时间复杂度就是 O(mklogk) = O(nlog(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。
1.2 桶排序使用场景
实际上,桶排序对要排序数据的要求是非常苛刻的。
- 数据能划分成 m 个桶,并且桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。
- 数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
桶排序比较适合用在外部排序中。所谓的外部排序,就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?
理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。
不过,订单可能分布不均匀 ,导致某个区间的订单特别多,其对应的文件也就会很大,还是不能一次性加载到内存。此时需要继续对这个区间的订单再划分更小的桶,直到所有的文件都能读入内存为止。
2. 计数排序
2.1 计数排序原理分析
计数基数排序:使用一个计数器,记录每个桶里会装多少个元素,进而计算出小于等于这个元素的累加个数。通过这个累加个数,我们就可以知道元素在有序数组中的位置。如某个元素累加个数为 k,那么它在有序数组中的位置为 k - 1。需要注意的是,如果还有另外一个相同值的元素,那么它只能排在 k - 2 的位置了。
int[] arr = {2, 5, 3, 0, 2, 3, 0, 3}; // 原始数组
int[] tmp = {0, 0, 0, 0, 0, 0, 0, 0}; // 临时数组,存放排序后的数组
0 1 2 3 4 5 6 7
int[] c1 = {2, 0, 2, 3, 0, 1} // 统计每个元素有多少个
int[] c2 = {2, 2, 4, 7, 7, 8} // 统计累加元素有多少个
0 1 2 3 4 5
现在,我们依次从后遍历 arr 数组,首先遍历的 arr[7] = 3,我们可以从 c2[3] = 7 获取元素值为 3 的元素在有序数据中的位置的 tmp[7 - 1] = 3,将 arr[7] 排序完成后元素 3 的个数减一,即 c2[3] = 7 - 1。然后再处理 arr[6] = 0,即 tmp[2 - 1] = 0 且 c2[0] = 2 - 1。依次类推,数组最终排序完成。这部分核心代码如下:
for (int i = n - 1; i >= 0; --i) {
int k = c[arr[i]] - 1; // 1. 根据累加个数,计算元素在有序元素中的位置 k
r[k] = arr[i]; // 2. 将元素放到临时的有序数组中 tmp
c[arr[i]]--; // 3. 如果有元素值 value 相同的,则放到 k-1 的位置
}
计数排序完整代码如下:
public void sort(Integer[] arr) {
if (arr.length <= 1) return;
int n = arr.length;
// 1. 查找数组中数据的范围
int max = arr[0];
for (int i = 1; i < n; ++i) {
if (max < arr[i]) {
max = arr[i];
}
}
// 2. 申请一个计数数组c,下标大小[0,max]
int[] c = new int[max + 1];
for (int i = 0; i <= max; ++i) {
c[i] = 0;
}
// 3. 计算每个元素的个数,放入c中
for (int i = 0; i < n; ++i) {
c[arr[i]]++;
}
// 4. 依次累加
for (int i = 1; i <= max; ++i) {
c[i] = c[i - 1] + c[i];
}
// 临时数组r,存储排序之后的结果
int[] r = new int[n];
// 5. 计数排序核心步骤:依次将数组arr放到有序数组r中
for (int i = n - 1; i >= 0; --i) {
int index = c[arr[i]] - 1;
r[index] = arr[i];
c[arr[i]]--;
}
// 6. 将结果拷贝给a数组
for (int i = 0; i < n; ++i) {
arr[i] = r[i];
}
}
2.2 计数排序使用场景
计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
3. 基数排序
基数排序也是基于其它线性排序算法,才能做到时间复杂度为 O(n)。
3.1 基数排序原理分析
基数排序根据每个数的各个位数进行排序。先根据个位数排序,再根据十位数排序,最后根据最高位。某位相同的数,维持之前的顺序(低位排列的顺序)。
比如对 10 万个手机号码进行排序,先按照最后一位来排序手机号码,然后再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。
15172354984
13972354985
基数排序时间复杂度分析:如果使用桶排序或者计数排序(必需是稳定排序算法),时间复杂度可以做到 O(n)。如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(kn)。当 k 不大的时候,比如手机号码排序的例子,基数排序的时间复杂度就近似于 O(n)。
3.2 基数排序使用场景
基数排序对要排序的数据要求如下:
- 需要分割出独立的"位"来比较,而且位之间可以进行比较。
- 每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n)。
- 如果排序的元素位数不一样,位数不够的可以在后面补位。
参考:
- 排序动画演示:http://www.jsons.cn/sort/
每天用心记录一点点。内容也许不重要,但习惯很重要!