0%

数据结构与算法自学笔记(9)- 基数排序&排序算法总结

参照的是左程云的课程:https://space.bilibili.com/8888480/lists/3509640?type=series
本笔记包括了基数排序的原理与实现,以及重要排序算法的性能总结与选择策略,包括了class028 -> class029的内容

028【必备】基数排序

基数排序概述

基数排序是一种非基于比较的排序算法,它通过对数字的每一位进行排序来实现整体排序。与其他基于比较的排序算法不同,基数排序对数据类型有特定要求。

测试链接:https://www.luogu.com.cn/problem/P1177

基数排序例子

基于比较 vs 非基于比较的排序

基于比较的排序:

  • 只需要定义好两个对象之间怎么比较即可
  • 对象的数据特征并不关心,很通用
  • 例如:快速排序、归并排序、堆排序等

非基于比较的排序:

  • 和比较无关的排序,对于对象的数据特征有要求
  • 并不通用,但在特定条件下效率极高
  • 例如:计数排序、桶排序、基数排序

从计数排序到基数排序

计数排序

计数排序是最简单的非比较排序算法:

  • 用一个数组或哈希表来记录每个数字出现的次数
  • 遍历数组统计,然后按顺序输出
  • 限制:数值范围比较大时就不实用了

桶排序

桶排序是计数排序的升级版:

  • 将数据分到有限数量的桶里,然后对每个桶再分别排序,最后合并
  • 计数排序可以看成每个桶只存储相同元素
  • 桶排序每个桶存储一定范围的元素
  • 需要确定两个信息:桶的数量和每个桶的区间范围

桶排序例子

基数排序

基数排序进一步优化了桶的使用:

  • 按位进行排序,比如先按个位数字放进桶里再倒出,再按十位数字放进桶里再倒出
  • 这样就得到一个有序的序列

基数排序的核心思想

基数排序采用**LSD(Least Significant Digit)**策略,从最低位开始排序:

  1. 从个位开始:按个位数字进行计数排序
  2. 依次向高位:按十位、百位、千位…依次排序
  3. 保持稳定性:每次排序都要保持之前排序的相对顺序

关键点:

  • 前缀数量分区的技巧
  • 数字提取某一位的技巧
  • 时间复杂度O(n),额外空间复杂度O(m)

基数排序实现详解

核心代码结构

1
2
3
4
5
6
7
8
# 可以设置进制,不一定10进制
BASE = 10 # 基数,当前设置为10进制

MAXN = 100001 # 最大数据量
arr = [0] * MAXN # 存储待排序数组
help_arr = [0] * MAXN # 辅助数组
cnts = [0] * BASE # 计数数组
n = 0 # 元素数量

处理负数的技巧

由于基数排序要求非负整数,需要特殊处理负数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def sort():
# 找到数组中的最小值
min_val = arr[0]
for i in range(1, n):
min_val = min(min_val, arr[i])

max_val = 0
for i in range(n):
arr[i] -= min_val # 全部减去最小值,转为非负数
max_val = max(max_val, arr[i])

# 根据最大值在BASE进制下的位数,决定基数排序做多少轮
radixSort(bits(max_val))

# 数组中所有数都减去了最小值,所以最后不要忘了还原
for i in range(n):
arr[i] += min_val

进位得到数字

计算位数

1
2
3
4
5
6
7
def bits(number):
"""返回number在BASE进制下有几位"""
ans = 0
while number > 0:
ans += 1
number //= BASE
return ans

基数排序核心算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
def radixSort(bits_):
"""
基数排序核心代码
arr内要保证没有负数
bits_是arr中最大值在BASE进制下有几位
"""
offset = 1 # 当前位权重

for _ in range(bits_):
# 每一轮针对某一位排序

# 1. 计数数组清零
for i in range(BASE):
cnts[i] = 0

# 2. 统计该位各数字出现次数
for i in range(n):
cnts[(arr[i] // offset) % BASE] += 1

# 3. 转换为前缀和(确定位置)
for i in range(1, BASE):
cnts[i] += cnts[i - 1]

# 4. 从后往前放入辅助数组(保证稳定性)
for i in range(n - 1, -1, -1):
idx = (arr[i] // offset) % BASE # 该位的值
cnts[idx] -= 1
help_arr[cnts[idx]] = arr[i]

# 5. 拷回原数组
for i in range(n):
arr[i] = help_arr[i]

offset *= BASE # 下一位

算法详细步骤演示

以数组 [170, 45, 75, 90, 2, 802, 24, 66] 为例:

初始数组:[170, 45, 75, 90, 2, 802, 24, 66]

第一轮(个位排序)

Step 1 - 统计个位数字:

1
2
个位: [0, 5, 5, 0, 2, 2, 4, 6]
cnts: [2, 0, 2, 0, 1, 2, 1, 0, 0, 0]

Step 2 - 计算前缀和:

1
cnts: [2, 2, 4, 4, 5, 7, 8, 8, 8, 8]

Step 3 - 从后往前放置:

1
结果: [170, 90, 2, 802, 24, 45, 75, 66]

第二轮(十位排序)

Step 1 - 统计十位数字:

1
2
十位: [7, 9, 0, 0, 2, 4, 7, 6]
cnts: [2, 0, 1, 0, 1, 0, 1, 2, 0, 1]

Step 2 - 计算前缀和并放置:

1
结果: [2, 802, 24, 45, 66, 170, 75, 90]

第三轮(百位排序)

最终结果:[2, 24, 45, 66, 75, 90, 170, 802]

稳定性保证

基数排序的稳定性通过以下方式保证:

  1. 从后往前处理:在放入辅助数组时从后往前遍历原数组
  2. 前缀和技巧:使用前缀和确定每个元素在结果数组中的准确位置
  3. 逐位处理:每一位的排序都保持前一位排序的相对顺序
1
2
3
4
5
# 关键:从后往前,确保稳定性
for i in range(n - 1, -1, -1):
idx = (arr[i] // offset) % BASE
cnts[idx] -= 1
help_arr[cnts[idx]] = arr[i]

复杂度分析

时间复杂度:O(n)

  • 设最大数有d位,需要进行d轮排序
  • 每轮排序需要O(n + k)时间,其中k是基数(通常k=10)
  • 总时间复杂度:O(d × (n + k)) ≈ O(n)

空间复杂度:O(m)

  • m为基数大小,需要辅助空间做类似桶的作用
  • 包括辅助数组help_arr和计数数组cnts

应用限制

一般来讲,基数排序要求:

  • 样本是10进制的非负整数
  • 如果不是就需要转化(如代码中处理负数的方式)
  • 可以设置任何进制来进行排序

局限性:

  • 一旦比较的对象不再是常规数字,改写代价显著增加
  • 不基于比较的排序并不通用

029【必备】重要排序算法的总结

排序算法稳定性

稳定性定义

排序算法的稳定性是指:同样大小的样本在排序之后不会改变原始的相对次序。

重要性:

  • 稳定性对基础类型对象来说毫无意义
  • 稳定性对非基础类型对象有意义,可以保留之前的相对次序

主要排序算法性能总结

排序算法 时间复杂度 空间复杂度 稳定性 备注
SelectionSort
选择排序
O(N²) O(1) ❌ 因为是随机的交换
BubbleSort
冒泡排序
O(N²) O(1) ✅ 数学归纳法可以保证
InsertionSort
插入排序
O(N²) O(1) ✅ 相等会直接停,能够保证稳定性
MergeSort
归并排序
O(N log N) O(N) ✅ 左右两边的区域满足偏序性
QuickSort
快速排序
O(N log N) O(log N) ❌ 普通的是固定选择,随机的随机选择
HeapSort
堆排序
O(N log N) O(1) ❌ 建堆的过程根本不在乎稳不稳定
CountSort
计数排序
O(N) O(M) ✅ 入桶和出桶的过程是按次序的
RadixSort
基数排序
O(N) O(M) ✅ 同计数排序

关键说明

随机快速排序的复杂度:

  • 一定要按照概率上的期望指标来估计
  • 用最差的复杂度估计无意义
  • 随机快排的详细说明在之前的视频中已有详细解释

重要结论:

基于比较的排序,时间复杂度O(n log n),空间复杂度低于O(n),还具有稳定性的排序算法目前没有找到

TimSort说明:

  • TimSort也不行,虽然在实际应用中通常不需要这么多的额外空间
  • 但空间复杂度指标就是O(n)
  • 在算法面试、笔试、比赛中都很少用到

希尔排序(ShellSort):

  • 也不常用,就是加入步长调整的插入排序
  • 有兴趣的同学可以研究一下

排序算法选择策略

排序算法的选择完全取决于你在排序过程中在乎什么:

1. 数据量非常小的情况

推荐:插入排序

  • 可以做到非常迅速
  • 实现简单,常数项小
  • 很多高级排序算法在小数据量时会切换到插入排序

2. 性能优异 + 实现简单 + 不在乎稳定性

推荐:随机快速排序

  • 性能优异,期望时间复杂度O(n log n)
  • 实现简单且利于改进
  • 面对不同业务可以选择不同划分策略
  • 空间复杂度较小O(log n)

3. 性能优异 + 需要稳定性 + 不在乎额外空间

推荐:归并排序

  • 性能优异,稳定的O(n log n)时间复杂度
  • 具有稳定性
  • 适合外部排序(大数据量)
  • 空间复杂度O(n)

4. 性能优异 + 额外空间要求O(1) + 不在乎稳定性

推荐:堆排序

  • 性能优异,稳定的O(n log n)时间复杂度
  • 额外空间占用O(1)
  • 最坏情况下性能依然稳定
  • 不具有稳定性

5. 特定数据特征 + 追求极致性能

推荐:基数排序/计数排序

  • 在特定条件下可以达到O(n)时间复杂度
  • 对数据类型有严格要求
  • 适用于整数排序且范围有限的场景

实际应用建议

编程竞赛/面试

  1. 快速排序:最常考查,需要熟练掌握
  2. 归并排序:稳定性要求时的首选
  3. 堆排序:空间复杂度有严格限制时

工程实践

  1. 小数组:插入排序
  2. 大数组:快速排序(随机化)
  3. 需要稳定性:归并排序
  4. 内存敏感:堆排序
  5. 特殊数据:计数排序/基数排序

语言内置排序

大多数编程语言的内置排序算法都是混合算法:

  • Python:Timsort(归并+插入的混合)
  • Java:双轴快排(小数组时用插入排序)
  • C++:通常是内省排序(快排+堆排序+插入排序的混合)

总结

理解各种排序算法的特点和适用场景比单纯记忆算法更重要。在实际应用中,需要根据具体需求(数据规模、稳定性要求、空间限制等)来选择合适的排序算法。基数排序作为非比较排序的代表,在特定场景下能够突破O(n log n)的理论下界,达到线性时间复杂度。