1、确定dp数组以及下标的含义。
2、确定递推公式。
3、dp数组如何初始化。
4、确定遍历顺序。
5、举例推导dp数组。
本题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,也很符合直觉!
网站上的示例代码也是按照上述方式编写的!
这道题动态规划的思路已经完全掌握了,数论的代码写法可以看看,熟悉熟悉,尤其是,提前除以分母的写法。
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数组压缩至一维。
这道题,代码随想录给出的代码是,剪枝减的最狠的一版: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里面取四个值,遍历只需遍历一半。
这道题的思路真的巧妙,要想到因为所给的数组是排好序的,假如是【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为节点的二叉搜索树的总数量
每一个物品都有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况。
这道题的精髓就是明白:两块质量不相等的石头相撞,质量大的那一个,还会把差值退回去!所以本题还是分两堆,然后用01背包去装。
这道题,是第一次接触求一共有多少种组合的题,后面类似的题目还会再次出现,本质上就是理解递推公式的核心。
二维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] # 状态转移方程,累加不同选择方式的数量
只要把一和零的个数,看做是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背包中,二维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])
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数组。
求最小值的题目,最需要注意的地方是,初始化,要初始化为最大值。
这道题,首先明确是,排列问题,而不是像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]时,需不需要前一天的状态。
对于限制最多交易次数的题,就按照顺序,去定义每一天的状态。
含冷冻期的题,注意定义的四种状态。持有股票的状态,不持有股票但是不在冷冻期的状态,今天卖出股票的状态,昨天卖出股票所以今天在冷冻期的状态。
不想写了,直接看之前自己写的博客吧。
子序列系列、编辑距离系列、回文子串系列
没什么方法论,见得多了自然就有了感觉。
本题贪心的核心在于:让峰值尽可能地保持峰值,然后删除单一坡度上的节点。
本题有很多要注意的点,首先在思路上,要意识到,可以通过两个变量,prediff 和 curdiff 来判断当前是不是坡度变换的时候。
主要考虑三种情况:
上下坡中有平坡,数组首尾两端,单调坡中有平坡。
每种情况都对应着不同的判断条件,在分析时,要通过举例,来分析判断条件的细节。
不过本题的难点也在于怎样想到这三种情况吧。
本题的动态规划方法,也值得学习。
贪心法:
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) # 返回上升和下降摆动序列的最大长度
当,连续子数组的和,为负数时,立即放弃。
要意识到,利润是可以拆分到每一天的。
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
这道题的关键在于作差。主要学习代码随想录的方法二,方法一难以理解。
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
这是第一道,需要用两次遍历的题目,两边同时考虑一定会顾此失彼。
同时,本题的遍历顺序也很重要,不过我觉得这个点倒是不容易犯错,确定右边大于左边的情况,用正序遍历。确定左边大于右边的情况,用倒序遍历。
本题中,最关键的贪心思想体现在,初始化后(初始化的数组为全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
本题很新颖,只要理解了题意就好做一些,后面要多看,总体思路就是,按身高排序,按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
本题的核心在于,先遍历字符串,统计每个字符的最后一次出现的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
代码随想录中给出的另一种思路,我觉得很牵强。
和前面,最小弓箭射气球的题目很像,只不过前一题是求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
这道题,基本上就是学习代码随想录的思路了,当时会了之后,基本就记住了,这道题需要注意的坑,还是比较多的,比如:要倒序遍历(遍历顺序很关键,正序遍历是不行的),要用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)`
不多说了,这道题太牛逼了。
自己要明确两点:一定是用后序遍历,因为要利用左右孩子的信息。然后就是状态的定义,一共定义三个状态,有覆盖,无覆盖,有摄像头。
因为在遍历树的过程中,就会遇到空节点,那么问题来了,空节点究竟是哪一种状态呢? 空节点表示无覆盖? 表示有摄像头?还是有覆盖呢?
回归本质,为了让摄像头数量最少,我们要尽量让叶子节点的父节点安装摄像头,这样才能摄像头的数量最少。
那么空节点不能是无覆盖的状态,这样叶子节点就要放摄像头了,空节点也不能是有摄像头的状态,这样叶子节点的父节点就没有必要放摄像头了,而是可以把摄像头放在叶子节点的爷爷节点上。
所以空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头了
代码随想录–监控二叉树
情况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