LeetCode-算法学习(必备篇)-Part1

相比于之前的入门篇,必备篇的难度是明显增加的,起码左神的视频是变长了的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)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值