排序算法之线性排序分析——桶排序、计数排序、基数排序

线性排序

桶排序、计数排序、基数排序是时间复杂度是 O(n) 的序算法。

因为这些排序算法的时间复杂度是线性的,所以我们把这类排序算法叫作线性排序(Linear sort)。

之所以能做到线性的时间复杂度,主要原因是,这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。 

此外,线性排序对排序数据的要求很苛刻,要注意线性排序算法的适用场景。

这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

  • 基数排序:根据键值的每位数字来分配桶;
  • 计数排序:每个桶只存储单一键值;
  • 桶排序:每个桶存储一定范围的数值;

桶排序

算法思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。

桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

桶内可能使用别的排序算法(根据桶内数据情况选择排序算法,一般选择时间复杂度为O(nlogn)的算法,桶内数据较少可以选择插入排序算法)或是以递归方式继续使用桶排序进行排序

 

桶排序的限制(桶排序的适用场景)

桶排序对于数据要求十分苛刻:

一、要排序的数据需要很容易就能划分成 m 个桶

二、桶与桶之间有着天然的大小顺序。(这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。)

三、数据在各个桶之间的分布是比较均匀的。

如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。

桶排序比较适合用在外部排序中。

所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

假如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。

我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。

理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。

但是订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。

针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。

桶排序的执行效率

最好情况下时间复杂度

如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。

若每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。

当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。

最坏情况下时间复杂度

如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。

平均情况下时间复杂度

因为桶排序对数据的要求较为苛刻,当数据符合桶排序的要求的时候我们才会使用桶排序。

桶排序的平均情况下时间复杂度为O(n)。

桶排序的内存消耗

桶排序需要用到“桶”,需要申请额外的桶“空间”

桶排序的空间复杂度为O(n+m),m为桶的数量。

桶排序的稳定性

桶排序的稳定性由桶内适用的排序方法决定,若桶内数据使用插入排序、归并排序等稳定排序算法,则桶排序是稳定的

若桶内数据适用快速排序等不稳定算法,则桶排序就是不稳定的

桶排序的代码实现

public class BucketSort {

    /**
     * 桶排序
     *
     * @param arr 数组
     * @param bucketSize 桶容量
     */
    public static void bucketSort(int[] arr, int bucketSize) {
        if (arr.length < 2) {
            return;
        }

        // 数组最小值
        int minValue = arr[0];
        // 数组最大值
        int maxValue = arr[1];
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] < minValue) {
                minValue = arr[i];
            } else if (arr[i] > maxValue) {
                maxValue = arr[i];
            }
        }

        // 桶数量
        int bucketCount = (maxValue - minValue) / bucketSize + 1;
        int[][] buckets = new int[bucketCount][bucketSize];
        int[] indexArr = new int[bucketCount];

        // 将数组中值分配到各个桶里
        for (int i = 0; i < arr.length; i++) {
            int bucketIndex = (arr[i] - minValue) / bucketSize;
            if (indexArr[bucketIndex] == buckets[bucketIndex].length) {
                ensureCapacity(buckets, bucketIndex);
            }
            buckets[bucketIndex][indexArr[bucketIndex]++] = arr[i];
        }

        // 对每个桶进行排序,这里使用了快速排序,快排代码省略了,这里理解桶排序即可
        int k = 0;
        for (int i = 0; i < buckets.length; i++) {
            if (indexArr[i] == 0) {
                continue;
            }
            quickSortC(buckets[i], 0, indexArr[i] - 1);
            for (int j = 0; j < indexArr[i]; j++) {
                arr[k++] = buckets[i][j];
            }
        }
    }

    /**
     * 数组扩容
     *
     * @param buckets
     * @param bucketIndex
     */
    private static void ensureCapacity(int[][] buckets, int bucketIndex) {
        int[] tempArr = buckets[bucketIndex];
        int[] newArr = new int[tempArr.length * 2];
        for (int j = 0; j < tempArr.length; j++) {
            newArr[j] = tempArr[j];
        }
        buckets[bucketIndex] = newArr;
    }

计数排序

计数排序可以说是桶排序的一种特殊情况。

当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k。

我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。

计数排序的思想:对于给定的输入序列中的每一个元素x,确定该序列中值小于等于x的元素的个数(此处并非比较各元素的大小,而是通过对元素值的计数和计数值的累加来确定)。

一旦有了这个信息,就可以将x直接存放到最终的输出序列的正确位置上。

如何计数?

假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8]中,它们分别是:2,5,3,0,2,3,0,3。

考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6]表示桶,其中下标对应分数。不过,C[6]内存储的并不是考生,而是对应的考生个数。

如何计数呢?思路是这样的:我们对 C[6]数组顺序求和,C[k]里存储小于等于分数 k 的考生个数。

我们从后到前依次扫描数组 A(从后到前扫描是为了算法的稳定性)。比如,当扫描到 3 时,我们可以从数组 C 中取出下标为 3 的值 7,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,也就是说 3 是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。

当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3]要减 1,变成 6。

计数排序的限制(计数排序的应用场景)

一、计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。 

二、计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

如果考生成绩精确到小数后一位,我们就需要将所有的分数都先乘以 10,转化成整数。

如果要排序的数据中有负数,数据的范围是[-1000, 1000],那我们就需要先对每个数据都加 1000,转化成非负整数。

计数排序的执行效率

计数排序的时间复杂度是O(n+k),其中k为数据范围。

计数排序的内存消耗

计数排序的空间复杂度为至少为O(k),若不希望破坏原数组,则空间复杂度为O(n+k)。

计数排序的稳定性

计数排序是稳定的排序算法,在扫描原数组的时候从后往前扫描可以保证算法的稳定性。

(后扫描到的相等数据,也就是说在原数组处于前面的相等数据,排序后仍保持其顺序)

计数排序的代码实现

public class CountingSort {

  // 计数排序,a是数组,n是数组大小。假设数组中存储的都是非负整数。
  public static void countingSort(int[] a, int n) {
    if (n <= 1) return;

    // 查找数组中数据的范围
    int max = a[0];
    for (int i = 1; i < n; ++i) {
      if (max < a[i]) {
        max = a[i];
      }
    }

    // 申请一个计数数组c,下标大小[0,max]
    int[] c = new int[max + 1];

    // 计算每个元素的个数,放入c中
    for (int i = 0; i < n; ++i) {
      c[a[i]]++;
    }

    // 依次累加
    for (int i = 1; i < max + 1; ++i) {
      c[i] = c[i-1] + c[i];
    }

    // 临时数组r,存储排序之后的结果
    int[] r = new int[n];
    // 计算排序的关键步骤了,有点难理解
    for (int i = n - 1; i >= 0; --i) {
      int index = c[a[i]]-1;
      r[index] = a[i];
      c[a[i]]--;
    }

    // 将结果拷贝会a数组
    for (int i = 0; i < n; ++i) {
      a[i] = r[i];
    }
  }

}

基数排序

基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。

由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。

假设我们有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。

这个思路有一个大前提就是:按照每位来排序的排序算法要是稳定的,否则这个实现思路就是不正确的。

因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。

根据每一位来排序,我们可以用刚讲过的桶排序或者计数排序,它们的时间复杂度可以做到 O(n)。

基数排序的限制(基数排序的应用场景)

一、基数排序要求数据可以分割出独立的“位”来比较,而且位之间有递进的关系

(如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了)

二、每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。

基数排序的执行效率

根据基数排序的适用场景,使用桶排序或者计数排序来给每一位排序。

如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(k*n)。

当 k 不大的时候,比如手机号码排序的例子,k 最大就是 11,所以基数排序的时间复杂度就近似于 O(n)。

基数排序的内存消耗

我们用计数排序对每一位排序,计数排序的空间复杂度为O(n+k),若k很小(如十进制就是0-9,为10),则近似为O(n)。

若数据为d位,则空间度复杂度为O(dn)。

基数排序的稳定性

基数排序是稳定的排序算法,因为基数排序要求按位排序的排序算法一定是要是稳定的。

基数排序的代码实现

public class RadixSort {

    /**
     * 基数排序
     *
     * @param arr
     */
    public static void radixSort(int[] arr) {
        int max = arr[0];
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] > max) {
                max = arr[i];
            }
        }

        // 从个位开始,对数组arr按"指数"进行排序
        for (int exp = 1; max / exp > 0; exp *= 10) {
            countingSort(arr, exp);     //这里每位用计数排序,基数排序实现也在文中
        }
    }
发布了91 篇原创文章 · 获赞 22 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_42006733/article/details/104460806