基数排序
基数排序(radix sort)的核心思想与计数排序一致,也通过统计个数来实现排序。计数排序适用于数据量n较大但数据范围m比较小的情况。假设我们需要对n=106个学号进行排序,而学号是一个8位数字,这意味着数据范围m=108非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。
基数排序在计数排序的基础上,利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。
算法流程
以学号数据为例,假设数字的最低位是第1位,最高位是第8位。算法流程如下:
- 初始化位数k = 1。
- 对学号的第k位执行“计数排序”。完成后,数据根据第k位从小到大排序。
- 将k增加1, 然后返回步骤
2.
继续迭代,直到所有位都排序完成后结束。
下面剖析代码实现。对于一个d进制的数字x,哟啊获取其第k位xk,可以使用以下计算公式:
其中 ⌊a⌋ 表示对浮点数a向下取整,而 mod d表示对d取模(取余)。对于学号数据,d=10且k ∈ [1, 8]。
此外,我们需要小幅度改动计数排序代码,是之可以根据数字的第k位进行排序:
/* 获取元素num的第k位 其中 exp = 10^(k - 1) */
int digit(int num, int exp){
// 传入exp而非k 可以避免在此重复执行昂贵的次方运算
return (num / exp) % 10;
}
/* 计数排序(根据num的第k位进行排序) */
void countingSortDigit(vector<int> &nums, int exp){
// 10进制的位范围是0~9,因此需要长度为10的桶数组
vector<int> counter(10, 0);
int n = nums.size();
// 统计0~9各数字的出现次数
for (int i = 0; i < n; ++i){
int d = digit(nums[i], exp); // 获取nums[i]的第k位,记为d
counter[d]++; // 统计数字d的出现次数
}
// 求前缀和,将“出现个数”转换为“数组索引”
for (int i = 1; i < 10; ++i){
counter[i] += counter[i - 1];
}
// 倒序遍历,根据桶内统计结果,将各元素填入res
vector<int> res(n, 0);
for (int i = n - 1; i >= 0; --i){
int d = digit(nums[i], exp);
int j = counter[d] - 1; // 获取d在数组中的索引 j
res[j] = nums[i]; // 将当前元素填入索引 j
counter[d]--; // 将d的数量减1
}
// 使用结果覆盖原数组
for (int i = 0; i < n; ++i){
nums[i] = res[i];
}
}
/* 基数排序 */
void radixSort(vector<int> &nums){
// 获取数组最大元素,用于判断最大位数
int m = *max_element(nums.begin(), nums.end());
// 按照从低位到高位的顺序遍历
for (int exp = 1; exp < m; exp *= 10){
countingSortDigit(nums, exp);
}
}
为什么从最低位开始排序?
在连续的排序轮次中,后一轮排序会覆盖前一轮排序的结果。举例来说,如果第一轮排序结果a < b,而第二轮排序结果a > b,那么第二轮的结果将取代第一轮的结果。由于数字的高位优先级高于低位,因此应该先排序低位再排序高位。
算法特性
相较于计数排序,基数排序适用于数值范围比较大的情况,但前提是数字必须是可以表示为固定位数的格式,且位数不能过大。例如,浮点数不适合使用基数排序,因为其位数k过大,可能导致时间复杂度O(nk)≫O(n2)。
- 时间复杂度O(nk):设数据量为n、数据为d进制、最大位数为k,则对某一位执行计数排序使用O(n+d)时间,排序所有k位使用O((n+d)k)时间。通常情况下,d和k都相对较小,时间复杂度趋向O(n)。
- 空间复杂度O(n+d)、非原地排序:与计数排序相同,基数排序需要借助长度为n和d的数组
res
和counter
。 - 稳定排序:当计数排序稳定时,基数排序也稳定;当计数排序不稳定时,基数排序无法保证得到正确的排序结果。