目录
[构建核心工具 ------ countingSortByDigit](#构建核心工具 —— countingSortByDigit)
[构建总指挥 ------ radixSort 主函数](#构建总指挥 —— radixSort 主函数)
我们来探讨排序算法中的一个"奇才"------基数排序 (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
代表百位
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
。这就是良好的模块化设计。
最终组装: 将 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)。
基数排序是一个非常聪明的非比较排序算法。它不是将数字作为一个整体来处理,而是将其分解,逐位征服。它通过巧妙地利用稳定的子排序算法(如计数排序),成功地对大范围的整数(或字符串等可以按位比较的数据)进行了高效的线性时间排序。