数据结构:基数排序 (Radix Sort)

目录

从"按位思考"而非"整体比较"

一个具体的排序过程

代码的逐步实现

[构建核心工具 ------ countingSortByDigit](#构建核心工具 —— countingSortByDigit)

[构建总指挥 ------ radixSort 主函数](#构建总指挥 —— radixSort 主函数)

复杂度与特性分析

时间复杂度 (Time Complexity)

空间复杂度 (Space Complexity)

稳定性 (Stability)


我们来探讨排序算法中的一个"奇才"------基数排序 (Radix Sort)。它再次刷新了我们对排序的认知,因为它甚至不需要完整地看待一个数字就能完成排序。

从"按位思考"而非"整体比较"

我们之前遇到的所有排序算法,无论是比较大小还是计算位置,都是将数字(例如 170)作为一个整体来看待的。

基数排序的革命性思想在于:

我们能不能不把数字当成一个整体,而是把它拆分成一个个独立的"位" (digit),然后逐位进行排序?

这个想法初听起来很奇怪。如果我只按个位数排序,那么 21 就会排在 19 的前面,这显然是错的。这个思路要怎样才能奏效呢?

💡核心洞察(The Magic): 这个"逐位排序"的思路要能成功,必须满足两个条件:

1️⃣ 排序的顺序 :我们必须从最低位(个位)开始,一轮一轮地向最高位排序。这个策略被称为 LSD (Least Significant Digit first) Radix Sort

2️⃣ 排序的稳定性 :每一轮"按位排序"时,所使用的排序算法必须是稳定的。也就是说,如果两个元素的当前位相同,它们在排序后必须保持上一轮的相对顺序。

为什么这样可行? 因为稳定性是关键。

当我们对"十位"进行排序时,所有"十位"相同的元素(例如 2428)会聚集在一起。但由于我们使用的排序算法是稳定的,它会保持这些元素在上一轮(按"个位"排序时)的相对顺序。

因为 24 的个位 4 小于 28 的个位 8,所以上一轮排序的结果是 ...24...28...。在按十位排完序后,它们的相对顺序依然是 ...24...28...

换句话说,低位的排序结果,成为了高位排序时解决"平局"的依据。当我们处理完最高位时,整个数组自然就完全有序了。

这个"利用稳定的低位排序结果作为高位排序的次要规则"的思想,就是基数排序的第一性原理。


一个具体的排序过程

我们来手动模拟这个过程。

待排序数组:arr = [170, 45, 75, 90, 802, 24, 2, 66]

第一轮:按"个位" (Least Significant Digit) 排序

  • 我们只看每个数字的个位数:0, 5, 5, 0, 2, 4, 2, 6

  • 我们需要一个稳定的算法来根据这些个位数(范围0-9)对原数组进行排序。什么算法最适合这个场景?计数排序!

  • 经过稳定的计数排序后,数组变为: [170, 90, 802, 2, 24, 45, 75, 66]

注意稳定性:17090 的个位都是0,但原数组中 17090 前面,所以新数组中它依然在前面。8022 也是同理。

第二轮:按"十位"排序

  • 我们基于上一轮的结果,按每个数字的十位数进行排序:7, 9, 0, 0, 2, 4, 7, 6

  • 再次使用稳定的计数排序: [802, 2, 24, 45, 66, 170, 75, 90]

  • 注意稳定性:8022 的十位都是0,但在上一轮结果中 8022 的前面,所以新数组中它依然在前面。17075 也是同理。

第三轮:按"百位"排序

  • 基于上一轮的结果,按百位数排序:1, 0, 0, 0, 8, 0, 0, 0

  • 再次使用稳定的计数排序: [2, 24, 45, 66, 75, 90, 170, 802]

现在,数组已经完全有序了!


代码的逐步实现

从上面的推导可以看出,基数排序本身是一个"总指挥",它需要一个"特种兵"来完成每一轮的按位排序。这个特种兵就是计数排序。

我们将把这个任务分解成两个主要部分:

  1. 构建核心工具:一个能"按特定位"进行排序的特殊计数排序函数。

  2. 构建总指挥:一个能调用上述工具,从低位到高位循环,完成整个排序的主函数。

构建核心工具 ------ countingSortByDigit

这个函数是基数排序的引擎。它的目标不是完全排序,而是根据数字的某一位(个位、十位...)来对整个数组进行一次稳定的重排。

第一阶段:函数框架和参数设计

我们需要告诉这个函数要排序哪个数组 (arr),数组有多大 (n),以及这次要根据哪一位来排序。我们用一个整数 exp 来代表"位权"。

  • exp = 1 代表个位

  • exp = 10 代表十位

  • exp = 100 代表百位

cpp 复制代码
// 一个根据'位权(exp)'对数组arr进行稳定排序的函数
void countingSortByDigit(int arr[], int n, int exp) {
    // 笔算步骤: "准备一张最终答案纸和一张计数用的草稿纸"
    // 代码翻译: 创建 output 数组和 count 数组

    // 1. output 数组用来临时存放本次按位排序的结果
    int* output = new int[n];

    // 2. count 数组用来对 0-9 这十个数字进行计数
    int count[10] = {0}; // 直接初始化为0

    // ... 核心逻辑将在这里展开 ...
    
    // 释放内存
    delete[] output;
}

第二阶段: isolating the Digit and Counting Frequencies

这是本函数最关键的改动:如何从一个数中"取出"我们想要的那一位数字,并用它来计数。

关键技巧:digit = (number / exp) % 10

例子:number = 824, exp = 10 (我们想要十位)

  • number / exp => 824 / 10 = 82 (整型除法,小数部分被舍去)

  • (result) % 10 => 82 % 10 = 2 (取余数)

  • 我们成功地取出了十位数 2

现在我们把这个技巧放入代码中。

cpp 复制代码
void countingSortByDigit(int arr[], int n, int exp) {
    int* output = new int[n];
    int count[10] = {0};

    // 步骤1: 统计频率
    // 笔算步骤: "看每个数字的特定位,在草稿纸上画正字"
    // 代码翻译: 遍历原数组,计算出特定位,然后在 count 数组中累加
    for (int i = 0; i < n; i++) {
        int digit = (arr[i] / exp) % 10;
        count[digit]++;
    }

    // ... 后续步骤 ...
}

第三阶段:计算累加和 & 构建输出数组

这部分和我们之前讲的标准计数排序完全一样。我们把代码直接放进来。

cpp 复制代码
void countingSortByDigit(int arr[], int n, int exp) {
    int* output = new int[n];
    int count[10] = {0};

    for (int i = 0; i < n; i++) {
        count[(arr[i] / exp) % 10]++;
    }

    // 步骤2: 计算累加和,将频率转换为"排名"
    for (int i = 1; i < 10; i++) {
        count[i] += count[i - 1];
    }

    // 步骤3: 构建 output 数组,从后往前保证稳定性
    for (int i = n - 1; i >= 0; i--) {
        int digit = (arr[i] / exp) % 10;
        output[count[digit] - 1] = arr[i];
        count[digit]--;
    }

    // ... 最后一步 ...
}

第四阶段:将结果拷贝回原数组

我们的"按位排序"任务完成了,结果在 output 数组里。现在需要更新原数组 arr,以便下一轮排序(比如按十位排序)能在一个正确的基础上进行。

cpp 复制代码
// 最终版本的 `countingSortByDigit`
void countingSortByDigit(int arr[], int n, int exp) {
    int* output = new int[n];
    int count[10] = {0};

    for (int i = 0; i < n; i++) {
        count[(arr[i] / exp) % 10]++;
    }

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

    for (int i = n - 1; i >= 0; i--) {
        output[count[(arr[i] / exp) % 10] - 1] = arr[i];
        count[(arr[i] / exp) % 10]--;
    }

    // 步骤4: 将本次排序的结果拷贝回 arr,为下一轮做准备
    for (int i = 0; i < n; i++) {
        arr[i] = output[i];
    }
    
    delete[] output;
}

至此,我们的核心工具(引擎)已经打造完毕。


构建总指挥 ------ radixSort 主函数

这个"总指挥"函数的工作流程非常清晰。

第一阶段:函数框架与寻找最大值

总指挥需要知道什么时候该"收工"。什么时候收工?当我们处理完最大数字的最高位之后。所以,第一步是找到数组中的最大值。

cpp 复制代码
#include <iostream>

// (这里应该包含上面写好的 countingSortByDigit 函数)

// 辅助函数,用来找到数组最大值
int getMax(int arr[], int n) {
    int max = arr[0];
    for (int i = 1; i < n; i++) {
        if (arr[i] > max) {
            max = arr[i];
        }
    }
    return max;
}

// 基数排序主函数框架
void radixSort(int arr[], int n) {
    // 笔算步骤: "看一眼所有数字,找到那个位数最多的"
    // 代码翻译: 调用 getMax 函数
    int max_val = getMax(arr, n);

    // ... 接下来是调用核心工具的循环 ...
}

第二阶段:实现主循环

我们需要一个循环,不断地更新 exp 的值(从1到10,再到100...),并反复调用我们的核心工具 countingSortByDigit

循环应该什么时候停止?当 exp 的值已经超过了最大数的范围时。例如,最大数是 802,当 exp 变成 1000 时,802 / 1000 的结果是 0,这意味着在千位以及更高位上,所有数字都是0,没有必要再排序了。所以循环的条件就是 max_val / exp > 0

cpp 复制代码
void radixSort(int arr[], int n) {
    int max_val = getMax(arr, n);

    // 笔算步骤: "先按个位排,再按十位排,再按百位排..."
    // 代码翻译: 用一个 for 循环来控制 exp (1, 10, 100...)
    //           并在循环中调用我们的核心工具
    for (int exp = 1; max_val / exp > 0; exp *= 10) {
        countingSortByDigit(arr, n, exp);
    }
}

这个 radixSort 函数本身非常简洁,因为它把所有复杂的工作都委托给了 countingSortByDigit。这就是良好的模块化设计。

最终组装:getMaxcountingSortByDigitradixSort 放在一起,就构成了完整的基数排序实现。每一步都清晰地对应了你笔算的思路,从准备工作到核心计算,再到最终的指挥调度。


复杂度与特性分析

时间复杂度 (Time Complexity)

  • n 是元素个数,d 是最大元素的位数。

  • 基数排序的主循环执行 d 次。

  • 循环内部调用 countingSortByDigit。这个函数的复杂度是多少?

  • 它的内部有几个循环,都是遍历 n 个元素或者遍历 count 数组(大小固定为10,即基数 k)。所以 countingSortByDigit 的复杂度是 O(n+k)。

  • 总的时间复杂度 = 位数 d × 每一位排序的成本 O (n+k)。

  • 通常,基数 k 是一个常数(比如十进制是10),所以复杂度可以简化为:O(d⋅n)

📍关键点:d 和什么什么有关?

它和最大值 max_val 有关,d 大约是 log₁₀(max_val)。所以时间复杂度也可以写成 O (n・log (max_val))。当数字很大时,d 也会变大。


空间复杂度 (Space Complexity)

  • 基数排序的空间开销主要来自于其内部使用的计数排序。

  • countingSortByDigit 函数中,我们需要一个大小为 noutput 数组和一个大小为 k(在这里是10)的 count 数组。

  • 因此,空间复杂度是:O(n+k)


稳定性 (Stability)

  • 正如我们从第一性原理中推导的那样,基数排序的正确性完全依赖于其内部排序算法的稳定性。

  • 我们选用了计数排序作为内部排序,而我们已经知道计数排序是稳定的。

  • 因此,基数排序也是一个稳定的排序算法 (Stable Sort)

基数排序是一个非常聪明的非比较排序算法。它不是将数字作为一个整体来处理,而是将其分解,逐位征服。它通过巧妙地利用稳定的子排序算法(如计数排序),成功地对大范围的整数(或字符串等可以按位比较的数据)进行了高效的线性时间排序。

相关推荐
James. 常德 student9 小时前
leetcode-hot-100 (贪心算法)
算法·leetcode·贪心算法
thesky1234569 小时前
《Visual Abstraction: A Plug-and-Play Approach for Text-Visual Retrieval》
人工智能·算法·目标跟踪
张元清9 小时前
二分查找的艺术:`left <= right` 与 `left < right` 的终极抉择
前端·javascript·算法
lisanndesu9 小时前
区间DP .
算法·区间dp
午彦琳10 小时前
力扣222 代码随想录Day15 第四题
算法·leetcode·职场和发展
胡萝卜3.010 小时前
【LeetCode&数据结构】栈和队列的应用
数据结构·学习·算法··队列·栈和队列oj题
mengjiexu_cn10 小时前
强化学习PPO/DDPG算法学习记录
python·学习·算法
闻缺陷则喜何志丹11 小时前
【逆序对 博弈】P10737 [SEERC 2020] Reverse Game|普及+
c++·算法·洛谷·博弈·逆序堆
love you joyfully11 小时前
图论简介与图神经网络(Dijkstra算法,图卷积网络GCN实战)
人工智能·深度学习·神经网络·算法·贪心算法·图论