动态规划 Dynamic programming
动态规划
动态规划是一种从底至顶的方法,从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解。其核心特征包括:
- 最优子结构:原问题的最优解是从子问题的最优解构建得来的;
- 无后效性:已经求解的子问题,不会再受到后续决策的影响。
为此,采用数组(称为 dp 数组)来存储各子问题的最优解,其每个元素对应一个子问题的最优解。通过定义状态转移方程明确子问题间的关系,并按顺序依次求解各个阶段的问题。
例题
70. 爬楼梯 :给定一个共有 nnn 阶的楼梯,你每步可以上 111 阶或者 222 阶,请问有多少种方案可以爬到楼顶?
要到达第 iii 个台阶,有两种方式:从第 i−1i-1i−1 个台阶踏一步上来,或者从第 i−2i-2i−2 个台阶踏两步上来。因此到达第 iii 个台阶的总方案数等于前两个台阶方案数之和。
为了存储每个问题的解,定义一个 dp 数组,其中 dp[i]dp[i]dp[i] 表示爬到第 iii 台阶的方案数。根据以上的分析,可得到状态转移方程: dp[i]=dp[i−1]+dp[i−2]dp[i] = dp[i-1] + dp[i-2]dp[i]=dp[i−1]+dp[i−2]。这表明爬楼梯问题具有最优子结构特性,问题的最终解可以通过子问题的解递推得到。
此外,需要初始化边界,设置最小子问题的解,即 dp[0]=1dp[0]=1dp[0]=1,dp[1]=1dp[1]=1dp[1]=1。代码如下所示:
def climbing_stairs_dp(n: int) -> int:"""爬楼梯:动态规划"""if n == 1 or n == 2:return n# 初始化 dp 表,用于存储子问题的解dp = [1] * (n + 1)# 状态转移:从较小子问题逐步求解较大子问题for i in range(2, n + 1):dp[i] = dp[i - 1] + dp[i - 2]return dp[n]
由于 dp[i]dp[i]dp[i] 只与 dp[i−1]dp[i-1]dp[i−1] 和 dp[i−2]dp[i-2]dp[i−2] 相关,因此无须使用一个数组 dp 来存储所有子问题的解,而只需两个变量滚动前进即可。代码如下所示:
def climbing_stairs_dp_comp(n: int) -> int:"""爬楼梯:空间优化后的动态规划"""if n == 1 or n == 2:return na, b = 1, 1for _ in range(2, n + 1):a, b = b, a + breturn b
0-1 背包
有 nnn 件物品和一个容量为 www 的背包,其中第 iii 件物品的重量和价值分别为 wiw_iwi 和 viv_ivi。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
上述问题中,由于每个物体只有两种状态(取或者不取),对应二进制中的 000 和 111,因此这类问题便被称为 0-1 背包问题。
分割等和子集
题目含义:给定一个长度为 nnn 且只包含正整数的非空数组 numsnumsnums,求这个数组是否可以分割成两个子集,使得两个子集的元素和相等。
例如,[1,5,11,5][1,5,11,5][1,5,11,5] 可以分割为 [1,5,5][1,5,5][1,5,5] 和 [11][11][11],而 [1,2,3,5][1,2,3,5][1,2,3,5] 不能分割成两个元素和相等的子集。
转换思路:根据以上的例子可得,s=sum(nums)s = sum(nums)s=sum(nums) 必须是偶数才能分割出两个子集,且分割后的子集总和为 a=s2a = \frac{s}{2}a=2s。那么当前问题就转化为 0-1 背包,即在数组的每个元素均有限的条件下,容量为 aaa 的背包可以放入物品的最大价值/总和。
动态规划:
1、定义 dp 数组:dp[i][j]dp[i][j]dp[i][j] 表示前 i−1i-1i−1 个元素(长度为 iii)在容量为 jjj 的背包中的最大价值,维度为 (n+1)×(a+1)(n+1) \times (a + 1)(n+1)×(a+1);
2、状态转移方程:每个物品都有不放入和放入这两个选择:如果放入物品则背包容量减少,如果不放入物品则背包容量不变。确定当前物品 nums[i−1]nums[i-1]nums[i−1] 的决策后,转而考虑前 i−2i-2i−2 个物品最大价值的子问题:
a、当元素 nums[i−1]nums[i-1]nums[i−1] 超出背包容量 jjj 时,物品不能放入背包,因此 dp[i][j] = dp[i-1][j]
;
b、当 nums[i−1]nums[i-1]nums[i−1] 小于等于容量 jjj 时,需要从不放入物品和不放入物品两种选择最大的价值,即 dp[i] = max(dp[i-1][j], dp[i-1][j-nums[i-1]] + nums[i-1])
。
3、确定边界条件:
- dp[i][0],i>0dp[i][0], i > 0dp[i][0],i>0 表示前 iii 个物品装入容量为 000 的背包,这种情况无解,故设为 000;
- dp[0][j],j>0dp[0][j], j > 0dp[0][j],j>0 表示用零个物品填满容量为 jjj 的背包,同样无解,设为 000;
- dp[0][0]dp[0][0]dp[0][0] 表示从零个物品放入容量为 000 的背包,合理的且最大价值为 000。
空间和时间复杂度:空间复杂度和时间复杂度均为 O(n×amt)O(n \times amt)O(n×amt)。
class Solution:def canPartition(self, nums: List[int]) -> bool:def fn(nums, target):n = len(nums)dp = [[0] * (target+1) for _ in range(n+1)]for i in range(1, n+1):for j in range(1, target + 1):if j < nums[i-1]:dp[i][j] = dp[i-1][j]else:dp[i][j] = max(dp[i-1][j], dp[i-1][j-nums[i-1]] + nums[i-1])return dp[n][target] == targets = sum(nums)if s % 2 != 0:return Falsereturn fn(nums, s//2)
空间优化的一维DP解法:
根据上述分析,dp[i][j]dp[i][j]dp[i][j] 的状态转移仅与 dp[i−1][j]dp[i-1][j]dp[i−1][j] 与 dp[i−1][j−nums[i−1]]dp[i-1][j-nums[i-1]]dp[i−1][j−nums[i−1]] 相关,而与之前各行无关,因此可将二维数组优化为一维数组,即在遍历到第 iii 元素时,该数组存储的是第 i−1i-1i−1 行的状态。
注意,利用第 i−1i-1i−1 行来更新第 iii 行的信息,如果采用正序遍历,则 dp[j]dp[j]dp[j] 所依赖的数据 dp[j−nums[i]]dp[j-nums[i]]dp[j−nums[i]] 会被新的值覆盖,导致状态转移错误,为此必须采用倒序遍历。
class Solution:def canPartition(self, nums: List[int]) -> bool:def fn(t):dp = [0] * (t+1)for x in nums:for j in range(t, -1, -1):if x <= j:dp[j] = max(dp[j], dp[j-x] + x)return dp[t] == ts = sum(nums)if s % 2 != 0:return Falsereturn fn(s // 2)# 上方一致,可少一些判断
class Solution:def canPartition(self, nums: List[int]) -> bool:def fn(t):dp = [0] * (t+1)for x in nums:for j in range(t, x-1, -1):dp[j] = max(dp[j], dp[j-x] + x)return dp[t] == ts = sum(nums)if s % 2 != 0:return Falsereturn fn(s // 2)
完全背包
完全背包模型与 0-1 背包类似,与 0-1 背包的区别仅在于一个物品可以选取无限次,而非仅能选取一次。
零钱兑换
题目含义:给定 nnn 种不同硬币和目标金额 amtamtamt,其中每种硬币的个数不限。问能够凑出目标金额的最少硬币数量,如果无法凑出目标金额,则返回 −1-1−1。
例如:coins = [1, 2, 5]
, amt=11
,则最少硬币数量为 333,即两个面值为 555 和一个面值为 111 的硬币。
动态规划:
(1)二维的 dp 数组:dp[i][j]
表示使用前 i
个硬币凑出目标金额 j
的最少硬币个数,其中数组维度为 (n+1)×(amt+1)(n+1) \times (amt+1)(n+1)×(amt+1);
(2)状态转移方程:
(2.1)当 coins[i] > j
时,dp[i][j] = dp[i-1][j]
,表明当前硬币的面值过大,选择不了当前硬币;
(2.2)当 coins[i] <= j
时,dp[i][j] = min(dp[i-1][j], dp[i][j-coins[i]] + 1)
,表明硬币面值不超过当前目标金额 j
下,从不选择硬币和选择硬币中判断最佳方案。
dp[i][j-coins[i]]+1
:由于物品的数量是无限的,因此将硬币coins[i]
放入背包后,仍可以从前 iii 个硬币中选择;由于背包已放入了coins[i]
,为了不破坏当前金额的总数,需得到 j−coins[i]j-coins[i]j−coins[i] 最少的硬币个数。
(3)确定边界条件:
(3.1)当目标金额为 000 时,凑出它的最少硬币数量为 000,因此 dp 数组首列都为 000,即 dp[i][0] = 0
;
(3.2)当没有硬币时,无法凑出目标金额 >0>0>0,相当于无效解,因此可将它们置为 amt+1amt+1amt+1 来表示无效,即 dp[0][j] = amt+1
,且 j > 0
。
时间和空间复杂度:空间复杂度时间复杂度均为 O(n×amt)O(n \times amt)O(n×amt)。
class Solution:def coinChange(self, coins: List[int], amount: int) -> int:n = len(coins)dp = [[0] * (amount+1) for _ in range(n+1)] for j in range(1, amount+1):dp[0][j] = 1 + amountfor i in range(1, n+1):for j in range(1, amount+1):if coins[i-1] > j:dp[i][j] = dp[i-1][j] # 面值太大,凑不成总金额为jelse:dp[i][j] = min(dp[i-1][j], dp[i][j-coins[i-1]] + 1)return dp[n][amount] if dp[n][amount] != (1 + amount) else -1
空间优化的一维DP解法:
根据上面的解法,dp[i][j]
的状态是由上方的 dp[i-1][j]
和左方的 dp[i][j-coins[i]]
转移来的,因此将二维数组优化为一维的:
(1)当硬币面值大于 jjj 时,dp[j] = dp[j]
;
(2)当硬币面值小于等于 aaa 时,dp[j] = min(dp[j], dp[j-coins[i]]+1)
;
(3)初始化:dp[0]=0
,当 j>0j>0j>0,dp[j]=amt+1
。
(4)空间复杂度:空间复杂度为 O(amt)O(amt)O(amt)。
class Solution:def coinChange(self, coins: List[int], amount: int) -> int:mmax = amount + 1# 没有硬币,不能凑成一定的金额,标为无效值 mmaxdp = [mmax for _ in range(amount+1)] dp[0] = 0for c in coins:# 正序遍历for j in range(1, amount+1):if c <= j:dp[j] = min(dp[j], dp[j-c]+1) return dp[amount] if dp[amount] != mmax else -1
class Solution:def coinChange(self, coins: List[int], amount: int) -> int:mmax = amount + 1dp = [mmax for _ in range(amount+1)] dp[0] = 0for c in coins:# 直接从 c 开始,跳过了硬币面值超过金额a的情况for j in range(c, amount+1):dp[j] = min(dp[j], dp[j-c]+1) return dp[amount] if dp[amount] != mmax else -1
零钱规划Ⅱ
题目含义:给定一个包含 nnn 种不同面额的硬币数组 coinscoinscoins 和目标金额 amtamtamt,求可以凑成总金额的硬币组合数,其中每种硬币可以重复使用。
例如,coins=[1,2,5]coins=[1,2,5]coins=[1,2,5] 和 amt=5amt=5amt=5,有四种方式凑成目标金额:{5}、{2, 2, 1} 、{2, 1, 1, 1} 和 {1,1,1,1,1}。
动态规划:
(1)定义维度为 (n+1)×(amt+1)(n+1) \times (amt+1)(n+1)×(amt+1) 的 dp 数组,其中 dp[i][j]dp[i][j]dp[i][j] 表示使用前 iii 种硬币组成金额 jjj 的方案数。
(2)对于硬币 coins[i−1]≤jcoins[i-1] \leq jcoins[i−1]≤j,无论选择不使用还是选择使用,都构成一种可行方案,因此转移方程为 dp[i][j]=dp[i−1][j]+dp[i][j−coins[i−1]]dp[i][j] = dp[i-1][j] + dp[i][j-coins[i-1]]dp[i][j]=dp[i−1][j]+dp[i][j−coins[i−1]]。
(3)边界:
- 当 i=0,j=0i=0, j=0i=0,j=0 时,dp[0][0]=1dp[0][0] = 1dp[0][0]=1;
- 当 i≠0,j=0i \neq 0, j=0i=0,j=0 时,dp[i][0]=1dp[i][0] = 1dp[i][0]=1(不选择任何硬币);
- 当 i=0,j≠0i=0, j \neq 0i=0,j=0 时,dp[0][j]=0dp[0][j] = 0dp[0][j]=0。
复杂度:空间复杂度和时间复杂度均为 O(n×amt)O(n \times amt)O(n×amt)。
class Solution:def change(self, amount: int, coins: List[int]) -> int:n = len(coins)dp = [[0] * (amount+1) for _ in range(n+1)]for i in range(n+1):dp[i][0] = 1for i in range(1, n+1):for j in range(1, amount+1):if coins[i-1] > j:dp[i][j] = dp[i-1][j]else:dp[i][j] = dp[i-1][j] + dp[i][j-coins[i-1]]return dp[n][amount]
空间优化的一维DP解法:
class Solution:def change(self, amount: int, coins: List[int]) -> int:dp = [0] * (amount+1)# 没有硬币就不能组成 a>0 的目标金额,因此没有组合方案dp[0] = 1for c in coins:for a in range(c, amount+1):dp[a] += dp[a-c]return dp[amount]
编辑距离
题目含义:给定两个字符串 sss 和 ttt,返回将 sss 转换为 ttt 所需的最少编辑步数,其中编辑操作有三种:插入一个字符、删除一个字符、将字符替换为任意一个字符。
例如:将 s='horse'
转为 t = 'ros'
至少需要 333 步,包括将 sss 里的 ‘h’ 替换为 ‘r’(‘rorse’),再删除第二个 ‘r’ (‘rose’),最后删除 ‘e’(‘ros’)。
动态规划:
(1)二维 dp 数组:每一轮的编辑都是针对字符串 sss 的第 iii 个字符,使两字符串尾部的字符相同,从而跳过它们,转而考虑其子问题:sss 前 i−1i-1i−1 个字符与 ttt 前 j−1j-1j−1 个字符的最少编辑次数。
假设 nnn 和 mmm 分别是字符串 sss 和 ttt 的长度,建立一个维度为 (n+1)×(m+1)(n+1) \times (m+1)(n+1)×(m+1) 的 dp 数组,其 dp[i][j]
表示将 sss 前 i−1i-1i−1 个字符更改为 ttt 前 j−1j-1j−1 个字符所需的最少编辑步数。
(2)状态转移方程:
- 插入:在 s[i−1]s[i-1]s[i−1] 后头添加字符 t[j−1]t[j-1]t[j−1],则问题转化为计算 sss 前 i−1i-1i−1 个字符与 ttt 前 j−2j-2j−2 个字符的最少编辑次数,即
dp[i][j-1]
; - 删除:删除 s[i−1]s[i-1]s[i−1],则问题转化为计算 sss 前 i−2i-2i−2 个字符与 ttt 前 j−1j-1j−1 个字符的最少编辑次数,即
dp[i-1][j]
; - 更新:将 s[i−1]s[i-1]s[i−1] 替换为 t[j−1]t[j-1]t[j−1],则问题转化为计算从 sss 前 i−2i-2i−2 个字符与 ttt 前 j−2j-2j−2 个字符的最少编辑次数,即
dp[i-1][j-1]
; - 没有修改:此时 s[i−1]=t[j−1]s[i-1] = t[j-1]s[i−1]=t[j−1],则问题转化为计算 sss 的前 i−2i-2i−2 个字符与 ttt 前 j−2j-2j−2 个字符的最少编辑次数,即
dp[i-1][j-1]
;
根据以上的分析可得,状态转移方程为:
dp[i][j]={min(dp[i][j−1],dp[i−1][j],dp[i−1][j−1]),s[i−1]≠t[j−1]dp[i−1][j−1],s[i−1]=t[j−1]dp[i][j] = \begin{cases} min \left(dp[i][j-1], \ dp[i-1][j], \ dp[i-1][j-1] \right), \ s[i-1] \neq t[j-1] \\ dp[i-1][j-1], \ s[i-1] = t[j-1] \end{cases} dp[i][j]={min(dp[i][j−1], dp[i−1][j], dp[i−1][j−1]), s[i−1]=t[j−1]dp[i−1][j−1], s[i−1]=t[j−1]
(3)确定边界条件:
当两字符串都为空时,编辑步数为 000,即 dp[0][0]=0dp[0][0]=0dp[0][0]=0;
当 sss 为空而 ttt 不为空,最少的编辑次数为 ttt 的长度,即 dp[0][j]=jdp[0][j]=jdp[0][j]=j;
当 sss 为不空而 ttt 为空,最少的编辑次数为 sss 的长度,即 dp[i][0]=idp[i][0]=idp[i][0]=i。
复杂度:时间复杂度为 O(n×m)O(n \times m)O(n×m),空间复杂度为 O(n×m)O(n \times m)O(n×m)。
class Solution:def minDistance(self, s: str, t: str) -> int:nS, nT = len(s), len(t)dp = [[0]*(nT+1) for _ in range(nS+1)]for i in range(1, nS + 1):dp[i][0] = ifor j in range(1, nT + 1):dp[0][j] = jfor i in range(1, nS + 1):for j in range(1, nT + 1):if s[i-1] == t[j-1]:dp[i][j] = dp[i-1][j-1]else:dp[i][j] = min(dp[i-1][j-1], dp[i][j-1], dp[i-1][j]) + 1return dp[nS][nT]
空间优化的一维DP解法:
根据上面的分析,dp[i][j]dp[i][j]dp[i][j] 状态与上方 dp[i−1][j]dp[i-1][j]dp[i−1][j]、左方 dp[i][j−1]dp[i][j-1]dp[i][j−1] 以及左上角 dp[i−1][j−1]dp[i-1][j-1]dp[i−1][j−1] 相关。
若采用一维数组优化,在正序遍历过程中需注意 dp[i−1][j−1]dp[i-1][j-1]dp[i−1][j−1] 会被覆盖的问题。
因此,除初始化一维数组外,还需引入变量 oldoldold 存储左上角的 dp[j−1]dp[j-1]dp[j−1]。
class Solution:def minDistance(self, s: str, t: str) -> int:nT = len(t)dp = [0] * (nT + 1)for j in range(1, nT+1):dp[j] = jfor c in s:old = dp[0]dp[0] += 1 for j in range(1, nT+1): # 在更新dp[j]前存储其值,作为下一轮的 dp[i-1][j-1]tmp = dp[j] if c == t[j-1]:dp[j] = oldelse:dp[j] = min(dp[j], dp[j-1], old) + 1old = tmpreturn dp[nT]
参考:Hello 算法