动态规划解析
暴力穷举的另外一种算法就是动态规划了,一般有关动态规划的问题,大多是让你求最值的。
动态规划的框架为:
明确basecase -> 明确【状态】 -> 明确【选择】 -> 定义dp数组/函数的含义
在定义dp数组时不要陷入到二维数组、三维数组里,而是不同的状态,然后做不同的选择。
动态规划可以分为两种方式一种是自底向上一种是自顶向下:
# 自顶向下递归的动态规划
def dp(状态1, 状态2, ...):
for 选择 in 所有可能的选择:
# 此时的状态已经因为做了选择而改变
result = 求最值(result, dp(状态1, 状态2, ...))
return result
# 自底向上迭代的动态规划
# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ... dp[状态1][状态2][...] = 求最值(选择1,选择2...)
求解动态规划的核心问题是穷举,只有列出正确的“状态转移方程”,才能正确地穷举。另外,动态规划问题存在“重叠子问题”,如果暴力穷举的话效率会很低,所以需要使用“备忘录”或者“DP table”来优化穷举过程,避免不必要的计算。最后还可以通过状态压缩来进一步优化复杂度,比如把二维的DP table压缩成一维。
拿509. 斐波那契数举例:
暴力递归
如果使用暴力递归的话,代码如下:
func fib(_ n: Int) -> Int {
if n == 1 || n == 0 {
return n
}
return fib(n-1) + fib(n-2)
}
这样虽然可以解决问题,但会有大量的重复计算,如果想计算f(20)就得先计算出子问题f(19)和f(18),然后计算f(19)就要先计算出子问题f(18)和f(17),直到计算到f(1)和f(0),递归结束。
带“备忘录”自顶向下
明确了问题所在之后,可以优化掉耗时的重复计算,可以先把答案存储到“备忘录”中,遇到值了直接取出即可。这样就能避免不必要的耗时计算了,这种方式也是自顶向下递归的方式。
class Solution {
func fib(_ n: Int) -> Int {
// 备忘录全初始化为-1
var memo = Array(repeating: -1, count: n+1)
// 进行带备忘录的递归
return dfs(memo: &memo, n: n)
}
func dfs(memo: inout [Int], n: Int) -> Int {
// base case
if n == 0 || n == 1 {
return n
}
// 已经计算过,不用再计算了
if memo[n] != -1 {
return memo[n]
}
memo[n] = dfs(memo: &memo, n: n-1) + dfs(memo: &memo, n: n-2)
return memo[n]
}
}
自底向上
有了“备忘录”的方式,可以吧这个“备忘录”独立出来成一张表,通常叫做DP table。实际上带备忘录的递归解法中的“备忘录”,最终完成后就是这个DP table,只不过是反过来算而已。 数学公式为:
class Solution {
func fib(_ n: Int) -> Int {
// base case
if n == 0 || n == 1 {
return n
}
var dp: [Int] = Array(repeating: 0, count: n+1)
dp[0] = 0
dp[1] = 1
if n > 1 {
for i in 2...n {
dp[i] = dp[i-1] + dp[i-2]
}
}
return dp[n]
}
}
状态压缩
通过上面的状态转移方程可以发现,当前状态只和之前的两个状态有关,其实并不需要那么长的DP table来存储所有状态,只保存之前的两个状态即可。
class Solution {
func fib(_ n: Int) -> Int {
if n == 0 || n == 1 {
return n
}
var dp_i_1 = 1
var dp_i_2 = 0
for _ in 2...n {
let dp_i = dp_i_1 + dp_i_2
dp_i_2 = dp_i_1
dp_i_1 = dp_i
}
return dp_i_1
}
}
这样就将空间从一个DP数组压缩到了两个值。当然,这种方式是建立在DP table的基础上的,个人建议是如果自顶向下和自底向上两种方式掌握一种之后再去考虑如果进行状态压缩,一般能解答出这种就可以了。
经典动态规划
动态规划的基本思路就是这样,动态规划的题非常多,也是非常高频的题,下面就列一些经典的题。
经典动态规划
70. 爬楼梯、剑指Offer 47. 礼物的最大价值、62. 不同路径、64. 最小路径和、编辑距离、279. 完全平方数、931. 下降路径最小和、887. 鸡蛋掉落
子序列问题
516. 最长回文子序列、53. 最大子数组和、剑指Offer 42. 连续子数组的最大和、300. 最长递增子序列
最长公共子序列LCS
1143. 最长公共子序列、583. 两个字符串的删除操作、712. 两个字符串的最小ASCII删除和
背包问题
322. 零钱兑换、416. 分割等和子集、518. 零钱兑换 II
股票买卖问题
121. 买卖股票的最佳时机、122. 买卖股票的最佳时机 II、123. 买卖股票的最佳时机 III、188. 买卖股票的最佳时机 IV、309. 最佳买卖股票时机含冷冻期、714. 买卖股票的最佳时机含手续费