datetime:2023-06-03 15:30
author:nzb
认识O(NlogN)的排序
剖析递归行为和递归行为时间复杂度的估算
用递归方法找一个数组中的最大值,系统上到底是怎么做的?
master
公式:T(N) = a*T(N/b) + O(N^d)
解释
T(N)
:母问题的数据量是N
级别的,数据量规模为N
aT(N/b)
:子问题的规模都是N/b
,子问题的规模都是等量的,a
是子问题调用次数O(N^d)
:除了子问题的调用之外,剩下的时间复杂度- 这样一类的递归都可以使用
master
公式来算时间复杂度
结论
log(b,a) > d
-> 复杂度为O(N^log(b,a))
log(b,a) = d
-> 复杂度为O(N^d * logN)
log(b,a) < d
-> 复杂度为O(N^d)
示例(求最大值)
# idx= [0, 1, 2, 3, 4, 5]
data = [3, 2, 5, 6, 7, 4]
"""
正常解法,重头遍历到尾,找最大值,时间复杂度 O(N)
用递归找最大值,递归结构图
p(0, 5):0~5找最大值
/ \
p(0, 2) p(3, 5)
/ \ / \
p(0, 1) p(2, 2) p(3, 4) p(5, 5)
/ \ / \
p(0, 0) p(1, 1) p(3, 3) p(4, 4)
多叉树后序遍历,每个节点都需要子节点汇总得到结果才能得出最大值
"""
# master公式:T(N) = 2 * T(N/2) + O(1)
# a = 2, b = 2, d=0
# log(2, 2) > 0,所以时间复杂度:O(N^log(2,2) = O(N) 等价于重头遍历到尾,找最大值
def max_value(d, left, right):
if left == right: # O(1)
return d[left]
mid = left + ((right - left) >> 1)
left_d = max_value(d, left, mid) # T(N/2)
right_d = max_value(d, mid + 1, right) # T(N/2)
return max(left_d, right_d)
print(max_value(data, 0, len(data) - 1))
- 示例2:假设一个数组
- 前面1/3调用一次递归获取最大值,中间1/3调用一次递归获取最大值,后面1/3调用一次递归获取最大值,最后获取最大值,符合master公式:
T(N) = 3 * T(N/3) + O(1)
- 左边2/3调用一次递归获取最大值,右边2/3调用一次递归获取最大值,最后获取最大值,虽然有重叠部分,但是也符合master公式:
T(N) = 2 * T(N/(3/2)) + O(1)
- 左边1/3调用一次递归获取最大值,右边2/3调用一次递归获取最大值,最后获取最大值,不符合master公式,因为子问题规模不等
- 前面1/3调用一次递归获取最大值,中间1/3调用一次递归获取最大值,后面1/3调用一次递归获取最大值,最后再打印一遍数据,然后获取最大值,符合master公式:
T(N) = 3 * T(N/3) + O(N)
- 前面1/3调用一次递归获取最大值,中间1/3调用一次递归获取最大值,后面1/3调用一次递归获取最大值,最后获取最大值,符合master公式:
归并排序
- 整体就是一个简单递归,左边排好序、右边排好序、让其整体有序
- 让其整体有序的过程里用了排外序方法
- 利用master公式来求解时间复杂度,
master
公式:T(N) = 2 * T(N/2) + O(N)
,log(2, 2) = 1,所以时间复杂度:O(N * logN)
- 归并排序的实质
- 时间复杂度:
O(N * logN)
,额外空间复杂度O(N)
# idx= [0, 1, 2, 3, 4, 5]
data = [3, 2, 1, 5, 6, 2]
def merge_sort(arr):
if len(arr) < 2:
return arr
mid = len(arr) >> 1 # len(arr) // 2
left = merge_sort(arr[:mid]) # T(N/2)
right = merge_sort(arr[mid:]) # T(N/2)
return merge(left, right)
# mid = len(arr) >> 1 # len(arr) // 2
# return merge(merge_sort(arr[:mid]), merge_sort(arr[mid:]))
def merge(left, right):
tmp = [] # 数据排序到新数据,left和right,要么left要么right,拷贝到tmp,最后一次拷贝是整个数组长度,所以O(N)
while left and right: # 遍历一遍 O(N)
if left[0] <= right[0]:
tmp.append(left.pop(0))
else:
tmp.append(right.pop(0))
tmp += left[:]
tmp += right[:]
return tmp
print(merge_sort(data))
- 归并排序实质,归并排序是如何做到时间复杂度从
O(N^2)
到O(N*logN)
?- 冒泡、选择排序,浪费了大量的比较行为,比如选择排序,0~N-1范围上,比较了N次才知道了放到0位置,只搞定了一个数,0~N-2范围上,比较了N-1次才搞定一个数,以此类推,每一轮的比较都是独立的,浪费了多次比较才搞定一个数
- 归并排序没有浪费比较行为,左侧部分有序,右侧部分有序,接下来比较是左侧部分的指针和右侧部分的指针,依次从左到右,左侧跟右侧的比,这个比较行为信息没有浪费, 变成了一个整体有序的部分,下一回轮到这个大部分继续跟另一个大部分继续merge出来一个更长的有序部分,依次往下传递,所以时间复杂度更优
归并排序的扩展
- 小和问题:在一个数组中,每一个数左边比当前数小的数累加起来,叫做这个数组的小和。求一个数组的小和。
- 例子:
[1,3,4,2,5]
1左边比1小的数,没有; 3左边比3小的数,1; 4左边比4小的数,1、3; 2左边比2小的数,1; 5左边比5小的数,1、3、4、2; 所以小和为1+1+3+1+1+3+4+2=16
- 暴力解法:O(N^2)
- 转换思路,求一个数前面的小和,比如对于1来说,右边有多少个数比1大,就产生多少个数乘以1的小和,右边有多少个数比3大,就产生多少个数乘以3的小和
1: 4 * 1
3: 2 * 3
4: 4 * 1
2: 2 * 1
- 例子:
def small_sum(arr):
if len(arr) <= 1:
return arr, 0 # 返回已排序的数组和小和
mid = len(arr) >> 1 # len(arr) // 2
left, left_sum = small_sum(arr[:mid])
right, right_sum = small_sum(arr[mid:])
# 左组在排序的时候求小和,右组在排序的时候求小和,左组和右组合并的时候也要求小和
return merge(left, right, left_sum + right_sum)
def merge(left, right, total_sum):
result = []
i, j = 0, 0
while i < len(left) and j < len(right): # 都不越界
if left[i] < right[j]: # 左边的数比右边的数小了,那么右边剩余的数都比它小
total_sum += left[i] * (len(right) - j) # 求小和
# 打印
# for item in right[j:]:
# print(f"{item} > {left[i]}")
result.append(left[i])
i += 1
else: # 跟普通归并排序不同的点,左组的数和右组的数相等的时候,需要先拷贝右组的数,并且不产生小和,否则不知道右组有多少个小和:[1,1,1,2,2,3,4,5] [1,1,2,3,4,4,5,5]
result.append(right[j])
j += 1
# 归并排序,排序不可少,不然不清楚右边有多少个数比左边大
result.extend(left[i:])
result.extend(right[j:])
return result, total_sum
# 示例
data = [1, 3, 4, 2, 5]
sorted_arr, small_sum = small_sum(data)
print("排序后的数组:", sorted_arr)
print("小和:", small_sum)
# 排序后的数组: [1, 2, 3, 4, 5]
# 小和: 16
- 逆序对问题:在一个数组中,左边的数如果比右边的数大,则这两个数构成一个逆序对,请打印所有逆序对(请找到逆序对)。
- 例子:
[3,2,4,5,0]
3,2
3,0
2,0
4,0
5,0
- 例子:
def reverse_pair(arr):
if len(arr) <= 1:
return arr, 0 # 返回已排序的数组和逆序对数
mid = len(arr) >> 1 # len(arr) // 2
left, left_cnt = reverse_pair(arr[:mid])
right, right_cnt = reverse_pair(arr[mid:])
# 左组在排序的时候求逆序对数,右组在排序的时候求逆序对数,左组和右组合并的时候也要求逆序对数
return merge(left, right, left_cnt + right_cnt)
def merge(left, right, total_cnt):
result = []
i, j = 0, 0
while i < len(left) and j < len(right): # 都不越界
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
# 左边的数开始比右边的数大了,那么左边剩余的数都比右边的数大了
total_cnt += len(left) - i # 求逆序对数
# # 打印逆序对
# for it in left[i:]:
# print(f"{it}->{right[j]}")
result.append(right[j])
j += 1
# 归并排序,排序不可少,不然不清楚左边有多少个数比右边大
result.extend(left[i:])
result.extend(right[j:])
return result, total_cnt
# 示例
data = [3, 2, 4, 5, 0]
print(f"排序前的数组:{data}")
sorted_arr, small_sum = reverse_pair(data)
print("排序后的数组:", sorted_arr)
print("逆序对数:", small_sum)
# 排序后的数组: [0, 2, 3, 4, 5]
# 小和: 5
快速排序
荷兰国旗问题
问题一
给定一个数组
arr
,和一个数num
,请把小于等于num
的数放在数组的左边,大于num
的数放在数组的右边。要求额外空间复杂度O(1)
,时间复杂度O(N)
"""
|
<=区 | [3, 5, 6, 7, 4, 3, 5, 8], num = 5
| i位置,指向3
1、[i] <= num, 把当前数[i]和小于等于区的下一个数做交换,小于等于区往右扩,i++
2、[i] > num, 小于等于区不动, i++
流程:
arr[i] = 3 <= num
|
<=区 [3, | 5, 6, 7, 4, 3, 5, 8]
| i位置,指向5
arr[i] = 5 <= num
|
<=区 [3, 5, | 6, 7, 4, 3, 5, 8]
| i位置,指向6
arr[i] = 6 > num,小于等于区不动, i++
|
<=区 [3, 5, | 6, 7, 4, 3, 5, 8]
| i位置,指向7
arr[i] = 7 > num,小于等于区不动, i++
|
<=区 [3, 5, | 6, 7, 4, 3, 5, 8]
| i位置,指向4
arr[i] = 4 <= num
|
<=区 [3, 5, 4, | 7, 6, 3, 5, 8]
| i位置,指向3
arr[i] = 3 <= num
|
<=区 [3, 5, 4, 3, | 6, 7, 5, 8]
| i位置,指向5
arr[i] = 5 <= num
|
<=区 [3, 5, 4, 3, 5, | 7, 6, 8]
| i位置,指向8
arr[i] = 8 > num
|
<=区 [3, 5, 4, 3, 5, | 7, 6, 8]
| i位置越界停止
"""
问题二(荷兰国旗问题)
给定一个数组
arr
,和一个数num
,请把小于num
的数放在数组的左边,等于num
的数放在数组的中间,大于num
的数放在数组的右边。要求额外空间复杂度O(1)
,时间复杂度O(N)
"""
| |
<区 | [3, 5, 6, 3, 4, 5, 2, 6, 9, 0], num = 5 | > 区
| i位置,指向3 |
1、[i] < num, 把当前数[i]和小于区的下一个数做交换,小于区往右扩,i++
2、[i] = num, i++
3、[i] > num, 把当前数[i]和大于区的前一个数做交换,大于区往左扩,i原地不变
流程:
arr[i] = 3 < num
| |
<=区 [3, | 5, 6, 3, 4, 5, 2, 6, 9, 0] | > 区
| i位置,指向5 |
arr[i] = 5 = num
| |
<=区 [3, | 5, 6, 3, 4, 5, 2, 6, 9, 0] | > 区
| i位置,指向6 |
arr[i] = 6 > num,把当前数[i]和大于区的前一个数做交换,大于区往左扩,i原地不变,为什么不变,因为它是右边过来的没作比较过
| |
<=区 [3, | 5, 0, 3, 4, 5, 2, 6, 9, | 6] > 区
| i位置,指向0 |
arr[i] = 0 < num
| |
<=区 [3, 0, | 5, 3, 4, 5, 2, 6, 9, | 6] > 区
| i位置,指向3 |
arr[i] = 3 < num
| |
<=区 [3, 0, 3, | 5, 4, 5, 2, 6, 9, | 6] > 区
| i位置,指向4 |
arr[i] = 4 < num
| |
<=区 [3, 0, 3, 4, | 5, 5, 2, 6, 9, | 6] > 区
| i位置,指向5 |
arr[i] = 5 = num
| |
<=区 [3, 0, 3, 4, | 5, 5, 2, 6, 9, | 6] > 区
| i位置,指向2 |
arr[i] = 2 < num
| |
<=区 [3, 0, 3, 4, 2, | 5, 5, 6, 9, | 6] > 区
| i位置,指向6
arr[i] = 6 > num
| |
<=区 [3, 0, 3, 4, 2, | 5, 5, 9, | 6, 6] > 区
| i位置,指向9
arr[i] = 9 > num, 自己跟自己换,大于区左扩,大于区域和i相等时停止
| |
<=区 [3, 0, 3, 4, 2, | 5, 5,| 9, 6, 6] > 区
| i位置,指向9
"""
快排1.0->不改进的快速排序(每次搞定一个数)
- 1)把数组范围中的最后一个数作为划分值,然后把数组通过分成三个部分:左侧<划分值、中间的一个值==划分值、右侧>划分值
2)对左侧范围和右侧范围,递归执行
分析
- 1)划分值越靠近两侧,复杂度越高;划分值越靠近中间,复杂度越低
- 2)可以轻而易举的举出最差的例子,所以不改进的快速排序时间复杂度为
O(N^2)
- 为什么是
O(N^2)
?- 例子:
[1, 2, 3, 4, 5, 6, 7, 8, 9]
- 拿9做划分值,只搞定了9,只有左区域,没有右区域,partition=9
- 拿8做划分值,只搞定了8,只有左区域,没有右区域,partition=8
- 拿7做划分值,只搞定了7,只有左区域,没有右区域,partition=7
- ...等差数列,所以最差情况,时间复杂度是
O(N^2)
- 例子:
- 为什么是
- 最好时间复杂度
O(NlogN)
,划分值刚好打在中间,[...] p [...]
master
公式:T(N) = 2T(N/2) + O(N)
,时间复杂度:O(N*logN)
- 划分值打在偏左(右)或就在两侧,所以最差情况,时间复杂度是
O(N^2)
"""
[4, 3, 5, 6, 5, 0, 1, 7, 8, 5]
以 5 划分值(搞定一个5)
[4, 3, 5, 5, 0, 1, 5, 7, 8, 6]
左侧再以 1 为划分值递归(搞定一个1)
[0, 1, 3, 5, 5, 4 5, 7, 8, 6]
右侧再以 6 为划分值递归(搞定一个6)
[0, 1, 3, 5, 5, 4 5, 6, 7, 8]
再以 4 和 8 为划分值递归(搞定一个4和8)
[0, 1, 3, 4, 5, 5 5, 6, 7, 8]
再以 5 和 8 为划分值递归(搞定一个5和8)
"""
快排2.0->不改进的快速排序(每次搞定一批数)
- 1)把数组范围中的最后一个数作为划分值,然后把数组通过荷兰国旗问题分成三个部分:左侧<划分值、中间区域==划分值、右侧>划分值
2)对左侧范围和右侧范围,递归执行
分析同1.0版本
"""
[4, 3, 5, 6, 5, 0, 1, 7, 8, 5]
以 5 划分值
[4, 3, 0, 1, 5, 5, 5, 7, 8, 6]
左侧再以 1 为划分值递归
右侧再以 6 为划分值递归
[0, 1, 4, 3, 5, 5, 5, 6, 7, 8]
左侧再以 0 和 6 为划分值递归
右侧再以 3 和 8 为划分值递归
[0, 1, 3, 4, 5, 5, 5, 6, 7, 8]
"""
快排3.0->随机快速排序(改进的快速排序)
- 1)在数组范围中,等概率随机选一个数作为划分值,然后把数组通过荷兰国旗问题分成三个部分: 左侧<划分值、中间==划分值、右侧>划分值
- 2)对左侧范围和右侧范围,递归执行
- 3)时间复杂度为
O(N*logN)
,空间复杂度,最坏O(N)
(划分值在两次或一侧数据量特别少),最好O(logN)
(刚好随机到中间位置,二叉树结构的递归),概率累加后为:O(logN)
- 4)分析
- 随机可能打出最差情况,
O(N^2)
- 打在
1/5
处,T(N) = T(N/5) + T(4N/5) + O(N)
- 打在
1/3
处,T(N) = T(N/3) + T(2N/3) + O(N)
- 打在
1/2
处,T(N) = 2T(N/2) + O(N)
- 打在
4/5
处,T(N) = T(4N/5) + T(N/5) + O(N)
- ...
- 每一种都是等概率事件,每一种权重只占
1/N
,把所有公式概率累加,再求数学上的长期期望,得出时间复杂度:O(N*logN)
- 随机可能打出最差情况,
# 使用 https://pythontutor.com/ 查看流程,便于理解
import random
def quick_sort(arr, low, high):
if low < high:
# 随机选择基准元素
pivot_index = random.randint(low, high)
arr[pivot_index], arr[high] = arr[high], arr[pivot_index]
# 荷兰国旗问题的分区
"""
idx = [10, 11, 12, 13, 14, 15]
arr = [3, 6, 2, 5, 7, 5] # 5为划分值
partition后
idx = [10, 11, 12, 13, 14, 15]
arr = [ 3, 2, 5, 5, 6, 7] # 5,5 对应下标为 12、13,分别-1表示划分值的左边界和右边界
"""
lt, gt = partition(arr, low, high)
# 递归排序小于和大于pivot的部分
quick_sort(arr, low, lt - 1) # 小于区域
quick_sort(arr, gt + 1, high) # 大于区域
def partition(arr, low, high):
pivot = arr[high] # arr[high]为划分值,quick_sort随机选出来,跟最后一个数做的交换
# 三个指针,lt表示小于pivot的部分,gt表示大于pivot的部分,i用于遍历数组
lt, gt, i = low, high, low
while i <= gt:
if arr[i] < pivot: # 当前数 < 划分值
arr[i], arr[lt] = arr[lt], arr[i]
lt += 1
i += 1
elif arr[i] > pivot: # 把当前数[i]和大于区的前一个数做交换,大于区往左扩,i原地不变,为什么不变,因为它是右边过来的没作比较过
arr[i], arr[gt] = arr[gt], arr[i]
gt -= 1
else:
i += 1
return lt, gt
# 示例
data = [4, 3, 5, 6, 5, 0, 1, 7, 8, 5]
quick_sort(data, 0, len(data) - 1)
print(data)