一、动态规划理论
1.1什么是动态规划
动态规划(Dynamic Programming,简称 DP)主要用来解决"有很多重复子问题"的情况,它的核心就是通过已经算出来的结果,去推导新的结果。也就是说,动态规划中的每一个状态,都是从之前的状态一步一步推出来的,这一点是它和贪心算法最大的区别。贪心是每一步直接选当前最优,不关心之前的状态,而动态规划必须依赖之前的结果。
比如经典的 0-1 背包问题:有 N 件物品和一个容量为 W 的背包,每个物品有重量 weight[i] 和价值 value[i],每个物品只能选一次,求最大总价值。用动态规划时,我们定义 dp[j] 表示容量为 j 时的最大价值,那么 dp[j] 是由 dp[j - weight[i]] 推导出来的,在"选当前物品"和"不选当前物品"之间取最大值,也就是 max(dp[j], dp[j - weight[i]] + value[i])。可以看出,每一步都依赖之前的状态,这就是"推导"的过程。
如果用贪心来做,就会变成每次选价值最大或者性价比最高的物品,这种做法并没有考虑之前已经选了什么,因此很容易出错,也说明贪心并不能解决所有动态规划问题。
其实在刷题时,不用死记那些像"最优子结构""重叠子问题"这种抽象概念,记住一句话就够了:动态规划是推出来的,贪心是选出来的。理解这一点,再多做几道题,自然就能体会两者的区别。至于背包问题,后面可以再单独深入讲解。
1.2动态规划解题步骤
做动态规划题的时候,很多人容易掉进一个误区:以为把状态转移公式背下来,照葫芦画瓢就能写代码,甚至题目 AC 了,也不太清楚 dp[i] 表示的是什么。这种"朦胧状态"会让你遇到稍微复杂一点的题就不会做了,然后看题解,再照搬公式,如此循环。
其实,状态转移公式很重要,但动态规划不仅仅是公式而已。真正掌握 DP,需要搞清楚五个步骤:
-
确定 dp 数组及下标的含义
-
确定递推公式
-
明确 dp 数组如何初始化
-
确定遍历顺序
-
举例推导 dp 数组
有人可能会问,为什么先确定递推公式再考虑初始化?因为有些情况下,公式本身决定了 dp 数组的初始化方式。很多刷过 DP 题的同学知道公式重要,但只记公式而不理解 dp 数组的含义、初始化和遍历顺序,结果写的程序怎么改都通过不了。
二、实战
746. 使用最小花费爬楼梯
1.到达i位置最少花费dp[i]
我们要找出到达某一层时的最小花费,到达这一层有两个途径,前一层爬一个台阶和前两层爬两个台阶。
2.dp[i]=Math.min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2])
我们可以选择从0和1的台阶开始往上爬,因此0和1不需要花费
3.dp[0]=0;dp[1]=0;
4.从前往后遍历
class Solution {
public int minCostClimbingStairs(int[] cost) {
int n=cost.length;
int[] dp=new int[n+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[n];
}
}
63. 不同路径 II
1.dp[i][j]:到达(i,j)所有的路径
因为只能往右往下走,因此一个位置它由上方和左方到达,他的路径就是上方和左方的路径种数和。
2.dp[i][j]=dp[i-1][j]+dp[i][j-1]
3.因为一个位置它由上方和左方到达,因此最上面和最左面要初始化,又因为到达最上面和最左面只有一条路径,所以全部初始化为1。
4.从前往后遍历
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int r=obstacleGrid.length;
int c=obstacleGrid[0].length;
int[][] dp=new int[r][c];
for(int i=0;i<r;i++){
if(obstacleGrid[i][0] == 1) break;
dp[i][0]=1;
}
for(int j=0;j<c;j++){
if(obstacleGrid[0][j] == 1) break;
dp[0][j]=1;
}
for(int i=1;i<r;i++){
for(int j=1;j<c;j++){
if(obstacleGrid[i][j] == 1){
dp[i][j] = 0;
} else {
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
}
return dp[r-1][c-1];
}
}
因为要考虑障碍,因此遇到障碍时最上方和最右方就走不通,不在最上方或者最右方的话,障碍的地方也到不了,dp数组为0
三、01背包
这道题的暴力解法是回溯,当然我们也可以使用动态规划。
1.dp[i][j]:物品 0-i 任取,放到容量 j 的背包里
2.若不放物品 i ,则他的最大价值是 dp[i-1][j];
若放物品 i,则它的最大价值是dp[i-1][j-weight[i]]+value[i],大家可能看不懂,我们可以这样理解:已经决定要放i了,所以我们需要把相应的重量腾出来,看在剩下的重量下,前面的最大价值,最后再加上 i 的价值。
3.列是物品,所以在容量为0时,所有物品都放不下去,因此竖着全部初始化为0,最上面横着,只有在容量大于物品0时,才初始化为物品0的价值,前面都是0。如图:

4.从前往后遍历
四、完全背包
完全背包和0-1背包有什么区别呢?区别就是完全背包中,每个物品可以使用无数次。
1.dp[j]:容量为 j 时的最大价值
2.dp[0] = 0
3.对于每一个物品 i,再从 j = weight[i] 开始到背包容量 bagWeight 正序遍历。对于每一个容量 j,都有两种选择:一是不放当前物品 i,此时价值就是原来的 dp[j];二是放入当前物品 i,由于完全背包可以重复选,所以放入之后剩余容量 j - weight[i] 仍然可以继续放当前物品,因此价值为 dp[j - weight[i]] + value[i]。两者取最大值,即 dp[j] = max(dp[j], dp[j - weight[i]] + value[i])。
4.按物品从前往后遍历