1. 基数排序:不比较也能排序的神奇算法
第一次听说基数排序时,我完全无法理解不比较数字大小怎么排序。直到亲眼看到它处理10万个手机号排序的场景,速度比快速排序还快3倍,才明白这种"按位分治"的独特魅力。
基数排序就像整理图书馆的书籍编号:先按第一个字母分区域(A区、B区...),每个区域内再按第二个字母细分。这种思路用在数字处理上,就是按数字的每一位进行分组排序。实际项目中我常用它处理:
- 固定长度的ID排序(如员工工号)
- 多关键字排序(先按部门再按工号)
- 超大范围整数排序(比如百万级的用户ID)
与常见的比较排序不同,它的时间复杂度能达到O(n),但需要额外空间。下面这张表对比了几种常见排序算法:
| 算法类型 | 平均时间复杂度 | 空间复杂度 | 稳定性 | 适用场景 |
|---|---|---|---|---|
| 快速排序 | O(nlogn) | O(logn) | 不稳定 | 通用场景 |
| 归并排序 | O(nlogn) | O(n) | 稳定 | 链表排序 |
| 基数排序 | O(n*k) | O(n+k) | 稳定 | 整数排序 |
提示:k代表数字最大位数,当k远小于n时,基数排序优势明显
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. LSD基数排序:从个位开始的稳定排序
2.1 LSD原理图解
上周团队新来的实习生问我:"为什么叫最低位优先?"我用超市存包柜的例子解释:假设柜子编号从00到99,我们整理包裹时:
- 先按个位数分到0~9号区域(第一轮排序)
- 再按十位数重新分配(第二轮排序)
- 最后按顺序取出就是有序的
这个过程就是LSD(Least Significant Digit)的生动体现。来看具体步骤:
- 找最大值:确定需要几轮排序
python复制max_num = max(arr)
max_digit = len(str(max_num)) # 最大位数
- 按位排序:从个位开始到最高位
python复制for digit in range(max_digit):
# 创建10个桶(0-9)
buckets = [[] for _ in range(10)]
# 分配数字到桶中
for num in arr:
radix = (num // (10**digit)) % 10
buckets[radix].append(num)
# 按桶顺序重组数组
arr = [num for bucket in buckets for num in bucket]
2.2 代码优化实战
第一次实现LSD时,我犯了个典型错误:每轮都创建新桶,导致内存激增。后来优化为复用桶空间:
java复制// 优化后的Java实现
public static void lsdSort(int[] arr) {
final int RADIX = 10;
int[][] buckets = new int[RADIX][arr.length];
int[] counts = new int[RADIX];
int max = Arrays.stream(arr).max().getAsInt();
int digitCount = String.valueOf(max).length();
for (int d = 0; d < digitCount; d++) {
// 分配阶段
for (int num : arr) {
int radix = (num / (int)Math.pow(10, d)) % RADIX;
buckets[radix][counts[radix]++] = num;
}
// 收集阶段
int idx = 0;
for (int k = 0; k < RADIX; k++) {
for (int i = 0; i < counts[k]; i++) {
arr[idx++] = buckets[k][i];
}
counts[k] = 0; // 清空计数器
}
}
}
注意:当处理负数时,需要先将所有数加上最小值的绝对值转为非负数,排序后再转换回去
3. MSD基数排序:递归分治的高效策略
3.1 最高位优先的递归魔法
去年优化一个地名排序系统时,我发现LSD对长短不一的字符串效率低下。改用MSD(Most Significant Digit)后性能提升40%,它的核心思想是:
- 先按最高位分到不同组
- 组内元素>1时,递归处理下一位
- 最后合并结果
这个过程类似文件系统的目录结构:
- 先按首字母建立文件夹(A-Z)
- 每个文件夹内再按第二个字母建立子文件夹
- 直到所有文件都能唯一定位
3.2 递归实现细节
这是我优化过的MSD实现,关键点在于:
- 递归终止条件:处理到个位或组内元素≤1
- 基数计算:每次递归时基数缩小10倍
- 空间优化:避免每次递归创建新数组
python复制def msd_sort(arr, radix):
if len(arr) <= 1 or radix == 0:
return arr
# 初始化桶
buckets = [[] for _ in range(10)]
# 分配元素
for num in arr:
pos = (num // radix) % 10
buckets[pos].append(num)
# 递归处理非空桶
result = []
for bucket in buckets:
if not bucket:
continue
if radix // 10 > 0 and len(bucket) > 1:
result += msd_sort(bucket, radix // 10)
else:
result += bucket
return result
实际项目中,当数据位数差异大时(如同时存在5位数和8位数ID),MSD相比LSD能减少不必要的低位排序操作。
4. LSD与MSD的终极对决
4.1 性能对比实验
在百万级数据集测试中,我发现:
| 数据特征 | LSD耗时 | MSD耗时 | 内存占用 |
|---|---|---|---|
| 固定8位数字 | 1.2s | 1.5s | LSD少15% |
| 位数3-8位不等 | 2.8s | 1.1s | MSD少40% |
| 包含大量重复前缀 | 1.5s | 0.9s | 基本持平 |
关键结论:
- LSD优势:实现简单、内存友好、适合固定位数
- MSD优势:提前终止无效操作、适合位数差异大的数据
4.2 选择决策树
根据实战经验,我总结出选择原则:
code复制是否需要排序数字?
├─ 是 → 数据位数是否固定?
│ ├─ 是 → 选择LSD
│ └─ 否 → 数据量是否大于1万?
│ ├─ 是 → 选择MSD
│ └─ 否 → 两者均可
└─ 否 → 考虑其他排序算法
特殊场景处理建议:
- 海量数据:结合外部排序,分批处理后再合并
- 字符串排序:将字符转为ASCII码后用MSD处理
- 并行优化:LSD每轮排序可并行处理不同桶
5. 实战中的坑与解决方案
5.1 内存爆炸问题
第一次处理千万级数据时,程序因为bucket数组过大直接OOM。后来采用以下优化:
- 分批处理:每批10万数据
- 位图压缩:对于稀疏数据用BitSet代替数组
- 磁盘缓存:超大桶数据临时写入文件
优化后的Java片段:
java复制// 使用ByteBuffer做磁盘缓存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024*1024);
for (int num : currentBucket) {
buffer.putInt(num);
if (!buffer.hasRemaining()) {
flushToTempFile(buffer);
}
}
5.2 稳定性保证
在电商订单排序时发现,原本稳定的基数排序在优化后出现错乱。原因是:
- 错误地复用了桶空间
- 收集阶段没有保持原始顺序
修正方法:
python复制# 正确保持稳定性的收集方式
output = []
for bucket in buckets:
output.extend(bucket) # 保持桶内原始顺序
5.3 负数处理技巧
金融数据常包含负数,我的处理方案是:
- 找到最小值偏移量
- 所有数加上偏移量转为非负
- 排序后再减去偏移量
cpp复制int min_val = *min_element(arr.begin(), arr.end());
int offset = abs(min_val);
for (int& num : arr) num += offset;
// ...排序操作...
for (int& num : arr) num -= offset;
这些经验都来自真实项目中的教训,现在团队新人上手前我都会让他们先看这段防坑指南。
