一、基本理论
什么是动态规划
动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。
所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,
动态规划的解题步骤
对于动态规划问题,拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式(状态转移方程)
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
为什么要先确定递推公式,然后在考虑初始化呢?
因为一些情况是递推公式决定了dp数组要如何初始化!
动态规划应该如何debug
找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的。
做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果。
然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。
如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。
如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。
这样才是一个完整的思考过程。
二、习题
用这道简单题目加深对动态规划五部曲的理解。
动态规划
动规五部曲:
这里我们要用一个一维dp数组来保存递归的结果
1.确定dp数组及下标含义
dp[i]的定义为:第i个数的斐波那契数值是dp[i]
2.确定递推公式
dp[i] = dp[i-1] + dp[i-2]
3.确定dp数组如何初始化
dp[0] = 0;
dp[1] = 1;
4.确定遍历顺序
从递推公式:dp[i] = dp[i-1] + dp[i-2] 可以看出,后面的依赖于前面的,所以要从前往后。
5.举例推到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==0) return 0;
int[] dp = new int[n+1];
dp[0] = 0;
dp[1] = 1;
for(int i = 2; i <= n ; i++){
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
}
也可以滚动维护两个数:
class Solution {
public int fib(int n) {
if(n== 0) return 0;
int[] dp = new int[2];
dp[0] = 0;
dp[1] = 1;
int sum = 0;
for(int i = 2; i <= n ; i++){
sum = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = sum;
}
return dp[1];
}
}
递归解法
本题还可以使用递归解法来做
代码如下:
class Solution {
public:
int fib(int N) {
if (N < 2) return N;
return fib(N - 1) + fib(N - 2);
}
};
2.70. 爬楼梯
思路
爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。
那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。
所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。
爬到第n层楼梯有两种可能:从第n-1层跨一步过来,或者 从第n-2层跨两步过来,那么第n层楼梯的爬法数应当为第n-1层爬法数+第n-2层爬法数。
动规五部曲:
定义一个一维数组来记录不同楼层的状态
1.dp数组及下标含义:
dp[i]表示爬到第i层可以的方法。
2.确定递推公式:
根据上面思路中的推导,可以知道 dp[i] = dp[i-1] + dp[i-2]
在推导dp[i]的时候,一定要时刻想着dp[i]的定义,否则容易跑偏。
3.dp数组初始化:
dp[1] = 1;
dp[2] = 2;
为了统一,dp[0] = 1;(题目中说n!=0,也可以完全不考虑 dp[0]初始化)
4.遍历顺序:
根据递推公式,后面依赖前面,所以从前往后遍历
5.举例推导dp数组
当n为5的时候,dp table(dp数组)应该是这样的
代码如下:
class Solution {
public int climbStairs(int n) {
if(n==1) return 1;
if(n==2) return 2;
int[] dp = new int[n+1];
dp[1] = 1;
dp[2] = 2;
for(int i = 3; i <= n ; i++){
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
}
优化空间复杂度:
class Solution {
public int climbStairs(int n) {
if(n==1) return 1;
if(n==2) return 2;
int[] dp = new int[2];
dp[0] = 1;
dp[1] = 2;
int sum = 0;
for(int i = 3; i <= n ; i++){
sum = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = sum;
}
return dp[1];
}
}
这是一个最优化问题,首先考虑贪心算法,经过简单的 1,100,200举例可知,如果采取贪心策略总选择局部最小的,那么不能达到全局最优。
考虑动态规划。
1.确定dp数组及下标含义
dp[i]:到达i层,所需要的花费。
2.确定递推公式:
可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]。
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] = Math.min(dp[i-1] + cost[i-1] , dp[i-2] + cost[i-2])
3.dp数组初始化
dp[0] = 0;
dp[1] = 0;
4.确定遍历顺序:
根据递推公式,可以知道应当从前往后比遍历
5.举例递推数组
拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:
注意本题要到达的时楼梯顶部,而不是最后一个台阶,要返回 dp[cost.length], dp长度应为 cost.length+1;
class Solution {
public int minCostClimbingStairs(int[] cost) {
int[] dp = new int[cost.length+1];
dp[0] = 0;
dp[1] = 0;
for(int i = 2 ; i <= cost.length ; i++){
dp[i] = Math.min(dp[i-1] + cost[i-1] , dp[i-2] + cost[i-2]);
}
return dp[cost.length];
}
}
空间优化:
class Solution {
public int minCostClimbingStairs(int[] cost) {
int dp0 = 0 , dp1 = 0;
int next = 0;
for(int i = 2 ; i <= cost.length ; i++){
next = Math.min(dp0 + cost[i-2], dp1 + cost[i-1]);
dp0 = dp1;
dp1 = next;
}
return dp1;
}
}
小结
如果代码写出来了,一直AC不了,灵魂三问:
- 这道题目我举例推导状态转移公式了么?
- 我打印dp数组的日志了么?
- 打印出来了dp数组和我想的一样么?
4.62.不同路径
思路
深搜
这道题目,刚一看最直观的想法就是用图论里的深搜,来枚举出来有多少种路径。
注意题目中说机器人每次只能向下或者向右移动一步,那么其实机器人走过的路径可以抽象为一棵二叉树,而叶子节点就是终点!
如图举例:
此时问题就可以转化为求二叉树叶子节点的个数,代码如下:
class Solution {
private:
int dfs(int i, int j, int m, int n) {
if (i > m || j > n) return 0; // 越界了
if (i == m && j == n) return 1; // 找到一种方法,相当于找到了叶子节点
return dfs(i + 1, j, m, n) + dfs(i, j + 1, m, n);
}
public:
int uniquePaths(int m, int n) {
return dfs(1, 1, m, n);
}
};
会超时。
分析一下时间复杂度,这个深搜的算法,其实就是要遍历整个二叉树。
这棵树的深度其实就是m+n-1(深度按从1开始计算)。
那二叉树的节点个数就是 2^(m + n - 1) - 1。可以理解深搜的算法就是遍历了整个满二叉树(其实没有遍历整个满二叉树,只是近似而已)
所以上面深搜代码的时间复杂度为O(2^(m + n - 1) - 1),可以看出,这是指数级别的时间复杂度,是非常大的。
动态规划
机器人从(0 , 0) 位置出发,到(m - 1, n - 1)终点。
按照动规五部曲来分析:
1. dp[i][j] : 从 start 到 i,j有dp[i][j]种路径
2.状态转移方程 ,机器人只能向下或向右移动一步,所以只有可能从上面或者左边过来两种可能:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
3.初始化:
首先dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。
所以初始化代码为:
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
4.遍历顺序:
根据递推公式,dp[i][j]从左边和上边得来,所以从左到右,从上到下遍历,这样可以保证计算dp[i][j]时一定有所需要的数据。
5.举例推导dp数组
如图所示:
代码如下:
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
for(int i = 0; i < m ; i++) dp[i][0] = 1;
for(int j = 0; j < n ; j++) dp[0][j] = 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];
}
}
- 时间复杂度:O(m × n)
- 空间复杂度:O(m × n)
数论方法
在这个图中,可以看出一共m,n的话,无论怎么走,走到终点都需要 m + n - 2 步。
在这m + n - 2 步中,一定有 m - 1 步是要向下走的,不用管什么时候向下走。
那么有几种走法呢? 可以转化为,给你m + n - 2个不同的数,随便取m - 1个数,有几种取法。
那么这就是一个组合问题了。
那么答案,如图所示:
求组合的时候,要防止两个int相乘溢出! 所以不能把算式的分子都算出来,分母都算出来再做除法。
例如如下代码是不行的。
class Solution {
public:
int uniquePaths(int m, int n) {
int numerator = 1, denominator = 1;
int count = m - 1;
int t = m + n - 2;
while (count--) numerator *= (t--); // 计算分子,此时分子就会溢出
for (int i = 1; i <= m - 1; i++) denominator *= i; // 计算分母
return numerator / denominator;
}
};
需要在计算分子的时候,不断除以分母,代码如下:
class Solution {
public:
int uniquePaths(int m, int n) {
long long numerator = 1; // 分子
int denominator = m - 1; // 分母
int count = m - 1;
int t = m + n - 2;
while (count--) {
numerator *= (t--);
while (denominator != 0 && numerator % denominator == 0) {
numerator /= denominator;
denominator--;
}
}
return numerator;
}
};
- 时间复杂度:O(m)
- 空间复杂度:O(1)
计算组合问题的代码还是有难度的,特别是处理溢出的情况。
62.不同路径中我们已经详细分析了没有障碍的情况,有障碍的话,其实就是标记对应的dp table(dp数组)保持初始值(0)就可以了。
动规五部曲:
1.确定dp数组(dp table)以及下标的含义
dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。
2.确定递推公式
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
如果有障碍,dp[i][j]保持初始状态0
3.初始化
如果(i, 0) 这条边有了障碍之后,障碍之后(包括障碍)都是走不到的位置了,所以障碍之后的dp[i][0]应该还是初始值0。
初始化代码为:
for (int i = 0; i < obstacleGrid.length && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
for (int j = 0; j < obstacleGrid[0].length && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;
4.确定遍历顺序
从左到右,从上到下,能保证计算时所需要的数据一定计算过了。
5.举例推导dp数组
拿示例1来举例如题:
对应的dp table 如图:
代码如下:
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int[][] dp = new int[obstacleGrid.length][obstacleGrid[0].length];
for (int i = 0; i < obstacleGrid.length && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
for (int j = 0; j < obstacleGrid[0].length && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;
for(int i = 1; i < obstacleGrid.length ; i++){
for(int j = 1; j <obstacleGrid[0].length ; j++){
if(obstacleGrid[i][j] == 1){
dp[i][j] = 0;
}else{
dp[i][j] = dp[i-1][j] +dp[i][j-1];
}
}
}
return dp[obstacleGrid.length-1][obstacleGrid[0].length -1];
}
}
思路
动态规划
动规五部曲,分析如下:
1.确定dp数组(dp table)以及下标的含义
dp[i]:分拆数字i,可以得到的最大乘积为dp[i]。
dp[i]的定义将贯彻整个解题过程,下面哪一步想不懂了,就想想dp[i]究竟表示的是啥。
2.确定递推公式
可以想 dp[i]最大乘积是怎么得到的呢?
其实可以从1遍历j,然后有两种渠道得到dp[i].
一个是j * (i - j) 直接相乘。
一个是j * dp[i - j],相当于是拆分(i - j),对这个拆分不理解的话,可以回想dp数组的定义。
那有同学问了,j怎么就不拆分呢?
j是从1开始遍历,拆分j的情况,在遍历j的过程中其实都计算过了。那么从1遍历j,比较(i - j) * j和dp[i - j] * j 取最大的。递推公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
也可以这么理解,j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘。
如果定义dp[i - j] * dp[j] 也是默认将一个数强制拆成4份以及4份以上了。
所以递推公式:dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j});
那么在取最大值的时候,为什么还要比较dp[i]呢?
因为在递推公式推导的过程中,每次计算dp[i],取最大的而已。
3.dp的初始化
dp[0] dp[1]应该初始化多少呢?
有的题解里会给出dp[0] = 1,dp[1] = 1的初始化,但解释比较牵强,主要还是因为这么初始化可以把题目过了。
严格从dp[i]的定义来说,dp[0] dp[1] 就不应该初始化,也就是没有意义的数值。
拆分0和拆分1的最大乘积是多少?
这是无解的。
这里我只初始化dp[2] = 1,从dp[i]的定义来说,拆分数字2,得到的最大乘积是1,这个没有任何异议。
4.确定遍历顺序
确定遍历顺序,先来看看递归公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]。
所以遍历顺序为:
for (int i = 3; i <= n ; i++) {
for (int j = 1; j < i - 1; j++) {
dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
}
}
注意 枚举j的时候,是从1开始的。从0开始的话,那么让拆分一个数拆个0,求最大乘积就没有意义了。
j的结束条件是 j < i - 1 ,其实 j < i 也是可以的,不过可以节省一步,例如让j = i - 1,的话,其实在 j = 1的时候,这一步就已经拆出来了,重复计算,所以 j < i - 1
至于 i是从3开始,这样dp[i - j]就是dp[2]正好可以通过我们初始化的数值求出来。
更优化一步,可以这样:
for (int i = 3; i <= n ; i++) {
for (int j = 1; j <= i / 2; j++) {
dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
}
}
因为拆分一个数n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的。
例如 6 拆成 3 * 3, 10 拆成 3 * 3 * 4。 100的话 也是拆成m个近似数组的子数 相乘才是最大的。
只不过我们不知道m究竟是多少而已,但可以明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。
那么 j 遍历,只需要遍历到 n/2 就可以,后面就没有必要遍历了,一定不是最大值。
5.举例推导dp数组
举例当n为10 的时候,dp数组里的数值,如下:
代码如下:
class Solution {
public int integerBreak(int n) {
int[] dp = new int[n+1];
dp[2] = 1;
for(int i = 3 ; i <= n ; i++){
for(int j = 1; j < i ; j++){
dp[i] = Math.max(dp[i],Math.max( j*(i-j) , j*dp[i-j]));
}
}
return dp[n];
}
}
贪心
本题也可以用贪心,每次拆成n个3,如果剩下是4,则保留4,然后相乘,但是这个结论需要数学证明其合理性!
我没有证明,而是直接用了结论。感兴趣的同学可以自己再去研究研究数学证明哈。
给出我的C++代码如下:
class Solution {
public:
int integerBreak(int n) {
if (n == 2) return 1;
if (n == 3) return 2;
if (n == 4) return 4;
int result = 1;
while (n > 4) {
result *= 3;
n -= 3;
}
result *= n;
return result;
}
};
- 时间复杂度:O(n)
- 空间复杂度:O(1)
类似于多少种方案这种问题,考虑到要避免重复计算,后面依赖于前面,可以考虑动态规划思想。
1.dp数组及下标含义
dp[i] : 由i个节点组成且节点值分别为1到i的不同二叉搜索树的个数
2.递推方程
以j为头结点
dp[i] = sum(dp[j-1] * dp[i-j]) 其中 j从1到i
这得益于树的递归定义,比如:
dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
有2个元素的搜索树数量就是dp[2]。
有1个元素的搜索树数量就是dp[1]。
有0个元素的搜索树数量就是dp[0]。
3.初始化
初始化,只需要初始化dp[0]就可以了,推导的基础,都是dp[0]。
那么dp[0]应该是多少呢?
从定义上来讲,空节点也是一棵二叉树,也是一棵二叉搜索树,这是可以说得通的。
从递归公式上来讲,dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量] 中以j为头结点左子树节点数量为0,也需要dp[以j为头结点左子树节点数量] = 1, 否则乘法的结果就都变成0了。
所以初始化dp[0] = 1
4.遍历顺序
从左到右
5.举例推导dp数组
n为5时候的dp数组状态如图:
如果自己画图举例的话,基本举例到n为3就可以了,n为4的时候,画图已经比较麻烦了。
这里列到了n为5的情况,是为了方便 debug代码的时候,把dp数组打出来,看看哪里有问题。
代码如下:
class Solution {
public int numTrees(int n) {
if(n==1) return 1;
int[] dp = new int[n+1];
dp[0] = 1;
for(int i = 1; i <= n ; i++){
for(int j = 1; j <= i ; j++){
dp[i] += dp[j-1]*dp[i-j];
}
}
return dp[n];
}
}