相比于之前的入门篇,必备篇的难度是明显增加的,起码左神的视频是变长了的hhh,所以学习进度也是变慢了。必备篇内容也比较多,就不像入门篇一次更新完了,分Part更新,做到即时记录避免后续忘记。如有理解不对的地方也请各位大佬赐教~
Part1对应左神视频019-025
开始探索算法之美吧~
一、算法笔试中处理输入和输出
由于我目前只做过Leetcode的题目,也没有参加过比赛,所以对ACM风格的输入输出比较懵逼。1.填函数分格也就是像Leetcode这种的非常简单,我们只用关注算法逻辑就行,后台会自动为我们进行数据的输入,我们只用按照要求输出,后台就会自己进行比较。
2.ACM风格就需要自己进行数据的读取,核心就是减小系统IO的吞吐,尽量一次将数据从系统读入到内存中,输出也同理,先将所有输出保存到内存,再一次性给系统吐出,这样可以减少吞吐数据的时间占用。左神讲解的是java中的相关函数,我主要用的是Python,这里方几个我收集到的Python-ACM风格的读数据相关的代码链接,后面用到了再进一步学习。
Python ACM 模式下的输入输出 - 文山湖的猫 - 博客园
python3 ACM模式的输入输出例子教学_amc模式python读取输入-CSDN博客
python3 ACM模式的输入输出例子教学 - 汀、人工智能 - 博客园
3.关于临时动态空间和全局静态空间,定义全局静态空间只用申请一次空间,在有些平台,如果一直在一个while里申请临时动态空间,后台会一直计算空间复杂度,虽然在系统中会自然析构再申请,但是平台不管😂,所以可能会因为这个导致不通过。注意一下即可,尽量多用全局静态空间。
二、递归和master公式
递归是一个很神奇的逻辑,分析递归逻辑时最好的办法就是画调用图去理解算法的逻辑,去看每一步递归如何实现如何返回,因为递归的代码一般十分简单,就是套娃🤦,这部分本人现在理解的还是较浅,只能理解特定算法的递归逻辑,多练习之后再补充吧。(挖坑)
递归的本质是调用系统栈,利用系统栈的压入弹出保存和更新每层递归的中间量。既然是用的栈,所以任何递归都可以改为非递归,可以参考二叉树遍历的递归与非递归实现。并且递归调用的系统栈,系统栈的空间是相对较小的,如果递归太深就会爆栈,这个相信很多人在实际工程应用中都遇到过,我之前在写一个优化算法时,利用递归又是就会因为递归太深导致算法崩溃。。。。所以在工程中,除非能确定递归不会太深,都得改为非递归。非递归算法会调用内存空间,内存空间一般是非常大的,很难爆空间。当然在笔试或比赛能,能通过就不用改非递归了。
master公式,非常重要!
master公式只能对子问题规模相同的递归才能使用,T(n) = a * T(n/b) + O(n^c),a、b、c都是常数,第一项就说明如果子问题规模不同就不会出现这样一项,就会变成a * T(n/b) +c* T(n/d)+...这样,那么就不能用此公式了。
并且master公式只用看一层递归即可,也就是直接看代码字面来估计,不用一层一层展开。
记住这些公式即可,不用太纠结证明。。。
如果log(b,a) < c,复杂度为:O(n^c) ->>>>>log(b,a)这个意思是以b为底a的对数
如果log(b,a) > c,复杂度为:O(n^log(b,a))
如果log(b,a) == c,复杂度为:O(n^c * logn)
特例:T(n) = 2*T(n/2) + O(n*logn),时间复杂度是O(n * ((logn)^2))
三、归并排序
我们用递归来解决一个具体问题---排序,归并排序的思想很简单,我们把数组分成左右两份,之后让左右两部分都有序,再利用一个函数merge让左右两边整体有序。merge函数也很简单,对于左右两个有序部分,我们定义一个辅助数组大小大小只要大于最开始要排序的数组就行,之后我们同步遍历左右两个有序部分,谁小将其拷贝进辅助数组,当一边结束就将另一边剩余的所有数据拷贝进辅助数组即可。此问题就可以用递归来实现,我不断的从中间切分数组,直到剩一个数的时候其自然有序,之后merge,让左右两边有序,再向上返回,此时返回的已经是一个有序的子数组了,再和另一边merge,如次向上返回即可实现。我在实现时遇到了一个坑,如果按照b去定义全局变量,那么此时self.num 和self.help会指向同一个地址,也就是改变其中一个另一个也会改变,导致报错,此时应该按照a去写,让一个浅拷贝一个深拷贝。
(a)self.num = nums (b)self.num = nums
self.help = nums[:] self.help = nums
第二个坑 :mid=int(l+((r-l)>>1)),一定要加int,不然会变成小数,直接开始无穷递归
class Solution:
def __init__(self):
self.help=[]
self.num = []
def sortArray(self, nums: List[int]) -> List[int]:
self.num = nums
self.help = nums[:]
if len(nums)>1:
self.mergesort1(0,len(nums)-1)
return self.num
def mergesort1(self,l,r):
if l==r:
return
mid = int(l + ((r-l)>>1))
self.mergesort1(l,mid)
self.mergesort1(mid+1,r)
self.merge(l,mid,r)
def merge(self,l,mid,r):
i = l
a = l
b = mid+1
while a<=mid and b<=r:
if self.num[a]<=self.num[b]:
self.help[i] = self.num[a]
a +=1
else:
self.help[i] = self.num[b]
b +=1
i +=1
while a<=mid:
self.help[i] = self.num[a]
i+=1
a+=1
while b<=r:
self.help[i] = self.num[b]
i+=1
b+=1
for i in range(l,r+1):
self.num[i] = self.help[i]
我们可以利用master公式来分析该递归的复杂度,T(n) = 2*T(n/2) + O(n)。merge的过程就是把数据过一遍呗,复杂度就是O(n)级别的。此时a=2,b=2,c=1,此时log(b,a) = c,所以时间复杂度为O(N*logN),因为用到了辅助数组,所以额外空间复杂度为O(N)。
所有递归都可以用非递归实现,那么该算法也可以用非递归实现。我们引入一个步长变量step,该变量每次增加都是乘2,当步长>数组长度时停止。step变量是干啥的呢?其实就是在从底向上实现递归,step是1是,相当于我从数组头开始,取下标0,1--2,3--4,5--...这样的对分别分成左右部分,再进行merge操作,这不就是递归时最深一层的状态嘛,单个数字自然有序,然后merge,这样相邻两个数就有序的,之后step=2,取下标0,1,2,3--4,5,6,7---,8,9,10,11--....再将这样的对分别分成左右部分,此时左右部分各自分别有序了,再merge,之后step=4重复该过程即可。在过程中会存在step内数据小于step/2,此时如果分左右部分,右部分没有数字,那就不用merge,只有左边满了,右边才有可能有数字,才需要merge操作,这种情况肯定是发生在尾部,所以注意判断右边部分的右边界和数组长度之间的关系即可。
def mergesort2(self):
l=m=r=0
step =1
while step < len(self.num):
l=0
while l < len(self.num):
m = l+step-1
if m+1>len(self.num):
break
r = min(l+2*step-1,len(self.num)-1)
self.merge(l,m,r)
l=r+1
step *=2
这个时间复杂度就很好分析,step每次乘2,那就是O(logn),merge操作为O(n), 并且在循环中所以最后的复杂度就是O(n*logn)。
为什么归并的思想就比之前的三傻排序(插入,冒泡,选择)快呢?因为三傻排序浪费了比较行为,他们每次都是遍历完N个数据后只返回一个值,其余部分在下次遍历时并没有利用上一次比较的信息。
四、归并分治
归并是一种思想,并不是只能用在排序中。开始公式化,什么问题可以用归并的思想然后套归并的模板呢?1)该问题的答案是否等于,左部分的答案+右部分的答案+跨左右产生的答案 2) 如果加上左右有序这个条件会不会更方便解答。 基本上满足这两个条件就可以用归并的思想,套用归并排序的模板即可。
举例:小和问题,每个位置的小和为该位置左边比他小的数之和,最后返回所有位置的小和。暴力解很简单,遍历一个个算。时间复杂度O(n^2),我们需要更优的解(此处可以用对数器调试自己的最优解)。那么该问题满足归并思想吗?假设数组为[4,2,5,7,3,9],我们将其分为左右两部分[4,2,5]与[7,3,9],对于小和来说,我先计算左部分的小和,再计算右部分的小和,在计算右边相对于左边的小和是不是和整个数组的小和一样。并且在计算跨左右的小和时,由于左边的和右边的已经算过了,此时排序不会影响结果,但是会加速过程,如果排序之后就是[2,4,5]与[3,7,9],那么比如4<7,此时我不用再去看4前面的数是否满足<7,因为有序,一定满足,再比如,我发现5<7,并且5已经是左边最后一个数了,那么对于右边的数组7之后的数据都不用比较了一定满足直接算小和就行,所以我左边数组和右边数组只用遍历一次即可。(下面的代码时GPT修改老师的JAVA得到的,并没有在链接上测试,但是逻辑是没问题的,理解逻辑即可)时间复杂度O(n*logn)
class Solution:
def __init__(self):
self.MAXN = 100001
self.arr = [0] * self.MAXN
self.help_arr = [0] * self.MAXN
def small_sum(self, l, r):
if l == r:
return 0
m = (l + r) // 2
return self.small_sum(l, m) + self.small_sum(m + 1, r) + self.merge(l, m, r)
def merge(self, l, m, r):
ans = 0
sum_left = 0
i = l
for j in range(m + 1, r + 1):
while i <= m and self.arr[i] <= self.arr[j]:
sum_left += self.arr[i]
i += 1
ans += sum_left
# 正常merge
i = l
a = l
b = m + 1
while a <= m and b <= r:
if self.arr[a] <= self.arr[b]:
self.help_arr[i] = self.arr[a]
a += 1
else:
self.help_arr[i] = self.arr[b]
b += 1
i += 1
while a <= m:
self.help_arr[i] = self.arr[a]
a += 1
i += 1
while b <= r:
self.help_arr[i] = self.arr[b]
b += 1
i += 1
for i in range(l, r + 1):
self.arr[i] = self.help_arr[i]
return ans
def main(self):
import sys
input = sys.stdin.read
data = input().split()
n = int(data[0])
for i in range(n):
self.arr[i] = int(data[i + 1])
print(self.small_sum(0, n - 1))
# 运行时需要创建Solution对象并调用main方法
if __name__ == "__main__":
solution = Solution()
solution.main()
举例:翻转对:给定一个数组 nums
,如果 i < j
且 nums[i] > 2*nums[j]
我们就将 (i, j)
称作一个重要翻转对。你需要返回给定数组中的重要翻转对的数量。
暴力解也很简单,遍历一个个算呗,时间复杂度O(n^2)。我们也可用归并的思想实现O(n*logn)的复杂度。思想和小和一样,只是换了一个判断方法,我这里直接贴出Leetcode上通过的代码,一看就懂
class Solution:
def __init__(self):
self.help=[]
self.arr=[]
def reversePairs(self, nums: List[int]) -> int:
self.help=nums
self.arr = nums[:] #必须用全局变量 也必须深拷贝
if len(nums)>1:
return self.pair(0,len(nums)-1)
return 0
def pair(self,l,r):
if l==r:
return 0
m = int(l + (r-l)/2) #必须转换成int
return self.pair(l,m) + self.pair(m+1,r) + self.merge(l,m,r)
def merge(self,l,m,r):
ans = 0
j=m+1
for i in range(l,m+1):
while j<=r and self.arr[i]>self.arr[j]*2:
j+=1
ans +=j-m-1
i = l
a = l
b = m+1
while a<=m and b<=r:
if self.arr[a] <= self.arr[b]:
self.help[i] = self.arr[a]
a+=1
else:
self.help[i] = self.arr[b]
b+=1
i+=1
while a<=m:
self.help[i] = self.arr[a]
a+=1
i+=1
while b<=r:
self.help[i] = self.arr[b]
b+=1
i+=1
for i in range(l,r+1):
self.arr[i] = self.help[i]
return ans
五、随机快速排序
经典的随机快速排序的思想是,我随机在数组中选取一个数x,然后我可以将该数组分为<=x的区域和>x的区域,并且保证在<=x的区域中,x一定在最右侧该位置的下标为mid,该过程是partition函数实现。此时mid的位置固定了,数组的l-mid-1与mid+1-r的区域仍无序,在左侧区间内再随机找一个数,调用partition函数确定该数的位置,右侧区域同理,就可以确定每个数字的位置。
举例说明:[4,2,4,6,9,5,3],假设我们随机取x=4,我们的定义a与i变量最开始都在指向数组的最左侧,当i位置的数值满足<=4时,交换i与a位置,a++,i++,当i位置的数满足>4时,只i++,并且当i的数值=4时,记录该位置,当循环结束时交换该位置与a-1位置也就是<=4区域的左边一定是4。
该算法还有个问题,在leetcode提交时,会被有大量重复数字的数组报错,所有有了荷兰国旗改进,就是为什么我们不降=x的区域一次都确定了,这样每次partition之后,数组被分为<x,=x,>x三个区域,这样的好处就是在x很多时,我一次就能分完,相当于一次确定了多个数字的位置。
举例说明:[5,3,6,2,4,3,5,6,7,2,9],假设我们随机取x=5.定义a为小于区域的越界位置也就是a-1才是真正的边界,i为遍历下标,b为大于区域的越界位置也就是b+1才是大于位置的边界,a=i=l,b=r。当i处的数字<5时,交换a与i位置,a++,i++,当i处的数值=5时,只进行i++,这样可以保证=5的数一直被交换到最右边也就是a位置肯定一直是5,当i处数值>5时,交换b与i b--此时i不能变,因为交换之前b位置的数字并不知道是多少,进行交换之后还需要比较,当b<i时终止。此时<5在左边 =5在中间 >5在右边。
quicksort1与partition1对应改进前,quicksort2与partition2应改进后.对着代码还是很好理解的。注意self.first和self.last全局静态变量的设置,如果不在partition中用一个新的变量传入之后的部分,会改变self.first和self.last的值导致出差
import random
class Solution:
def __init__(self):
self.first = 0
self.last = 0
self.num = []
def sortArray(self, nums: List[int]) -> List[int]:
self.num = nums
if len(nums)>1:
self.quickSort2(0,len(nums)-1)
return self.num
def quickSort1(self,l,r):
if l >= r:
return
x = self.num[random.randint(l,r)]
mid = self.partition1(l,r,x)
self.quickSort1(l,mid-1)
self.quickSort1(mid+1,r)
def quickSort2(self,l,r):
if l>=r:
return
x = self.num[random.randint(l,r)]
self.partition2(l,r,x)
left = self.first
right = self.last
self.quickSort1(l,left-1)
self.quickSort1(right+1,r)
def partition1(self,l,r,x):
a = l
i = l
xi = 0
while i<=r:
if self.num[i]<=x:
self.swap(a,i)
if self.num[a]==x:
xi=a
a+=1
i+=1
self.swap(xi,a-1)
return a-1
def partition2(self,l,r,x):
self.first = l
self.last = r
i = l
while self.last>=i:
if self.num[i]<x:
self.swap(i,self.first)
self.first+=1
i+=1
elif self.num[i] ==x:
i+=1
else:
self.swap(self.last,i)
self.last-=1
def swap(self,i,j):
temp = self.num[i]
self.num[i] = self.num[j]
self.num[j] = temp
普通快速排序时间复杂度为O(n^2),额外空间复杂度为O(n)
随机快速排序时间复杂度为O(n*logn),额外空间复杂度为O(logn)
六、随机选择算法
给定整数数组 nums
和整数 k
,请返回数组中第 k
个最大的元素。请注意,你需要找的是数组排序后的第 k
个最大的元素,而不是第 k
个不同的元素。复杂度要求为O(n)。
相当将数组从小到大排序选取第k-1个数,就是第K大的。但是不能排序完再直接索引,因为排序的复杂度目前最小的也是O(n*logn)。
我们其实只关心数值,因为一个数可能有很多个,我们并不关心具体的索引。那么随机快速排序的改进版会给我们一个=x的区域,假设此时=x的区域对应的范围时l-r,我们可以先判断需要的k-1索引在不在=x的范围内,如果在直接返回x即可,如果k-1<l,那说明应该在<x的区域里再找,如果k-1>r则应该再x>x下的区域里再寻找,直到=x的区域包含k-1索引。相当于之前的随机选择排序每次最多只选择一半进行排序,另一半直接抛弃。
经过数学家计算这个的时间复杂度就是O(n)
代码中第k大那对应的索引就是数组长度-k对应的索引
import random
class Solution:
def __init__(self):
self.num = []
self.first = 0
self.last = 0
def findKthLargest(self, nums: List[int], k: int) -> int:
self.num = nums
return self.findnum(len(nums)-k)
def findnum(self,k):
l = 0
r = len(self.num)-1
while l<=r:
x = self.num[random.randint(l,r)]
self.partition(l,r,x)
if k < self.first :
r = self.first-1
elif k>self.last:
l = self.last+1
else:
ans =self.num[k]
break
return ans
def partition(self,l,r,x):
self.first = l
self.last = r
i = l
while self.last>=i:
if self.num[i]<x:
self.swap(i,self.first)
self.first+=1
i+=1
elif self.num[i]==x:
i+=1
else:
self.swap(i,self.last)
self.last-=1
def swap(self,i,j):
temp = self.num[i]
self.num[i] = self.num[j]
self.num[j] = temp
七、堆结构和堆排序
堆结构是一种完全二叉树(当该层满了之后才会开启下一层,并且从左向右加元素)。比如,数组为[1,2,3,4,5,6,7,8,9],那么完全二叉树的就够就如下图。
对于任意一个节点对应的数组下标i,其父节点一定是int((i-1)/2),其左节点一定是2*i+1,右节点是2*i+2,使用单独的size变量来控制树的大小,比如2*i+1>size了,说明此时节点就没有左节点那就更不会有右节点了,只有计算的下标小于size该节点才存在。
定义了大根堆和小根堆。其中大根堆就是对于任意一颗子树,其父节点最大,小根堆就同理呗。以下都是以大根堆的调整为例。对于一个已经定义好的大根堆,此时如果加入了一个数字到任意位置index,如果该数字比其父节点大,则需要向上调整,如果比其父节点小则需要向下调整。
1.向上调整(heapinsert):对于index其父节点就是 int((index-1)/2),我就与其父节点比较,如果比父节点大就交换,并令index=int((index-1)/2),继续向上看,直到到达最顶端或者其父节点比该数大。
2.向下调整(heapify):对于index其左右节点分别为2*index+1与2*idex+2,首先先在左右节点中选择大的那个,再将index与大的节点比较,如果小于该子节点则交换,如果是左节点大就是index= 2*index+1, 否则就是index= 2*index+2,当子节点不存在或者该节点比其左右节点都大时停止。需注意判断左节点不存在则一定没有子节点,左节点存在时不一定存在右节点,避免数组索引超界报错。
知道了两种排序方法之后就可以进行堆排序了。堆排序第一步是建立堆,第二步是大数归位。
建立堆:根据之前提到的两种调整方法,很容易想到可以用两种方法来建堆。我们可以从上到下建堆也可以从下到上建堆。
从上到下建堆,就是先建立根节点,每加入一个都是在最下面,所以不断地向上调整即可。时间复杂度O(n*logn)
从下到上建堆,就是以最后一个数为基准,每一个插入的数都在上一层,需要向下进行调整保持大根堆。时间复杂度O(n)
为什么从下到上建堆时间复杂度小呢?因为从上到下建堆,对于节点来说上面的节点数量肯定比下层节点数量少,而从上到下建堆是越到下层需要向上调整的数据越多并且操作越多,从下到上建堆正好反过来了,是数据多的时候操作少,操作最多的是最顶层的一个节点。
堆排序:我们已经形成了大根堆之后,可以保证数组的第一个数字一定是最大的,此时我们将第一个数字与最后一个数字交换,最大的数字就归为了,我们令大根堆的size-1,之后让交换过来放在第一位的数字进行向下调整,重复该过程直到size=0,该过程的时间复杂度是O(n*logn)。
class Solution:
def __init__(self):
self.num=[]
def sortArray(self, nums: List[int]) -> List[int]:
self.num = nums
if len(self.num)>1:
self.heapsort2()
return self.num
def heapInsert(self,index):
f_index = int((index-1)/2)
while self.num[index]>self.num[f_index]:
self.swap(index,f_index)
index = f_index
f_index = int((index-1)/2)
def heapify(self,index,size):
l_index = 2*index+1
while l_index <size:
r_index = l_index+1
if r_index < size and self.num[r_index] >= self.num[l_index]:
best = r_index
else:
best = l_index
if self.num[index] < self.num[best]:
self.swap(index,best)
index = best
l_index = 2*index+1
else:
break
def swap(self,i,j):
temp = self.num[i]
self.num[i] = self.num[j]
self.num[j] = temp
def heapsort1(self):
size = len(self.num)
for i in range(size):
self.heapInsert(i)
while size >1:
size -= 1
self.swap(0,size)
self.heapify(0,size)
def heapsort2(self):
size = len(self.num)
for i in range(size-1,-1,-1):
self.heapify(i,size)
while size >1:
size -= 1
self.swap(0,size)
self.heapify(0,size)