一、动态规划
动态规划的核心是:把原问题拆成若干更小的子问题,并利用这些子问题的答案推出原问题的答案。
分析一道动态规划题,通常都要先回答下面几个问题:
1.状态是什么
dp[i] 或 dp[i][j] 表示什么。
2.状态怎么转移
当前状态由哪些更小的状态推出来。
3.初始条件是什么
哪些最小规模的子问题可以直接得到答案。
4.遍历顺序是什么
先算谁,后算谁,才能保证转移时依赖项已经求出。
以 爬楼梯 这道题为例,每次可以爬 1 阶或 2 阶,问到第 n 阶有多少种方法。
设 dp[i] 表示到达第 i 阶的方法数。那么到第 i 阶,最后一步只有两种情况:
从第 i-1 阶走 1 步上来
从第 i-2 阶走 2 步上来
如果最后一步是从第 i-1 阶上来,前面部分就是到第 i-1 阶的方法数,到第 i-1 阶的每一种走法,后面再走 1 步,都能变成一种到第 i 阶的走法。同样的到第 i-2 阶的每一种走法,后面再走 2 步,也都能变成一种到第 i 阶的走法
所以有转移:
python
dp[i] = dp[i-1] + dp[i-2]
这就是动态规划的思路,把原问题可以拆成规模更小的同类子问题,并将子问题的答案保存下来重复使用,一步步推出答案。
二、从递归到DP
很多动态规划题,最自然的起点其实不是直接写 dp 数组,而是先写出递归。因为递归更贴近人的直觉:先想"当前问题怎么拆成更小的问题",再逐步优化。
这一过程通常可以分为三个阶段:
1.暴力递归:直接按照问题定义去拆分
2.记忆化搜索:在递归基础上加缓存,避免重复计算
3.自底向上的动态规划:把递归关系改写成循环,按顺序求解所有子问题
用同一道题来贯穿这三个阶段,例子:零钱兑换
给定一个整数数组 coins,表示不同面额的硬币;再给定一个整数 amount,表示总金额。每种硬币可以使用无限次,问凑成总金额 amount 所需的最少硬币个数。如果无法凑出,返回 -1。
2.1 暴力递归
如果要凑出金额 amount,那么第一步可以选择任意一种硬币。
假设当前选择了面额 coin,那么问题就变成:凑出 amount - coin 最少需要多少枚硬币。
它的递归关系就是:
python
dfs(rem) = min(dfs(rem - coin) + 1)
其中 coin 遍历所有硬币面额。
递归终止条件有两个:
如果 rem == 0,说明刚好凑出,返回 0
如果 rem < 0,说明当前选择无效,返回一个不合法值
代码如下:
python
class Solution(object):
def coinChange(self, coins, amount):
def dfs(rem):
if rem == 0:
return 0
if rem < 0:
return float('inf')
ans = float('inf')
for coin in coins:
ans = min(ans, dfs(rem - coin) + 1)
return ans
res = dfs(amount)
return -1 if res == float('inf') else res
它的问题是会重复计算大量相同的子问题。
例如在求 dfs(11) 时,可能会递归到 dfs(10)、dfs(9)、dfs(6);而在求 dfs(10) 时,又会继续递归到 dfs(9)、dfs(8)、dfs(5)。
这样 dfs(9)、dfs(8) 等子问题会被反复计算很多次。效率很低
2.2 记忆化搜索
既然递归中存在大量重复子问题,一个自然的优化就是:每个子问题只算一次,算完之后把结果存起来,后面直接复用。
只需要增加一个缓存结构,例如字典 memo,用来记录金额 rem 的答案已经算过了。如果下次再遇到 dfs(rem),就不用重新递归,直接返回缓存结果。
代码如下:
python
class Solution(object):
def coinChange(self, coins, amount):
memo = {}
def dfs(rem):
if rem == 0:
return 0
if rem < 0:
return float('inf')
if rem in memo:
return memo[rem]
ans = float('inf')
for coin in coins:
ans = min(ans, dfs(rem - coin) + 1)
memo[rem] = ans
return ans
res = dfs(amount)
return -1 if res == float('inf') else res
2.3 自底向上的动态规划
在记忆化搜索里,子问题的答案会被重复利用,每个状态只需要算一次。这时就可以进一步把递归改写成循环,也就是自底向上的动态规划。
状态定义:设 dp[i] 表示凑出金额 i 所需的最少硬币数。
枚举最后一枚使用的硬币 coin,前一步所需硬币数就是 dp[i - coin] + 1
得到转移:
python
dp[i] = min(dp[i], dp[i - coin] + 1)
初始值:
dp[0] = 0,因为凑出金额 0 不需要任何硬币
代码如下:
python
class Solution(object):
def coinChange(self, coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in coins:
if i - coin >= 0:
dp[i] = min(dp[i], dp[i - coin] + 1)
return -1 if dp[amount] == float('inf') else dp[amount]
记忆化搜索是从大问题出发,递归地去求更小的问题,需要时才计算某个状态;动态规划是从最小状态出发,按顺序把所有状态都填出来,最后直接得到答案
三、例子
3.1 leetCode 70. 爬楼梯

定义状态:
dp[i] 表示 爬到第 i 阶楼梯,一共有多少种不同的方法。
因为每次只能爬 1 阶或者 2 阶,所以到第 i 阶,最后一步只可能在i-1 阶或i-2阶
python
dp[i] = dp[i-1] + dp[i-2]
初始条件就是:
python
dp[1] = 1
dp[2] = 2
从小到大遍历,从 3 到 n。
代码为
python
class Solution(object):
def climbStairs(self, n):
"""
:type n: int
:rtype: int
"""
if n == 1:
return 1
if n == 2:
return 2
dp = [0] * (n + 1)
dp[1] = 1
dp[2] = 2
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
3.2 LeetCode 198. 打家劫舍

我们要求的是:从前若干间房子里,在不偷相邻房子的前提下,最多能偷多少钱。
定义:dp[i] 表示 偷前 i 间房屋时,能够偷到的最高金额。
现在最核心的问题是:当处理前 i 间房时,第 i 间房到底偷不偷?可以分两种情况
1. 不偷第 i 间房,最多能偷的就是前 i-1 间房的最优结果,即dp[i-1]
2. 偷第 i 间房,那第 i-1 间就绝对不能偷。最多是"前 i-2 间房"的最优结果,再加上第 i 间房的钱。dp[i-2] + nums[i-1]
那么,第 i 间房偷还是不偷,取更大的那个:
python
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
初始条件
python
dp[0] = 0
dp[1] = nums[0]
遍历顺序是从2 到 n
python
class Solution(object):
def rob(self, nums):
n = len(nums)
if n == 0:
return 0
if n == 1:
return nums[0]
dp = [0] * (n + 1)
dp[0] = 0
dp[1] = nums[0]
for i in range(2, n + 1):
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i - 1])
return dp[n]
3.3 LeetCode 300. 最长递增子序列

题目要找的是:最长严格递增子序列的长度
定义状态 dp[i] 表示以 nums[i] 这个元素结尾的最长严格递增子序列长度。
最后整个数组的最长递增子序列,不一定正好以最后一个元素结尾,所以最终答案是:
python
max(dp)
现在来想怎么求 dp[i],也就是以 nums[i] 结尾 的最长递增子序列长度是多少?
既然要以 nums[i] 结尾,那它前面那个元素一定来自某个位置 j,满足:
j < i,因为前面的元素才能接到后面
nums[j] < nums[i],因为要严格递增
所以:
如果 nums[j] < nums[i],那么dp[i] = dp[j] + 1
但前面的 j 可能有很多个,我们当然要选能让长度最大的那个。
python
dp[i] = max(dp[i], dp[j] + 1)
初始条件是:dp[i] = 1,默认每个位置都至少能单独成一个长度为 1 的序列。
遍历顺序
外层:i 从 0 到 n-1
内层:j 从 0 到 i-1
python
class Solution(object):
def lengthOfLIS(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
n = len(nums)
dp = [1] * n
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
3.4 LeetCode 152. 乘积最大子数组

我们要求的是:以当前位置结尾的连续子数组,乘积最大是多少。但这题只记一个最大值不够,因为负数会把最小值翻成最大值。
所以要定义两个状态:
max_dp[i] 表示 以 nums[i] 结尾的连续子数组中的最大乘积
min_dp[i] 表示 以 nums[i] 结尾的连续子数组中的最小乘积
假设当前数是:nums[i],以它结尾的连续子数组,只有三种可能:
第一种,只选它自己 即从当前位置重新开始,乘积就是:nums[i]
第二种,把它接到前一个"最大乘积子数组"后面,乘积是:max_dp[i-1] * nums[i]
第三种,把它接到前一个"最小乘积子数组"后面,乘积是:min_dp[i-1] * nums[i]
因此:
python
max_dp[i] = max(nums[i], max_dp[i-1] * nums[i], min_dp[i-1] * nums[i])
min_dp[i] = min(nums[i], max_dp[i-1] * nums[i], min_dp[i-1] * nums[i])
当 i = 0 时,只有一个数。以 nums[0] 结尾的连续子数组,只能是它自己。
python
max_dp[0] = nums[0]
min_dp[0] = nums[0]
代码为:
python
class Solution(object):
def maxProduct(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
n = len(nums)
max_dp = [0] * n
min_dp = [0] * n
max_dp[0] = nums[0]
min_dp[0] = nums[0]
ans = nums[0]
for i in range(1, n):
max_dp[i] = max(nums[i], max_dp[i - 1] * nums[i], min_dp[i - 1] * nums[i])
min_dp[i] = min(nums[i], max_dp[i - 1] * nums[i], min_dp[i - 1] * nums[i])
ans = max(ans, max_dp[i])
return ans
3.5 LeetCode 72. 编辑距离

定义状态
dp[i][j] 表示把 word1 的前 i 个字符,变成 word2 的前 j 个字符,最少需要多少步。
这种定义下的初始值,0表示空字符串,则有
python
dp[0][0] = 0
dp[i][0] = i
dp[0][j] = j
把 word1[:i] 变成 word2[:j] 要看最后一个字符:word1[i - 1]、word2[j - 1]
如果最后一个字符相同
python
word1[i - 1] == word2[j - 1]
问题就变成:把前面的部分 word1[:i-1] 变成 word2[:j-1]
python
dp[i][j] = dp[i - 1][j - 1]
如果最后一个字符不同
那就要操作一次,有三种操作
1. 删除 word1[i - 1]
相当于先把 word1[:i-1] 变成 word2[:j],再加一步删除操作
python
dp[i][j] = dp[i - 1][j] + 1
2. 插入word2[j-1]
先把 word1[:i] 变成 word2[:j-1],最后插入
python
dp[i][j] = dp[i][j - 1] + 1
3. 把 word1[i - 1] 直接替换成 word2[j - 1]
python
dp[i][j] = dp[i - 1][j - 1] + 1
取三种操作中最小的,完整的转移方程为
python
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(
dp[i - 1][j], # 删除
dp[i][j - 1], # 插入
dp[i - 1][j - 1] # 替换
) + 1
完整代码
python
class Solution(object):
def minDistance(self, word1, word2):
"""
:type word1: str
:type word2: str
:rtype: int
"""
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
return dp[m][n]
