Hello大家好!额思来想去,还是给排序这块收个尾,机器学习的内容还是这周后面再写吧,大家有看不懂或者觉得有问题的地方可以直接评论区打出来呀,我看到会马上回复的~
这篇文章主要内容就是两种进阶的排序算法:归并和快速,以及master公式(计算递归时间复杂度),也是面向萌新盆友,所以写的会详细一点,希望对大家有所帮助~
好啦,咱们话不多说,直接进入正题!
1、master定理(计算递归时间复杂度)
master定理是一种特别好用的计算递归时间复杂度的方法,在满足其前提条件的情况下,我们可以根据公式直接得出其时间复杂度。(可以说是站在前人的肩膀上操作了哈哈哈)在递归算法中,问题通常被分解成若干子问题,解决这些子问题后,再将结果合并以得到原问题的解。Master定理用于分析这种递归关系,它有三个主要部分:分解、递归、合并。
下面是具体公式:
其中:
- n 是问题的规模(就是所有递归调用的范围)
- a 是每次递归调用的子问题数(就是你写了几个递归,不是指递归了几次)
- n/b 是每个子问题的规模(这里要求每个子问题的规模必须要相等,否则无法使用此公式)
- f(n) 是在分解和合并过程中花费的时间(也可以理解为除去递归这块代码外其他部分代码所消耗的时间)
#在这里提一嘴,所谓规模可以理解为递归调用数的范围,比如总的范围的数组是9个数,然后可以分为三个子问题,每个问题规模是三个数,三个子问题分别内部递归,就可以使用我们的定理。此时a=3,b=3,至于f(n)还要看具体有什么其他操作。
Master定理分三种情况来给出递归关系的渐进时间复杂度:
情况一: 并且
在这种情况下,T(n) 的渐进时间复杂度为:
情况二: 并且
在这种情况下,T(n) 的渐进时间复杂度为:
情况三: 并且
在这种情况下,T(n) 的渐进时间复杂度为:
#上述定理的证明要通过数学公式来求证,内容很复杂,这里就暂且不展示了。大家只要会用就行,记下来就好,前人种树好乘凉嘛~
以下举一个代码实例,帮助大家理解:
def find_max(arr, left, right):
# 如果子数组长度为1,直接返回该元素
if left == right:
return arr[left]
# 计算子数组的中点
mid = (left + right) // 2
# 分别递归求解左半部分和右半部分的最大值
left_max = find_max(arr, left, mid)
right_max = find_max(arr, mid + 1, right)
# 返回左半部分和右半部分的最大值中的较大值
return max(left_max, right_max)
#这是一个简单的分治算法,我们可以以他为例子,计算一下时间复杂度
写这个公式太麻烦了,我直接给你们看看AI辅助的分析过程吧
#上述过程我们就可以很清楚的看到整个定理的使用方法,下面还会再给一个例子,这个公式还是很好用的,大家可以自己去找几个来练练手~
2、归并排序
归并排序(Merge Sort)是一种经典的分治算法(我上面提了一半),用于将一个无序数组排序为有序数组。归并排序的基本思想是将数组递归地分成两半,然后将这两半分别排序,最后将排序后的两半合并成一个有序数组,也就是我们说的:分解、递归、合并。
def merge_sort(arr):
# 如果数组长度小于等于1,直接返回数组,因为它已经是有序的
if len(arr) <= 1:
return arr
# 计算数组的中间索引,将数组分成两半
mid = len(arr) // 2
# 递归地对左半部分进行归并排序
left = merge_sort(arr[:mid])
# 递归地对右半部分进行归并排序
right = merge_sort(arr[mid:])
# 合并排序后的左半部分和右半部分
return merge(left, right)
def merge(left, right):
result = [] # 用于存储合并后的结果
i = j = 0 # 初始化两个指针,分别指向左半部分和右半部分的起始位置
# 合并两个有序数组
while i < len(left) and j < len(right):
# 如果左半部分的当前元素小于右半部分的当前元素
if left[i] < right[j]:
result.append(left[i]) # 将左半部分的当前元素添加到结果中
i += 1 # 移动左半部分的指针到下一个位置
else:
result.append(right[j]) # 将右半部分的当前元素添加到结果中
j += 1 # 移动右半部分的指针到下一个位置
# 如果左半部分还有剩余元素,添加到结果中
result.extend(left[i:])
# 如果右半部分还有剩余元素,添加到结果中
result.extend(right[j:])
return result # 返回合并后的结果
#递归的过程就是不断的通过去中间数的办法,把数组不断分开,最后成一个数都分成一个数(有点像二叉树的展开),然后再开始通过合并的函数往回带,把数组一块一块排好序,然后小块递归回溯成一个有序的大块。
下面举一个实例来帮助大家理解
初始数组:[3, 1, 4, 1, 5, 9, 2, 6]
(1)分成两半:[3, 1, 4, 1] 和 [5, 9, 2, 6]
(2)对每一半继续分解:[3, 1] 和 [4, 1],[5, 9] 和 [2, 6]
(3)继续分解直到每个子数组长度为1:[3] 和 [1],[4] 和 [1],[5] 和 [9],[2] 和 [6]
(4)合并 [3]
和 [1]
: [1, 3]
(5)合并 [4]
和 [1]
:[1, 4]
(6)合并 [5]
和 [9]
:[5, 9]
(7)合并 [2]
和 [6]
:[2, 6]
(8)合并 [1, 3]
和 [1, 4]
:[1, 1, 3, 4]
(9)合并 [5, 9]
和 [2, 6]
:[2, 5, 6, 9]
(10)最后合并 [1, 1, 3, 4]
和 [2, 5, 6, 9]
:[1, 1, 2, 3, 4, 5, 6, 9]
#每一步会排好顺序的原因是因为在每一个数都会经过合并的函数,里面有带排序的方法,所以出来的数组都是有序的。
#合并的过程基本思想就是给左右数组两个指针,依次往下滑,谁小取谁,相等取左边,把取出来的数加入到自己设置的result数列表里面。到左右有一个指针过界(就是超过他的那部分数组的长度)的时候,取剩下没过界的一边的数组加入列表,完成合并(这里不用if语句是为了简化代码,而且指针越界的情况肯定会出现,必然会有一边有剩余一边没有的情况)
#这个算法稍有复杂,大家多花点时间,静下心来,多画画图,一定可以拿下!
下面以他为例子,使用master公式计算时间复杂度:
#因为每次都是取中点平均分,所以b=2,有左右数组两个递归调用,所以a=2,合并两个子数组的时间复杂度为线性时间,所以c=n。
下面再举一个力扣经典问题——“求小和”
题干类似于:给定一个数组 arr
,我们称 arr
的小和为所有 arr[i]
的左侧所有比 arr[i]
小的元素之和的总和。请编写一个函数来计算数组 arr
的小和。
具体来说,对于一个数组中的每个元素,找到所有比它小的元素之和。这可以通过归并排序的思想来高效解决。归并排序在合并两个子数组时,记录小和,这样可以极大减少时间复杂度,提高代码效率。
def small_sum(arr):
"""
主函数,用于计算数组的小和。
如果数组为空或长度小于2,直接返回0。
否则,调用递归排序并计算小和的辅助函数。
"""
if not arr or len(arr) < 2:
return 0
return merge_sort_and_count(arr, 0, len(arr) - 1)
def merge_sort_and_count(arr, left, right):
"""
递归函数,进行归并排序并计算小和。
arr: 输入数组
left: 左边界索引
right: 右边界索引
return: 小和
"""
# 如果左右索引相等,表示只有一个元素,返回0
if left == right:
return 0
# 计算中间索引
mid = (left + right) // 2
# 递归计算左半部分的小和,其实可以理解为继续拆分数组的动作
left_sum = merge_sort_and_count(arr, left, mid)
# 递归计算右半部分的小和,这个和上面的也是一样
right_sum = merge_sort_and_count(arr, mid + 1, right)
# 合并左右部分并计算跨越中间点的小和,这就是在合并的过程中同时计算小和的步骤了
merge_sum = merge_and_count(arr, left, mid, right)
# 返回总的小和
return left_sum + right_sum + merge_sum
def merge_and_count(arr, left, mid, right):
"""
合并并计算小和的函数。
arr: 输入数组
left: 左边界索引
mid: 中间索引
right: 右边界索引
return: 小和
"""
temp = [] # 临时数组,用于存储合并后的结果
i, j = left, mid + 1 # 初始化指针
small_sum = 0 # 初始化小和
# 合并两个有序数组,并计算小和
while i <= mid and j <= right:
if arr[i] < arr[j]:
# 如果左边元素小于右边元素,计算小和
# arr[i] 对应的小和是 arr[i] 乘以右边剩余元素的数量
small_sum += arr[i] * (right - j + 1)
temp.append(arr[i])
i += 1
else:
temp.append(arr[j])#单向比较,所以这里没有小和,不然会重复
j += 1
# 如果左半部分还有剩余元素,添加到临时数组
while i <= mid:
temp.append(arr[i])
i += 1
# 如果右半部分还有剩余元素,添加到临时数组
while j <= right:
temp.append(arr[j])
j += 1
# 将排序后的临时数组内容复制回原数组(原地址修改,这样就可以同步修改数组中的数的排序了)
for i in range(len(temp)):
arr[left + i] = temp[i]
return small_sum # 返回小和
#从函数的定义可以看出,求小和的过程就是在归并的基础上,在merge的时候多了一步计算小和的过程,并且在merge的时候,与归并的定义不同的是,左右两块数组相等的时候,取右边数组的元素加入临时数组中,因为此时你不知道左边这个数有多少右边的数比他大,这样会产生错误
#使用归并排序不会有重复计算或者漏算,因为每次数组只计算一次小和,排序好的数组就不计算小和
#求小和的时候我们只关注右侧有多少数比左侧大,对应的,当右侧数比左侧小时,不计入小和!!!只是单纯把他放到临时数组里面,也就是说计算小和的有效数只有左侧数组!!!
假设以数组[1, 3, 4, 2, 5]为例子,我们可以看一下他的计算过程:
#这一部分确实有点复杂,大家可以多结合简单实际例子多想多画图,也可以详细看看我在代码里面的注释,里面标注了一些关键的点,希望大家能轻松把他拿下!
3、快速排序
快速排序(Quick Sort)是一种高效的排序算法,使用分治法(Divide and Conquer)来排序。其核心思想是通过选择一个枢轴元素,将数组划分成三部分,中间的轴元素自为一部分,剩下两部分里,其中一部分的元素都小于枢轴,另一部分的元素都大于枢轴,然后递归地对这两部分进行排序。
下面是代码展示:
import random
def quick_sort(arr):
"""
快速排序的主函数。
arr: 输入数组
return: 已排序的数组
"""
if len(arr) <= 1:
return arr # 递归结束条件:如果数组为空或只有一个元素,直接返回数组
# 调用辅助函数进行排序
quick_sort_helper(arr, 0, len(arr) - 1)
return arr
def quick_sort_helper(arr, low, high):
"""
快速排序的辅助函数,使用递归对数组进行排序。
arr: 输入数组
low: 当前排序子数组的左边界
high: 当前排序子数组的右边界
"""
if low < high:
# 获取划分点
lt, gt = partition(arr, low, high)
# 对划分点左边的子数组进行排序
quick_sort_helper(arr, low, lt )
# 对划分点右边的子数组进行排序
quick_sort_helper(arr, gt + 1, high)
def partition(arr, low, high):
"""
三向切分函数,重新排列数组,使得所有小于枢轴的元素位于枢轴左边,
所有等于枢轴的元素位于枢轴中间,所有大于枢轴的元素位于枢轴右边。
arr: 输入数组
low: 当前子数组的左边界
high: 当前子数组的右边界
return: 小于和大于枢轴的分界点
"""
# 随机选择一个元素作为枢轴,并将其与第一个元素交换
pivot_index = random.randint(low, high)
pivot = arr[pivot_index] # 选择随机元素作为枢轴
arr[pivot_index], arr[low] = arr[low], arr[pivot_index] # 将枢轴移到开头
lt, i, gt = low, low + 1, high # 初始化三个指针
# lt指向小于枢轴的部分,i用于遍历数组,gt指向大于枢轴的部分
while i <= gt:
if arr[i] < pivot:
arr[lt], arr[i] = arr[i], arr[lt] # 交换小于枢轴的元素到前面
lt += 1 # 移动lt指针
i += 1 # 移动i指针
elif arr[i] > pivot:
arr[i], arr[gt] = arr[gt], arr[i] # 交换大于枢轴的元素到后面
gt -= 1 # 移动gt指针
else:
i += 1 # 等于枢轴的元素保持在中间,移动i指针
return lt, gt # 返回小于和大于枢轴的分界点
# 示例数组
arr = [3, 6, 8, 10, 1, 2, 1, 8]
# 执行快速排序
sorted_arr = quick_sort(arr)
# 输出结果
print(f"Sorted array: {sorted_arr}")
#这里导入了random库,旨在随机选取一个元素作为枢轴(也就是我们的划分点),可以使代码的时间复杂度降到O(n*logn),这里证明要用数学里面的长期期望,从概率的角度去证,大家把结果记住就好
#算法的基本思想就是利用递归,重复把数组拆成两个部分,再定义一个函数更新拆分的那个枢轴元素,同时会在原有数组的地址上面更新数组的顺序,做到数组顺序与枢轴元素同步更新
#在循环的时候利用三个指针,i作为主要指针,循环比较的位置,lt与gt两个指针作为小于和大于的边界根据具体情况不断变化,最终将数组分成(小于x,x,大于x)这种形式
#大家可以着重看一下我的注释,里面写的挺详细的了
下面举一个实例,帮助大家理解:
#可以看到,在进行一次排序后,虽然整个数组还未完全有序,但是,大于小于等于的区间已经完全出来了,而我们的程序在排列的基础上进行递归,每次排好一个元素,保证每个元素都排列到位,最后整个数组有序(也可以看成本次递归,保证了所有大于6 的数都在右部分,所有小于6的数都在左部分,等于6的数都在中间,然后递归这种方法在左右两个部分,逐渐减小无序部分,直到类似于只剩三个数,左右中各一个数,他们有序后再反推回来,这三个数一定是小于或者大于某个数a的,这样的话,只要a的两个部分有序,a的这个小数组就有序,接着就可以递归回溯去推更大的数组直达全都有序)
#时刻注意lt与gt的值,他们和i一样是数组下标,要和数组一起看,不然非常容易混淆成第几个数,注意数组是从0开始计数的;还要注意,大于的情况,i是不增加的!!!相等的情况只增加i!!
#光看不练假把式,一定要自己去实现一遍才有可能发现问题所在,多想多练多画图!
4、一些小tip
#篇幅受限,后面的快速排序就不给例题啦,大家可以自己去网上找找~
#这篇文章的内容确实不太好理解,大家也不要灰心,一定要坚持,总有一天会明白的!!
#一定要画图,一步一步想清楚,人脑是有极限的,一定也要自己去实现,看懂到写出来到能运行,天差地别
#最后,本次笔记就到这里啦,希望能给大家一点点帮助,有任何问题也可以在评论区打出,我看到会马上回复哒~