数据结构:基数排序 (Radix Sort)
目录
从“按位思考”而非“整体比较”
一个具体的排序过程
代码的逐步实现
构建核心工具 —— countingSortByDigit
构建总指挥 —— radixSort 主函数
复杂度与特性分析
时间复杂度 (Time Complexity)
空间复杂度 (Space Complexity)
稳定性 (Stability)
我们来探讨排序算法中的一个“奇才”——基数排序 (Radix Sort)。它再次刷新了我们对排序的认知,因为它甚至不需要完整地看待一个数字就能完成排序。
从“按位思考”而非“整体比较”
我们之前遇到的所有排序算法,无论是比较大小还是计算位置,都是将数字(例如 170
)作为一个整体来看待的。
基数排序的革命性思想在于:
我们能不能不把数字当成一个整体,而是把它拆分成一个个独立的“位” (digit),然后逐位进行排序?
这个想法初听起来很奇怪。如果我只按个位数排序,那么 21
就会排在 19
的前面,这显然是错的。这个思路要怎样才能奏效呢?
💡核心洞察(The Magic): 这个“逐位排序”的思路要能成功,必须满足两个条件:
1️⃣ 排序的顺序:我们必须从最低位(个位)开始,一轮一轮地向最高位排序。这个策略被称为 LSD (Least Significant Digit first) Radix Sort。
2️⃣ 排序的稳定性:每一轮“按位排序”时,所使用的排序算法必须是稳定的。也就是说,如果两个元素的当前位相同,它们在排序后必须保持上一轮的相对顺序。
为什么这样可行? 因为稳定性是关键。
当我们对“十位”进行排序时,所有“十位”相同的元素(例如 24
和 28
)会聚集在一起。但由于我们使用的排序算法是稳定的,它会保持这些元素在上一轮(按“个位”排序时)的相对顺序。
因为 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]
注意稳定性:170
和 90
的个位都是0,但原数组中 170
在 90
前面,所以新数组中它依然在前面。802
和 2
也是同理。
第二轮:按“十位”排序
-
我们基于上一轮的结果,按每个数字的十位数进行排序:
7, 9, 0, 0, 2, 4, 7, 6
-
再次使用稳定的计数排序:
[802, 2, 24, 45, 66, 170, 75, 90]
-
注意稳定性:
802
和2
的十位都是0,但在上一轮结果中802
在2
的前面,所以新数组中它依然在前面。170
和75
也是同理。
第三轮:按“百位”排序
-
基于上一轮的结果,按百位数排序:
1, 0, 0, 0, 8, 0, 0, 0
-
再次使用稳定的计数排序:
[2, 24, 45, 66, 75, 90, 170, 802]
现在,数组已经完全有序了!
代码的逐步实现
从上面的推导可以看出,基数排序本身是一个“总指挥”,它需要一个“特种兵”来完成每一轮的按位排序。这个特种兵就是计数排序。
我们将把这个任务分解成两个主要部分:
-
构建核心工具:一个能“按特定位”进行排序的特殊计数排序函数。
-
构建总指挥:一个能调用上述工具,从低位到高位循环,完成整个排序的主函数。
构建核心工具 —— countingSortByDigit
这个函数是基数排序的引擎。它的目标不是完全排序,而是根据数字的某一位(个位、十位…)来对整个数组进行一次稳定的重排。
第一阶段:函数框架和参数设计
我们需要告诉这个函数要排序哪个数组 (arr
),数组有多大 (n
),以及这次要根据哪一位来排序。我们用一个整数 exp
来代表“位权”。
-
exp = 1
代表个位 -
exp = 10
代表十位 -
exp = 100
代表百位
// 一个根据'位权(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
!
现在我们把这个技巧放入代码中。
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]++;}// ... 后续步骤 ...
}
第三阶段:计算累加和 & 构建输出数组
这部分和我们之前讲的标准计数排序完全一样。我们把代码直接放进来。
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
,以便下一轮排序(比如按十位排序)能在一个正确的基础上进行。
// 最终版本的 `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
主函数
这个“总指挥”函数的工作流程非常清晰。
第一阶段:函数框架与寻找最大值
总指挥需要知道什么时候该“收工”。什么时候收工?当我们处理完最大数字的最高位之后。所以,第一步是找到数组中的最大值。
#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
。
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
。这就是良好的模块化设计。
最终组装: 将 getMax
,countingSortByDigit
和 radixSort
放在一起,就构成了完整的基数排序实现。每一步都清晰地对应了你笔算的思路,从准备工作到核心计算,再到最终的指挥调度。
复杂度与特性分析
时间复杂度 (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
函数中,我们需要一个大小为n
的output
数组和一个大小为k
(在这里是10)的count
数组。 -
因此,空间复杂度是:O(n+k)
稳定性 (Stability)
-
正如我们从第一性原理中推导的那样,基数排序的正确性完全依赖于其内部排序算法的稳定性。
-
我们选用了计数排序作为内部排序,而我们已经知道计数排序是稳定的。
-
因此,基数排序也是一个稳定的排序算法 (Stable Sort)。
基数排序是一个非常聪明的非比较排序算法。它不是将数字作为一个整体来处理,而是将其分解,逐位征服。它通过巧妙地利用稳定的子排序算法(如计数排序),成功地对大范围的整数(或字符串等可以按位比较的数据)进行了高效的线性时间排序。