参考
一、基础
1. 什么是动态规划
动态规划中每一个状态一定是由上一个状态推导出来的,和贪心不同(贪心是没有从状态推导的,而是从局部直接选择最优的)
2. 解题步骤
- 确定dp数组(dp table)以及其下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
3. 如何debug
- 这道题目我举例推导状态转移公式了么?
- 我打印dp数组的日志了么?
- 打印出来了dp数组和我想的一样么?
二、基础题目
1. 509. 斐波那契数【简单】
- 时间复杂度:O(n)
- 空间复杂度:O(n)
按照动态规划的思路理解:
- 确定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数组
class Solution {
public int fib(int n) {
if (n <2) return n;
int[] dp = new int[n+1];
dp[0] = 0;
dp[1] = 1;
dp[n] = dp[0] + dp[1];
for (int i = 2 ;i < n+1; i++){
dp[i] = dp[i-1] + dp[i- 2];
}
return dp[n];
}
}
2. 70. 爬楼梯【简单】
- 确定dp数组以及下标的含义,dp[i]的定义为:
第i个数的楼梯数值是dp[i]爬到第i层楼梯,有dp[i]种方法 - 确定递推公式,dp[i] = dp[i - 1] + dp[i - 2]
- dp数组如何初始化,不考虑dp[0]如何初始化,只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样才符合dp[i]的定义。
- 确定遍历顺序。从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,遍历顺序一定是从前向后遍历的
- 举例推导dp数组,类似斐波那契数列,但是dp[0]在本题没意义
- 时间复杂度:O(n)
- 空间复杂度:O(n)
//和斐波那契一样的
class Solution {
public:
int climbStairs(int n) {
if (n <= 1) return n; // 因为下面直接对dp[2]操作了,防止空指针
vector<int> dp(n + 1);
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) { // 注意i是从3开始的
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
优化后,其实只需要一个sum,而不需要存储每一步的结果
class Solution {
public int climbStairs(int n) {
if (n <2 ) return n; //必须特殊处理
int[] dp = new int[3]; //不要设置成2,这样更清楚下标的意思
int sum = 0;
dp[1] = 1;
dp[2] = 2;
for(int i = 3;i < n+1 ;i++){
sum = dp[1]+dp[2];
dp[1]=dp[2];
dp[2] = sum;
}
return dp[2]; //不要返回sum,循环结束后被释放了
}
}
3. 746. 使用最小花费爬楼梯【简单】
- 确定dp下标的意思,到达第i台阶所花费的最少费用为dp[i]。
- 确定递推公式,dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
- 初始化 dp[0] = 0,dp[1] = 0
- 遍历顺序:从前到后遍历cost数组
- 举例
- 时间复杂度:O(n)
- 空间复杂度:O(n)
class Solution {
public int minCostClimbingStairs(int[] cost) {
//确定dp下标的意思
int[] dp = new int[cost.length +1];
//初始化
dp[0] = 0;
dp[1] = 0;
//遍历顺序
for(int i = 2;i < cost.length+1;i++){
//确定方程
dp[i] = Math.min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2]);
}
return dp[cost.length];
}
}
4. 62. 不同路径【中等】
- 确定dp数组(dp table)以及下标的含义:dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。
- 确定递推公式(这一步没考虑到):dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
- 初始化dp数组:首先dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。 这是初始化一行一列,而不是单独的单元格。循环来使得他初始化
- 确定遍历顺序:dp[i][j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。
- 举例
四个循环
- 时间复杂度:O(m × n)
- 空间复杂度:O(m × n)
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]; //注意边界
}
}
5. 63. 不同路径 II【中等】
我的思路:
- 确定dp下标的含义:(0,0)表示从0行0列出发,有障碍物
- 确认递推公式:dp[i][j] = dp[i-1][j] + dp[i][j-1]
- 初始化 如果障碍物的位置不为1, dp[0][j] = 1,dp[i][0] = 1
- 确定遍历顺序,向右边向下
- 举例
注意代码的写法。不一定要引入if
- 时间复杂度:O(n × m),n、m 分别为obstacleGrid 长度和宽度
- 空间复杂度:O(n × m)
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length;
int n = obstacleGrid[0].length; // 列
int[][] dp = new int[m][n];
//特殊情况处理:起点或终点有障碍物
if(obstacleGrid[0][0] == 1 || obstacleGrid[m-1][n-1] == 1){
return 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++){
dp[i][j] = (obstacleGrid[i][j]== 0)? dp[i-1][j]+ dp[i][j-1] : 0;
}
}
return dp[m-1][n-1];
}
}
6. 343. 整数拆分【中等】
想不出来
- 确定dp数组以及其下标的含义:dp[i]分拆数字i,可以得到的最大乘积为dp[i]。
- 确定递推公式:
一个是j * (i - j) 直接相乘。
一个是j * dp[i - j],相当于是拆分(i - j),j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘。dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j});
-
dp的初始化,只初始化i=2, 0,1的时候没有意义
-
确定遍历顺序:从前向后
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-1;j++){
dp[i] = Math.max(dp[i], Math.max((i-j)*j,dp[i-j]*j));
}
}
return dp[n];
}
}
优化j的循环条件
- 时间复杂度:O(n^2)
- 空间复杂度:O(n)
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/2;j++){
dp[i] = Math.max(dp[i], Math.max((i-j)*j,dp[i-j]*j));
}
}
return dp[n];
}
}
7. 96. 不同的二叉搜索树【中等】
- 确认dp数组以及其下标的含义:dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]。
- 确认递推公式: dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]。j相当于是头结点的元素,从1遍历到i为止。dp[i] += dp[j - 1] * dp[i - j]。j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量。外部循环从2遍历到n,表示节点的数量,内部循环从1遍历到i,表示根节点的位置。
对于根节点j,左子树的节点个数为j-1,右子树的节点个数为i-j的原因如下:
在一个二叉搜索树中,左子树上的节点的值都小于根节点的值,右子树上的节点的值都大于根节点的值。
假设我们有i个节点,其中根节点的位置是j。那么在根节点的左侧,有j-1个节点,它们可以作为左子树的节点。同样地,在根节点的右侧,有i-j个节点,它们可以作为右子树的节点。
因此,对于第i个节点,我们需要计算以每个位置j为根节点时,左子树节点的个数是j-1,右子树节点的个数是i-j。
通过对每个位置j的遍历,我们可以累加左子树和右子树的组合方式,从而得到以第i个节点为根节点的二叉搜索树数量。
- dp的初始化:dp[0]=1
- 确定遍历顺序:节点数为i的状态是依靠 i之前节点数的状态。那么遍历i里面每一个数作为头结点的状态,用j来遍历。
- 举例
- 时间复杂度:O(n^2)
- 空间复杂度:O(n)
class Solution {
public int numTrees(int n) {
//初始化 dp 数组
int[] dp = new int[n + 1];
//初始化0个节点和1个节点的情况
dp[0] = 1;
dp[1] = 1;
//总结点数
for (int i = 2; i <= n; i++) {
//找根节点
for (int j = 1; j <= i; j++) {
//对于第i个节点,需要考虑1作为根节点直到i作为根节点的情况,所以需要累加
//一共i个节点,对于根节点j时,左子树的节点个数为j-1,右子树的节点个数为i-j
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
}
}