动态规划是一个难点,是值得认真钻研的。这里我推荐代码随想录讲的真心不错!能听懂,能学会!大家可以试试看。
What
动态规划:每个状态一定是由上一个状态推导出来的
贪心:没有状态推导,只是局部直接选最优
Steps
五部曲:
- 确定dp数组以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
常见类型
常规问题
斐波那契数列
class Solution:
def fib(self, n: int) -> int:
# 1. 确定dp数组的含义
# dp[i]:第i个斐波那契数的值
# 2. 递推公式
# dp[i] = dp[i-1]+dp[i-2]
# 3. 初始化
# dp[0] = 0 dp[1] = 1
# 4. 确定遍历顺序
# 从前向后遍历
# 5. 打印dp数组
# debug作用
dp = [0]*(n+1)
if n <= 1:
return n
else:
dp[0] = 0
dp[1] = 1
for i in range(2,n+1):
dp[i] = dp[i-1]+dp[i-2]
return dp[n]
爬楼梯
class Solution:
def climbStairs(self, n: int) -> int:
# 1. 确定dp数组含义
# dp[i] 达到第i节台阶有第dp[i]种方法
# 2. 确定递推公式
# dp[i] = dp[i-1] + dp[i-2]
# 3. 初始化
# dp[0] = 0 dp[1] = 1 dp [2] = 2
# 4. 确定遍历顺序
# 从头到尾
# 5. 打印
dp = [0]*(n+1)
if n <= 1:
return n
else:
dp[1] = 1
dp[2] = 2
for i in range(3,n+1):
dp[i] = dp[i-1]+dp[i-2]
return dp[n]
使用最小花费爬楼梯
- 状态转移方程
dp[i]
表示的意思是:爬到第i层所需要的最低花费,
cost[i]
表示的意思是:从楼梯第 i 个台阶向上爬需要支付的费用
对于dp[i]
求法应该是:
min(dp[i-1](在第i-1层时的最低花费)+cost[i-1](从第i-1层向上爬的费用),dp[i-2]+cost[i-2])
- 初始化
dp[0]和dp[1]都是0,这个地方稍微有点绕,题目说明了可以选择从第0层还是第1层开始爬,所以我们要站在第2层来看,到底是从1还是0层开始,看两者的花费那个更小,所以dp[2] = min(cost[0],cost[1])
下面是两种表示方法,可以体会一下不同,个人认为第一种方法比较好理解
class Solution:
def minCostClimbingStairs(self, cost: List[int]) -> int:
# 1. dp[i]:爬到第i层的最低花费
# 2. dp[i] += min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2])
# 3. 初始化dp[0] = 0 dp[1] = 0
# 4. 遍历顺序: 从前到后
dp = [0] * (len(cost)+1)
dp[2] = min(cost[0],cost[1])
for i in range(2,len(cost)+1):
dp[i] = min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2])
return dp[-1]
class Solution:
def minCostClimbingStairs(self, cost: List[int]) -> int:
# 1. dp[i]:爬到第i层的最低花费
# 2. dp[i] += min(dp[i-1]+cost[i],dp[i-2]+cost[i-1])
# 3. 初始化dp[0] = 0 dp[1] = 0
# 4. 遍历顺序: 从前到后
dp = [0] * len(cost)
dp[1] = min(cost[0],cost[1])
for i in range(2,len(cost)):
dp[i] = min(dp[i-1]+cost[i],dp[i-2]+cost[i-1])
return dp[-1]
不同路径
- 初始化
注意题目说只能从向下和向右,所以对于最上面一行和最左边的一列都要初始化为1,因为只有这一条路可以到达这个位置。
首先dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
# 1. dp[i][j]表示到达第(i,j)位置有多少种不同路径
# 2. dp[i][j] = dp[i-1][j] + dp[i][j-1]
# 3. dp[0][0] = 0
# 4. 遍历顺序:从前向后
dp = [[0]*n]*m
for i in range(m):
dp[i][0] = 1
for j in range(n):
dp[0][j] = 1
for i in range(1,m):
for j in range(1,n):
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[m-1][n-1]
不同路径2
- 不同点
和上一题的不同点主要体现在初始化,对于边界上的两条路径如果其中出现了障碍就需要将后面的路都设为0
在循环的过程中,如果碰到障碍就跳过,保留0
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
m = len(obstacleGrid)
n = len(obstacleGrid[0])
if obstacleGrid[m - 1][n - 1] == 1 or obstacleGrid[0][0] == 1:
return 0
#dp = [[0]*n]*m
dp = [[0] * n for _ in range(m)]
for i in range(m):
if obstacleGrid[i][0] == 0:
dp[i][0] = 1
else:
break
for j in range(n):
if obstacleGrid[0][j] == 0:
dp[0][j] = 1
else:
break
for i in range(1,m):
for j in range(1,n):
if obstacleGrid[i][j] == 1:
continue
dp[i][j] = dp[i-1][j]+dp[i][j-1]
print(dp)
return dp[-1][-1]
- tips
还学到一个知识点:
dp = [[0]*n]*m
-
- 这种方式创建了一个大小为 m x n 的二维数组,其中所有元素都被初始化为 0。
- 但是,它创建了内部列表的浅拷贝。这意味着所有内部列表都是同一个对象的引用。因此,如果修改了一个内部列表(即一行),那么所有其他行也会受到影响。这可能导致意外行为,特别是当你打算单独修改每行时。
dp = [[0] * n for _ in range(m)]
- 这种方式也创建了一个大小为 m x n 的二维数组,并且所有元素都初始化为 0。
- 但是,这种方法通过使用列表解析,为每一行都创建了一个新的列表,因此每一行都是独立的对象。这意味着你可以安全地修改一个行而不会影响其他行。
第二种方法更加安全,特别是在你需要在二维数组中进行修改时
整数拆分
- 状态转移方程
j * (i-j)
表示的是将数字i拆分成两个数字相称
j * dp[i-j]
表示将其拆分成更多的数字组合可能,2个以上的数字
dp[i] = max(j*(i-j),j * dp[i-j],dp[i])
是要好好琢磨的这个转移方程;最后的dp[i]是会随着j的迭代而不断地被最大值给覆盖的,所以这个部分是三者中取最大值
class Solution:
def integerBreak(self, n: int) -> int:
# 1. dp[i] :对i进行拆分,最大的乘积为dp[i]
# 2. dp[i] = max(j * (i-j) , j * dp[i-j] , dp[i])
# 3. dp[0] dp[1]是没有意义的初始为0 dp[2] = 1
# 4.
dp = [0]*(n+1)
dp[2] = 1
for i in range(3,n+1):
for j in range(1,i//2+1):
# 计算切割点j和剩余部分(i-j)的乘积,并与之前的结果进行比较取较大值
dp[i] = max(j*(i-j),j * dp[i-j],dp[i])
return dp[n]
不同的二叉搜索树
j-1
为j
为头结点左子树节点数量,i-j
为以j
为头结点右子树节点数量
class Solution:
def numTrees(self, n: int) -> int:
# 1. dp[i] : 由i个节点组成的二叉搜索树的种数
# 2. dp[i] = dp[j-1] * dp[i-j]
# 3. dp[0] = 1 dp[1] = 1 dp[2] = 2
# 4. 由小到大
dp = [0]*(n+1)
dp[0] = 1 # 空二叉树
for i in range(1,n+1):
for j in range(1,i+1):
dp[i] += dp[j-1] * dp[i-j]
print(dp)
return dp[-1]
背包问题
01背包:有n种物品,每种物品只有一个
完全背包:有n种物品,每种物品有无限个
01背包
暴力解法:每个物品只有取或者不取,所以时间复杂度是2的n次方
动态规划:
dp[i][j]
表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。- 递推公式:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
-
-
- 物品i放进背包:dp[i - 1][j - weight[i]] + value[i]
- 物品i不放进背包:dp[i - 1][j]不放进背包,价值就等于i-1,容量也不变,情况的最大价值
-
- 初始化:
-
- 当j背包容量为0时,什么东西都没办法放进去,其价值初始化为0
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。考虑初始化的原因
-
- 当放入0号物品的时候,当背包容量小于0号物品的重量(
weight[0]
)初始化价值为0,因为无法放进背包内;容量大于等于weight[0]
初始化为0号物体的价值(value[0]
),因为0号物体足够放进背包
- 当放入0号物品的时候,当背包容量小于0号物品的重量(
初始化之后,就可以使用状态转移公式进行推导
def test_2_wei_bag_problem1():
weight = [1, 3, 4]
value = [15, 20, 30]
bagweight = 4
# 初始化二维数组,全都设为0,大小是i(物品个数) x j(背包容量)
dp = [[0] * (bagweight + 1) for _ in range(len(weight))]
# 初始化 方物品0时
for j in range(weight[0], bagweight + 1):
dp[0][j] = value[0]
# weight数组的大小就是物品个数
for i in range(1, len(weight)): # 遍历物品
for j in range(bagweight + 1): # 遍历背包容量
if j < weight[i]:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
print(dp[len(weight) - 1][bagweight])
test_2_wei_bag_problem1()
滑动数组
使用一维数组
注意:遍历的顺序
def test_1_wei_bag_problem():
weight = [1, 3, 4]
value = [15, 20, 30]
bagWeight = 4
# 初始化
dp = [0] * (bagWeight + 1)
for i in range(len(weight)): # 遍历物品
for j in range(bagWeight, weight[i] - 1, -1): # 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
print(dp[bagWeight])
test_1_wei_bag_problem()
分割等和子集
和01背包的区别在于:最后选取的数字之和要恰好等于规定和的一半
class Solution:
def canPartition(self, nums: List[int]) -> bool:
# 1. dp[i] 背包容量为i最大价值
# 2. dp[i] = max(dp[i-num]+num,dp[i])
# 3. 初始化都是0
# 4. 从后向前
h = sum(nums)
target = h//2
if h % 2 == 1:
return False
dp = [0] * (target+1)
for num in nums:
for i in range(target,num -1,-1):
dp[i] = max(dp[i-num]+num , dp[i])
if dp[-1] == target :
return True
return False
最后一块石头的重量2
在力扣的评论区看到Eason同学的评论感觉非常的简单易懂,直击灵魂:简单来说就是任意选i块石头,使得他们的重量趋近于总重量的一半,因为这样和另一半抵消的差值就是最小的
class Solution:
def lastStoneWeightII(self, stones: List[int]) -> int:
# 1. dp[i] 背包容量为i时最大价值
# 2. dp[i] = max(dp[i],dp[i-stone]+stone)
# 3. 初始化
# 4. 从后向前
h = sum(stones)
target = h//2
dp = [0] * (target + 1)
for stone in stones:
for i in range(target,stone-1,-1):
dp[i] = max(dp[i],dp[i-stone]+stone)
return h - dp[target]-dp[target]
目标和
class Solution:
def findTargetSumWays(self, nums: List[int], target: int) -> int:
# 1. dp[i] 表示的是和为i有dp[i]种表示方法
# 2. dp[i] += dp[i-num]
# 3. 初始化dp[0] = 1
# 4. 一维数组要第二层倒序
h = sum(nums)
if (h + target) % 2 == 1:
return 0
if abs(target) > h :
return 0
dp = [0] * (h + 1)
dp[0] = 1
target_1 = (target + h) // 2
for num in nums:
for i in range(target_1,num-1,-1):
dp[i] += dp[i-num]
return dp[target_1]
完全背包
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
01背包和完全背包唯一不同就是体现在遍历顺序上
我们知道01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。
而完全背包的物品是可以添加多次的,所以要从小到大去遍历
def test_CompletePack():
weight = [1, 3, 4]
value = [15, 20, 30]
bagWeight = 4
dp = [0] * (bagWeight + 1)
for i in range(len(weight)): # 遍历物品
for j in range(weight[i], bagWeight + 1): # 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
print(dp[bagWeight])
test_CompletePack()
零钱兑换2
- 初始化:这个地方刚开始我就处理错了,如果dp[0]初始化为0的话后续dp数组都为0,感觉除了理解题意分析还可以动手推一下,看看到底是放0还是1。dp[0] = 1是 递归公式的基础
class Solution:
def change(self, amount: int, coins: List[int]) -> int:
# 1. dp[i] 凑成金额i由dp[i]种方式
# 2. dp[i] += dp[i-coin]
# 3. dp[0] = 1
# 4. 从左向右
dp = [0] * (amount+1)
dp[0] = 1
for coin in coins:
for i in range(coin,amount+1):
dp[i] += dp[i-coin]
return dp[amount]
组合总和IV
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
class Solution:
def combinationSum4(self, nums: List[int], target: int) -> int:
dp = [0] * (target + 1) # 创建动态规划数组,用于存储组合总数
dp[0] = 1 # 初始化背包容量为0时的组合总数为1
for i in range(1, target + 1): # 遍历背包容量
for j in nums: # 遍历物品列表
if i >= j: # 当背包容量大于等于当前物品重量时
dp[i] += dp[i - j] # 更新组合总数
return dp[-1] # 返回背包容量为target时的组合总数
零钱兑换
- 初始化:将dp数组初始化为正无穷,为了最后来判断是否合法,如果没有任何一种硬币组合能组成总金额返回-1,所以最后return的时候需要再进行判断是否为正无穷
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
# 1. dp[i] 凑成总金额i所需的最少硬币个数dp[i]
# 2. dp[i] = min(dp[i],dp[i-coin])
# 3. dp[0] = 0
dp = [float('inf')] * (amount + 1)
#dp = [0] * (amount+1)
dp[0] = 0
for coin in coins:
for i in range(coin,amount+1):
dp[i] = min(dp[i],dp[i-coin]+1)
return dp[amount] if dp[amount] != float('inf') else -1
子序列
最长递增子序列
这个类型也很重要
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
if len(nums) <= 1:
return len(nums)
dp = [1] * len(nums)
result = 1
for i in range(1,len(nums)):
for j in range(0,i):
if nums[i] > nums[j]:
dp[i] = max(dp[i],dp[j]+1)
result = max(result,dp[i])
return result