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 基数排序使用场景

基数排序对要排序的数据要求如下:

  1. 需要分割出独立的"位"来比较,而且位之间可以进行比较。
  2. 每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n)。
  3. 如果排序的元素位数不一样,位数不够的可以在后面补位。

参考:

  1. 排序动画演示:http://www.jsons.cn/sort/

每天用心记录一点点。内容也许不重要,但习惯很重要!

posted on 2020-03-06 15:27  binarylei  阅读(620)  评论(0编辑  收藏  举报

导航