基数排序(Radix Sort)
按位排序的线性时间算法
一、算法概述
基数排序(Radix Sort)是一种非比较型整数排序算法,其核心思想是按位排序——将整数按位数切割成不同的数字位,然后按每个位分别进行排序。基数排序属于分配式排序(Distribution Sort),通过键值的部分信息将要排序的元素分配至某些"桶"中,以达到排序的效果。
基数排序最早由IBM的Herman Hollerith在1887年用于打孔卡制表机(Tabulating Machine)中。这种机器通过读取打孔卡上不同位置的孔洞来对数据进行排序,其本质就是一种基数排序。在计算机诞生之前,基数排序就已经在商业数据处理中发挥了重要作用。
核心思想:
- 将待排序元素按位数(digit)拆分为多个部分
- 从某一位开始,对元素进行稳定排序
- 逐位处理,直到所有位处理完毕
- 最终得到完全有序的序列
基本术语
- 基数(Radix/Base):每一位数字的取值范围。十进制基数为10,二进制基数为2,ASCII码基数为256
- 位数(Digit):数字在某一数位上的值。十进制数123的个位为3,十位为2,百位为1
- LSD(Least Significant Digit):从最低位(最右边)开始排序
- MSD(Most Significant Digit):从最高位(最左边)开始排序
- 稳定排序(Stable Sort):相同值的元素在排序后保持原有相对顺序
基数排序之所以能在线性时间内完成排序,正是因为避开了基于比较的排序算法(如快速排序、归并排序等)的 O(n log n) 下限。它利用的基数和位数的特性,通过分配和收集的方式对数据排序。
二、LSD 基数排序(从最低位开始)
LSD(Least Significant Digit)基数排序是最常见的基数排序实现方式。它从数字的最低位(个位)开始,逐位向高位进行处理,每次使用一个稳定排序(通常是计数排序)对当前位进行排序。
算法步骤
- 确定待排序数组中的最大值,计算其位数 d
- 从低位到高位,依次处理每一位(个位、十位、百位...)
- 对每一位,使用计数排序(或其他稳定排序)进行排序
- 重复步骤3,直到最高位处理完毕
示例演示
以数组 [170, 45, 75, 90, 2, 802, 24, 66] 为例,演示 LSD 基数排序的完整过程:
初始: [170, 45, 75, 90, 2, 802, 24, 66]
按个位分配:
桶0: [170, 90] (个位为0)
桶2: [2, 802] (个位为2)
桶4: [24] (个位为4)
桶5: [45, 75] (个位为5)
桶6: [66] (个位为6)
收集后: [170, 90, 2, 802, 24, 45, 75, 66]
按十位分配:
桶0: [2, 802] (十位为0)
桶2: [24] (十位为2)
桶4: [45] (十位为4)
桶6: [66] (十位为6)
桶7: [170, 75] (十位为7)
桶9: [90] (十位为9)
收集后: [2, 802, 24, 45, 66, 170, 75, 90]
按百位分配:
桶0: [2, 24, 45, 66, 75, 90] (百位为0)
桶1: [170] (百位为1)
桶8: [802] (百位为8)
收集后: [2, 24, 45, 66, 75, 90, 170, 802]
从上述过程可以看出,每一轮排序只关注当前位的值,利用计数排序的稳定性,保证在后续高位排序时,低位已经排好的顺序不会被破坏。
LSD 的完整实现
以下是用 C 语言实现的 LSD 基数排序:
C 语言实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void countingSortByDigit(int arr[], int n, int exp) {
int* output = (int*)malloc(n * sizeof(int));
int count[10] = {0};
int i;
for (i = 0; i < n; i++)
count[(arr[i] / exp) % 10]++;
for (i = 1; i < 10; i++)
count[i] += count[i - 1];
for (i = n - 1; i >= 0; i--) {
int digit = (arr[i] / exp) % 10;
output[count[digit] - 1] = arr[i];
count[digit]--;
}
for (i = 0; i < n; i++)
arr[i] = output[i];
free(output);
}
void radixSort(int arr[], int n) {
int maxVal = arr[0], exp;
for (int i = 1; i < n; i++)
if (arr[i] > maxVal)
maxVal = arr[i];
for (exp = 1; maxVal / exp > 0; exp *= 10)
countingSortByDigit(arr, n, exp);
}
Python 实现更为简洁:
Python 语言实现
def counting_sort_by_digit(arr, exp):
n = len(arr)
output = [0] * n
count = [0] * 10
for i in range(n):
digit = (arr[i] // exp) % 10
count[digit] += 1
for i in range(1, 10):
count[i] += count[i - 1]
for i in range(n - 1, -1, -1):
digit = (arr[i] // exp) % 10
output[count[digit] - 1] = arr[i]
count[digit] -= 1
for i in range(n):
arr[i] = output[i]
def radix_sort(arr):
if len(arr) == 0:
return
max_val = max(arr)
exp = 1
while max_val // exp > 0:
counting_sort_by_digit(arr, exp)
exp *= 10
可以看到,LSD 基数排序的核心在于嵌套了两层循环:外层循环遍历每一位(共 d 位),内层循环对当前位执行计数排序(O(n+k))。由于计数排序是稳定排序,每轮排序后低位的顺序被完整保留到下一轮,最终得到全局有序序列。
三、MSD 基数排序(从最高位开始)
MSD(Most Significant Digit)基数排序与 LSD 相反,它从数字的最高位开始排序。MSD 的排序过程是递归式的:先按最高位分组,再在每个分组内递归地对较低位进行排序。
算法步骤
- 找到数组中最大值的位数 d
- 从最高位(第 d-1 位)开始处理
- 将元素按当前位的值分配到不同的桶中
- 对每个桶递归地按下一位进行排序
- 当所有位处理完毕或桶中元素个数小于等于1时停止
- 将各个桶按顺序连接起来
MSD 的特点
- 递归结构:每个桶独立递归排序,天然适合并行处理
- 早期分治:最高位决定了元素的大致区间,能快速将数据分散
- 桶内排序:桶内元素较少时,可切换到插入排序等简单排序进行优化
- 空间开销:递归调用栈带来额外空间消耗
- 应用场景:特别适合字符串排序和变长数据排序
MSD 与 LSD 的最重要区别在于:LSD 不需要递归,实现简单且稳定;MSD 需要递归处理每个桶,但能更早地分离不同区间的数据。对于字符串排序,MSD 尤其高效,因为短字符串可以提前结束递归,而 LSD 必须处理所有字符串的最大长度。
MSD 的 Python 实现
Python 语言实现(MSD)
def msd_radix_sort(arr, digit=None, base=10):
if len(arr) <= 1:
return arr
if digit is None:
max_val = max(arr)
if max_val == 0:
return arr
digit = len(str(max_val)) - 1
buckets = [[] for _ in range(base)]
divisor = base ** digit
for num in arr:
digit_val = (num // divisor) % base
buckets[digit_val].append(num)
if digit > 0:
for i in range(base):
if len(buckets[i]) > 1:
buckets[i] = msd_radix_sort(buckets[i], digit - 1, base)
result = []
for bucket in buckets:
result.extend(bucket)
return result
MSD 基数排序的一个典型应用是字符串排序。对于字符串数组,将每个字符视为一位,基数设为字符集大小(如ASCII码的256),从第一个字符开始递归分组排序。这一算法在字符串处理领域非常高效。
四、时间复杂度分析
基数排序的时间复杂度分析需要考虑三个关键参数:
- n:待排序元素个数
- d:最大元素的位数(数字的位数或字符串的最大长度)
- k:基数大小(每位可能的取值个数,如十进制 k=10)
| 指标 |
复杂度 |
说明 |
| 最好情况 |
O(d(n+k)) |
无论数据分布如何,都需要处理所有 d 位 |
| 最坏情况 |
O(d(n+k)) |
与最好情况相同,无额外的比较开销 |
| 平均情况 |
O(d(n+k)) |
稳定的线性时间,不依赖数据分布 |
| 空间复杂度 |
O(n+k) |
需要计数数组和输出数组 |
| 稳定性 |
稳定 |
取决于内部使用的排序是否稳定 |
复杂度的深入理解
对于十进制整数排序,每个位上的计数排序复杂度为 O(n+10) = O(n)。若有 d 位,则总复杂度为 O(d·n)。当 d 为常数(例如32位整数 d=10)时,时间复杂度退化为 O(n),实现了线性时间排序。
更一般地,如果排序的数字范围是 0 到 N,则位数 d = logkN(k 为基数),总时间复杂度为 O(n·logkN)。当 k 接近 N 时,logkN ≈ 1,O(n·logkN) ≈ O(n),这时基数排序的性能最优。
与比较排序的对比
基数排序 vs 快速排序
快速排序等比较排序的理论下限为 O(n log n)。基数排序 O(d(n+k)) 在 d 较小(即数字范围不大)时明显优于 O(n log n)。但当 d 很大时,例如对64位整数排序,d=20(以10为基),基数排序也不一定比快速排序快。实际测试中,基数排序在数据量大且位数较少时优势明显。
五、基数选择与优化
基数的选择是基数排序中的一个重要权衡点。基数 k 越大,每轮处理的位数越少(d 变小),但每轮计数排序的开销更大(k 变大)。
常见基数选择
- 基数为 2(二进制):每位只有0和1,计数排序退化为简单的二分。需要 d=32(对32位整数)轮,轮数最多但每轮最快
- 基数为 10(十进制):每轮按10个桶(0-9)分配,直观易懂。d 取决于最大值的十进制位数
- 基数为 256(字节):将整数视为4个字节,每轮按256个桶分配。只需4轮(对32位整数),效率极高
- 基数为 65536(半字):只需2轮(对32位整数),但需要65536个桶,空间开销大
以256为基数的优化实现
在实际工程中,最常用的优化方式是将基数设为 256(一个字节的大小),这样可以将任何32位整数视为4个"位"(每个位是一个字节),只需4轮计数排序即可完成排序。
C 语言实现(256 基数优化版)
#include <stdint.h>
void radixSort256(uint32_t arr[], int n) {
uint32_t* output = (uint32_t*)malloc(n * sizeof(uint32_t));
int count[256];
int shift, i;
for (shift = 0; shift < 32; shift += 8) {
memset(count, 0, sizeof(count));
for (i = 0; i < n; i++)
count[(arr[i] >> shift) & 0xFF]++;
for (i = 1; i < 256; i++)
count[i] += count[i - 1];
for (i = n - 1; i >= 0; i--) {
int byteVal = (arr[i] >> shift) & 0xFF;
output[count[byteVal] - 1] = arr[i];
count[byteVal]--;
}
for (i = 0; i < n; i++)
arr[i] = output[i];
}
free(output);
}
上述实现使用了位运算来提取每个字节,避免了耗时的除法和取模运算。对于32位无符号整数,只需4轮遍历即可完成排序,效率极高。这是许多高性能排序库(如 NVIDIA CUDA 的 thrust 库)中使用的核心技术。
基数选择的经验法则
- 对于32位整数,基数256通常是最优选择(4轮,256个桶)
- 对于64位整数,可以考虑基数65536(4轮,65536个桶)或基数256(8轮,256个桶)
- 对于字符串,基数取决于字符集大小(ASCII为128/256,Unicode需更大)
- 基数越大,每轮内存访问的随机性越强,缓存局部性变差
- 基数越小,轮数越多,但每轮的内存访问模式更线性
六、代码实现汇总
以下是基数排序在多种语言中的完整实现,便于读者对比学习:
Java 实现
Java 语言实现
public class RadixSort {
public static void countingSortByDigit(int[] arr, int exp) {
int n = arr.length;
int[] output = new int[n];
int[] count = new int[10];
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--) {
int digit = (arr[i] / exp) % 10;
output[count[digit] - 1] = arr[i];
count[digit]--;
}
System.arraycopy(output, 0, arr, 0, n);
}
public static void radixSort(int[] arr) {
if (arr.length == 0) return;
int maxVal = Arrays.stream(arr).max().getAsInt();
for (int exp = 1; maxVal / exp > 0; exp *= 10)
countingSortByDigit(arr, exp);
}
}
JavaScript 实现
JavaScript 语言实现
function radixSort(arr) {
const maxVal = Math.max(...arr);
let exp = 1;
while (Math.floor(maxVal / exp) > 0) {
countingSortByDigit(arr, exp);
exp *= 10;
}
return arr;
}
function countingSortByDigit(arr, exp) {
const n = arr.length;
const output = new Array(n).fill(0);
const count = new Array(10).fill(0);
for (let i = 0; i < n; i++)
count[Math.floor(arr[i] / exp) % 10]++;
for (let i = 1; i < 10; i++)
count[i] += count[i - 1];
for (let i = n - 1; i >= 0; i--) {
const digit = Math.floor(arr[i] / exp) % 10;
output[count[digit] - 1] = arr[i];
count[digit]--;
}
for (let i = 0; i < n; i++)
arr[i] = output[i];
}
七、应用场景
基数排序虽然不是通用排序算法,但在特定场景下有非常出色的表现:
1. 整数排序
当需要排序的整数范围较大但位数有限时(如32位或64位整数),基数排序可以在 O(n) 时间内完成。这在大规模数据处理中尤其有价值。例如,在数据库系统的排序算子和外部排序中,基数排序作为第一阶段的分配策略,能快速将数据划分为有序的归并段。
2. 字符串排序
基数排序是字符串排序的经典算法。将每个字符看作一位,从第一个字符(MSD)开始递归排序。这也是 Unix 系统 sort 命令的核心算法之一。对于长度差异较大的字符串集合,MSD 基数排序配合插入排序(在短桶中切换)是最高效的字符串排序方案。
3. 日期排序
日期格式天然适合基数排序。将日期分解为年、月、日三个"位",从日到年(LSD)或从年到日(MSD)进行排序。例如,对一组 YYYY-MM-DD 格式的日期排序:
日期数组: [2024-03-15, 2023-12-01, 2024-01-20, 2023-06-10]
按 LSD 排序过程:
第1轮(日): [2023-12-01, 2024-03-15, 2024-01-20, 2023-06-10]
第2轮(月): [2024-01-20, 2023-06-10, 2023-12-01, 2024-03-15]
第3轮(年): [2023-06-10, 2023-12-01, 2024-01-20, 2024-03-15] ✓
4. GPU 并行排序
基数排序在GPU 并行计算领域得到了广泛应用。NVIDIA CUDA 的 thrust 库和 CUB 库都提供了高性能的基数排序实现。GPU 的海量并行线程可以同时对多个桶进行计数和分配,充分利用基数排序的可并行特性。
实际应用中的注意事项
- 基数排序对负数和浮点数需要特殊处理(见下节)
- 基数排序是非原地排序,需要额外的 O(n) 空间
- 对于小规模数据(n < 1000),插入排序或快速排序通常更快
- 基数排序的内存访问模式不够连续(尤其是基数较大时),可能导致缓存不命中
八、负数处理
基本的基数排序只能处理非负整数。当数组中包含负数时,标准的取模运算会产生负数索引,导致排序失败。以下是几种常见的负数处理策略:
策略一:偏移法(加常数)
将整个数组的元素加上最小值的绝对值,使所有元素变为非负数,排序后再减去该值。
Python 实现(偏移法处理负数)
def radix_sort_with_negatives(arr):
if len(arr) == 0:
return arr
min_val = min(arr)
if min_val < 0:
offset = -min_val
for i in range(len(arr)):
arr[i] += offset
radix_sort(arr)
if min_val < 0:
for i in range(len(arr)):
arr[i] -= offset
return arr
策略二:符号位处理法
在按字节排序(基数256)时,可以将整数的符号位单独处理。对于有符号32位整数,其最高字节的最高位为符号位。可以通过反转符号位的方式,使负数按大小正确排列。
C 语言实现(符号位优化)
void radixSortSigned(int32_t arr[], int n) {
uint32_t* uarr = (uint32_t*)arr;
for (int i = 0; i < n; i++)
uarr[i] ^= (uint32_t)1 << 31;
radixSort256(uarr, n);
for (int i = 0; i < n; i++)
uarr[i] ^= (uint32_t)1 << 31;
}
符号位处理法的原理是:在 IEEE 754 标准的整数表示中,正整数的最高位为0,负整数的最高位为1。翻转符号位后,负数(原最高位为1)变为正数(最高位为0),但负数的顺序恰好相反。不过,由于基数排序从最低字节开始(LSD),只需要确保每个字节内的顺序正确即可。
浮点数排序
IEEE 754 浮点数也可以通过类似的位变换技巧使用基数排序。只需将浮点数的位表示当作整数处理,并对负数的位模式进行特殊变换(翻转所有位),就可以直接使用整数基数排序对有符号浮点数进行排序。
九、与计数排序、桶排序的对比
基数排序与计数排序、桶排序同属于非比较排序家族,三者既有联系又有区别。
| 属性 |
计数排序 |
桶排序 |
基数排序 |
| 核心思想 |
统计每个值的出现次数 |
按值区间分桶,桶内再排序 |
按位分桶,逐位排序 |
| 时间复杂度 |
O(n+k) |
O(n+k) |
O(d(n+k)) |
| 空间复杂度 |
O(k) |
O(n+k) |
O(n+k) |
| 适用场景 |
值范围小(k=O(n)) |
数据均匀分布 |
数据位数少 |
| 稳定性 |
稳定 |
稳定 |
稳定 |
| 是否原地 |
否 |
否 |
否 |
三者关系
- 计数排序适用于值范围较小的整数排序(如年龄、成绩)
- 桶排序适用于均匀分布的数据(如0-1之间的浮点数),需要额外排序算法处理桶内数据
- 基数排序是计数排序的泛化——它通过逐位处理将大范围的值拆解为小范围值的多次排序
- 基数排序内部通常使用计数排序作为稳定排序的原子操作
- 三者都不是基于比较的排序,都不受 O(n log n) 下限的限制
选型建议
- 如果待排序数据的值域范围 k 较小(不超过 n 的线性倍数),优先考虑计数排序
- 如果数据均匀分布且可用哈希函数映射到桶,考虑桶排序
- 如果数据是大范围整数或字符串且位数有限,优先考虑基数排序
- 如果数据规模较小(n < 1000),或者需要原地排序,考虑快速排序等比较排序
- 在实际工程中,混合策略往往效果最佳——例如当桶内元素较少时从基数排序切换到插入排序
十、算法优化与工程实践
缓存优化
基数排序的一个主要性能瓶颈是缓存不命中。当基数较大(如256或65536)时,计数数组较大,每轮排序需要多次随机访问计数数组,容易导致缓存缺失。优化策略包括:
- 分块处理:将大数组分成若干块,每块单独执行基数排序后再合并
- 自适应基数:根据数据特征动态选择最佳基数
- 原地变体:通过复杂的交换操作减少额外空间需求
混合排序策略
在实际工程中,纯粹的基数排序并不总是最优选择。许多现代排序库(如 Rust 的排序实现、Python 的 TimSort)都采用混合策略:
- 当数据规模较小时,使用插入排序(常数因子小)
- 当数据位数少时,使用基数排序
- 当数据规模大且位数多时,使用快速排序或归并排序
混合排序策略示例(伪代码)
def hybrid_sort(arr):
n = len(arr)
if n <= THRESHOLD:
insertion_sort(arr)
elif is_suitable_for_radix(arr):
radix_sort(arr)
else:
quick_sort(arr)
十一、核心要点总结
- 基本原理:基数排序通过按位拆分和稳定排序实现线性时间排序,避开了比较排序的 O(n log n) 下限
- LSD vs MSD:LSD 从最低位开始,迭代实现,简单稳定;MSD 从最高位开始,递归实现,适合字符串和变长数据
- 时间复杂度:O(d(n+k)),其中 d 为位数,k 为基数。当 d 为常数时等价于 O(n)
- 基数选择:基数为256时对32位整数最优(只需4轮),位运算代替除法和取模可显著加速
- 稳定性:基数排序是稳定排序,前提是内部使用的计数排序或桶排序是稳定的
- 空间开销:需要 O(n+k) 的额外空间,非原地排序
- 负数处理:通过偏移法或符号位翻转法处理负数;浮点数可通过位变换技巧处理
- 工程实践:基数排序适合大数据量、位数有限的场景;常与插入排序、快速排序等组成混合排序策略
- 应用场景:整数排序、字符串排序、日期排序、GPU 并行排序是基数排序的典型用例
- 并行潜力:MSD 的递归分桶天然适合并行化,GPU 上的基数排序可以达到极高的吞吐量
十二、进一步思考
练习与扩展
- 实现一个支持负数和浮点数的通用基数排序函数
- 比较基数排序与快速排序在不同数据规模(1万、10万、100万、1000万)下的实际运行时间
- 尝试将 MSD 基数排序改造为多线程版本,观察并行加速比
- 研究 GPU(CUDA/OpenCL)上的并行基数排序实现
- 思考如何用基数排序的思想解决其他问题——例如在大数据量的 Top-K 问题中利用 MSD 快速分桶
- 探索基数排序在后缀数组构建中的应用——基数排序是 DC3 等后缀数组线性时间构建算法的基石
"基数排序是少数能在线性时间内完成排序的算法之一。它巧妙地利用了数字的位置表示法,将看似需要全局比较的问题分解为局部位上的简单分配问题。理解基数排序,不仅是学会一种排序方法,更是理解'分而治之'思想在算法设计中的精妙应用。"