目录
动态规划的基本流程:
状态表示
动态规划基本都是要定义一个数组,一维或二维,我们通过某种方式将数组填满,数组的某一个值就是我们需要的答案。比如dp数组
而数组的任意一个值所代表的含义,就是状态表示
怎么看出状态表示?
1、题目要求中,会有关键信息,很有可能就是正确的状态表示
2、经验+题目要求,多刷题结合题目要求。
3、要学会分析问题,从问题中找到子问题。
这三个层次基本就是对动态规划的不同熟练程度了。
状态转移方程
上面说了,我们要将数组填满,那怎么填呢,也就是令任意的dp[i]等于什么值呢?
状态转移,从字面意义上,我们可以理解为将状态进行了转移,对应到dp数组,就是dp[i]等于dp的任意一个或多个不同的数组值在经历了一定的变换后的结果,简单的就是比如全部累加,或者平均值等等。
初始化
上面的状态转移中,我们知道dp[i]是1个或多个值经历一定变化后的结果。我们感性的想想,数组最初开辟出来都是0,那不管怎么操作,大概率每个位置还是0吧,那这时候,我们就必须将数组的一些位置手动填上数字,一方面让整个流程跑起来,另一方面,就是以免中途遇到一些没有初始化的值,导致最后的结果变得莫名其妙了。
填表顺序
填当前dp[i]的时候,我们要保证用到的其他状态,已经是填过的或者已经初始化过的,这样才能让结果正确
返回值
题目要求+状态表示。
通过这两者,将dp数组中代表答案的输出即可,比如dp[n]
空间优化:
主要是节省空间的使用。
斐波那契数列模型问题
1、第N个泰波那契数
1137. 第 N 个泰波那契数 - 力扣(LeetCode)
状态表示:
根据题目要求,dp[i]代表第i个泰波那契数
状态转移方程:
dp[i]=dp[i-3]+dp[i-2]+dp[i-1]
初始化:
dp[0]=0,dp[1]=dp[2]=1
填表顺序:
从下标3开始
返回值:
dp[n],第n个泰波那契数
cppclass Solution { public: int tribonacci(int n) { vector<int>mp(n+1,0); if(n==0)return 0; if(n==1||n==2)return 1; mp[0]=0,mp[1]=1,mp[2]=1; for(int i=3;i<=n;i++) { mp[i]=mp[i-3]+mp[i-2]+mp[i-1]; } return mp[n]; } };
下面是节省空间的写法。
用滚动数组,只用开4位。
cppclass Solution { public: int tribonacci(int n) { int mp[4]; if(n==0)return 0; if(n==1||n==2)return 1; mp[0]=0,mp[1]=1,mp[2]=1; for(int i=3;i<=n;i++) { int x=3; mp[x]=mp[x-3]+mp[x-2]+mp[x-1]; mp[0]=mp[1]; mp[1]=mp[2]; mp[2]=mp[3]; } return mp[3]; } };
2、三步问题
面试题 08.01. 三步问题 - 力扣(LeetCode)
状态表示:
根据题目要求,dp[i]代表台阶[i]一共有多少种方法跳上来
状态转移方程:
dp[i]=dp[i-3]+dp[i-2]+dp[i-1]
初始化:
dp[1]=1,dp[2]=2,dp[3]=4
填表顺序:
从下标4开始
返回值:
dp[n],第n个台阶的方法总数
cppclass Solution { public: int waysToStep(int n) { vector<int>mp(n+4,0); mp[1]=1; mp[2]=2; mp[3]=4; for(int i=4;i<=n;i++) { if(i-3>=1)mp[i]+=mp[i-3]; mp[i]%=1000000007; if(i-2>=1)mp[i]+=mp[i-2]; mp[i]%=1000000007; if(i-1>=1)mp[i]+=mp[i-1]; mp[i]%=1000000007; } return mp[n]; } };
滚动数组优化
cppclass Solution { public: int waysToStep(int n) { vector<int>mp(5,0); mp[1]=1; mp[2]=2; mp[3]=4; if(n==1)return 1; if(n==2)return 2; if(n==3)return 4; const int mod=1000000007; for(int i=4;i<=n;i++) { mp[4]=((mp[1]+mp[2])%mod+mp[3])%mod; mp[1]=mp[2]; mp[2]=mp[3]; mp[3]=mp[4]; } return mp[4]; } };
3、使用最小花费爬楼梯
思路1:
状态表示:
根据题目要求,dp[i]代表跳到台阶i的最小花费
状态转移方程:
dp[i]=min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2])
初始化:
dp[0]=dp[1]=0
填表顺序:
从下标2开始
返回值:
dp[n],跳到楼顶的最小花费
cppclass 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]; } };
思路2:
状态表示:
根据题目要求,dp[i]代表从[i]台阶到楼顶的最小花费
状态转移方程:
dp[i]=min(dp[i+1]+cost[i],dp[i+2]+cost[i])
初始化:
dp[n]=0,dp[n-1]=cost[n-1];
填表顺序:
从下标n-2开始,从右往左
返回值:
min(dp[0],dp[1])跳到楼顶的最小花费
cppclass Solution { public: int minCostClimbingStairs(vector<int>& cost) { int n=cost.size(); vector<int>dp(n+1,0); dp[n-1]=cost[n-1]; dp[n]=0; for(int i=n-2;i>=0;i--) { dp[i]=min(dp[i+1]+cost[i],dp[i+2]+cost[i]); } return min(dp[0],dp[1]); } };
4.解码方法
状态表示:
根据题目要求,dp[i]代表以i为结尾的方法总数
状态转移方程:
if(s[i]!='0')dp[i]=dp[i-1];//如果当前字符不是0的话,说明可以在前面i-1个字符解码方法的基础上再加个当前字符即可,所以直接赋值
if(s[i-1]!='0'&&(s[i-1]-'0')*10+s[i]-'0'<=26)//如果前一个不是0(有前导0,不能合并解码),并且下标i-1字符和i字符组成的数小于等于26,说明可以合并解码,让dp[i]+=dp[i-2],也就是在前i-2个字符的基础上加上这个合并解码,所以加的方法数就是dp[i-2]
{
if(i-2>=0)//越界了,那说明是前2个字符触发这个了,那就是再加1
dp[i]+=dp[i-2];
else dp[i]+=1;
}
初始化:
dp[0]=1
填表顺序:
从下标1开始,从左往右
返回值:
dp[s.size()-1],以最后一个字符为结尾的解码方法总数
cppclass Solution { public: int numDecodings(string s) { if(s[0]=='0')return 0;//如果第一个就是0,那直接返回0即可 dp[0]=1;//第一个肯定不是0,那肯定可以单独解码,也就是1 for(int i=1;i<s.size();i++) { if(s[i]!='0') dp[i]=dp[i-1]; if(s[i-1]!='0'&&(s[i-1]-'0')*10+s[i]-'0'<=26) { if(i-2>=0) dp[i]+=dp[i-2]; else dp[i]+=1; } } return dp[s.size()-1]; } int dp[101]; };
其实,也可以让下标映射整体往后挪一位,然后让下标0存1,这样就不用特意判断i-2>=0了
路径问题
1、不同路径1
这个在我记忆化搜索里其实写过。
状态表示:
根据题目要求,dp[i][j]代表走到[i][j]位置的不同路径和
状态转移方程:
dp[i][j]=dp[i-1][j]+dp[i][j-1]
初始化:
dp[1][1]=1,并且i、j其中任意一个为0的时候,都是0,这是一种虚拟节点,保证状态转移方程在'越界'的时候结果依旧正确
填表顺序:
从[1][1]位置开始一行行遍历,每行从左往右
返回值:
dp[m][n],即到[m][n]位置的不同路径和
cppclass Solution { public: int uniquePaths(int m, int n) { f[1][1]=1; for(int i=1;i<=m;i++) { for(int j=1;j<=n;j++) { if(i==1&&j==1)continue; f[i][j]=f[i-1][j]+f[i][j-1]; } } return f[m][n]; } long long f[101][101]; };
2、不同路径2
比上一题多了障碍物的概念
状态表示:
根据题目要求,dp[i][j]代表走到[i][j]位置的不同路径和
状态转移方程:
dp[i][j]=dp[i-1][j]+dp[i][j-1]
初始化:
dp[0][0]=obstacleGrid[0][0]==1?0:1;
填表顺序:
从[0][0]位置开始一行行遍历,每行从左往右
返回值:
dp[m][n],即到[m][n]位置的不同路径和
cppclass Solution { public: int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) { dp[0][0]=obstacleGrid[0][0]==1?0:1; int m=obstacleGrid.size(); int n=obstacleGrid[0].size(); for(int i=0;i<m;i++) { for(int j=0;j<n;j++) { if(j==0&&i==0)continue; if(obstacleGrid[i][j]==1)continue; if(i-1>=0)dp[i][j]+=dp[i-1][j]; if(j-1>=0)dp[i][j]+=dp[i][j-1]; } } return dp[m-1][n-1]; } int dp[101][101]; };
因为从[0][0]开始走,所以要注意下标越界问题,另外如果起始位置有障碍物,方法是0的,其实这时候可以直接返回0的,但我为了整体代码美观,就没写。
在遍历过程中,如果当前位置有障碍物,那就是0的,直接continue掉即可。
这个代码还可以把下标映射整体往后挪一位,这样不用写这些边界if判断
cppclass Solution { public: int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) { int m=obstacleGrid.size(); int n=obstacleGrid[0].size(); dp[1][0]=1; for(int i=1;i<=m;i++) for(int j=1;j<=n;j++) if(obstacleGrid[i-1][j-1]==0) dp[i][j]=dp[i-1][j]+dp[i][j-1]; return dp[m][n]; } int dp[101][101]; };
这个看起来更加简洁
3.礼物的最大价值
礼物的最大价值_牛客题霸_牛客网 (nowcoder.com)
这题前面两题的最大区别就是,本题的每个位置都有一个价值
状态表示:
根据题目要求,mp[i][j]代表移动到[i][j]位置的最大礼物价值
状态转移方程:
mp[i][j]=grid[i-1][j-1]+max(mp[i-1][j],mp[i][j-1]);
初始化:
无需特意初始化,只要把二维数组多开一圈,依靠默认的0即可,即凡是下标含0的,值都为0
填表顺序:
从[1][1]开始遍历,逐行变量,每行都从左往右,保证[i-1][j]和[i][j-1]的位置的值比当前位置早处理即可
返回值:
mp[n][m],即到[n][m]位置的最大礼物价值
cppclass Solution { public: /** * 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可 * * * @param grid int整型vector<vector<>> * @return int整型 */ int maxValue(vector<vector<int> >& grid) { int n=grid.size(),m=grid[0].size(); vector<vector<int>>mp(n+1,vector<int>(m+1)); for(int i=1;i<=n;i++) { for(int j=1;j<=m;j++) { mp[i][j]=grid[i-1][j-1]+max(mp[i-1][j],mp[i][j-1]); } } return mp[n][m]; } };
4.下降路径最小和
本质跟前面几题都是一样的,只是路径的选择稍稍有变化
状态表示:
根据题目要求,mp[i][j]代表移动到[i][j]位置的最小下降路径和
状态转移方程:
mp[i][j]=matrix[i-1][j-1]+min(min(mp[i-1][j],mp[i-1][j-1]),mp[i-1][j+1]);
初始化:
数组先开大一点,比如n+3或者n+2也行,主要是防止越界
数组初始化设为一个很大的值(因为是最小值,所以如果访问到了边界,这些值默认都应该为很大,这样才不会影响正常的路径选择)
[0][1~n+1]都要初始化为0,如果不初始化为0,[0][1~n+1]会被上面所设的很大的值影响,导致结果会被放大很多。
填表顺序:
从[1][1]开始遍历,逐行变量,每行都从左往右,保证[i-1][j]和[i-1][j-1]、[i-1][j+1]的位置的值比当前位置早处理即可
返回值:
当i遍历到最后一行,提前设置变量,对最后一行的每一个结果,进行比较,返回最小值即可
cppclass Solution { public: int minFallingPathSum(vector<vector<int>>& matrix) { int n=matrix.size(); vector<vector<int>>mp(n+2,vector<int>(n+2,9999999999)); for(int i=0;i<=n+1;i++) { mp[0][i]=0; } int ans=9999999999; for(int i=1;i<=n;i++) { for(int j=1;j<=n;j++) { mp[i][j]=matrix[i-1][j-1]+min(min(mp[i-1][j],mp[i-1][j-1]),mp[i-1][j+1]); if(i==n) { ans=min(ans,mp[i][j]); } } } return ans; } };
5.最小路径和
思路没有太多区别,可以用动归解决的路径问题,在转移方程上基本都是大同小异,核心变化反而是对于初始化的问题
状态表示:
根据题目要求,dp[i][j]代表移动到[i][j]位置的最小下降路径和
状态转移方程:
dp[i][j]=min(dp[i][j-1],dp[i-1][j])+grid[i-1][j-1];
初始化:
数组先开n+1
数组初始化设为一个很大的值(因为是最小值,所以如果访问到了边界,这些值默认都应该为很大,这样才不会影响正常的路径选择)
因为对于[i][j]来说,只能从[i][j-1]和[i-1][j]走过来,所以要保证边界值不影响[i][j]的判断,对于第1行来说,他们只能从左边获取值,不能从上面获取值,而由于上面默认都设置了大值,导致[1][1]的位置无法正常设置,因为对于[1][1]来说,[0][1]和[1][0]都是一个大值,选哪一个都会导致结果异常,所以[1][1]必须提前设置,并且后续不能循坏到[1][1]
填表顺序:
从[1][1]开始遍历,逐行变量,每行都从左往右,保证[i-1][j]和[i][j-1]的位置的值比当前位置早处理即可
返回值:
返回dp[m][n]即可,即到达[m][n]位置的最小路径和
cppclass Solution { public: int minPathSum(vector<vector<int>>& grid) { int m=grid.size(); int n=grid[0].size(); vector<vector<int>>dp(m+1,vector<int>(n+1,INT_MAX)); dp[1][1]=grid[0][0]; for(int i=1;i<=m;i++) { for(int j=1;j<=n;j++) { if(i==1&&j==1)continue; dp[i][j]=min(dp[i][j-1],dp[i-1][j])+grid[i-1][j-1]; } } return dp[m][n]; } };
6.地下城游戏(难度upup)
虽然同样是路径问题,但是在状态表示上有很大的不同。
状态表示:
根据题目要求,dp[i][j]代表进入[i][j]前,并且一定以[i][j]为起点,移动到[m][n]位置的最小健康值
注意,这跟前面有很大的区别,前面基本都是移动到[i][j],以[i][j]结尾,而这里是以[i][j]开始。
之所以这样,是因为本题后面的值,是会影响最初的健康值的。简而言之,要以不变往变走。
状态转移方程:
dp[i][j]=min(dp[i+1][j],dp[i][j+1])-dungeon[i-1][j-1];
假设dp[i][j]==x,dp[i-1][j+1]==y,dungeon[i-1][j-1]==mp[i-1][j-1],那么dp[i][j+1]只可能是x+mp[i-1][j-1]或y+mp[i-1][j-1],因此x>=dp[i][j+1]-mp[i-1][j-1],同理x>=dp[i+1][j]-mp[i-1][j-1];所以x==min(dp[i+1][j],dp[i][j+1])-dungeon[i-1][j-1]
dp[i][j]=max(1,dp[i][j]);
然而考虑到一个问题,那就是mp[i-1][j-1]可能大于min(dp[i+1][j],dp[i][j+1]),也就是某个房间加了大量的健康值,有人可能疑问为什么,因为在我们这个dp的第一个关系式中,每一个位置的关系是取决于后面房间的,也就是说,前一个房间和当前房间是没有关系的,这样的话mp[i-1][j-1]就完全有可能大于min(dp[i+1][j],dp[i][j+1]),因此遇到这种情况,说明只要以最小正整数1进入[i][j]位置,就可以保证后面可以走到[m][n]
初始化:
初始开[m+2][n+2]
全部填大值,然后让dp[m][n]=dungeon[m-1][n-1]>0?1:abs(dungeon[m-1][n-1])+1;
填表顺序:
从[m][n-1]开始遍历,逐行变量,每行都从右往左,保证[i+1][j]和[i][j+1]的位置的值比当前位置早处理即可
返回值:
返回dp[1][1]即可,即进入[1][1]前,并且一定以[1][1]为起点,移动到[m][n]位置的最小健康值
cppclass Solution { public: int calculateMinimumHP(vector<vector<int>>& dungeon) { int m=dungeon.size(); int n=dungeon[0].size(); vector<vector<int >>dp(m+2,vector<int>(n+2,INT_MAX)); dp[m][n]=dungeon[m-1][n-1]>0?1:abs(dungeon[m-1][n-1])+1; for(int i=m;i>=1;i--) { for(int j=n;j>=1;j--) { if(i==m&&j==n)continue; dp[i][j]=min(dp[i+1][j],dp[i][j+1])-dungeon[i-1][j-1]; dp[i][j]=max(1,dp[i][j]); } } return dp[1][1]; } };
本题难度是有的,主要是状态表示和状态转移方程上面有一定难度。