最近一直在刷算法题,一开始确实没什么头绪,也没什么技巧,就是简单粗暴的在LeetCode上刷题,刷了一段时间感觉越来越难,尤其是一些难理解的题。怎么想也想不出来,看完题解恍然大悟,但是自己写的时候又是一头雾水。所以最近决定一个专题一个专题的刷,接下来我会把我刷题的一些经验和过程记录下来,希望能帮助到大家,这也是对我的一种督促。
好了废话不多说了,开始进入正题。本片文章主要讲一讲我刷动态规划问题的一些心得,因为动态规划问题是一个很庞大的体系,像背包问题,打家劫舍问题,股票问题,子序列问题等等,所以本片文章主要讲一些基础问题,通过这些基础问题让大家了解解决动态规划问题的基本思路,帮助大家对动态规划问题有一个基本的了解。所以一些例题会比较简单,大家不要介意,大家重点关注解决问题的思路即可。另外我的动态规划是通过Carl老师(代码随想录)的文章还有一些B站上的视频学的,在此统一感谢一下。
什么是动态规划
动态规划,英⽂:Dynamic Programming,简称DP,如果某⼀问题有很多重叠⼦问题,使⽤动态规划是最有效的。所以动态规划中每⼀个状态⼀定是由上⼀个状态推导出来的。
动态规划问题解决基本步骤
- 确定dp(dp table)数组及其下标的含义(定状态)
- 确定递推公式(状态转移方程)
- dp数组初始化(这点很重要,但容易被忽略)
- 确定遍历的顺序
- 举例推导dp数组
大家可能会想为什么要先确定递推公式然后在进行初始化,因为有些时候通过观察递推公式我们可以更容易的总结出需要初始化哪些数据。
例题练习
1.斐波那契数
斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。
也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给定 n ,请计算 F(n) 。
1.确定dp(dp table)数组及其下标的含义
dp[i]:第i个斐波那契数为dp[i]
2.确定递推公式
这道题很简单,非常适合让大家直观的感受动态规划类型题的解决思路。
由题意可知:dp[i]=dp[i-1]+dp[i-2](i>=2)。
3.dp数组初始化
dp[0]=0
dp[1]=1
4.确定遍历的顺序
从递推公式dp[i]=dp[i-1]+dp[i-2]可以看出,dp[i]依赖于dp[i-1]和dp[i-2],我们想要得到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数组打印出来看看和我们推导的数列是不是⼀致的。
6.代码实现
6.1一维dp
class Solution {
public int fib(int n) {
int[] dp=new int[n+2];
dp[0]=0;
dp[1]=1;
for(int i=2;i<=n;i++){
dp[i]=dp[i-1]+dp[i-2];
}
return dp[n];
}
}
6.2优化空间
通过观察上述代码我们可以发现,要得到dp[i]只需要dp[i-1]和dp[i-2]两个值,其他dp数组中的数据我们并不关心,所以我们可以进一步优化这段代码,只需要维护这两个数值就可以了。
class Solution {
public int fib(int n) {
if(n==0||n==1) return n;
int a=0,b=1,c=0;
for(int i=2;i<=n;i++){
c=a+b;
a=b;
b=c;
}
return c;
}
}
2.爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
1 <= n <= 45
1.确定dp(dp table)数组及其下标的含义
dp[i]:爬上第i层楼梯,有dp[i]种方法
2.确定递推公式
假设我们处在第i级楼梯,其实分析题目可知我们有两种方式到达第i级楼梯。
第一种:在第i-1级楼梯,向上爬一个台阶。
第二种:在第i-2级楼梯,向上爬两个台阶。
所以可以得出:dp[i]=dp[i-1]+dp[i-2]。
所以到达第i级楼梯的方法等于到达第i-1级楼梯的方法加上到达第i-2级楼梯的方法。
其实本质上还是通过子问题的答案不断递推出最终问题的答案,这也就是所谓的动态转移方程,名字听着很唬人,但其实就是个递推公式,大家不要被他吓倒。
3.dp数组初始化
由题意可知1<=n<=45,我们平时做题一定要注意变量的取值范围,这样可以避免很多细节上的问题,所以我们其实只需要初始化dp[1]和dp[2]即可
dp[1]=1
dp[2]=2
4.确定遍历的顺序
从递推公式dp[i]=dp[i-1]+dp[i-2]可以看出,dp[i]依赖于dp[i-1]和dp[i-2],我们想要得到dp[i]必须先得到dp[i-1]和dp[i-2],所以我们需要从前往后遍历,这也正是动态规划的精髓所在,通过子问题的答案逐步递推出最终问题的答案,避免了递归过程中大量重复相同计算子问题的答案。
5.举例推导dp数组
按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导⼀下,当N为10的时候,dp数组应该是如下的数列:
1 2 3 5 8
如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是⼀致的。
6.代码实现
6.1一维dp
class Solution {
public int climbStairs(int n) {
int[] dp=new int[n+2];
dp[1]=1;
dp[2]=2;
for(int i=3;i<=n;i++){
dp[i]=dp[i-1]+dp[i-2];
}
return dp[n];
}
}
6.2优化空间
通过观察上述代码我们可以发现,要得到dp[i]只需要dp[i-1]和dp[i-2]两个值,其他dp数组中的数据我们并不关心,所以我们可以进一步优化这段代码,只需要维护这两个数值就可以了。
class Solution {
public int climbStairs(int n) {
if(n<=2) return n;
int a=1,b=2,c=0;
for(int i=3;i<=n;i++){
c=a+b;
a=b;
b=c;
}
return c;
}
}
3.使⽤最⼩花费爬楼梯
给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
提示:
- 2 <= cost.length <= 1000
- 0 <= cost[i] <= 999
1.确定dp(dp table)数组及其下标的含义
dp[i]:第i个台阶的顶部即第i+1个台阶的最小的花费为dp[i]
2.确定递推公式
我们要到达第i个台阶的顶部即第i+1个台阶上有两种方式。
第一种:花费dp[i-1]到达第i个台阶,然后支付cost[i]到达第i+1个台阶。
第二种:花费dp[i-2]到达第i-1个台阶,然后支付cost[i-1],跨越第i个台阶直接到达第i+1个台阶。
所以dp[i]=Math.min(dp[i-2]+cost[i-1],dp[i-1]+cost[i])。
3.dp数组初始化
由递推公式:dp[i]=Math.max(dp[i-2]+cost[i-1],dp[i-1]+cost[i])可知,我们只需要初始化dp[0]和dp[1]即可,其他数据就可以依次递推得出
dp[0]:到达第0个台阶的顶部,即第1个台阶,由题意可知我们可以从下标为 0 或下标为 1 的台阶开始爬楼梯,所以我们可以直接第1个台阶爬,即爬到第0个台阶的顶部可以不用花钱,即dp[0]=0。
dp[1]:到达第1个台阶的顶部,即第2个台阶,有两种方式可以到达第2个台阶。第一种:从第0个台阶开始爬,支付cost[0],然后跨过第1个台阶直接到达第2个台阶;第二种:从第1个台阶开始爬,支付cost[1],爬到第2个台阶。所以dp综上dp[1]=Math.min(cost[0],cost[1])。
dp[0]=0
dp[1]=Math.min(cost[0],cost[1])
4.确定遍历的顺序
从递推公式dp[i]=Math.min(dp[i-2]+cost[i-1],dp[i-1]+cost[i])可以看出,dp[i]依赖于dp[i-1]和dp[i-2],我们想要得到dp[i]必须先得到dp[i-1]和dp[i-2],所以我们需要从前往后遍历,这也正是动态规划的精髓所在,通过子问题的答案逐步递推出最终问题的答案,避免了递归过程中大量重复相同计算子问题的答案。
5.代码实现
5.1一维dp
class Solution {
public int minCostClimbingStairs(int[] cost) {
int[] dp=new int[cost.length];
dp[0]=0;
dp[1]=Math.min(cost[0],cost[1]);
for(int i=2;i<cost.length;i++){
dp[i]=Math.min(dp[i-2]+cost[i-1],dp[i-1]+cost[i]);
}
return dp[cost.length-1];
}
}
6.2优化空间
通过观察上述代码我们可以发现,要得到dp[i]只需要dp[i-1]和dp[i-2]两个值,其他dp数组中的数据我们并不关心,所以我们可以进一步优化这段代码,只需要维护这两个数值就可以了。
class Solution {
public int minCostClimbingStairs(int[] cost) {
int minCost0=0;
int minCost1=Math.min(cost[0],cost[1]);
int minCost=minCost1;
for(int i=2;i<cost.length;i++){
minCost=Math.min(minCost0+cost[i-1],minCost1+cost[i]);
minCost0=minCost1;
minCost1=minCost;
}
return minCost;
}
}
4.不同路径
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
提示:
- 1 <= m, n <= 100
- 题目数据保证答案小于等于 2 * 10^9
1.确定dp(dp table)数组及其下标的含义
dp[i][j]:到达坐标(i,j)共有dp[i][j]条不同的路径。
2.确定递推公式
因为机器人每次只能向下或者向右移动一步,所以我们有两种方式到达坐标(i,j)。
第一种:从坐标(i-1,j)向下移动一步到达坐标(i,j)。
第二种:从坐标(i,j-1)向右移动一步到达坐标(i,j)。
所以dp[i][j]=dp[i-1][j]+dp[i][j-1]。即到达坐标(i,j)的路径为到达坐标(i-1,j)的路径和到达坐标(i,j-1)的路径之和。
3.dp数组初始化
由递推公式:dp[i][j]=dp[i-1][j]+dp[i][j-1]可知,我们需要初始化dp[0][j]和dp[i][0]即可,其他数据就可以依次递推得出。
dp[0][j]:即机器到达第0行的任意位置,因为机器人只能向右或者向下移动,所以到达坐标(0,j)的路径只有一条,即从坐标(0,0)一直向右移动,直至到达坐标(0,j)。所以dp[0][j]=1。
dp[i][0]:即机器人到达第0列的任意位置,因为机器人只能向右或者向下移动,所以到达坐标(i,0)的路径只有一条,即从坐标(0,0)一直向下移动,直至到达坐标(i,0)。所以dp[i][0]=1。
dp[0][j]=1
dp[i][0]=1
4.确定遍历的顺序
从递推公式dp[i][j]=dp[i-1][j]+dp[i][j-1]可以看出,dp[i][j]依赖于dp[i-1][j]和dp[i][j-1],我们想要得到[i][j]必须先得到dp[i-1][j]和dp[i][j-1],所以我们需要从前往后遍历,注意因为这是一个二维dp数组,所以我们需要双重循环,至于先遍历i还是先遍历j,此处并没有影响,都可以。但是有的题需要注意遍历顺序,不同的遍历顺序会导致不同的结果。
5.代码实现
5.1二维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];
}
}
6.2优化空间
本题其实可以进一步优化空间,即dp数组用一维数组表示,也可以理解为一个滚动数组,滚动数组的内容我会在01背包问题讲解种详细介绍。
由dp[i][j]=dp[i-1][j]+dp[i][j-1]可知,推出dp[i][j]我们其实只需要i行和第i-1行的数据,至于i-2,i-3行的数据我们并不 关心,所以我们可以用一个一维dp数组代表第i-1行的数据,然后从第i行开始遍历,然后不断的刷新dp数组的值,这样随着i不断增加,而dp数组始终代表第i-1行的数据。所以可以得出新的递推公式:dp[j]=dp[j]+dp[j-1]。其中dp[j]表示到达坐标(i,j)的不同路径由多少条,i的值会随着循环不断增加。我们在循环中,从i=1,j=1开始遍历,这样dp[j]其实代表的是dp[i-1][j],dp[j-1]其实代表的是dp[i][-1j]。这里可能会有些难理解,不过大家不要着急,多做一题,自然可以总结出规律。如果实在理解不了可以先掌握二维dp数组即可。
class Solution {
public int uniquePaths(int m, int n) {
int[] dp=new int[n];
Arrays.fill(dp,1); //给dp数组赋初始值1
for(int i=1;i<m;i++){
for(int j=1;j<n;j++){
dp[j]+=dp[j-1];
}
}
return dp[n-1];
}
}
5.不同路径||
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
提示:
- m == obstacleGrid.length
- n == obstacleGrid[i].length
- 1 <= m, n <= 100
- obstacleGrid[i][j] 为 0 或 1
1.确定dp(dp table)数组及其下标的含义
dp[i][j]:到达坐标(i,j)共有dp[i][j]条不同的路径。
2.确定递推公式
因为机器人每次只能向下或者向右移动一步,所以我们有两种方式到达坐标(i,j)。
第一种:从坐标(i-1,j)向下移动一步到达坐标(i,j)。
第二种:从坐标(i,j-1)向右移动一步到达坐标(i,j)。
所以dp[i][j]=dp[i-1][j]+dp[i][j-1]。即到达坐标(i,j)的路径为到达坐标(i-1,j)的路径和到达坐标(i,j-1)的路径之和。
3.dp数组初始化
由递推公式:dp[i][j]=dp[i-1][j]+dp[i][j-1]可知,我们需要初始化dp[0][j]和dp[i][0]即可,其他数据就可以依次递推得出。
由于本题出现了障碍物,所以在初始化数据时,如果坐标(x,0)和坐标(0,y)处有障碍物,即obstacleGrid[0][y]=1或者obstacleGrid[x][0]=1,那么任意坐标(0,j)(j>=y),(i,0)(i>=x)都初始化为0,其余的坐标(0,j)(j<y),(i,0)(i<x)初始化为1。
这是因为机器人只能向右或者向下移动,如果在某一行或者某一列的某个位置出现障碍,那么机器人都不能继续向右移动或者向下移动,所以后面的位置也无法到达,这个有点像象棋中的卒一样,只能前进不能后退。
if(obstacleGrid[0][j]!=1) dp[0][j]=1
if(obstacleGrid[i][0]!=1) dp[i][0]=1
4.确定遍历的顺序
从递推公式dp[i][j]=dp[i-1][j]+dp[i][j-1]可以看出,dp[i][j]依赖于dp[i-1][j]和dp[i][j-1],我们想要得到[i][j]必须先得到dp[i-1][j]和dp[i][j-1],所以我们需要从前往后遍历,注意因为这是一个二维dp数组,所以我们需要双重循环,至于先遍历i还是先遍历j,此处并没有影响,都可以。但是有的题需要注意遍历顺序,不同的遍历顺序会导致不同的结果。需要大家注意的是,如果在遍历过程中遇到obstacleGrid[i][j]==1的情况,那么直接跳过,不需要对dp[i][j]进行赋值,因为此处为障碍物,显然为dp[i][j]=0,而java创建数值型数组时默认初始值为0,所以可以直接跳过。
5.代码实现
5.1二维dp
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m=obstacleGrid.length;
int n=obstacleGrid[0].length;
int[][] dp=new int[m][n];
for(int i=0;i<m&&obstacleGrid[i][0]!=1;i++){
dp[i][0]=1;
}
for(int j=0;j<n&&obstacleGrid[0][j]!=1;j++){
dp[0][j]=1;
}
for(int i=1;i<m;i++){
for(int j=1;j<n;j++){
//如果obstacleGrid[i][j]==1,即此处为障碍物,跳过本次循环,继续下一次循环
if(obstacleGrid[i][j]==1) continue;
dp[i][j]=dp[i-1][j]+dp[i][j-1];
}
}
return dp[m-1][n-1];
}
}
6.整数拆分
给定一个正整数 n ,将其拆分为 k 个 正整数 的和( k >= 2 ),并使这些整数的乘积最大化。
返回 你可以获得的最大乘积 。
提示:
2 <= n <= 58
1.确定dp(dp table)数组及其下标的含义
dp[i]:拆分正整数i可以得到的最大乘积为dp[i]
2.确定递推公式
假设2<=j<i,如果想要得到dp[j]有两种方式。
第一种:j*(j-i);第二种:jdp[i-j]。怎么理解这两种方式呢?第一种,直接相乘很好理解,第二种jdp[i-j],则是因为有可能i-j可以继续拆分,而且拆分后的乘积要大于i-j本身。所以dp[i]=max(dp[i],j*(i-j),j*dp[i-j])。
这里大家可能会疑惑为甚比较大小时要加上dp[i]本身,因为在遍历j的过程中当j=2时的dp[i]可能会大于j=3时的dp[i],所以比较大小时要将dp[i]本身算上,这样如果dp[i]本身比较大,那此次拆分也就没有意义了,继续保持dp[i]不变即可。
需要注意的是,i-j>1,因为2 <= n <= 58,所以dp[n]中的n>=2,即i-j>1。
3.dp数组初始化
由递推公式:dp[i]=Math.max(dp[i],j*(i-j),j*dp[i-j]),且n>=2可知,我们需要初始化dp[2]即可,其他数据就可以依次递推得出。
dp[2]显然等于1。
dp[2]=1
4.确定遍历的顺序
从递推公式dp[i]=max(dp[i],j*(i-j),j*dp[i-j])可以看出,dp[i]依赖于dp[i-j],们想要得到dp[i]必须先得到dp[i-j],所以我们需要从前往后遍历。
5.代码实现
5.1一维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-1;j++){
dp[i]=max(dp[i],j*(i-j),j*dp[i-j]);
}
}
return dp[n];
}
public int max(int a,int b,int c){
int max=Math.max(a,b);
return Math.max(max,c);
}
}
7.不同的⼆叉搜索树
给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的二叉搜索树有多少种?返回满足题意的二叉搜索树的种数。
提示:
1 <= n <= 19
1.确定dp(dp table)数组及其下标的含义
dp[i]:恰由 i 个节点组成且节点值从 1 到 i 互不相同的 二叉搜索树有dp[i]种。
2.确定递推公式
先来说一说二叉搜索树的性质:
二叉搜索树是一个有序树:
若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
它的左、右子树也分别为二叉搜索树。
了解了二叉搜索树的基本性质,我们继续来看本题。
假设0<=j<=i,假设i为组成二叉搜索树的节点个数,j为二叉搜索树的根节点,由二叉搜索树的性质可以得出(注意:本题节点值从1到i且互不相同,所以可以得出这样的结论),左子树的节点数量为i-1,右子树的节点数量为i-j,显然以j为根节点的二叉搜索树总数为:dp[i-1]*dp[j-i](如果不太理解,可以看一看dp[i]所代表的含义)。所以我们要求dp[i],只需要从1到i进行遍历,让每一个节点当作根节点,计算二叉搜索树数量,然后将以不同节点值作为根节点的二叉搜索树的数量相加,即可得到dp[i]。
综上可以得出:dp[i]+=dp[i-1]*dp[j-i]。
3.dp数组初始化
由递推公式:dp[i]+=dp[i-1]*dp[j-i],且1 <= n <= 19,所以我们只需要初始化dp[0]即可,其他数据就可以依次递推得出。
dp[0]显然等于1。
dp[0]=1
4.确定遍历的顺序
从递推公式dp[i]+=dp[i-1]*dp[i-j]可以看出,dp[i]依赖于dp[i-1],们想要得到dp[i]必须先得到dp[i-1],所以我们需要从前往后遍历。
5.代码实现
5.1一维dp
class Solution {
public int numTrees(int n) {
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];
}
}