【排序三部曲】3. 特殊排序算法

237 阅读11分钟

不基于比较的排序

插入排序,归并排序,快速排序,堆排序等等,都是基于比较的排序算法。意思就是说,同样一个过程,无论比较的是int,float,double,Student,Animal等任何类型,只要规定两个实例之间如何比大小,那么排序的过程完全可以复用。

不基于比较的数据都会受到数据状况的限制!

常见的不基于比较的排序有:计数排序,基数排序。

计数排序

1. 适用场景

现在有一个int类型的数组arr,该数组中存放的全是员工的年龄,现在要对全体员工的年龄进行排序。

已知,员工的年龄一定在[0, 200]之间,所以可以认为数据范围为0~200。

申请一个长度为0~200的int类型的数组frequency,作为词频统计表。数组的下标当作员工的年龄,每一个下标对应数组中的值当作该年龄员工的数量。例如:frequency[0]表示0岁的员工数量,frequency[1]表示1岁的员工数量。

遍历数组arr,当遍历到一个0岁员工时,frequency[0]自增1。遍历到一个1岁员工时,frequency[1]自增1。当遍历arr结束时,词频统计表frequency就构建完成了。

参照词频统计表frequency,构建一个排序好的数组result,该数组就是arr排序过后的结果。

2. 代码

public static int[] countingSort(int[] arr) {
    // 根据数据范围确定词频统计表的长度
    int[] frequency = new int[200];
    // 构建词频统计表
    for (int i = 0; i < arr.length; i ++) {
        frequency[arr[i]] ++;
    }
    // 通过词频统计表构建排序数组
    int index = 0;
    int[] result = new int[arr.length];
    for (int i = 0; i < frequency.length; i ++) {
        for (int j = 0; j < frequency[i]; j ++) {
            result[index ++] = i;
        }
    }
    return result;
}

3. 总结

计数排序时间复杂度为O(N),额外空间复杂度为O(N)

如果某场景的数据范围是[2^-31,2^31],那么意味着你的词频统计表的长度为2^32,这样以来会占用大量的额外空间。

所以不基于比较的排序,都是根据数据状况定制的排序算法,没有基于比较的排序那么广的应用范围。

基数排序

1. 排序过程

对整型数组[17,13,25,100,72]进行排序。

先确定数组中最大的元素的位数,本题为3位。然后给长度不够3位的元素的高位补0 —> [017,013,025,100,072]

再确定数组中的元素的进制数,本题为10进制。因此构建编号0~9的10个桶,桶的结构可以是数组,栈,队列(一般为队列)。

第一次遍历原始数组,根据元素的个位数进行桶排序

image.png

第二次遍历第一次排序的结果数组,根据元素的十位数进行桶排序

image.png

第三次遍历第二次排序的结果数组,根据元素的百位数进行桶排序

image.png

2. 代码

该代码实现的基数排序的方式非常优雅,对上述基数排序的过程在代码层面做了很大程度的优化。代码实现过程中,并没有逐个建立对应数量的桶,而是建立一个词频统计表和原数组等规模的辅助数组来模拟多个桶进行排序。

在每一轮中,给排序元素中相应的位做词频统计,然后将词频统计表转化成前缀和数组。之前通过查看词频统计表,可以知道对应位等于某个数的个数;现在通过查看前缀和数组,就可以知道对应位小于等于某个数的个数。从右向左遍历前一轮排序的结果数组(第一轮为原数组),再利用桶的结构为队列的性质,可以确定最后进桶的那个元素会排在某个区域最后一个位置。将该元素覆盖原数组对应位置,前缀和数组对应位置减1。当遍历结束后,相当于对某位模拟了一轮进出桶操作。

// 获取排序数组中最大数的位数
public static int getMaxDigit(int[] arr) {
    int max = Integer.MIN_VALUE;
    // 找出数组中的最大值
    for (int i = 0; i < arr.length; i ++) {
        if (arr[i] > max) {
            max = arr[i];
        }
    }
    int maxDigit = 0;
    // 获取最大值的位数
    while (max > 0) {
        maxDigit ++;
        max /= 10;
    }
    return maxDigit;
}

// 获取某个数第几位的值
public static int getDigitValue(int number, int digit) {
    for (int i = 1; i <= digit - 1; i ++) {
        number /= 10;
    }
    return number % 10;
}

/**
 * 基数排序
 * @param arr 排序数组
 * @param left 左下标
 * @param right 右下标
 * @param maxDigit 数组元素中最大数的位数
 */
public static void radixSort(int[] arr, int left, int right, int maxDigit) {
    // 基数,表示排序元素是几进制数
    int radix = 10;
    int i = 0, j = 0;
    // 辅助数组,用来存储每一轮排序之后的结果
    int[] help = new int[right - left + 1];
    // 最大元素的位数是多少,意味着需要进出桶多少轮,d表示当前排序的是第几位
    for (int d = 1; d <= maxDigit; d ++) {
        // 元素单个位的词频统计表,frequency[i]表示当前位d的值在0~i之间的元素的个数
        int[] frequency = new int[radix];
        // 构建词频统计表
        for (i = left; i <= right; i ++) {
            j = getDigitValue(arr[i], d);
            frequency[j] ++;
        }
        // 由词频统计表构建前缀和数组
        for (i = 1; i < radix; i ++) {
            frequency[i] = frequency[i - 1] + frequency[i];
        }
        // 从右向左遍历数组,模拟一轮进出桶排序,排序结果存储help
        for (i = right; i >= left; i --) {
            j = getDigitValue(arr[i], d);
            help[frequency[j] - 1] = arr[i];
            frequency[j] --;
        }
        for (i = left, j = 0; i <= right; i ++, j ++) {
            arr[i] = help[j];
        }
    }
}

3. 总结

基数排序从排序元素的低位开始排,随着高位依次进桶出桶。低位到高位,排序的优先级依次增加。

个位数优先级最低,最先排。在排十位数的时候,个位数的优先级被保留。在排百位数的时候,个位数和十位数的优先级都被保留。

这就是基数排序的本质。

基数排序要比计数排序要好,计数排序需要对一个范围内的所有排序元素做词频统计,会耗费大量的额外辅助空间,并且辅助空间的利用率非常低。而基数排序只要确定排序元素的进制位数,然后创建固定的桶数,额外辅助空间不会太大,并且利用率得到保证。

基数排序仍然收到数据状况的限制,因为基数排序的元素必须是一个有进制的数字

排序的稳定性

1. 说明

假如从小到大排序[3, 2, 3, 1, 1, 2, 3, 2, 3, 1]这个数组,排序的结果必然是[1, 1, 1, 2, 2, 2, 3, 3, 3, 3]。这个时候,多个相同的元素就排到了一起。如果[1, 1, 1],[2, 2, 2],[3, 3, 3, 3]这三组相同元素中的每一个元素,还能保持原数组中[3, 2, 3, 1, 1, 2, 3, 2, 3, 1]每一个元素的相对次序,那么可以说这个排序是稳定的。

2. 应用场景

排序的稳定性对于排序对象是1,2,3这种纯数字来说是毫无意义的。因为这些数字的含义都是相同的,排序完每个元素的相对次序一样还是不一样对整个数组所代表的整体含义没有丝毫影响。

当排序对象是非基础类型的时候,才能排上用场。

比如,排序的对象是学生,每个学生都有classID和age两个属性。

第一轮按照学生的age从小到大进行排序,第二轮按照学生classID从小到大进行排序。

如果第两轮的排序算法是稳定的,那么最后排序出来的结果不仅仅classID从小到大,同一个classID的学生的age也是从小到大。

也就意味着第二轮的排序操作保留了第一轮排序后每个元素的相对次序。

3. 选择排序

不具备稳定性。

选择排序的规则是每一轮找出未排序区域内最小的元素放到区域的左边界。

假设对[3,3,3,3,1,3,3,3]进行从小到大排序。

当[4]上的1放到左边界时,需要和[0]交换的时候,[0]上的3就违反了稳定性的规则。

4. 冒泡排序

具备稳定性。

冒泡排序的规则是每一轮在未排序区域内,通过相邻元素比大小进行调换的操作,将最大的元素调换到区域的右边界。

假设对[2,7,4,6,1,7]进行从小到大排序。

当[1]上的7经过3次比较三次调换的操作和[5]上的7相邻时,由于相邻元素相等,比较结束,开始下一轮。这样被[1]调换过的三个元素保持相对次序,[1]上的7和[5]上的7也保持了相对次序。其他数以此类推。

5. 插入排序

具备稳定性。

插入排序的规则是每一轮已排序区域的右边界向右扩大一个元素,然后将新元素从右往左进行比较调换。

假设对[4,7,9,4,1,6]进行从小到大排序。

当[3]上的4进入已排序区域后,需要和[1]和[2]做调换,调换完后,由于和[0]上的4相等,比较结束,开始下一轮。这样被[3]调换过的两个元素保持相对次序,[0]上的4和[3]上的4也保持了相对次序。其他数以此类推。

6. 归并排序

具备稳定性。

归并排序的规则是每一轮两个有序部分通过外排序合并成一个有序部分。

假设对[1,1,2,2,3]和[1,2,3,3,4]进行从小到大排序。

当第一个数组[0]和第二个数组[0]进行比较相等时,让第一个数组的[0]先进入辅助数组,第二个数组的[0]后进入辅助数组。第一个数组的[1]因为比第二个数组[1]小,随后进入辅助数组,这样第一个数组的[0]和[1]保持了相对次序。其他数以此类推。

7. 快速排序

不具备稳定性。

快速排序的规则是每一轮拿数组最后一个元素做划分,将小于该元素的放到数组左边,将大于该元素的放到数组右边,等于该元素的放到数组中间。最后将该元素与大于区域的左边界做交换,完成该元素的排序。

假设对[6,7,6,6,3,5]进行从小到大排序。

第一轮拿[5]上的5做划分,[0],[1],[2],[3]都大于5,小于区域没有变。遍历到[4]时,[4]上的3小于5,所以让[4]和小于区域的下一个做交换,也就是[4]和[0]做交换。这样[0]上的6就违反了稳定性的规则。

8. 堆排序

不具备稳定性。

堆排序的规则是每一轮先构建大根堆,再将大根堆的根和最后一个节点做交换,将最后一个节点脱离大根堆结构,从而完成对根的排序。

假设对[5,6,4,4]进行从小到大排序。

先构建大根堆,构建结束后数组为[5,4,4,6]。这样[3]上的4违反了稳定性的规则。

9. 基数排序

具备稳定性。

因为堆排序是依靠桶来进行排序的,桶的一般实现为队列。队列的性质可知,先进队列的元素一定先出队列。所以基数排序不会改变排序对象的相对次序。

排序总结

1. 排序性能汇总

时间复杂度空间复杂度稳定性
选择排序O(N^2)O(1)×
冒泡排序O(N^2)O(1)
插入排序O(N^2)O(1)
归并排序O(NlogN)O(N)
快速排序(随机)O(NlogN)O(logN)×
堆排序O(NlogN)O(1)×

2. 排序的选择

一般情况下,都会选择快速排序来实现排序的目的。因为快速排序的指标虽然和归并排序,堆排序指标相同,但是快速排序的常数项经过实验的验证是最低的。也就是说,在排序相同数据的条件下,快速排序效率最高。

如果需要使用到排序的稳定性,可以选择归并排序。

如果在空间严格限制的情况下,可以选择堆排序。

3. 排序的优化

  • 工程上对排序的优化:利用各个排序的优点组合在一起进行排序,例如在小范围上使用插入排序,大范围调度上使用快速排序。
  • 稳定性对排序的优化:将排序是否需要具备稳定性考虑在排序算法的具体实现中。

4. 拓展问题

问题一:基于比较的排序算法能否做到将时间复杂度降到O(NlogN)以下?

答:目前没有。

问题二:基于比较的排序算法能否在时间复杂度为O(NlogN)时将空间复杂度降到O(N)以下,并保证稳定性?

答:不行。

问题三:归并排序的空间复杂度能否降到O(1)?

答:可以,可以去了解"归并排序—内部缓存法"和'"原地归并排序法"。"归并排序—内部缓存法"非常难实现,不易掌握,而且当空间复杂度降到O(1)时,排序就不具备稳定性了。"原地归并排序法"不需要使用辅助数组,将空间复杂度降到O(1)时,排序的时间复杂度会上升到O(N^2)。

问题四:快速排序能否做到具备稳定性?

答:经典快速排序做不到,但是由经典快速排序衍生出来的一个论文级别的快排算法能够实现。可以去了解论文《01 stable sort》。非常难理解,不易掌握。

问题五:有一个数组,数组元素不是奇数就是偶数。能否将奇数全部放在数组左边,偶数全部放在数组右边,且保证数组每个元素的相对次序保持不变?

答:经典快排的partiton是01标准,和该题目的奇偶问题是一种调整策略。但是经典快排的partition做不到稳定性,所以经典快排解决不了该问题。解决该问题的方法请查看论文《01 stable sort》。

问题六:Arrays.sort方法为什么当传入参数是基础类型时,使用快速排序。当传入参数是自定义类型时,使用归并排序?

答:稳定性。因为排序算法的稳定性对于排序对象是基础类型时,是毫无作用的,所以就会使用常数时间较低的快排。当排序对象是非基础类型时,系统不知道业务是否需要利用排序的稳定性,所以就用归并排序保持排序稳定性。