8.动态规划
1. 理论基础¶
动态规划的每一个状态都是由上一个状态推导出来的
动态规划解题五部曲:
- 确定 dp 数组以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组(打印dp数组)
动态规划 debug:
将 dp 数组打印出来,看看是不是按照自己的思路推导的
自己先思考这三个问题:
- 这道题目我举例推导状态转移公式了么?
- 我打印dp数组的日志了么?
- 打印出来了dp数组和我想的一样么?
2. 斐波那契数列¶
来源:LeetCode 509
动态规划五部曲¶
-
确定 \(dp\) 数组 及其 下标的含义 \(dp[i]\) : 第 \(i\) 个斐波那契数列的数值是 \(dp[i]\)
-
确定递推公式 状态转移方程:\(dp[i] = dp[i-1] + dp[i-2]\)
-
\(dp\) 数组如何初始化 \(dp[0] = 0, dp[1] = 1\)
-
确定遍历顺序 从递归公式 \(dp[i] = dp[i - 1] + dp[i - 2]\) 中可以看出,\(dp[i]\) 是依赖 \(dp[i - 1]\) 和 \(dp[i - 2]\),因此遍历的顺序一定是从前到后遍历
-
举例推导 \(dp\) 数组 按照递推公式 \(dp[i] = dp[i-1] + dp[i-2]\) ,来推导一下,当 \(N=10\) 的时候,\(dp\) 数组应该是如下的数列: $$ 0 1 1 2 3 5 8 13 21 34 55 $$ 如果代码写出来发现结果不对,就把 \(dp\) 数组打印出来看看和推导的数列是不是一致的。
代码¶
class Solution {
public int fib(int n) {
if (n < 2) return n;
// 确定 dp 数组
int dp[] = new int[n+1];
// 初始化 dp 数组
dp[0] = 0;
dp[1] = 1;
for (int i=2; i<=n; i++){
// 状态转移方程
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
}
3. 爬楼梯¶
来源:LeetCode 70
思路¶
爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。
那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。
所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。
动态规划五部曲¶
-
确定 \(dp\) 数组以及下标的含义 \(dp[i]\) : 爬到第 \(i\) 层楼梯,有 \(dp[i]\) 种方法
-
确定递推公式 从 \(dp[i]\) 的定义可以看出,\(dp[i]\) 可以有两个方向推出来。
首先是 \(dp[i-1]\) ,上 \(i-1\) 层楼梯,有 \(dp[i-1]\) 种方法,那么再一步跳一个台阶不就是 \(dp[i]\) 了。 其次是 \(dp[i - 2]\) ,上 \(i-2\) 层楼梯,有 \(dp[i - 2]\) 种方法,那么再一步跳两个台阶不就是 \(dp[i]\) 了。
那么 \(dp[i]\) 就是 \(dp[i-1]\) 与 \(dp[i - 2]\) 之和!
所以 \(dp[i] = dp[i - 1] + dp[i - 2]\) 。
-
\(dp\) 数组如何初始化 \(dp[1] = 1\) : 爬到第一层有一种方式 \(dp[2] = 2\) : 爬到第二层有两种方式
-
确定遍历顺序 从递推公式 \(dp[i] = dp[i - 1] + dp[i - 2]\) 中可以看出,遍历顺序一定是从前向后遍历的
-
举例推导 \(dp\) 数组 当 \(n=5\) ,\(dp\) 数组:
代码实现¶
class Solution {
public int climbStairs(int n) {
if (n < 3) return n;
// 定义dp数组
int dp[] = new int[n+1];
// 初始化dp数组
dp[1] = 1;
dp[2] = 2;
// 递推公式
for (int i=3; i<=n; i++){
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
}
4. 使用最小花费品爬楼梯¶
来源:LeetCode 746
题目重点: “你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯” 也就是相当于 跳到 下标 0 或者 下标 1 是不花费体力的, 从 下标 0 下标1 开始跳就要花费体力了。
动态规划五部曲¶
-
确定 \(dp\) 数组以及下标的含义 使用动态规划,就要有一个数组来记录状态,本题只需要一个一维数组 \(dp[i]\) 就可以了。
\(dp[i]\) 的定义:到达第 \(i\) 台阶所花费的最少体力为 \(dp[i]\)
-
确定递归公式 可以有两个途径得到 \(dp[i]\) ,一个是 \(dp[i-1]\), 一个是 \(dp[i-2]\) (即可以从下标为 \(i-1\) 的台阶爬一个台阶到 第 \(i\) 台阶, 也可以从下标为 \(i-2\) 的台阶跳两个台阶到 第 \(i\) 台阶):
\(dp[i - 1]\) 跳到 \(dp[i]\) 需要花费 \(dp[i - 1] + cost[i - 1]\)。 \(dp[i - 2]\) 跳到 \(dp[i]\) 需要花费 \(dp[i - 2] + cost[i - 2]\)。
那么究竟是选从 \(dp[i - 1]\) 跳还是从 \(dp[i - 2]\) 跳呢? 一定是选最小的,所以: $$ dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]) $$ 3. \(dp\) 数组如何初始化 根据递推公式,\(dp[i]\) 由 \(dp[i - 1], dp[i - 2]\) 推出,既然初始化所有的 \(dp[i]\) 是不可能的,那么只初始化 \(dp[0]\) 和 \(dp[1]\) 就够了,其他的最终都是 \(dp[0]\) 和 \(dp[1]\) 推出。
根据 \(dp\) 数组的定义,到达第0台阶所花费的最小体力为 \(dp[0]\),到达第1台阶所花费的最小体力是 \(dp[1]\) ; 根据题目描述 “你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。” 也就是说到达第 0 个台阶是不花费体力的,但从 第0 个台阶 往上跳的话,需要花费 \(cost[0]\) ;
因此初始化 \(dp[0]=0, dp[1]=0\)
-
确定遍历顺序 因为是模拟台阶,而且 \(dp[i]\) 由 \(dp[i - 1], dp[i - 2]\) 推出,所以是从前到后遍历cost数组就可以了。
-
举例推导 \(dp\) 数组 根据示例2:\(cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]\) 来模拟 \(dp\) 数组的状态变化,如下:
代码实现¶
class Solution {
public int minCostClimbingStairs(int[] cost) {
if (cost.length == 0) return 0;
// 确定dp数组及下标含义
int dp[] = new int[cost.length+1]
// 初始化 dp 数组
dp[0] = 0;
dp[1] = 0;
// 状态转移方程
for (int i=2; i<dp.length; i++){
dp[i] = Math.min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]);
}
return dp[dp.length-1];
}
}
5. 不同路径¶
来源:LeetCode 62
动态规划五部曲¶
-
确定 \(dp\) 数组和下标含义 \(dp[i][j]\) : 二维数组,表示从 \([0, 0]\) 到 \([i, j]\) 的路径数目
-
确定递推公式 因为只能向右或向下走,所以 \([i, j]\) 可以由 \([i-1, j]\) 向右走一步得到, 也可以由 \([i, j-1]\) 向下走一步得到,因此: $$ dp[i][j] = dp[i-1][j] + dp[i][j-1] $$
-
\(dp\) 数组如何初始化 需要初始化最最上层一行和最左边一列: $$ dp[0][j] = 1, dp[i][0] = 1 $$
-
确定遍历顺序 根据递推公式 \(dp[i][j] = dp[i-1][j] + dp[i][j-1]\) , \(dp[i][j]\) 是从上方和左方推导得到的,因此双层遍历,从左到右,从上到下,从而保证在推导 \(dp[i][j]\) 的时候, \(dp[i-1][j]\) 和 \(dp[i][j-1]\) 是有值的
-
举例推导 \(dp\) 数组
代码实现¶
class Solution {
public int uniquePaths(int m, int n) {
// 确定dp数组和下标含义
int dp[][] = new int[m][n];
// 初始化dp数组
// 最上面一层
for (int j=0; j<n; j++) dp[0][j] = 1;
// 最左边一列
for (int i=0; i<m; i++) dp[i][0] = 1;
// 状态转移方程
for (int i=1; i<m; i++){
for (int j=1; j<n; j++){
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1];
}
}
6. 不同路径2¶
来源:LeetCode 63
动态规划五部曲¶
-
确定 \(dp\) 数组和下标含义 \(dp[i][j]\) : 二维数组,表示从 \([0, 0]\) 到 \([i, j]\) 的路径数目
-
确定递推公式 因为只能向右或向下走,所以 \([i, j]\) 可以由 \([i-1, j]\) 向右走一步得到, 也可以由 \([i, j-1]\) 向下走一步得到,因此: $$ dp[i][j] = dp[i-1][j] + dp[i][j-1] $$ 但是可能 \([i, j]\) 的位置是障碍物,那么就永远无法从 \([0,0]\) 到 \([i, j]\), 对应的路径数目也就是0,因此此时:
if (obstacleGrid[i][j] == 1) dp[i][j] = 0;
-
\(dp\) 数组如何初始化 需要初始化最最上层一行和最左边一列: $$ dp[0][j] = 1, dp[i][0] = 1 $$ 但是如果在最上面一行或者最左边一列中遇到障碍物, 则对于障碍物及其之后的位置都无法到达, 因此路径数目是0,因此初始化代码如下:
for (int j=0; j<n; j++) { if (obstacleGrid[0][j] == 1) { dp[0][j] = 0; break; } else dp[0][j] = 1; } for (int i=0; i<m; i++) { if (obstacleGrid[i][0] == 1) { dp[i][0] = 0; break; } else dp[i][0] = 1; }
-
确定遍历顺序 根据递推公式 \(dp[i][j] = dp[i-1][j] + dp[i][j-1]\) , \(dp[i][j]\) 是从上方和左方推导得到的,因此双层遍历,从左到右,从上到下,从而保证在推导 \(dp[i][j]\) 的时候, \(dp[i-1][j]\) 和 \(dp[i][j-1]\) 是有值的
-
举例推导 \(dp\) 数组 对于题目中的示例1,\(dp\) 数组如下:
代码实现¶
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
// 确定dp数组
int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
int dp[][] = new int[m][n];
// 初始化dp数组
// 如果在最上面一行或者最左边一列中遇到障碍物, 则对于障碍物及其之后的位置都无法到达, 因此路径数目是0
for (int j=0; j<n; j++) {
if (obstacleGrid[0][j] == 1) {
dp[0][j] = 0;
break;
}
else dp[0][j] = 1;
}
for (int i=0; i<m; i++) {
if (obstacleGrid[i][0] == 1) {
dp[i][0] = 0;
break;
}
else dp[i][0] = 1;
}
// 状态转移方程
for (int i=1; i<m; i++){
for (int j=1; j<n; j++){
// 如果是障碍物, 则无法到达该位置, 路径数目是0
if (obstacleGrid[i][j] == 1) dp[i][j] = 0;
else dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1];
}
}
7. 整数拆分¶
来源:LeetCode 343
动态规划五部曲¶
1. 确定 \(dp\) 数据以及下标的含义
\(dp[i]\) : 分拆数字 \(i\) , 可以得到的最大乘积为 \(dp[i]\)
这个定义非常重要,说明至少会将 \(i\) 拆成两个整数
2. 确定递推公式
如何得到 \(dp[i]\) ?
首先可以将 \(i\) 拆成两部分,一部分是 \(j\), 另一部分就是 \(i - j\) , 然后从 \(1\) 开始遍历,即使得 \(1<=j<i\) (这个时候是将 \(i\) 拆分成了两个整数), 此时:\(dp[i] = j * (i-j)\) .
其次可以将 \(i\) 拆分成 3个甚至更多的整数,这个时候 \(j\) 不变,仍然是从 \(1\) 开始遍历(\(1<=j<i\)), 将 \(i-j\) 进行进一步拆分,那么对 \(i-j\) 拆分得到的最大乘积为 \(dp[i-j]\) , 因此此时: \(dp[i] = j * dp[i-j]\)
补充:什为么不对 \(j\) 进行进一步拆分? (1)\(j\) 是从 1 开始遍历,拆分 \(j\) 的情况,在遍历j的过程中其实都计算过了 (2) \(j * (i - j)\) 是单纯的把整数拆分为两个数相乘,而 \(j * dp[i - j]\)是拆分成两个以及两个以上的个数相乘,如果定义\(dp[i - j] * dp[j]\) 默认将一个数强制拆成4份以及4份以上了
综上,递推公式为: $$ dp[i]= max(j * (i-j), j * dp[i-j], dp[i]) $$
3. 如何对 \(dp\) 数组进行初始化?
\(dp[0],\ dp[1]\) 应该初始化多少呢?
严格从 \(dp[i]\) 的定义来说,\(dp[0],\ dp[1]\) 就不应该初始化,也就是没有意义的数值。 拆分0和拆分1的最大乘积是多少?这是无解的。
因此从 \(dp[2]\) 开始初始化: $$ dp[2] = 1 $$
4. 确定遍历顺序
根据递归公式:\(dp[i]= max(j * (i-j), j * dp[i-j], dp[i])\) ,
\(dp[i]\) 是依靠 \(dp[i - j]\) 的状态,所以遍历一定是从前向后遍历,先有 \(dp[i - j]\) 再有 \(dp[i]\) 。
5. 举例推导dp数组
当 \(n=10\), \(dp\) 数组如下:
代码实现¶
class Solution {
public int integerBreak(int n) {
// 确定dp数组
int dp[] = new int[n+1];
// 初始化dp数组
dp[2] = 1;
// 状态转移方程
for (int i=3; i<=n; i++)
for (int j=1; j<i; j++){
// j从1开始, 从0开始的话,那么拆分一个数拆出个0,求最大乘积就没有意义了
dp[i] = Math.max(dp[i], Math.max(j * (i-j), j * dp[i-j]));
}
return dp[n];
}
}
8. 不同的二叉搜索树¶
思路¶
先尝试画图寻找规律
\(n\) 为 \(1\) 的时候有一棵树,\(n\) 为 \(2\) 有两棵树,这个是很直观的。
\(n\) 为 \(0\) 的时候是空二叉树,也可以算是一棵二叉搜索树
当 \(1\) 为头结点的时候,其右子树有两个节点,这两个节点的布局 和 \(n\) 为 \(2\) 的时候两棵树的布局是一样的!
虽然节点数值不一样,但是我们是求不同树的数量,并不用把搜索树都列出来,所以不用关心其具体数值的差异
当 \(3\) 为头结点的时候,其左子树有两个节点,这两个节点的布局 和 \(n\) 为 \(2\) 的时候两棵树的布局也是一样的!
当2为头结点的时候,其左右子树都只有一个节点,布局 和 \(n\) 为 \(1\) 的时候只有一棵树的布局也是一样的!
发现到这里,就找到了重叠子问题了,其实也就是发现 可以通过 \(dp[1]\) 和 \(dp[2]\) 来推导出来 \(dp[3]\) 的某种方式。
思考到这里,这道题目就有眉目了!!!
\(dp[3]\) 即是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量 元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量 元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
有2个元素的搜索树数量就是 \(dp[2]\) 有1个元素的搜索树数量就是 \(dp[1]\) 有0个元素的搜索树数量就是 \(dp[0]\) 所以: \(dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]\)
动态规划五部曲¶
1. \(dp\) 数组以及下标的含义
\(dp[i]\) : 以数值 1 到 \(i\) 为节点组成的二叉搜索树的个数为 \(dp[i]\), 也可以理解成 \(i\) 个不同元素组成的二叉搜索树的二叔
2. 确定 递归公式
\(dp[i]\) : 元素 \(1\) 为头节点的二叉搜索树的数量 + 元素 \(2\) 为头节点的二叉搜索树的数量 + ...... + 元素 \(i\) 为头节点的二叉搜索树的数量
其中如果元素 \(j\) 为头节点, 根据二叉搜索树的定义,则左子树有 \(j-1\) 个节点,右子树有 \(i-j\) 个节点,那么元素 \(j\) 为头节点的二叉搜索树的数量 \(dp[j] = dp[j-1] ** dp[i-j]\)
\(dp[i]\) 一开始为 0, 从 1 到 \(i\) 对 \(j\) 进行遍历,那么递推公式为: $$ dp[i] = dp[0]dp[i-1] + dp[1]dp[i-2] + ... + dp[i-1]*dp[0] $$ 进行简化:
for (int j=1; j<=i; j++){
dp[i] = dp[i] + dp[j-1] * dp[i-j]
}
3. 初始化 \(dp\) 数组
只需要初始化 \(dp[0]\), \(0\) 个节点的二叉搜索树数量,空节点也是二叉搜索树,因此: $$ dp[0] = 1 $$
4. 确定遍历顺序
首先一定是遍历节点数,从递归公式:\(dp[i] = dp[i] + dp[j - 1] * dp[i - j]\) 可以看出,节点数为 \(i\) 的状态是依靠 \(i\) 之前节点数的状态,因此从后向前进行遍历。
for (int i=1; i<=n; i++){
// 以 1到i分别为头节点
for (int j=1; j<=i; j++){
dp[i] += dp[j-1] * dp[i-j];
}
}
5. 举例推导 \(dp\) 数组
\(n=5\), \(dp\) 数组如下:
代码实现¶
class Solution {
public int numTrees(int n) {
// 确定dp数组
int dp[] = new int[n+1];
// 初始化dp数组
dp[0]=1;
// 状态转移方程
// 从前向后进行遍历
for (int i=1; i<=n; i++)
// 以 1到i分别为头节点
for (int j=1; j<=i; j++){
dp[i] += dp[j-1] * dp[i-j];
}
return dp[n];
}
}