代码随想录算法训练营第二十一天 | 读PDF复习环节1

读PDF复习环节1

  • 本博客的内容只是做一个大概的记录,整个PDF看下来,内容上是不如代码随想录网站上的文章全面的,并且PDF中有些地方的描述,是很让我疑惑的,在困扰我很久后,无意间发现,其网站上的讲解完全符合我的思路。这次看完这些PDF后,暂时一段时间内不会再看了,要复习还是依靠,代码随想录网站,视频,和我自己写的博客吧
  • 动态规划章节
    • 动态规划五部曲
    • 有一些情况是,递推公式决定了dp数组要如何初始化
    • 不要盲目追求空间压缩,以现在的水平,先把最能体现动态规划思维过程的代码写熟练了再说
    • 746 使用最少花费爬楼梯
    • 62 不同路径
    • 343. 整数拆分
    • 96 不同的二叉搜索树
    • 背包问题的暴力解法
    • 目前的认知是:原版二维dp数组背包,遍历区间要完全,要有if判断,不满足放入条件就等于上一个值。实用版一维dp数组背包,可以在for循环中的遍历区间稍作修改,不遍历不满足放入条件的情况。
    • 1049.最后一块石头的重量II
    • 494.目标和
    • 题目做到这里,也发现了,背包问题,能用一维数组的,就按照惯性用一维数组过吧,二维数组要处理的细节更多,且初始化部分,可能要根据递推公式确定初始值,有些初始值很难赋予意义。二维数组的坑也更多。
    • 474 一和零
    • 01背包和完全背包的遍历顺序
    • 完全背包问题存在的疑问!在求排列数的相关问题上,为什么用二维DP数组,写不出来?
    • 求装满背包有多少种方法,递推公式一般都是,dp[i]+=dp[i-nums[j]]
    • 求组合数的经典题目:零钱兑换
    • 求排列数的经典题目:组合总和
    • 爬楼梯(进阶版)
    • 后面两题,零钱兑换和完全平方数,是动态规划题目中,求满足条件的最小数目的题目
    • 139 单词拆分
    • 打家劫舍问题,需要学习的就两个点,处理环形数据的方法就是拆分为两段,分别计算,不带头,不带尾。还有就是,如何利用动态规划求解二叉树结构的题目。
    • 买卖股票系列
    • 子序列系列、编辑距离系列、回文子串系列
  • 贪心算法章节
    • 376 摆动序列
    • 53 最大子序和
    • 122 买卖股票的最佳时机II
    • 跳跃游戏问题系列
    • 134 加油站
    • 135 分发糖果
    • 406 根据身高重建队列
    • 引爆气球和无重叠区间
    • 763 划分字母区间
    • 56 合并区间
    • 738 单调递增的数字
    • 968 监控二叉树
      • 对空节点的讨论
      • 总结出一共只有四种情形

本博客的内容只是做一个大概的记录,整个PDF看下来,内容上是不如代码随想录网站上的文章全面的,并且PDF中有些地方的描述,是很让我疑惑的,在困扰我很久后,无意间发现,其网站上的讲解完全符合我的思路。这次看完这些PDF后,暂时一段时间内不会再看了,要复习还是依靠,代码随想录网站,视频,和我自己写的博客吧

动态规划章节

动态规划五部曲

1、确定dp数组以及下标的含义。
2、确定递推公式。
3、dp数组如何初始化。
4、确定遍历顺序。
5、举例推导dp数组。

有一些情况是,递推公式决定了dp数组要如何初始化

不要盲目追求空间压缩,以现在的水平,先把最能体现动态规划思维过程的代码写熟练了再说

746 使用最少花费爬楼梯

本题PDF中的描述,让人迷惑!我不赞同第一步要花费体力的想法,在没走之前肯定就不花费啊。我认为本题的理解应该是这样的,在开始时,人是站在层底的,而最后,要求站在最后一层层顶,即:* 0 1 2 3 * 4,假如选择从0开始,那么人就站在第一个星花的位置,而要求爬上台阶3,就是要到第二个星花的位置,那么我们可以往后想一步,不就是台阶4的层底吗?

所以 dp[i] 自然就定义为:站在第 i 层的层底的最小花费,dp[i] = min( dp[i-1] + cost[i-1] , dp[i-2] + cost[i-2] )

非常合理且自然!初始化也很好做,前两个状态设置为0,也很符合直觉!

网站上的示例代码也是按照上述方式编写的!

62 不同路径

这道题动态规划的思路已经完全掌握了,数论的代码写法可以看看,熟悉熟悉,尤其是,提前除以分母的写法。

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        numerator = 1  # 分子
        denominator = m - 1  # 分母
        count = m - 1  # 计数器,表示剩余需要计算的乘积项个数
        t = m + n - 2  # 初始乘积项
        while count > 0:
            numerator *= t  # 计算乘积项的分子部分
            t -= 1  # 递减乘积项
            while denominator != 0 and numerator % denominator == 0:
                numerator //= denominator  # 约简分子
                denominator -= 1  # 递减分母
            count -= 1  # 计数器减1,继续下一项的计算
        return numerator  # 返回最终的唯一路径数

带障碍的路径问题,我和代码随想录的看法一致,难以将二维DP数组压缩至一维。

343. 整数拆分

这道题,代码随想录给出的代码是,剪枝减的最狠的一版:dp[i] 只比较 (i - j) * j, dp[i - j] * j , 且 j 只遍历到 i 的一半。

for i in range(3, n + 1):
            # 遍历所有可能的切割点
            for j in range(1, i // 2 + 1):
                # 计算切割点j和剩余部分(i-j)的乘积,并与之前的结果进行比较取较大值                
                dp[i] = max(dp[i], (i - j) * j, dp[i - j] * j)

我觉得,最让我容易理解的应该是下面这版:

for i in range(3, n + 1):
            # 遍历所有可能的切割点
            for j in range(1, i // 2 + 1):
                # 计算切割点j和剩余部分(i-j)的乘积,并与之前的结果进行比较取较大值                
                dp[i] = max(dp[i], (i - j) * j, dp[i - j] * j, dp[i - j] * dp[j], (i - j) * dp[j])

dp[i] 是由四个值决定的。当然这种,四个值的情况,很自然的就可以取一半,而不用遍历全部的值。

我觉得之所以能够这样剪枝,根本原因在于这道题自身的特性,当一个数大于4时,分解他一定比不分解他的值大,所以最大值基本上来自于 dp[i - j] * j , 且 j 是一个小值。

如果题意变化,如果要延续,只用两个状态放在递推公式里的话,就不能只遍历一半:

for i in range(3, n + 1):
            # 遍历所有可能的切割点
            for j in range(1, i):
                # 计算切割点j和剩余部分(i-j)的乘积,并与之前的结果进行比较取较大值                
                dp[i] = max(dp[i], (i - j) * j, dp[i - j] * j)

这样也是可以说明,是考虑到所有情况的,即:所有分解的情况中,只要包含因子 j 的情况,我都已经考虑到了。因为分解数 i ,那么其中一个加数一定是【1,i-1】。

最让我舒服的还是,max里面取四个值,遍历只需遍历一半。

96 不同的二叉搜索树

这道题的思路真的巧妙,要想到因为所给的数组是排好序的,假如是【1,n】,那么我们选择其中一个数,i , 那么很自然的就有:【1,i-1】为左子树,【i+1,n】为右子树(这里,经典重现:有序数组和二叉搜索树的对应关系),那么选择 i 为头结点,所能组成的二叉搜索树的数量,就是【1,i-1】(左子树)是二叉搜索树的数量,乘上,【i+1,n】(右子树)是二叉搜索树的数量。

只要想到了这个思路,递推公式就呼之欲出,代码也基本在脑海中成型了。

class Solution:
    def numTrees(self, n: int) -> int:
        dp = [0] * (n + 1)  # 创建一个长度为n+1的数组,初始化为0
        dp[0] = 1  # 当n为0时,只有一种情况,即空树,所以dp[0] = 1
        for i in range(1, n + 1):  # 遍历从1到n的每个数字
            for j in range(1, i + 1):  # 对于每个数字i,计算以i为根节点的二叉搜索树的数量
                dp[i] += dp[j - 1] * dp[i - j]  # 利用动态规划的思想,累加左子树和右子树的组合数量
        return dp[n]  # 返回以1到n为节点的二叉搜索树的总数量


背包问题的暴力解法

每一个物品都有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况。

目前的认知是:原版二维dp数组背包,遍历区间要完全,要有if判断,不满足放入条件就等于上一个值。实用版一维dp数组背包,可以在for循环中的遍历区间稍作修改,不遍历不满足放入条件的情况。

1049.最后一块石头的重量II

这道题的精髓就是明白:两块质量不相等的石头相撞,质量大的那一个,还会把差值退回去!所以本题还是分两堆,然后用01背包去装。

494.目标和

这道题,是第一次接触求一共有多少种组合的题,后面类似的题目还会再次出现,本质上就是理解递推公式的核心。
二维DP数组:先不管符不符合,把上一个值赋值过来。在判断,如果符合放入条件,在此基础上累加。
一维DP数组:由于滚动数组的特性,自动完成拷贝赋值,所以直接累加就好。

 # 动态规划过程
        for i in range(1, len(nums) + 1):
            for j in range(target_sum + 1):
                dp[i][j] = dp[i - 1][j]  # 不选取当前元素
                if j >= nums[i - 1]:
                    dp[i][j] += dp[i - 1][j - nums[i - 1]]  # 选取当前元素
 for num in nums:
            for j in range(target_sum, num - 1, -1):
                dp[j] += dp[j - num]  # 状态转移方程,累加不同选择方式的数量

题目做到这里,也发现了,背包问题,能用一维数组的,就按照惯性用一维数组过吧,二维数组要处理的细节更多,且初始化部分,可能要根据递推公式确定初始值,有些初始值很难赋予意义。二维数组的坑也更多。

474 一和零

只要把一和零的个数,看做是01背包问题中的物品重量,换句话说,重量信息由一维,变为了两维,其他的保持01背包编写方式就好!

所以本题的最原始方式,是三维DP数组,用滚动数组后,为二维DP数组。

class Solution:
    def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
        dp = [[0] * (n + 1) for _ in range(m + 1)]  # 创建二维动态规划数组,初始化为0
        # 遍历物品
        for s in strs:
            ones = s.count('1')  # 统计字符串中1的个数
            zeros = s.count('0')  # 统计字符串中0的个数
            # 遍历背包容量且从后向前遍历
            for i in range(m, zeros - 1, -1):
                for j in range(n, ones - 1, -1):
                    dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1)  # 状态转移方程
        return dp[m][n]


01背包和完全背包的遍历顺序

01背包中,二维DP数组的两个for遍历,先后顺序可以颠倒。一维DP数组的两个for循环的顺序,一定是先遍历物品,再遍历背包,另外背包要倒序遍历,因为要保证每个物品只拿一个。而如果,01背包的一维DP数组,倒序遍历背包,但是先遍历背包,再遍历物品,得到的结果是,最后的背包中只有一个物品。(这里因为背包是倒序遍历,上来就把结果位置的输出遍历掉了,此时其他位置的值还都是初始值)

完全背包,不管是一维DP数组还是二维DP数组,遍历顺序都可以颠倒,一维DP数组时,背包的遍历顺序为正序遍历,且必须是正序遍历。

但是完全背包有一个要注意的点,就是完全背包问题的拓展应用题中,会涉及组合数和排列数的问题,而01背包没有类似的问题。求组合数,必须是先遍历物品,再遍历背包;求排列数,是先遍历背包,再遍历物品、

二维DP,区别仅仅在于 max 比较中的部分,01背包是 dp[i-1][j-weight[i]]+value[i],而完全背包是dp[i][j-weight[i]]+value[i]。

01背包
for i in range(n):
	for j in range(m):
		if j >= weight[i] :
			dp[i][j] = max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i])
		else :
			dp[i][j] = dp[i-1][j]
完全背包
for i in range(n):
	for j in range(m):
		if j >= weight[i] :
		# 区别仅仅就在这一句话
			dp[i][j] = max(dp[i-1][j],dp[i][j-weight[i]]+value[i])
		else :
			dp[i][j] = dp[i-1][j]

一维DP数组,01背包的遍历为从后向前,完全背包的遍历为从前向后,01背包需要的是i-1,所以不能用新值去覆盖i-1,所以必须倒序遍历,这样dp[j]的更新公式,用的才是上一个i的值。完全背包需要的是i,所以要用新值去覆盖i-1,需要正序遍历,这样dp[j]的更新公式,用的是当前i的值。

01背包
for i in range(n):
	for j in range(m-1,weight[i]-1,-1):
		dp[j] = max(dp[j],dp[j-weight[i]]+value[i])
		
完全背包
for i in range(n):
	for j in range(weight[i],m):
		dp[j] = max(dp[j],dp[j-weight[i]]+value[i])

完全背包问题存在的疑问!在求排列数的相关问题上,为什么用二维DP数组,写不出来?

求装满背包有多少种方法,递推公式一般都是,dp[i]+=dp[i-nums[j]]

求组合数的经典题目:零钱兑换

class Solution:
    def change(self, amount: int, coins: List[int]) -> int:
        dp = [0]*(amount + 1)
        dp[0] = 1
        # 遍历物品
        for i in range(len(coins)):
            # 遍历背包
            for j in range(coins[i], amount + 1):
                dp[j] += dp[j - coins[i]]
        return dp[amount]

求排列数的经典题目:组合总和

注意本题的代码写法,因为先遍历的背包,后遍历的物品,所以在递推公式那里,要加一个 if 判断。

class Solution:
    def combinationSum4(self, nums: List[int], target: int) -> int:
        dp = [0] * (target + 1)
        dp[0] = 1
        for i in range(1, target + 1):  # 遍历背包
            for j in range(len(nums)):  # 遍历物品
                if i - nums[j] >= 0:
                    dp[i] += dp[i - nums[j]]
        return dp[target]

爬楼梯(进阶版)

本题需要注意的点:本题是一个完全背包问题,另外要明确到,是求排列数,要先遍历背包,再遍历物品。

class Solution:
    def climbStairs(self, n: int) -> int:
        dp = [0]*(n + 1)
        dp[0] = 1
        m = 2
        # 遍历背包
        for j in range(n + 1):
            # 遍历物品
            for step in range(1, m + 1):
            # 这里的 if 判断,是要学习的点
                if j >= step:
                    dp[j] += dp[j - step]
        return dp[n]

后面两题,零钱兑换和完全平方数,是动态规划题目中,求满足条件的最小数目的题目

求解要点:首先是完全背包问题,其次在递推公式上,就是求min的过程,先遍历背包还是物品都没有关系,但是先遍历物品的代码比较好写,因为在遍历背包那里,可以直接在循环中限制范围,用一维DP数组。

求最小值的题目,最需要注意的地方是,初始化,要初始化为最大值。

139 单词拆分

这道题,首先明确是,排列问题,而不是像PDF中介绍的,组合或排列均可。

但这道题,同前面的求排列的题一样,二维DP数组的我写不出来。

这道题算是利用动态规划求解字符串问题的典型题了,重视重视再重视。

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        dp = [False]*(len(s) + 1)
        dp[0] = True
        # 遍历背包
        for j in range(1, len(s) + 1):
            # 遍历单词
            for word in wordDict:
                if j >= len(word):
                    dp[j] = dp[j] or (dp[j - len(word)] and word == s[j - len(word):j])
        return dp[len(s)]

打家劫舍问题,需要学习的就两个点,处理环形数据的方法就是拆分为两段,分别计算,不带头,不带尾。还有就是,如何利用动态规划求解二叉树结构的题目。

题目:337 打家劫舍III

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def rob(self, root: Optional[TreeNode]) -> int:
        # dp数组(dp table)以及下标的含义:
        # 1. 下标为 0 记录 **不偷该节点** 所得到的的最大金钱
        # 2. 下标为 1 记录 **偷该节点** 所得到的的最大金钱
        dp = self.traversal(root)
        return max(dp)

    # 要用后序遍历, 因为要通过递归函数的返回值来做下一步计算
    def traversal(self, node):
        
        # 递归终止条件,就是遇到了空节点,那肯定是不偷的
        if not node:
            return (0, 0)

        left = self.traversal(node.left)
        right = self.traversal(node.right)

        # 不偷当前节点, 偷子节点
        val_0 = max(left[0], left[1]) + max(right[0], right[1])

        # 偷当前节点, 不偷子节点
        val_1 = node.val + left[0] + right[0]

        return (val_0, val_1)

买卖股票系列

买卖股票系列,最重要的就是学会这种DP数组定义方式,dp[i][0]表示第i天持有股票的最大现金,dp[i][1]表示第i天不持有股票的最大现金。

关于股票问题,想想清楚,对于限制,只可买卖一次,和不限制买卖次数的题目来说,dp数组的定义可以是相同的,就定义两个状态,dp[i][0]:持有股票,dp[i][1]:不持有股票,都对第0天做单独初始化,唯一区别在于,在循环中推导 dp[i][0]时,需不需要前一天的状态。

对于限制最多交易次数的题,就按照顺序,去定义每一天的状态。

含冷冻期的题,注意定义的四种状态。持有股票的状态,不持有股票但是不在冷冻期的状态,今天卖出股票的状态,昨天卖出股票所以今天在冷冻期的状态。

子序列系列、编辑距离系列、回文子串系列

不想写了,直接看之前自己写的博客吧。

子序列系列、编辑距离系列、回文子串系列

贪心算法章节

没什么方法论,见得多了自然就有了感觉。

376 摆动序列

本题贪心的核心在于:让峰值尽可能地保持峰值,然后删除单一坡度上的节点。

本题有很多要注意的点,首先在思路上,要意识到,可以通过两个变量,prediff 和 curdiff 来判断当前是不是坡度变换的时候。

主要考虑三种情况:
上下坡中有平坡,数组首尾两端,单调坡中有平坡。

每种情况都对应着不同的判断条件,在分析时,要通过举例,来分析判断条件的细节。

不过本题的难点也在于怎样想到这三种情况吧。

本题的动态规划方法,也值得学习。

代码随想录算法训练营第二十一天 | 读PDF复习环节1_第1张图片

贪心法:

class Solution:
    def wiggleMaxLength(self, nums):
        if len(nums) <= 1:
            return len(nums)  # 如果数组长度为0或1,则返回数组长度
        curDiff = 0  # 当前一对元素的差值
        preDiff = 0  # 前一对元素的差值
        result = 1  # 记录峰值的个数,初始为1(默认最右边的元素被视为峰值)
        for i in range(len(nums) - 1):
            curDiff = nums[i + 1] - nums[i]  # 计算下一个元素与当前元素的差值
            # 如果遇到一个峰值
            if (preDiff <= 0 and curDiff > 0) or (preDiff >= 0 and curDiff < 0):
                result += 1  # 峰值个数加1
                preDiff = curDiff  # 注意这里,只在摆动变化的时候更新preDiff
        return result  # 返回最长摆动子序列的长度

动态规划方法:

class Solution:
    def wiggleMaxLength(self, nums):
        dp = [[0, 0] for _ in range(len(nums))]  # 创建二维dp数组,用于记录摆动序列的最大长度
        dp[0][0] = dp[0][1] = 1  # 初始条件,序列中的第一个元素默认为峰值,最小长度为1
        for i in range(1, len(nums)):
            dp[i][0] = dp[i][1] = 1  # 初始化当前位置的dp值为1
            for j in range(i):
                if nums[j] > nums[i]:
                    dp[i][1] = max(dp[i][1], dp[j][0] + 1)  # 如果前一个数比当前数大,可以形成一个上升峰值,更新dp[i][1]
            for j in range(i):
                if nums[j] < nums[i]:
                    dp[i][0] = max(dp[i][0], dp[j][1] + 1)  # 如果前一个数比当前数小,可以形成一个下降峰值,更新dp[i][0]
        return max(dp[-1][0], dp[-1][1])  # 返回最大的摆动序列长度

up-down 方法:(这个思路真的很巧妙,但是很难想到)

class Solution:
    def wiggleMaxLength(self, nums):
        if len(nums) <= 1:
            return len(nums)  # 如果数组长度为0或1,则返回数组长度
        
        up = down = 1  # 记录上升和下降摆动序列的最大长度
        for i in range(1, len(nums)):
            if nums[i] > nums[i-1]:
                up = down + 1  # 如果当前数比前一个数大,则可以形成一个上升峰值
            elif nums[i] < nums[i-1]:
                down = up + 1  # 如果当前数比前一个数小,则可以形成一个下降峰值
        
        return max(up, down)  # 返回上升和下降摆动序列的最大长度

53 最大子序和

当,连续子数组的和,为负数时,立即放弃。

122 买卖股票的最佳时机II

要意识到,利润是可以拆分到每一天的。
p3 - p1 = p3 - p2 + p2 - p1

取每一天的正利润即可。

跳跃游戏问题系列

跳跃问题的关键点在于,去计算可跳的覆盖范围。跳跃问题,不要去纠结每次究竟跳几步,而是看覆盖范围。

第一个跳跃问题,问是否可以到达终点,问题就可以转化为跳跃覆盖范围究竟可不可以覆盖到终点,每移动一个单位,就更新最大覆盖范围。

第二个跳跃问题,思路是,在当前的可移动范围内(即当前最大覆盖范围),尽可能多走,如果还没到终点,步数就加一。这里就需要统计两个覆盖范围,当前的最大覆盖和到目前为止的最大覆盖,如果移动下标到了当前的最大覆盖范围,但是没到终点,步数加一,当前最大覆盖更新为之前统计的最大覆盖。

第一题代码:

class Solution:
    def canJump(self, nums: List[int]) -> bool:
        cover = 0
        if len(nums) == 1: return True
        i = 0
        # python不支持动态修改for循环中变量,使用while循环代替
        while i <= cover:
            cover = max(i + nums[i], cover)
            if cover >= len(nums) - 1: return True
            i += 1
        return False

第二题代码:

class Solution:
    def jump(self, nums):
        if len(nums) == 1:
            return 0
        
        cur_distance = 0  # 当前覆盖最远距离下标
        ans = 0  # 记录走的最大步数
        next_distance = 0  # 下一步覆盖最远距离下标
        
        for i in range(len(nums)):
            next_distance = max(nums[i] + i, next_distance)  # 更新下一步覆盖最远距离下标
            if i == cur_distance:  # 遇到当前覆盖最远距离下标
                ans += 1  # 需要走下一步
                cur_distance = next_distance  # 更新当前覆盖最远距离下标(相当于加油了)
                if next_distance >= len(nums) - 1:  # 当前覆盖最远距离达到数组末尾,不用再做ans++操作,直接结束
                    break
        
        return ans

134 加油站

这道题的关键在于作差。主要学习代码随想录的方法二,方法一难以理解。

i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,因为这个区间选择任何一个位置作为起点,到i这里都会断油,那么起始位置从i+1算起,再从0计算curSum。

那么为什么一旦[0,i] 区间和为负数,起始位置就可以是i+1呢,i+1后面就不会出现更大的负数?如果出现更大的负数,就是更新i,那么起始位置又变成新的i+1了。

那有没有可能 [0,i] 区间 选某一个作为起点,累加到 i这里 curSum是不会小于零呢?依然不可能,这种情况如果会出现,在之前也一定被考虑了。

那么局部最优:当前累加rest[i]的和curSum一旦小于0,起始位置至少要是i+1,因为从i之前开始一定不行。全局最优:找到可以跑一圈的起始位置。

class Solution:
    def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
        curSum = 0  # 当前累计的剩余油量
        totalSum = 0  # 总剩余油量
        start = 0  # 起始位置
        
        for i in range(len(gas)):
            curSum += gas[i] - cost[i]
            totalSum += gas[i] - cost[i]
            
            if curSum < 0:  # 当前累计剩余油量curSum小于0
                start = i + 1  # 起始位置更新为i+1
                curSum = 0  # curSum重新从0开始累计
        
        if totalSum < 0:
            return -1  # 总剩余油量totalSum小于0,说明无法环绕一圈
        return start

135 分发糖果

这是第一道,需要用两次遍历的题目,两边同时考虑一定会顾此失彼。

同时,本题的遍历顺序也很重要,不过我觉得这个点倒是不容易犯错,确定右边大于左边的情况,用正序遍历。确定左边大于右边的情况,用倒序遍历。

本题中,最关键的贪心思想体现在,初始化后(初始化的数组为全1数组),假如我们先进行正序遍历,得到了一个candy数组,那么在接下来的,进行倒序遍历的过程中,我们要对当前的candy[i]和candy[i+1]+1的值,二者取max,因为只有取max才能同时满足两边的情况。

class Solution:
    def candy(self, ratings: List[int]) -> int:
        candyVec = [1] * len(ratings)
        
        # 从前向后遍历,处理右侧比左侧评分高的情况
        for i in range(1, len(ratings)):
            if ratings[i] > ratings[i - 1]:
                candyVec[i] = candyVec[i - 1] + 1
        
        # 从后向前遍历,处理左侧比右侧评分高的情况
        for i in range(len(ratings) - 2, -1, -1):
            if ratings[i] > ratings[i + 1]:
                candyVec[i] = max(candyVec[i], candyVec[i + 1] + 1)
        
        # 统计结果
        result = sum(candyVec)
        return result

406 根据身高重建队列

本题很新颖,只要理解了题意就好做一些,后面要多看,总体思路就是,按身高排序,按Index插入。

本题同样是有两个维度的题,和前一题有某种程度的相似,看到这种题目,一定要想如何确定一个维度,然后再按照另一个维度重新排列。

class Solution:
    def reconstructQueue(self, people: List[List[int]]) -> List[List[int]]:
    	# 先按照h维度的身高顺序从高到低排序。确定第一个维度
        # lambda返回的是一个元组:当-x[0](维度h)相同时,再根据x[1](维度k)从小到大排序
        people.sort(key=lambda x: (-x[0], x[1]))
        que = []
	
	# 根据每个元素的第二个维度k,贪心算法,进行插入
        # people已经排序过了:同一高度时k值小的排前面。
        for p in people:
            que.insert(p[1], p)
        return que

引爆气球和无重叠区间

二者是同一类型的题目,其求解思路,都是一定要先排序,将最有可能重叠的区间,放在一起。然后要注意区间端点的判断细节,当A的右边界=B的左边界,题目到底是如何定义的。在最后,就是这类题相同的核心:当区间发生重叠后,注意更新重叠后的最小右边界,取min。

用箭引爆气球:

class Solution:
    def findMinArrowShots(self, points: List[List[int]]) -> int:
        if len(points) == 0: return 0
        points.sort(key=lambda x: x[0])
        result = 1
        for i in range(1, len(points)):
            if points[i][0] > points[i - 1][1]: # 气球i和气球i-1不挨着,注意这里不是>=
                result += 1     
            else:
                points[i][1] = min(points[i - 1][1], points[i][1]) # 更新重叠气球最小右边界
        return result

无重叠区间:
直接统计重叠区间的版本

class Solution:
    def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
        if not intervals:
            return 0
        
        intervals.sort(key=lambda x: x[0])  # 按照左边界升序排序
        count = 0  # 记录重叠区间数量
        
        for i in range(1, len(intervals)):
            if intervals[i][0] < intervals[i - 1][1]:  # 存在重叠区间
                intervals[i][1] = min(intervals[i - 1][1], intervals[i][1])  # 更新重叠区间的右边界
                count += 1
        
        return count

统计不重叠区间数量:(不重叠数量即为所需弓箭数)

class Solution:
    def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
        if not intervals:
            return 0
        
        intervals.sort(key=lambda x: x[0])  # 按照左边界升序排序
        
        result = 1  # 不重叠区间数量,初始化为1,因为至少有一个不重叠的区间
        
        for i in range(1, len(intervals)):
            if intervals[i][0] >= intervals[i - 1][1]:  # 没有重叠
                result += 1
            else:  # 重叠情况
                intervals[i][1] = min(intervals[i - 1][1], intervals[i][1])  # 更新重叠区间的右边界
        
        return len(intervals) - result


763 划分字母区间

本题的核心在于,先遍历字符串,统计每个字符的最后一次出现的index,然后遍历字符串,当 遍历下标==当前统计的字符中出现的最远下标 时,这就是分割出来的一个字符串了。

class Solution:
    def partitionLabels(self, s: str) -> List[int]:
        last_occurrence = {}  # 存储每个字符最后出现的位置
        for i, ch in enumerate(s):
            last_occurrence[ch] = i

        result = []
        start = 0
        end = 0
        for i, ch in enumerate(s):
            end = max(end, last_occurrence[ch])  # 找到当前字符出现的最远位置
            if i == end:  # 如果当前位置是最远位置,表示可以分割出一个区间
                result.append(end - start + 1)
                start = i + 1

        return result

代码随想录中给出的另一种思路,我觉得很牵强。

56 合并区间

和前面,最小弓箭射气球的题目很像,只不过前一题是求min,本题是求max,同样,做这类题目时,不要忘记先排序,将最有可能合并的区间,放在一起。

不要忘记,每次符合合并条件时,区间右端点要取max,之前是取min,总之,合并区间的题,在符合合并条件后,一定要对当前右端点进行操作的,不管是min还是max,什么都不做一定是不对的。

class Solution:
    def merge(self, intervals):
        result = []
        if len(intervals) == 0:
            return result  # 区间集合为空直接返回

        intervals.sort(key=lambda x: x[0])  # 按照区间的左边界进行排序

        result.append(intervals[0])  # 第一个区间可以直接放入结果集中

        for i in range(1, len(intervals)):
            if result[-1][1] >= intervals[i][0]:  # 发现重叠区间
                # 合并区间,只需要更新结果集最后一个区间的右边界,因为根据排序,左边界已经是最小的
                result[-1][1] = max(result[-1][1], intervals[i][1])
            else:
                result.append(intervals[i])  # 区间不重叠

        return result

738 单调递增的数字

这道题,基本上就是学习代码随想录的思路了,当时会了之后,基本就记住了,这道题需要注意的坑,还是比较多的,比如:要倒序遍历(遍历顺序很关键,正序遍历是不行的),要用flag记录末尾9的个数(这只是一个小技巧)。

代码随想录的解析文章链接如下:
738 单调递增的数字

在这里插入代码片`class Solution:
    def monotoneIncreasingDigits(self, N: int) -> int:
        # 将整数转换为字符串
        strNum = str(N)
        # flag用来标记赋值9从哪里开始
        # 设置为字符串长度,为了防止第二个for循环在flag没有被赋值的情况下执行
        flag = len(strNum)
        
        # 从右往左遍历字符串
        for i in range(len(strNum) - 1, 0, -1):
            # 如果当前字符比前一个字符小,说明需要修改前一个字符
            if strNum[i - 1] > strNum[i]:
                flag = i  # 更新flag的值,记录需要修改的位置
                # 将前一个字符减1,以保证递增性质
                strNum = strNum[:i - 1] + str(int(strNum[i - 1]) - 1) + strNum[i:]
        
        # 将flag位置及之后的字符都修改为9,以保证最大的递增数字
        for i in range(flag, len(strNum)):
            strNum = strNum[:i] + '9' + strNum[i + 1:]
        
        # 将最终的字符串转换回整数并返回
        return int(strNum)`

968 监控二叉树

不多说了,这道题太牛逼了。

自己要明确两点:一定是用后序遍历,因为要利用左右孩子的信息。然后就是状态的定义,一共定义三个状态,有覆盖,无覆盖,有摄像头。

对空节点的讨论

因为在遍历树的过程中,就会遇到空节点,那么问题来了,空节点究竟是哪一种状态呢? 空节点表示无覆盖? 表示有摄像头?还是有覆盖呢?

回归本质,为了让摄像头数量最少,我们要尽量让叶子节点的父节点安装摄像头,这样才能摄像头的数量最少。

那么空节点不能是无覆盖的状态,这样叶子节点就要放摄像头了,空节点也不能是有摄像头的状态,这样叶子节点的父节点就没有必要放摄像头了,而是可以把摄像头放在叶子节点的爷爷节点上。

所以空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头了

代码随想录–监控二叉树

总结出一共只有四种情形

情况1:左右节点都有覆盖
情况2:左右节点至少有一个无覆盖的情况
情况3:左右节点至少有一个有摄像头
情况4:头结点没有覆盖

class Solution:
         # Greedy Algo:
        # 从下往上安装摄像头:跳过leaves这样安装数量最少,局部最优 -> 全局最优
        # 先给leaves的父节点安装,然后每隔两层节点安装一个摄像头,直到Head
        # 0: 该节点未覆盖
        # 1: 该节点有摄像头
        # 2: 该节点有覆盖
    def minCameraCover(self, root: TreeNode) -> int:
        # 定义递归函数
        result = [0]  # 用于记录摄像头的安装数量
        if self.traversal(root, result) == 0:
            result[0] += 1

        return result[0]

        
    def traversal(self, cur: TreeNode, result: List[int]) -> int:
        if not cur:
            return 2

        left = self.traversal(cur.left, result)
        right = self.traversal(cur.right, result)

        # 情况1: 左右节点都有覆盖
        if left == 2 and right == 2:
            return 0

        # 情况2:
        # left == 0 && right == 0 左右节点无覆盖
        # left == 1 && right == 0 左节点有摄像头,右节点无覆盖
        # left == 0 && right == 1 左节点无覆盖,右节点有摄像头
        # left == 0 && right == 2 左节点无覆盖,右节点覆盖
        # left == 2 && right == 0 左节点覆盖,右节点无覆盖
        if left == 0 or right == 0:
            result[0] += 1
            return 1

        # 情况3:
        # left == 1 && right == 2 左节点有摄像头,右节点有覆盖
        # left == 2 && right == 1 左节点有覆盖,右节点有摄像头
        # left == 1 && right == 1 左右节点都有摄像头
        if left == 1 or right == 1:
            return 2


你可能感兴趣的:(算法,pdf)