理论基础
什么是动态规划
动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。
所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,
在关于贪心算法,你该了解这些! (opens new window)中我举了一个背包问题的例子。
例如:有$N$件物品和一个最多能背重量为 $W$ 的背包。第$i$件物品的重量是 $weight[i]$,得到的价值是 $value[i] $。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
动态规划中 $dp[j]$ 是由 $dp[j-weight[i]]$ 推导出来的,然后取$max(dp[j], dp[j - weight[i]] + value[i])$。
但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。
所以贪心解决不了动态规划的问题。
动态规划的解题步骤
做动规题目的时候,很多同学会陷入一个误区,就是以为把状态转移公式背下来,照葫芦画瓢改改,就开始写代码,甚至把题目AC之后,都不太清楚 $dp[i]$ 表示的是什么。
这就是一种朦胧的状态,然后就把题给过了,遇到稍稍难一点的,可能直接就不会了,然后看题解,然后继续照葫芦画瓢陷入这种恶性循环中。
状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。
对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢?
因为一些情况是递推公式决定了dp数组要如何初始化!
动态规划应该如何debug
相信动规的题目,很大部分同学都是这样做的。
看一下题解,感觉看懂了,然后照葫芦画瓢,如果能正好画对了,万事大吉,一旦要是没通过,就怎么改都通过不了,对 dp数组的初始化,递推公式,遍历顺序,处于一种黑盒的理解状态。
写动规题目,代码出问题很正常!
找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的!
一些同学对于dp的学习是黑盒的状态,就是不清楚dp数组的含义,不懂为什么这么初始化,递推公式背下来了,遍历顺序靠习惯就是这么写的,然后一鼓作气写出代码,如果代码能通过万事大吉,通过不了的话就凭感觉改一改。
这是一个很不好的习惯!
做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果。
然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。
如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。
如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。
这样才是一个完整的思考过程,而不是一旦代码出问题,就毫无头绪的东改改西改改,最后过不了,或者说是稀里糊涂的过了。
这也是我为什么在动规五步曲里强调推导dp数组的重要性。
发出这样的问题之前,其实可以自己先思考这三个问题:
- 这道题目我举例推导状态转移公式了么?
- 我打印dp数组的日志了么?
- 打印出来了dp数组和我想的一样么?
如果这灵魂三问自己都做到了,基本上这道题目也就解决了,或者更清晰的知道自己究竟是哪一点不明白,是状态转移不明白,还是实现代码不知道该怎么写,还是不理解遍历dp数组的顺序。
509. 斐波那契数
很简单的动规入门题,但简单题使用来掌握方法论的,还是要有动规五部曲来分析。
509. 斐波那契数 - 力扣(LeetCode)
- 确定dp数组以及下标的含义
dp[i]的定义为:第i个数的斐波那契数值是dp[i]
- 确定递推公式
状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]
- dp数组如何初始化
- 确定遍历顺序
从递归公式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
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
| class Solution { public: int fib(int N) { if (N <= 1) return N; vector<int> dp(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]; } };
|
改进:不需要那麽多数组,两个位置即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| class Solution { public: int fib(int n) { if (n < 2) return n; vector<int> dp(2); dp[0] = 0; dp[1] = 1; int sum; for (int i = 2; i <= n; i++) { sum = dp[0] + dp[1]; dp[0] = dp[1]; dp[1] = sum; } return dp[1]; } };
|
70. 爬楼梯
70. 爬楼梯 - 力扣(LeetCode)
思路:和上题思路一致,f(n)=爬n阶的方法=f(n-1)+ f(n-2)
使用递归
1 2 3 4 5 6 7 8
| class Solution { public: int climbStairs(int n) { if(n<=2) return n; return climbStairs(n-1)+climbStairs(n-2);
} };
|
常规dp思路
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class Solution { public: int climbStairs(int n) { if (n <= 2) return n; vector<int> dp(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]; } };
|
改进版本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class Solution { public: int climbStairs(int n) { if (n <= 2) return n; int dp[2]; dp[0] = 1; dp[1] = 2; for (int i = 3; i <= n; i++) { int sum = dp[0] + dp[1]; dp[0] = dp[1]; dp[1] = sum; } return dp[1]; } };
|
再次改进
746. 使用最小花费爬楼梯
746. 使用最小花费爬楼梯 - 力扣(LeetCode)
1 2 3 4 5 6 7 8 9 10 11 12 13
| class Solution { public: int minCostClimbingStairs(vector<int>& cost) { int n=cost.size(); vector<int> dp(n+1,0); dp[0]=0; dp[1]=0; for(int i=2;i<=n;i++){ dp[i]=min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2]); } return dp[n]; } };
|
62.不同路径
62. 不同路径 - 力扣(LeetCode)
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class Solution { public: int uniquePaths(int m, int n) { vector<vector<int>> dp(m, vector<int>(n, 0)); 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]; } };
|
63. 不同路径 II
63. 不同路径 II - 力扣(LeetCode)
增加了障碍物 , 0为无,1为有
故递推需判断,初始化需要处理
1 2 3
| if (obstacleGrid[i][j] == 0) { dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; }
|
1 2 3
| vector<vector<int>> dp(m, vector<int>(n, 0)); for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1; for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class Solution { public: int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) { int m = obstacleGrid.size(); int n = obstacleGrid[0].size(); if (obstacleGrid[m - 1][n - 1] == 1 || obstacleGrid[0][0] == 1) return 0; vector<vector<int>> dp(m, vector<int>(n, 0)); for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1; for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1; for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { if (obstacleGrid[i][j] == 1) continue; dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; } } return dp[m - 1][n - 1]; } };
|
343. 整数拆分
343. 整数拆分
没思路!
初始化:
遍历顺序:从左子树个数为0开始遍历,直到n,右子树个数相应变换
1 2 3 4 5 6 7 8 9 10 11 12 13
| class Solution { public: int numTrees(int n) { vector<int> dp(n + 1); dp[0] = dp[1] = 1; for (int i = 2; i <= n; i++) { for (int j = i - 1; j >= 0; j--) { dp[i] += dp[j] * dp[i - j - 1]; } } return dp[n]; } };
|