https://www.hello-algo.com/chapter_sorting/
选择排序
- 初始未排序的区间是[0,n-1]
- 在[0,n-1]中查找最小元素,和索引0交换,此时未排序的区间是[1,n-1]
- 在[1,n-1]中查找最小元素,和索引1交换,此时未排序区间是[2,n-1]
- 以此类推,在n-1轮后,数组前n-1个元素已排序
- 剩下的元素是最大的元素
def selection_sort(nums: list[int]):
n = len(nums)
for i in range(n-1):
# 假设最小元素的索引是min_index
min_index = i
for j in range(i+1, n):
if nums[j] < nums[min_index]:
# 如果出现比min_index还小的元素,就将索引记录下来
min_index = j
# 最终,将i和[i+1, n]中的最小元素交换
nums[i], nums[min_index] = nums[min_index], nums[i]
时间复杂度O(n²),空间复杂度O(1),原地排序,非稳定排序(因为排序中相等的元素,相对位置可能发生变化)
冒泡排序
- 对n个元素进行冒泡,将最大的元素移动至索引n-1的位置
- 对n-1个元素进行冒泡,将最大元素移动至索引n-2的位置
- 以此类推,第n-1轮之后,除第一个元素外,所有的元素都已排序
- 最终剩下的是最小的元素
def bubble_sort(nums: list[int]):
n = len(nums)
for i in range(n-1, 0, -1):
for j in range(i):
if nums[j] > nums[j+1]:
nums[j], nums[j+1] = nums[j+1], nums[j]
优化,如果某轮么有进行交换,那说明已经排序完成了,可以提前结束循环:
def bubble_sort(nums: list[int]):
n = len(nums)
for i in range(n-1, 0, -1):
flag = False
for j in range(i):
if nums[j] > nums[j+1]:
nums[j], nums[j+1] = nums[j+1], nums[j]
flag = True
if not flag:
break
时间复杂度O(n²),空间复杂度O(1),原地排序,稳定排序。
插入排序
- 假设第一个元素已排序
- 将第二个元素作为base,将它插入到第一个元素的正确位置,这样前2个元素已排序
- 将第三个元素作为base,插入前面的正确位置,前3已排序
- 以此类推,第n轮,所有元素已排序。
def insertion_sort(nums: list[int]):
n = len(nums)
for i in range(1, n):
# 将第i个元素作为base
base = nums[i]
j = i-1
# 第j个元素大于base,就把该元素往后移动,空出位置
while j >= 0 and nums[j] > base:
nums[j+1] = nums[j]
j -= 1
# 最终将base移动到正确位置
nums[j+1] = base
在数据量小的时候,插入排序比较快。
时间复杂度O(n²),自适应,空间复杂度O(1),原地排序,稳定排序。
快速排序
哨兵划分,最终的结果是将数组划分成左子数组,哨兵,和右子数组,并满足a<b<c的关系。
def partition(nums: list[int], left: int, right: int) -> int:
"""哨兵划分"""
# 以 nums[left] 为基准数
i, j = left, right
while i < j:
# 从右向左,找首个小于基准数的数字
while i < j and nums[j] >= nums[left]:
j -= 1
# 从左到右,找首个大于基准数的数字
while i < j and nums[i] <= nums[left]:
i += 1
# 交换这两个数字
nums[i], nums[j] = nums[j], nums[i]
# 将基准数交换至边界
nums[i], nums[left] = nums[left], nums[i]
return i
接下来就是递归进行哨兵划分,直到不能再分
- 首先,对原数组执行一次“哨兵划分”,得到未排序的左子数组和右子数组。
- 然后,对左子数组和右子数组分别递归执行“哨兵划分”。
- 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序。
def quick_sort(nums: list[int], left: int, right: int):
"""快速排序"""
# 子数组长度为 1 时终止递归
if left >= right:
return
# 哨兵划分
pivot = partition(nums, left, right)
# 递归左子数组、右子数组
quick_sort(nums, left, pivot - 1)
quick_sort(nums, pivot + 1, right)
时间复杂度:O(nlogn)
空间复杂度:O(n),原地排序,非稳定排序
快速排序为什么快
- 出现最差情况的概率很低
- 缓存使用效率高
- 复杂度的常数系数小:在前三种算法中,比较、复制、交换的总数最少。
基准数优化
快速排序在某些输入的情况下效率很低,例如数组完全倒序的情况下。这样每次哨兵划分,右子数组都是一个空数组。为了避免这种情况,一般取数组的首尾和中间三个元素,计算中位数作为基准数。
def median_three(nums: list[int], left: int, mid: int, right: int) -> int:
"""选取三个候选元素的中位数"""
# 此处使用异或运算来简化代码
# 异或规则为 0 ^ 0 = 1 ^ 1 = 0, 0 ^ 1 = 1 ^ 0 = 1
if (nums[left] < nums[mid]) ^ (nums[left] < nums[right]):
return left
elif (nums[mid] < nums[left]) ^ (nums[mid] < nums[right]):
return mid
else:
return right
def partition2(nums: list[int], left: int, right: int) -> int:
"""哨兵划分(三数取中值)"""
# 以 nums[left] 为基准数
med = self.median_three(nums, left, (left + right) // 2, right)
# 将中位数交换至数组最左端
nums[left], nums[med] = nums[med], nums[left]
# 以 nums[left] 为基准数
i, j = left, right
while i < j:
while i < j and nums[j] >= nums[left]:
j -= 1 # 从右向左找首个小于基准数的元素
while i < j and nums[i] <= nums[left]:
i += 1 # 从左向右找首个大于基准数的元素
# 元素交换
nums[i], nums[j] = nums[j], nums[i]
# 将基准数交换至两子数组的分界线
nums[i], nums[left] = nums[left], nums[i]
return i # 返回基准数的索引
尾递归优化
在某些输入下,比如数组完全有序的情况下,快速排序的空间占有率很高。
解决方法是在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进行递归。
def quick_sort2(self, nums: list[int], left: int, right: int):
"""快速排序(尾递归优化)"""
# 子数组长度为 1 时终止
while left < right:
# 哨兵划分操作
pivot = self.partition(nums, left, right)
# 对两个子数组中较短的那个执行快速排序
if pivot - left < right - pivot:
self.quick_sort(nums, left, pivot - 1) # 递归排序左子数组
left = pivot + 1 # 剩余未排序区间为 [pivot + 1, right]
else:
self.quick_sort(nums, pivot + 1, right) # 递归排序右子数组
right = pivot - 1 # 剩余未排序区间为 [left, pivot - 1]
归并排序
原理:
划分阶段:通过递归,不断把数组从中点分开,将长数组排序转化为短数组排序问题。
合并阶段:当数组长度为1时停止划分,持续将两个较短的有序数组进行合并
def merge(nums: list[int], left: int, mid: int, right: int):
"""合并左子数组和右子数组"""
# 左子数组区间为 [left, mid], 右子数组区间为 [mid+1, right]
# 创建一个临时数组 tmp ,用于存放合并后的结果
tmp = [0] * (right - left + 1)
# 初始化左子数组和右子数组的起始索引
i, j, k = left, mid + 1, 0
# 当左右子数组都还有元素时,进行比较并将较小的元素复制到临时数组中
while i <= mid and j <= right:
if nums[i] <= nums[j]:
tmp[k] = nums[i]
i += 1
else:
tmp[k] = nums[j]
j += 1
k += 1
# 将左子数组和右子数组的剩余元素复制到临时数组中,这两个循环实际只会执行一次
# 因为剩下的那个元素必定是左右数组中最大的元素,所以放在最后
while i <= mid:
tmp[k] = nums[i]
i += 1
k += 1
while j <= right:
tmp[k] = nums[j]
j += 1
k += 1
# 将临时数组tmp中的元素复制回原数组nums的对应区间
for k in range(0, len(tmp)):
nums[left + k] = tmp[k]
def merge_sort(nums: list[int], left: int, right: int):
"""归并排序"""
# 终止条件,当子数组长度为1时终止递归
if left >= right:
return
# 划分阶段
mid = (left + right) // 2 # 计算中点
merge_sort(nums, left, mid) # 递归左子数组
merge_sort(nums, mid + 1, right) # 递归右子数组
# 合并阶段
merge(nums, left, mid, right)
时间复杂度:O(nlogn) 空间复杂度:O(n) 非原地排序 稳定排序
堆排序
原理:
- 输入数组建立小顶堆,此时最小元素位于堆顶。
- 不断执行出堆操作,记录出堆元素,即可获取排序数组。
在实际操作中,上述方法需要占用额外存储空间,所以采用以下方法实现:
- 输入数组,建立大顶堆
- 将堆顶元素和最右叶节点的元素交换,也就是数组首元素和尾元素交换,此时已排序元素数量为1,堆长度减1
- 对堆执行从顶到底堆化操作。恢复堆的性质。
- 重复步骤2,3。循环n-1次后,就完成了排序
def sift_down(nums: list[int], n: int, i: int):
"""堆的长度为 n ,从节点 i 开始,从顶至底堆化"""
while True:
# 找出i, l , r中最大的节点,记为ma
l = 2 * i + 1
r = 2 * i + 2
ma = i
if l < n and nums[l] > nums[ma]:
ma = l
if r < n and nums[r] > nums[ma]:
ma = r
# 若节点i最大,或索引 l, r 越界,则无须继续堆化,跳出循环
if ma == i:
break
# 交换两节点
nums[i], nums[ma] = nums[ma], nums[i]
# 循环向下堆化
i = ma
def heap_sort(nums: list[int]):
"""堆排序"""
# 建堆操作:堆化除叶节点以外的其他所有节点
for i in range(len(nums) // 2 - 1, -1, -1):
sift_down(nums, len(nums), i)
# 从堆中提取最大元素,循环n-1轮
for i in range(len(nums) -1, 0, -1):
# 交换根节点与最右叶节点(也就是数组首位元素
nums[0], nums[i] = nums[i], nums[0]
# 以根节点为起点,从顶至底进行堆化
sift_down(nums, i, 0)
时间复杂度:O(nlogn) 空间复杂度O(1),原地排序 ,非稳定排序
桶排序
之前的排序都是基于“比较”的排序,接下来的是“非比较排序”算法。
桶排序首先设置一些有大小顺序的桶,每个桶对应一个数据范围,将数据评价分配到各个桶中,然后在各个桶内进行排序,最后将数据合并。
例如将一个长度为n的数组,元素范围是[0,1)
- 初始化k个桶,将n个元素分配到k个桶中
- 对每个桶分别指定排序
- 按照桶从小到大的顺序合并结果
def bucket_sort(nums: list[float]):
"""桶排序"""
# 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
k = len(nums) // 2
buckets = [[] for _ in range(k)]
# 1,将数组元素分配到各个桶中
for num in nums:
# 输入数据范围为[0,1),使用num*k映射到索引范围[0, k-1]
# 因为num的值是小于1的,所以num*k 始终在 [0, k-1] 范围内
i = int(num * k)
buckets[i].append(num)
# 2.对各个桶执行排序
for bucket in buckets:
bucket.sort()
# 3.遍历桶合并结果
i = 0
for bucket in buckets:
for num in bucket:
nums[i] = num
i += 1
时间复杂度:O(n+k),自适应排序,空间复杂度O(n+k),稳定性取决于桶内排序算法。
桶排序适用于数据量非常大的数据。比如系统内存无法一次性加载百万条数据的时候。
桶排序的关键在于,如何将数据平均地分配到各个桶中
方法一:首先将数据大致分到三个桶中,然后再对数据较多的桶往下划分
方法二:知道数据的大概分布,我们就可以设置合理的价格区间
计数排序
简单实现思路:
- 遍历数组,找出其中最大的数字m,创建一个长度为m+1的数组counter
- 借助counter统计nums中各数字的出现次数,其中counter[num]对应各数字的出现次数
- 由于counter的索引是天然有序的,所以所有元素已经排序完毕。只需遍历counter,按照次数填入nums即可
def counting_sort_naive(nums: list[int]):
"""计数排序"""
# 简单实现,无法用于排序对象
# 1. 统计数组最大元素 m
m = 0
for num in nums:
m = max(m, num)
# 2. 统计各数字的出现次数
# counter[num] 代表 num 的出现次数
counter = [0] * (m + 1)
for num in nums:
counter[num] += 1
# 3. 遍历 counter ,将各元素填入原数组 nums
idx = 0
for num in range(m+1):
# 没出现的数字,会跳过循环,因为counter[num]=0
for _ in range(counter[num]):
nums[idx] = num
idx += 1
只适用于非负整数排序,数据量大但数据范围较小的情况
时间复杂度O(n+m),空间复杂度O(n+m),非原地排序,简单实现不稳定,如果需要稳定请见库中的高级实现。
基数排序
基数排序是对基数排序的一种优化,利用数字各位数之间的关系进行递进排序,适用于数字位数比较多的情况。
def digit(num: int, exp: int) -> int:
"""获取元素 num 的第 k 位,其中 exp = 10^(k-1)"""
# 传入 exp 而非 k 可以避免在此重复执行昂贵的次方计算
return (num // exp) % 10
def counting_sort_digit(nums: list[int], exp: int):
"""计数排序(根据 nums 第 k 位排序)"""
# 十进制的位范围为 0~9 ,因此需要长度为 10 的桶数组
counter = [0] * 10
n = len(nums)
# 统计 0~9 各数字的出现次数
for i in range(n):
d = digit(nums[i], exp) # 获取 nums[i] 第 k 位,记为 d
counter[d] += 1 # 统计数字 d 的出现次数
# 求前缀和,将“出现个数”转换为“数组索引”
for i in range(1, 10):
counter[i] += counter[i - 1]
# 倒序遍历,根据桶内统计结果,将各元素填入 res
res = [0] * n
for i in range(n - 1, -1, -1):
d = digit(nums[i], exp)
j = counter[d] - 1 # 获取 d 在数组中的索引 j
res[j] = nums[i] # 将当前元素填入索引 j
counter[d] -= 1 # 将 d 的数量减 1
# 使用结果覆盖原数组 nums
for i in range(n):
nums[i] = res[i]
def radix_sort(nums: list[int]):
"""基数排序"""
# 获取数组的最大元素,用于判断最大位数
m = max(nums)
# 按照从低位到高位的顺序遍历
exp = 1
while exp <= m:
# 对数组元素的第 k 位执行计数排序
# k = 1 -> exp = 1
# k = 2 -> exp = 10
# 即 exp = 10^(k-1)
counting_sort_digit(nums, exp)
exp *= 10