算法框架系列之动态规划
1. 动态规划基础知识
-
动态规划问题的⼀般形式就是求最值 ,⽐如说让你求最⻓递增⼦序列、最⼩编辑距离等等
-
动态规划三要素:重叠⼦问题、 最优⼦结构、 状态转移⽅程
问题:你怎么知道这个问题是个动态规划问题呢,你怎么知道它就存在「重叠⼦问题」 呢?(以编辑距离为例)
1.先抽象出算法框架:
def dp(i, j): # 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
dp(i - 1, j - 1) #1
dp(i, j - 1) #2
dp(i - 1, j) #3
2.提出类似的问题 : 请问如何从原问题 dp(i, j) 触达⼦问题 dp(i - 1, j - 1) ?
⾄少有两种路径
- #1 : 即 dp(i , j ) => dp(i -1, j -1),
- #2 => #3 : 即 dp(i, j) => dp(i , j - 1) -> dp(i - 1, j - 1)。
一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。
因此, 本问题一定存在重叠⼦问题, 一定需要动态规划的优化技巧来处理。
2. 求解动态规划问题的思路:
- 核⼼问题 :穷举(这类问题存在「重叠⼦问题」==> 需要「备忘录」 或者「DP table」来优化穷举过程, 避免不必要的计算 )。
- 动态规划问题⼀定会具备「最优⼦结构」 , 才能通过⼦问题的最值得到原问题的最值
- 思考状态转移⽅程: 明确「状态」 -> 定义 dp(也即helper) 数组/函数的含义 -> 明确「选择」 -> 明确 base case。
动态规划三点:状态,选择,dp 数组的定义。最后的代码就可以套这个框架:
# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 求最值(选择1,选择2...)
A. 备忘录
备忘录的作用:剪枝
针对于'自顶向下',备忘录的作用:记录dp 1,2,3...的值
String key = i+","+j;
//备忘录就是一个HashMap,因为其存取复杂度都是O(1)
HashMap<String, Boolean> memo = new HashMap<>();
memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
1.'自顶向下':计算 f(20) --> 先计算出⼦问题 f(19) 和 f(18) --> 计算 f(19) --> f(18)和 f(17) 实际上先求的还是f(1)、f(2),
故而需要'memo记录dp函数值'
1) memo接收dp返回值,最后return memo
2) 情况分支太多,我们可以使用res接受结果值,在return res之前put进memo
-----------------------------------------
带有备忘录的自顶向下递归求解:
情形一:
def minDistance(s1, s2) -> int:
memo = dict() # 备忘录
def dp(i, j):
if (i, j) in memo:
return memo[(i, j)]
...
if s1[i] == s2[j]:
memo[(i, j)] = ...
else:
memo[(i, j)] = ...
return memo[(i, j)]
return dp(len(s1) - 1, len(s2) - 1)
-----------------------------------------
情形二:
boolean dp(String s,String p ,int i , int j,HashMap<String, Boolean> memo){
String key = i+","+j;
if (memo.containsKey(key)){
return memo.get(key);
}
boolean res = false;
//相同时
if (){
if (){
res = dp...
}else {
res = dp...
}
}else {
if (){
res = dp...
}else {
res = dp...
}
}
memo.put(key,res);
return res;
2.自底向上:对于自底向上的情况'不需要新建备忘录',因为'dp本身就是备忘录'
int minDistance(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1];
// base case
for (int i = 1; i <= m; i++)
dp[i][0] = i;
for (int j = 1; j <= n; j++)
dp[0][j] = j;
// 自底向上求解
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++)
if (s1.charAt(i-1) == s2.charAt(j-1))
dp[i][j] = dp[i - 1][j - 1];
else
dp[i][j] = min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i-1][j-1] + 1
);
// 储存着整个 s1 和 s2 的最小编辑距离
return dp[m][n];
}
B. dp函数(数组)
/'dp函数(数组)定义:'/
1、第一种思路模板是一个'一维的 dp 数组':
/-------------------------------------------------------------
int n = array.length;
int[] dp = new int[n];
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
dp[i] = 最值(dp[i], dp[j] + ...)
}
}
-------------------------------------------------------------/
举个我们写过的例子'最长递增子序列',在这个思路中 dp 数组的定义是:
在子数组'array[0..i]'中,以'array[i]'结尾的'目标子序列(最长递增子序列)'的长度是'dp[i]'。
为啥最长递增子序列需要这种思路呢?因为'这样符合归纳法',可以找到状态转移的关系。
2、第二种思路模板是一个'二维的 dp 数组':
/-------------------------------------------------------------
int n = arr.length;
int[][] dp = new dp[n][n];
for (int i = 0; i < n; i++) {
for (int j = 1; j < n; j++) {
if (arr[i] == arr[j])
dp[i][j] = dp[i][j] + ...
else
dp[i][j] = 最值(...)
}
}
-------------------------------------------------------------/
这种思路运用相对更多一些,尤其是涉及两个字符串/数组的子序列。本思路中 dp 数组含义又分为'「只涉及一个字符串」和「涉及两个字符串」'两种情况。
2.1 '涉及两个字符串/数组时'特点:两个数组对比(比如最长公共子序列/编辑距离),
dp 数组的含义如下:
在子数组'「arr1[0..i]和子数组arr2[0..j]」'中,我们要求的子序列(最长公共子序列)长度为'dp[i][j]'
2.2 '只涉及一个字符串/数组时'(比如最长回文子序列/气球问题/),
dp 数组的含义如下:
在子数组'「array[i..j]」'中,我们要求的子序列(最长回文子序列)长度为'dp[i][j]'
涉及'两个状态参数'的问题:
1.高楼扔蛋: '最坏情况'下,你'至少要扔几次鸡蛋',才能确定这个楼层F呢(高于F的楼层都会碎,低于F的楼层都不会碎)
参数: 当前拥有的鸡蛋数K和需要测试的楼层数N
dp(K, N): #当前状态为(K 个鸡蛋,N 层楼),返回这个状态下的最优结果
base case: if K == 1: return N if N == 0: return 0
res = min( res,
max(
dp(K - 1, i - 1), # 碎
dp(K, N - i) # 没碎
) + 1 # '在第 i 楼扔了一次'
)
2.背包问题: 1) 0-1背包 2) 子集背包 3)完全背包
2.1 0-1背包:
这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。
'dp[i][w]的定义'如下:对于前i个物品,当前背包的容量为w,这种情况下可以装的最大价值是dp[i][w]
0-1背包框架:
int dp[N+1][W+1]
dp[0][..] = 0
dp[..][0] = 0
for i in [1..N]:
for w in [1..W]:
dp[i][w] = max(
把物品 i 装进背包,
不把物品 i 装进背包
)
return dp[N][W]
2.2子集背包:
给一个'可装载重量为sum/2的背包和N个物品',每个物品的重量为nums[i]。现在让你装物品,是否存在一种装法,能够恰好将背包装满?
dp[i][j] = x表示,对于前i个物品,当前背包的容量为j时,若x为true,则说明可以恰好将背包装满,若x为false,则说明不能恰好将背包装满。想求的最终答案就是dp[N][sum/2]
由于i是从 1 开始的,而数组索引是从 0 开始的,所以第i个物品的重量应该是nums[i-1].
代码框架:
int target = sum / 2;
int n = nums.length;
// 创建二维状态数组,行:物品索引,列:容量(包括 0)
boolean[][] dp = new boolean[n][target + 1];
//base case:
//如果不选取任何正整数,则被选取的正整数等于 0
for (int i = 0; i < n; i++) {
dp[i][0] = true;
}
//当 i==0 时,只有一个正整数nums[0]可以被选取
dp[0][nums[0]] = true;
for (int i = 1; i < n; i++) {
int num = nums[i];
for (int j = 1; j <= target; j++) {
if (j >= num) { //容量够用,可以选也可以不选
dp[i][j] = dp[i - 1][j] | dp[i - 1][j - num];
} else { //容量不够,不选 j不变
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[n - 1][target];//n个商品 --> 0...n-1
2.3 完全背包 '物品可以重复使用'
若只使用前i个物品,当背包容量为j时,有dp[i][j]种方法可以装满背包。
若只使用coins中的前i个硬币的面值,若想凑出金额j,有dp[i][j]种凑法。
'代码框架:
int dp[N+1][amount+1]
dp[0][..] = 0
dp[..][0] = 1
for i in [1..N]:
for j in [1..amount]:
把物品 i 装进背包,
不把物品 i 装进背包
return dp[N][amount]
3.气球问题
dp[i][j] = x : 戳破'气球i和气球j之间'(开区间,不包括i和j)的所有气球,可以'获得的最高分数为x'。
dp[i][j] = dp[i][k] + dp[k][j] + points[i]*points[k]*points[j]
4.取钱问题:
dp(int[] nums, int start) : 返回 nums[start..] 能抢到的最大值,'从start家开始之后可取到的钱'
'代码框架:
private int dp(int[] nums, int start) {
if (start >= nums.length) {
return 0;
}
int res = Math.max(
// 不抢,去下家
dp(nums, start + 1),
// 抢,去下下家
nums[start] + dp(nums, start + 2)
);
return res;
}
4. 遍历方式
从下往上遍历:
// i 应该从下往上
for (int i = n; i >= 0; i--) {
// j 应该从左往右
for (int j = i + 1; j < n + 2; j++) {
三种遍历方式代码:
- 正向遍历:
int[][] dp = new int[m][n];
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
// 计算 dp[i][j]
- 反向遍历:
for (int i = m - 1; i >= 0; i--)
for (int j = n - 1; j >= 0; j--)
// 计算 dp[i][j]
- 斜向遍历:
for (int l = 2; l <= n; l++) {
for (int i = 0; i <= n - l; i++) {
int j = l + i - 1;
// 计算 dp[i][j]
}
}