# 基数排序

说明:本节内容不重要,不用仔细看。

基本思路:也称为基于关键字的排序,例如针对数值排序,个位、十位、百位就是关键字。针对日期数据的排序:年、月、日、时、分、秒就是关键字。

「基数排序」用到了「计数排序」

摘要:基数排序是一种基于「关键字」的排序方法,这里的「关键字」是每一个数位,重点在于理解结论:低位优先的有效性。

重点理解:基数排序的子过程:计数排序(因为要保证稳定性)。

# 基数排序简介

  • 基数排序是一种非比较的排序方法,它是多关键字的排序方法;
  • 为了使得基数排序的描述更为直观,我们只以非负整数的排序任务为例;
  • 在基数排序中认为一个整数的个位、十位分别是一个关键字。

基数排序有两种方式:高位优先(Most significant digital)和低位优先(Least significant digital)。常见的做法是 低位优先,对高位优先我们只做简单介绍,重点介绍低位优先。

# 高位优先(不推荐)

# 基数排序(高位优先)的基本思路

高位优先比较直观:先按照高位升序排序,然后按照次高位排序,依次这样进行下去排到最低位。

该方法的实现使用了「分而治之」的思想递归执行下去,需要借助递归方法实现,且空间复杂度较大。事实上,完全可以先按照低位排序,一直排到最高位。这种做法不仅仅是正确的,实现起来还更简单。

# 低位优先(推荐)

我们通过一个具体的例子,看一下低位优先是如何排序的。例如:[329, 457, 657, 839, 436, 720, 355],使用基数排序的「低位优先」算法执行流程。

  • 首先按照个位数字进行一次 稳定排序(相同数字顺序不变),得到 [720, 355, 436, 457, 657, 329, 839]
  • 然后按照十位数字进行一次 稳定排序(相同数字顺序不变),得到 [720, 329, 436, 839, 355, 457, 657]
  • 然后按照百位数字进行一次 稳定排序(相同数字顺序不变),得到 [329, 355, 436, 457, 657, 720, 839]

# 低位优先的有效性

我们比较两个数字的时候,总是先比较最高位。低位优先的基数排序,越高位的排序是放在后面进行的,在高位相同的情况下,需要比较次高位,而次高位在之前的排序中已经排好序

这其中非常重要的一点是,每一趟基于关键字的排序 必须使用稳定排序,请大家在理解了上面的例子以后仔细体会这一点。

# 代码编写

细节:如何得到每个数位上的数值。

  • 先把低位抹去;
  • 再取个位(模 即可)。
// 计算数位上的数是几,先取个位,再十位、百位
int remainder = (arr[j] / divisor) % 10;

完整代码:「力扣」第 912 题

Java 代码:

public class RadixSort {

    public void sort(int[] nums) {
        int len = nums.length;
        // 第 1 步:找出最大的数字
        int max = nums[0];
        for (int i = 0; i < len; i++) {
            if (nums[i] > max) {
                max = nums[i];
            }
            // 数据有效性校验,因为要将数值作为 count 的索引用,因此 nums[i] 不能小于 0
            if (nums[i] < 0) {
                throw new IllegalArgumentException("该数组不适合使用计数排序");
            }
        }

        // 第 2 步:计算出最大的数字有几位,这个数值决定了我们要将整个数组看几遍
        int maxLen = getMaxLen(max);

        // 第 3 步:每一趟都使用计数排序
        int[] count = new int[10];
        int[] temp = new int[len];

        int divisor = 1;
        // 有几位数,外层循环就得执行几次
        for (int i = 0; i < maxLen; i++) {
            // 每一步都使用计数排序,保证排序结果是稳定的,这一步需要额外空间保存结果集,因此把结果保存在 temp 中
            countingSort(nums, temp, divisor, len, count);

            System.arraycopy(temp, 0, nums, 0, len);
            divisor *= 10;
        }
    }

    /**
     *
     * @param nums 原始数组
     * @param temp 在计数排序的过程中使用的辅助数组,这一次基于 divisor 关键字的排序结果存在这里
     * @param divisor
     * @param len 原始数组的长度(冗余变量)
     * @param count 计数数组
     */
    private void countingSort(int[] nums, int[] temp, int divisor, int len, int[] count) {
        // 内层循环得把数组从头到尾看一遍
        for (int j = 0; j < len; j++) {
            // 计算数位上的数是几,先取个位,再十位、百位
            int remainder = (nums[j] / divisor) % 10;
            count[remainder]++;
        }

        for (int j = 1; j < 10; j++) {
            count[j] += count[j - 1];
        }

        for (int j = len - 1; j >= 0; j--) {
            int remainder = (nums[j] / divisor) % 10;
            int index = count[remainder] - 1;
            temp[index] = nums[j];
            count[remainder]--;
        }

        // 重置数组 count,以便下次使用
        for (int j = 0; j < 10; j++) {
            count[j] = 0;
        }
    }

    /**
     * 获取一个整数的最大位数
     *
     * @param num
     * @return
     */
    private int getMaxLen(int num) {
        int maxLen = 0;
        while (num > 0) {
            num /= 10;
            maxLen++;
        }
        return maxLen;
    }

}

Java 代码:

public class Solution {

    // 基数排序:低位优先

    private static final int OFFSET = 50000;

    public int[] sortArray(int[] nums) {
        int len = nums.length;

        // 预处理,让所有的数都大于等于 0,这样才可以使用基数排序
        for (int i = 0; i < len; i++) {
            nums[i] += OFFSET;
        }

        // 第 1 步:找出最大的数字
        int max = nums[0];
        for (int num : nums) {
            if (num > max) {
                max = num;
            }
        }

        // 第 2 步:计算出最大的数字有几位,这个数值决定了我们要将整个数组看几遍
        int maxLen = getMaxLen(max);

        // 计数排序需要使用的计数数组和临时数组
        int[] count = new int[10];
        int[] temp = new int[len];

        // 表征关键字的量:除数
        // 1 表示按照个位关键字排序
        // 10 表示按照十位关键字排序
        // 100 表示按照百位关键字排序
        // 1000 表示按照千位关键字排序
        int divisor = 1;
        // 有几位数,外层循环就得执行几次
        for (int i = 0; i < maxLen; i++) {

            // 每一步都使用计数排序,保证排序结果是稳定的
            // 这一步需要额外空间保存结果集,因此把结果保存在 temp 中
            countingSort(nums, temp, divisor, len, count);

            // 交换 nums 和 temp 的引用,下一轮还是按照 nums 做计数排序
            int[] t = nums;
            nums = temp;
            temp = t;

            // divisor 自增,表示采用低位优先的基数排序
            divisor *= 10;
        }

        int[] res = new int[len];
        for (int i = 0; i < len; i++) {
            res[i] = nums[i] - OFFSET;
        }
        return res;
    }

    private void countingSort(int[] nums, int[] res, int divisor, int len, int[] count) {
        // 1、计算计数数组
        for (int i = 0; i < len; i++) {
            // 计算数位上的数是几,先取个位,再十位、百位
            int remainder = (nums[i] / divisor) % 10;
            count[remainder]++;
        }

        // 2、变成前缀和数组
        for (int i = 1; i < 10; i++) {
            count[i] += count[i - 1];
        }

        // 3、从后向前赋值
        for (int i = len - 1; i >= 0; i--) {
            int remainder = (nums[i] / divisor) % 10;
            int index = count[remainder] - 1;
            res[index] = nums[i];
            count[remainder]--;
        }

        // 4、count 数组需要设置为 0 ,以免干扰下一次排序使用
        for (int i = 0; i < 10; i++) {
            count[i] = 0;
        }
    }

    /**
     * 获取一个整数的最大位数
     *
     * @param num
     * @return
     */
    private int getMaxLen(int num) {
        int maxLen = 0;
        while (num > 0) {
            num /= 10;
            maxLen++;
        }
        return maxLen;
    }
}

复杂度分析:(这部分内容不太重要,增加学习负担)

  • 时间复杂度:,这里 为输入数组的长度, 为关键字的个数。以非负整数数组的排序任务为例,最大值有几位()就需要看数组几遍;
  • 空间复杂度:,这里 (一个数的位数)通常比 (输入数组的长度)小很多。

# 总结

基数排序是稳定排序(要求子过程也必须是稳定排序),且是非原地进行的。我们继续完善表格:

最坏时间复杂度 平均时间复杂度 最好时间复杂度 额外空间复杂度 稳定性 是否原地排序
选择排序 不稳定 原地排序
冒泡排序 稳定 原地排序
插入排序 稳定 原地排序
希尔排序 (没有相关研究) 不稳定 原地排序
归并排序 稳定 非原地排序
快速排序 不稳定 原地排序
计数排序 稳定 非原地排序
基数排序 稳定 非原地排序

说明:

  • 计数排序中, 是数组的长度, 是数组的最大值(假设数组的最小值为 );
  • 基数排序中, 是数组的长度, 是最大值的位数(关键字的个数)。

# 练习

  • 使用基数数排序完成「力扣」第 912 题:排序数组(中等)。

说明:注意题目中给出的输入数组元素的数值范围。

这一节重点在于理解「基数排序」是一种基于多关键字的排序方法,并且通过具体的例子理解「低位优先」的合理性是由每一位数上的 稳定排序 保证的。

本节内容已经发布在 掘金博客 (opens new window)


作者:liweiwei1419 链接:https://suanfa8.com/non-comparison-sorting-algorithm/radix-sort 来源:算法吧 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Last Updated: 11/18/2024, 11:23:03 PM