前言
这篇文章详细概括了动态规划的所有题型,以及各个题型的解决公式方法。如果看完这篇动态规划还是不会做题,我来给你补,我爱说点实话。
动态规划
何为动态规划?
动态规划,有一点暴力求解的感觉。用最通俗的语言来解释,便是按照顺序一步一步往后推导,一直推导到自己想要的值。
举一个最简单的例子:数列
在高中,我们解决数列的问题往往是想办法凑出数列的通项公式,然后直接代入具体的某个数值求解。但是动态规划更暴力,假如我们想求n=100时的值,动态规划便从n=1开始一步步往后推导,一直计算到100得出结果。
再打个比方:牢大打复活赛
如果牢大想打赢复活赛,就必须思考怎么打赢哟西。而打赢哟西的先制条件,便是哟西要打赢孙笑川,而牢大要打赢kmg。但是,我们怎么知道哟西一定会优于孙笑川晋级呢?我们又怎么知道牢大一定可以战胜kmg呢?而动态规划便是在已经知道这些先制条件的情况下,再来推导在这些已知条件下的最优解。 换句话说,动态规划就是一步步为想得到的结果铺垫,一直铺垫到所有条件都已知后,再得到我们想要的答案。
何时用动态规划?
以上只是举了两个具体的例子。但是真实的问题场景肯定不会这么具象,不过大多时候,其实看一眼就能排除是不是动态规划了。
动态规划适用的场景,是在解决不断出现重复子问题的时候,采用的一种思想。用人话说,就是一段连续的线性空间(比如数组)中,某个未知的值和该位置前的一些值有一定关系,但是那些带有关系的位置的值也是未知的。不过好在,每个位置对应的关系都是固定的,依次根据这个固定的关系求每个位置的值,便叫重复子问题,而解决这个问题的方法,便叫动态规划。
还是以斐波那契数列为例。
- 我们想求n=100时的值,只需要知道n=99的值和n=98的值,然后将其相加。但是不幸的是,这也是一个未知值。于是,我们必须求n=99和n=98时的值。
- 求n=99,就要知道n=98和n=97时的值,然后将其相加,不幸的是,他们也是一个未知数。
发现没有,依次往下推,虽然数值不一样,但是其问题都是重复的,这便是重复子问题。 而我们一步步向下,其实只是找到先制条件的源头,但是我们不妨想想,我们直接从已知值这个源头开始,不断为后面的问题创造先制条件,问题是不是便简化了很多。而从这个已知的源头开始向着未知推导,就是动态规划的思想。
所以答案便很显然:
用特殊颜色标记的词语要格外注意,在后面会详细说明。
动态规划的解题步骤
动态规划的解题步骤相对来说较为公式,拿到一道题,首先看可不可以用动态规划的思想来解决,如果可以直接套入公式便可以了。
在说明公式之前,首先介绍几个概念:
- 什么是dp表?
就是动态规划所有需要求的值,全部放在一张表里,这个表就叫做dp表。还是以斐波那契为例。
用动态规划解决这个问题的时候,dp表便是这个样子:
填写dp表,便是从n=3开始,依次根据dp表中已有的数值向后计算出其他的数值。
- 什么是状态?
在这里,状态这个概念就比较抽象了。状态并不是像牢大一样是起飞还是坠毁,而是数组的每个空间存储的数值,无论其有什么含义,都被称之为状态。
第一步:状态表示
什么是状态表示?也是一句话概括:确定dp表中每个位置所代表的含义。
简单的状态表示比如斐波那契,每个状态表示便是一个简单的数值。但是在解决一些具体的问题时,状态表示便没有这么简单。每个问题的状态表示都不一样,而后面的步骤都是在该状态表示下所展开推导,所以想清楚状态表示是每个问题最重要的一步。
确定状态标识,没有特别统一的方法,但是所有的状态表示可以总结为几种题型,这也会在接下来的汇总中介绍。
第二步:状态转移方程
状态转移方程,说人话便是:怎么从dp表中前一段已知的值得到后面未知的值。
而得到状态转移方程的方式也很简单:枚举。
这一个简单的式子,就是状态转移方程——从dp表前两个空间的值,得到该未知空间的值,便是状态转移。
第三步:初始化dp表
第四步:填写dp表
第五步:返回值
动态规划的重点只在于前两步:确定状态表示和推导状态转移方程。结束了前两步,这道题也便结束了,剩下的便是一些细枝末节的工作。在这三步里,我们只需要注意一些细节:
- dp表在初始化时注意开辟空间的大小,避免越界
- 填写dp表时注意填写的顺序,一定要线性顺序填写
- 返回值要和自己的状态表示相对应,不同状态表示下的返回值也不同
这些细节,也会在接下来的题型分类中分别讲到。
在以下阶段,只会挑选最具代表性或者最经典的问题来进行讲解,剩余的问题一定可以通过讲解的方法解出来,所以其余问题一定要自己尝试解决一下,动态规划如果不去一次性解决那必然是常看常新。
单状态动态规划
简介
单状态dp是动态规划中最简单的题目,同时也是动态规划的开始。我们采用一道经典的题目来讲解动态规划在具体问题中的应用。
不是已经被说烂了的斐波那契数列使用最小花费爬楼梯
746. 使用最小花费爬楼梯 - 力扣(LeetCode)
题目翻译
就是一段楼梯,我们可以选择爬一步或者爬两步,但是每一个台阶上有几托史,我们到了那个台阶就得把该台阶上的史赤干净,我们需要求解赤石最少爬完楼梯的路线。
解题步骤
1.状态表示
题目要求什么?到达最终目的时,所需要的最小花费。那我们最先想到的是什么?到达每一个位置时候的最小花费。
为什么我们会这样想?因为dp表中包含了最后一个空间,所以dp表中的每一个状态表示,一定包含着最后一个空间的状态表示。而我们不妨反向去思考,既然最后一个空间的状态表示被所有状态表示包含,那么有没有可能,最后一个空间的状态表示就是所有空间的状态表示?
这仅仅是一个尝试,也是单状态题目的明显特点——最后一个空间的状态表示,就是每一个空间的状态表示。
于是,这题的状态表示便轻易推了出来——dp[i]表示在到达台阶i的时候,最小的花费。
2.状态转移方程
状态转移方程,是由状态方式推出来的。我们在推导状态转移方程的时候,必须根据题目意思,找到需求的状态和前面的状态之间的关系。
求解状态转移方程,实际上是一个数学问题。就比如在这个题中,我们求解状态转移方程的方法:
dp[i]之和两个位置有关:dp[i-1]和dp[i-2]。因为在每个台阶,最多只能爬两步,也就是只有dp[i-1]爬一步到达dp[i]和dp[i-2]爬两步到达dp[i]这两种方法。也就是说,dp[i]只有两种可能的值:
- dp[i]=dp[i-1]+台阶i对应的花费
- dp[i]=dp[i-2]+台阶i对应的花费
同时,题目要求的是最小的花费,所有我们舍取的时候,只需要取其最小的值便可以了
最终得到状态转移方程:
3.初始化
初始化的最重要原则,便是要保证dp表的填写不越界。这个不越界,包括了两个界限:上界和下界。
就比如在这里,何为上界?上界就是我们需要填到的地方。台阶顶部对应的值为台阶个数+1,所以我们至少要开辟台阶个数+1的dp表空间。
何为下界?下界就是我们在访问dp表空间时,不能访问到负数。比如我们会访问i-1和i-2,那么就必须保证i大于等于2,也就是只能从i=2开始填写。但是,i=0和i=1的空间不能空出,这些空间我们只能手动填写。
解题代码
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
cost.push_back(0);
int n=cost.size();
if(n==1||n==2)
return 0;
vector<int> dp(n+1);//初始化dp表
dp[0]=cost[0];
dp[1]=cost[1];//手动初始化i=0和i=1
for(int i=2;i<n;i++)//从i=2开始填写
{
dp[i]=min(dp[i-1],dp[i-2])+cost[i];//状态转移方程
}
return dp[n-1];//返回值
}
};
类似题目
1137. 第 N 个泰波那契数 - 力扣(LeetCode)
面试题 08.01. 三步问题 - 力扣(LeetCode)
91. 解码方法 - 力扣(LeetCode)
路径问题
简介
路径问题,实际上也是一种单状态动态规划的问题。但是将其单独列出来,只是想介绍一个新的概念——多维dp表
LCR 099. 最小路径和 - 力扣(LeetCode)
题目翻译
你现在有个键盘,但是W和A被抠了,只剩下S和D,求在这种情况下,你能赤到最少史的数量。
解题步骤
1.状态表示
还是首先考虑最后一个空间的状态表示——到达最后一个空间的最小和。
但是,这里的dp表会有一定的变化——一维dp表已经无法满足题目条件了,因为题目给的数据已经是二维了。所以,我们在创造dp表的时候,也需要创造出二维dp表。
所以最终的状态表示:dp[i][j]表示到达第i行第j列时,对应的最小和。
2. 状态转移方程
想到达第i行第j列,只有两种情况:从[i-1][j]下走一步,或者从[i][j-1]向右走一步,分别对应的值也有两种情况:
- dp[i][j]=dp[i-1][j]+cost[i][j]
- dp[i][j]=dp[i][j-1]+cost[i][j]
因为题目需要求最小值,所以只需要舍取一种情况,对应的状态转移方程:
3.初始化
我们可以和一维时的一样,因为i和j都必须大于等于1,所以i=0和j=0时需要手动初始化。但是这是一个大工程,我们不妨开空间的时候,多开出一行和一列,用来防止访问越界。
解题代码
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int row=grid.size();
int col=grid[0].size();
vector<vector<int>> dp(row+1);
for(auto& e:dp)
{
e.resize(col+1,INT_MAX);
}//初始化:多开出一行一列
dp[0][1]=0;
for(int i=1;i<row+1;i++)
{
for(int j=1;j<col+1;j++)
{
dp[i][j]=min(dp[i-1][j],dp[i][j-1])+grid[i-1][j-1];//状态转移方程
}
}
return dp[row][col];
}
};
类似题目
LCR 098. 不同路径 - 力扣(LeetCode)
63. 不同路径 II - 力扣(LeetCode)
931. 下降路径最小和 - 力扣(LeetCode)
174. 地下城游戏 - 力扣(LeetCode)
多状态动态规划
简介
多状态动态规划,是在单状态来进行状态表示时,会有一些欠缺的地方。我们无法一开始便判断其为单状态还是多状态,只有在先用单状态的情况去考虑时,发现无论如何都无法完整表示出状态,此时才用多状态去思考。
而单状态和多状态本质是一样的,都是去寻找某一状态和其他状态的关系,只不过从一个dp表变成了多个dp表。这样说过于抽象,还是从题目来进行理解。
不过在这里, 挑选了两类题目,一类作为入门,而另一类则是多状态的终极题目。
打家劫舍系列
LCR 089. 打家劫舍 - 力扣(LeetCode)
题目翻译
假如你想卢关了,那么如果今天进行手艺活,明天必须要戒掉一天否则身体吃不消。但是,明天戒掉了不代表后天必须卢关,只要不是相邻的两天卢关,其他想戒多少天就可以戒多少天。
而我们需要求在一段时间里,卢关的最大收益。
解题步骤
1.状态表示
按照往常的经验,我们尝试一下,dp[i]表示到达i位置的时候,最大的收益。
但是通过这个状态表示,我们在推导状态转移方程的时候会发现一个问题:我们并不知道dp[i-1]是i-1天卢关的情况还是i-1天没有卢关的情况。用式子更直观去看:
- 如果i-1天卢关了,那么第i天一定不能卢关,于是第i天没有产生任何收益,即
- 但是第i-1天没有卢关,那么第i天可以卢关,为了产生最大收益,
不过,这显然一个dp表无法表示这么多状态,我们只能用多个dp表来表示多个dp状态,这便是多状态的动态规划问题。
在这个问题中,有着两个状态:第i天休息和第i天打劫。所以我们需要开辟两个dp表:fdp和gdp。
为什么要这样取名?和数学函数的f(x)和g(x)一样,dp表也可以按照这个规律取名。
- fdp[i]表示第i家没有打劫的时候,产生的最大收益
- gdp[i]表示第i家打劫了的时候,产生的最大收益
2.状态转移方程
多个dp表之间不是独立的。既然创立了多个dp表,其意义也是多个dp表的相互联系,所以我们也要找到dp表之间的联系。
在第i家的时候,只有两种情况:第i-1家没有打劫;第i-1家打劫了。而这两种情况也会分别对第i家产生不同的影响。
- 假如第i-1家打劫了,则第i家一定没有被打劫,于是此时fdp[i]=gdp[i-1]
- 假如第i-1家没有打劫,则第i家可以打劫也可以不打劫,于是此时fdp[i]=fdp[i-1],gdp[i]=fdp[i-1]+cost[i]
但是,题目要求的是最大收益,所以把这些情况总结一下便是:
3.初始化
和单状态一样,唯一的区别只是我们开两个dp表fdp和gdp,然后注意是否会越界。
解题代码
注意:这里fdp和gdp换了个顺序,但是不影响解题
class Solution {
public:
int rob(vector<int>& nums) {
int n=nums.size();
vector<int> fdp(n+1);
vector<int> gdp(n+1);
fdp[0]=nums[0];
gdp[0]=0;
for(int i=1;i<n;i++)
{
fdp[i]=gdp[i-1]+nums[i];
gdp[i]=max(fdp[i-1],gdp[i-1]);
}
return max(gdp[n-1],fdp[n-1]);
}
};
思考
还是打家劫舍,但是如果置换一下条件,将所有屋子变成环形,那么和线性的有什么区别?
LCR 090. 打家劫舍 II - 力扣(LeetCode)
在这里,我们会发现这个问题:
为什么动态规划只能解决线性的问题
如果屋子是环形的,那么在偷第一件屋子的时候,会参考最后一间屋子的状态。同样,最后一间屋子的状态会再向前参考,一直再回到第一间屋子,进入了一个死循环。这就是为什么说,一定要按顺序去填dp表,因为我们只能参考已知的状态,不能参考未知的状态,否则很容易陷入死循环。
但是解决这个问题的方案也很简单——将非线性变为线性。同时,这也是解决非线性动态规划的第一思路:变为线性的情况。
在这里应该怎么做?我们去思考改变带来的影响是什么——最后一间屋子和第一间屋子多了一个限制,所以,我们只要把最后一间屋子和第一间屋子单独拎出来,便成为了普通的打家劫舍。
这就是非线性变为线性的思想。
class Solution {
public:
int robRange(vector<int>& nums, int start, int end) {
int first = nums[start], second = max(nums[start], nums[start + 1]);
for (int i = start + 2; i <= end; i++) {
int temp = second;
second = max(first + nums[i], second);
first = temp;
}
return second;
}
int rob(vector<int>& nums) {
int length = nums.size();
if (length == 1) {
return nums[0];
} else if (length == 2) {
return max(nums[0], nums[1]);
}
return max(robRange(nums, 0, length - 2), robRange(nums, 1, length - 1));
}
};
股票系列
股票系列是多状态的终极问题。并不是股票系列难,而是股票系列可以产生很多状态,而且不同的题目分析状态的方法也不同。但是,既然是多状态问题,其解题的方法一定是固定的,仍是举出所有的状态,然后理清状态之间的关系,无非是多几个状态转移方程。
188. 买卖股票的最佳时机 IV - 力扣(LeetCode)
题目翻译
股票的价格每天都在波动,买入和卖出都是当天的价格。但是在一天只能进行买入或者卖出之一的操作,不能当天买入当天卖出,并且手上只能有一支股票。问在只买卖k次的情况下,能获得的最大收益。
解题步骤
1.状态表示
不用多解释,单状态肯定是表示不出来的。因为如果我们用dp[i]表示第i天的最大收益,我们不知道第i天已经买卖了多少次,后续的状态也无法推导。
那我们应该怎么去补充状态呢?
还是和打家劫舍的思路一样,多状态是多个单状态,多个dp表只是为了补充单状态的缺失情况。单状态缺少的是第i天交易了几次的状态,那我们只需要用多张dp表,分别来表示第i天交易了j次的状态,那所有的状态都可以正常表示了。
但是,如果采用打家劫舍的思想,分别用fdp和gdp来表示两种状态,会出现一个问题:我们不知道究竟要开几张dp表。因为如果按照刚刚的状态表示,如果有3次买卖机会,那么就是4张dp表(0,1,2,3次);如果5次机会便是6张,这个dp表的数量是不固定的。
这个时候一个显而易见的思路——用二维数组的方式。因为二维数组的开辟空间是可变的。
状态表示:
- fdp[i][j]表示第i天已经交易了j次,此时手上没有持有股票,可以获得的最大收益。
- gdp[i][j]表示第i天已经交易了j次,此时手上持有股票,可以获得的最大收益。
2.状态转移方程
股票系列的最大难度,就在于状态繁多。这个时候,我们可以用一个新概念解决这个问题——状态机。
这个词就不必过多深入了解,用人话来解释状态机便是——枚举。
枚举出来后,再用式子来分别表示:
- fdp[i][j]=fdp[i-1][j]
- fpd[i][j]=gpd[i-1][j-1]+cost[i]
- gdp[i][j]=gdp[i-1][j]
- gdp[i][j]=fdp[i-1][j]-cost[i]
但是题目的要求是最大值,所以只需要取最大
最终的状态表示便为:
3.初始化
初始化部分已经在状态表示中说明了,此时只需要注意一个点:j-1不要越界。
并且在这里介绍一个神奇的数字:
这个数字会在算法题中经常见到。是INT_MAX的一半,但是在算法题中,如果我们直接把最大值定为INT_MAX,可能会导致最大值加上一个数后,变为了负数最小值。为了这个避免这个问题,用便可以表示可进行计算的最大值,同样,自然就成为了可理论计算的最小值。
解题代码
class Solution {
public:
int MIN=-0x3f3f3f3f;
int maxProfit(int k, vector<int>& prices) {
int day=prices.size();
vector<vector<int>> ondp(day);
vector<vector<int>> offdp(day);
for(int i=0;i<day;i++)
{
ondp[i].resize(k+1);
offdp[i].resize(k+1);
}
ondp[0][0]=-prices[0];
offdp[0][0]=0;
for(int i=1;i<k+1;i++)
{
ondp[0][i]=MIN;
offdp[0][i]=MIN;
}
for(int i=1;i<day;i++)
{
for(int j=0;j<k+1;j++)
{
ondp[i][j]=max(ondp[i-1][j],offdp[i-1][j]-prices[i]);
offdp[i][j]=offdp[i-1][j];
if(j>=1)
offdp[i][j]=max(offdp[i-1][j],ondp[i-1][j-1]+prices[i]);
}
}
int ret=0;
for(auto e:offdp.back())
{
if(e>ret)
ret=e;
}
return ret;
}
};
类似题目
面试题 17.16. 按摩师 - 力扣(LeetCode)
LCR 091. 粉刷房子 - 力扣(LeetCode)
309. 买卖股票的最佳时机含冷冻期 - 力扣(LeetCode)
子数组问题
简介
子数组问题的套路更加固定,状态表示几乎只有一种:以第i个元素为结尾,能满足条件的数组。因为子数组一定是连续的,以第i个元素为结尾,无论前有多少个元素,一定包含着第i个元素,所以看到子数组问题,直接列出dp表,dp[i]表示以第i个元素为结尾时满足的条件,然后再推导状态表示方程。
在这里,我挑选了一道最为经典和简单的子数组问题,其他的无非是在子数组的基础上加入多状态。
53. 最大子数组和 - 力扣(LeetCode)
题目翻译
有一串数组,子数组是其中连续的一串,求所有子数组中最大的和。
解题步骤
1.状态表示
和在简介中说的一样,dp[i]表示以i为结尾所有子数组中,最大子数组和。
比如题目翻译中的数组,以哟西结尾的数组中,只有:牢大和哟西;哟西;两个子数组。
2.状态转移方程
从dp[i]前面的状态推导出dp[i],只有两种情况:子数组包含i-1,子数组不包含i-1。因为如果包含了i-1,就是要求以i-1为结尾的所有子数组中最大和,然后再加上num[i],便是以i为结尾的所有子数组最大和。此时,以i-1为结尾的所有子数组中最大和恰好就是dp[i-1]的状态表示,所以我们可以直接列出式子:
- dp[i]=num[i](不包含i-1)
- dp[i]=dp[i-1]+num[i](包含i-1)
因为题目要求最大值,所以状态转移方程为:
3.初始化
没有太多要注意的点
解题代码
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n=nums.size();
vector<int> dp(n);
dp[0]=nums[0];
for(int i=1;i<n;i++)
{
dp[i]=max(0,dp[i-1])+nums[i];
}
int ret=dp[0];
for(auto e:dp)
ret=max(e,ret);
return ret;
}
};
类似题目
918. 环形子数组的最大和 - 力扣(LeetCode)
152. 乘积最大子数组 - 力扣(LeetCode)
139. 单词拆分 - 力扣(LeetCode)
子序列问题
简介
子序列问题其实和子数组问题大差不差。子序列指的是保持元素的排列顺序不变,在其中可以不连续地选取元素。而因为其不连续性,所以无法只看i-1的情况,反之必须将从0到i-1全部遍历一遍,不过状态表示仍为dp[i]表示以i为结尾满足题目条件的子序列。
同样,在此只挑选出子序列最经典的题目,其他题目只不过是在子序列的基础上参入其他问题,解题方法还是一样的。
300. 最长递增子序列 - 力扣(LeetCode)
题目翻译
在保持元素顺序不变的情况下,从中随意挑选元素使其保持递增,求挑选元素最多的个数。
解题步骤
1.状态表示
和简介中一样,dp[i]表示以i为结尾,最长子序列的长度。
2.状态转移方程
和子数组不同的是,子序列不一定要求连续。也就是说,从0到i-1的任何dp[k],都可以推导出dp[i]。此时,我们只能遍历所有的0到i-1,然后把所有的可能都列举出来
- 如果num[i]>num[k],则其可以作为子序列的最后一个元素,dp[i]=dp[k]+1
- 如果num[i]<=num[k],则其不能作为子序列的最后一个元素,dp[i]=1
因为题目要求最大值,所以状态转移方程为:
解题代码
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n=nums.size();
vector<int> dp(n);
dp[0]=1;
for(int i=1;i<n;i++)
{
int ins=0;
for(int j=0;j<i;j++)
{
if(nums[i]>nums[j])
{
ins=max(ins,dp[j]);
}
}
dp[i]=ins+1;
}
int ret=0;
for(auto e:dp)
{
if(e>ret)
ret=e;
}
return ret;
}
};
类似题目
376. 摆动序列 - 力扣(LeetCode)
646. 最长数对链 - 力扣(LeetCode)
446. 等差数列划分 II - 子序列 - 力扣(LeetCode)
回文串问题
简介
动态规划并不是回文串问题最好的解决方法。但是,动态规划是对回文串问题的预处理,其目的是判断其中所有的子串是不是回文串,然后在预处理的基础上解决问题。遇见回文串问题,直接无脑写入代码预处理子串,然后再根据题目要求来解决接下来的问题。
而所有的回文串预处理都是同样的代码:dp[i][j]表示从i到j是不是回文串,遍历所有的i和j就可以知道所有的子串是否为回文串。
而状态转移方程也很简单。如果s[i]==s[j],那么只需要满足dp[i+1][j-1]是回文串,两边再加上同样的字符,自然新的子串也是回文串。不过,i+1和j-1可能会出现i+1>j-1的情况,其意义是两个字符中间没有任何字符。但是这也满足回文串的要求,所以其也为一个回文串。
通用代码
//假如给的是一个字符串s
int n=s.size();
vector<vector<bool>> dp(n+1,vector<bool>(n+1));
//初始化dp表,因为是从i到j,所以必须要二维
//从后往前遍历,如果从前往后则参考了未知状态
for(int i=n;i>0;i--)
{
for(int j=i;i<n+1;j++)
{
if(s[i-1]==s[j-1])
{
//如果i+1<=j-1,表示中间还有字符,需要判断dp[i+1][j-1]
//否则,中间没有字符,其直接为一个回文串
dp[i][j]=i+1<j-1?true:dp[i+1][j-1];
}
}
}
//预处理完毕,可以知道从i到j的所有子串是否为回文串
所以题目看到是回文串就可以把以上代码先丢上去了。
挑选一道经典的题目:LCR 020. 回文子串 - 力扣(LeetCode)
(因为过于简单就不翻译了)
解题步骤
看一眼题目,是不是回文子串?食德,直接预处理:
int countSubstrings(string s) {
int n=s.size();
vector<vector<bool>> dp(n,vector<bool>(n));
for(int i=n-1;i>=0;i--)
{
for(int j=i;j<n;j++)
{
if(s[i]==s[j])
{
dp[i][j]=i+1<j?dp[i+1][j-1]:true;
}
}
}
}
然后,我们便知道所有子串是否为回文串。之后,我们再去看题目要求:求回文子串的数目。既然都已经知道了所有子串是否为回文串,那只需要再遍历一遍dp表,然后如果dp[i][j]==true就多计数一次,最终得到的值就是回文子串的个数。
int countSubstrings(string s) {
int ret=0;
for(int i=n-1;i>=0;i--)
{
for(int j=i;j<n;j++)
{
if(dp[i][j]==true)
ret++;
}
}
return ret;
}
而且,这两步其实可以同时进行,不过对解题没有影响。最终得到的代码为:
解题代码
class Solution {
public:
int countSubstrings(string s) {
int n=s.size();
int ret=0;
vector<vector<bool>> dp(n,vector<bool>(n));
for(int i=n-1;i>=0;i--)
{
for(int j=i;j<n;j++)
{
if(s[i]==s[j])
{
dp[i][j]=i+1<j?dp[i+1][j-1]:true;
}
if(dp[i][j])
ret++;
}
}
return ret;
}
};
类似题目
1745. 分割回文串 IV - 力扣(LeetCode)
LCR 094. 分割回文串 II - 力扣(LeetCode)
516. 最长回文子序列 - 力扣(LeetCode)
两个数组的dp问题
简介
两个数组的动态规划,解题也比较公式化,状态表示也经常只有一种:以第一个数组的i为结尾,第二个数组的j为结尾,满足题目条件。不过,两个数组的dp问题最难的地方在于条件杂多,其思考部分甚至不如单状态的dp,但是不断枚举很容易将人写红温。
当然,我们不挑这个私人题目,我们还是从最经典的入手
1143. 最长公共子序列 - 力扣(LeetCode)
题目翻译
有两个数组,求其所有子序列组合中,最长的相同子序列长度。
解题步骤
1.状态表示
和简介中说的一样,我们需要开辟二维dp表,其中dp[i][j]表示以第一个数组第i元素为结尾,第二个数组第j元素为结尾, 满足条件即完全相同的最长子序列长度。我们很容易发现,这又转换成了一个子序列的小问题,这也是两个数组dp问题最常见的现象——套入公式,然后变成其他的问题,而往往其他问题也是公式解决。
2. 状态转移方程
既然变成了子序列的问题,那我们免不了用子序列的思想去思考问题。但是,题目给我们的条件也不能忽略:子序列必须要相同。
第一个数组以i为结尾,第二个数组以j为结尾,那就会分为两种情况:test1[i]是否等于test2[j]
如果test1[i]等于test2[j],那么只要在dp[i-1][j-1]的基础上加上这个公共字符,就是新的最长公共序列。
反之,如果不等于,则其无法组成新的最长公共序列,只能从旧的已知最长值中取最大值
列为式子为:
- 如果test[i]==test[j],dp[i][j]=dp[i-1][j-1]+1;
- 否则,dp[i][j]=max(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])
同时,我们发现在不等的情况,dp[i-1][j-1]被其他两种情况已经包含了,所以最终的状态转移方程为:
解题代码
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int n=text1.size();
int m=text2.size();
vector<vector<int>> dp(n+1,vector<int>(m+1));
for(int i=1;i<n+1;i++)
{
for(int j=1;j<m+1;j++)
{
if(text1[i-1]==text2[j-1])
{
dp[i][j]=dp[i-1][j-1]+1;
}
else
{
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
}
}
}
return dp[n][m];
}
};
类似题目
1035. 不相交的线 - 力扣(LeetCode)
44. 通配符匹配 - 力扣(LeetCode)
10. 正则表达式匹配 - 力扣(LeetCode)(私人题目,慎做)
LCR 096. 交错字符串 - 力扣(LeetCode)
背包问题
简介
背包问题是动态规划里最重要的问题!
背包问题在动态规划里的地位相当于3D区里的蒂法,不仅特别常考,而且也是难度最大的一类问题。
如果背包问题再往深了讲,可能会涉及到禁赛的难度,所以在此只介绍两个问题——01背包和完全背包。
背包问题,用人话来解释,就是0元购,但是背包的容量有限,要从这些有限的容量中找到最优解。其中又分为01背包和完全背包:
- 01背包是每个商品只有一个
- 完全背包是每个商品的供应是无限的
但是01背包和完全背包虽然被分为了两类题目,其解题却是没有什么区别的,而且解题方法也很套路化。其中会用到一些数学推导,但是只要文化水平比丁真高一定能看得懂。
01背包
LCR 101. 分割等和子集 - 力扣(LeetCode)
题目翻译
给一个数组,将数组分为两个部分,使两个数组的和相同。
或者换句话说,数组的和必须为偶数,并且从数组中挑取元素,看能否有一种组合使得和刚好为数组和的二分之一。
解题步骤
1.状态表示
背包问题的状态表示很固定,虽然题目给出的是一维的,但是我们往往需要开出给出的维度+1的多维dp表。
说人话就是,题目给出了一维数据,我们则需要开辟二维dp表;题目给二维数据,我们就要开二维dp表。
为什么?
因为我们不仅要表示价值,我们还要表示一个隐藏的限制条件——背包的容量。
就比如在这里,dp[i][j]表示,只取数组中前i个数,能否达到和为j。
但是我们将题目换一下,用简介中给出的情况
那么dp[i][j]表示,只取前i个物品,最大容积为j的时候,能取到最大的价值。
找到状态表示的共性了吗?
背包问题的特性便是给出很多选择,然后在一定的限制条件下挑选。我们可以分选择和限制条件两方面来思考,其中我们从最少的选择开始,一直到所以的选择,而限制条件我们一样从最局限的条件开始,慢慢宽松。最少的选择和最局限的条件一定是最容易求解的,而在慢慢扩大的时候,我们便可以借用之前已经求解到的条件,推导出最后的答案。
虽然我们的状态表示看似和题目没有什么关系,但其实只是从问题的特性由小到大推导,这便是背包问题的隐藏条件。
2.状态转移方程
状态转移方程在想明白了状态表示的隐藏条件后,便容易推导了许多。
想到达dp[i][j]这个状态,肯定是从dp[i-1]最好得到。但是到达了dp[i],也会有两种情况:元素i到底取不取呢?
如果不取,则很简单,dp[i][j]=dp[i-1][j]
但是如果取了,就代表原本可以组合产生和为j的元素组,现在可以使和为j+nums[i];我们可以反向思考,如果现在可以组合产生和为j的元素组,并且这个元素组包含nums[i],则只要dp[i-1]可以和为j-num[i],就能满足条件。即dp[i][j]=dp[i-1][j-nums[i]]
用式子表示,便是
- dp[i][j]=dp[i-1][j]
- dp[i][j]=dp[i-1][j-nums[i]]
因为只要满足一种条件便可以,所以状态转移方程为:
同时要注意,j-nums[i]不能越界。
3.初始化
数组的大小n自然不可少。同时,再带上一个隐藏条件体积,也就是数组元素和的二分之一,所以我们初始化dp表为横为数组大小n,纵为数组元素和二分之一的dp表。
解题代码
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n=nums.size();
int sum=0;
for(auto e:nums)
sum+=e;
if(sum%2==1)
return false;
int target=sum/2;
vector<vector<bool>> dp(n+1,vector<bool>(target+1));
for(int i=0;i<n+1;i++)
dp[i][0]=true;
for(int i=1;i<n+1;i++)
{
for(int j=1;j<target+1;j++)
{
dp[i][j]=dp[i-1][j]|((j-nums[i-1]>=0)&&(dp[i-1][j-nums[i-1]]));
}
}
return dp[n][target];
}
};
类似题目
LCR 102. 目标和 - 力扣(LeetCode)
1049. 最后一块石头的重量 II - 力扣(LeetCode)
完全背包
LCR 103. 零钱兑换 - 力扣(LeetCode)
解题步骤
1.状态表示
完全背包和01背包几乎一模一样,dp[i][j]表示只用前i种零钱,组成和为j的最少钞票数。
2.状态转移方程
也通过同样的方式推导,不过完全背包会多出几种情况:
一个钞票,01背包我只能取一张,但是完全背包我可以取好多张,也就是说
所以上下两个式子,虽然形式不同,但是其表示的意义都是完全一样的。为了避免使用太多的k,我们直接使用下面的式子dp[i][j]=dp[i][j-nums[i]]+1
而实际上,这个规律可以推广到所有完全背包的状态转移方程中,即相比01背包,我们可以从同行的数据中找到状态转移的关系。这也成完全背包问题的一大解题技巧。
最终的状态表示为:
其中要保证j-nums[i]不越界
类似题目
518. 零钱兑换 II - 力扣(LeetCode)
279. 完全平方数 - 力扣(LeetCode)
总结
而以上,就是动态规划的所有题型以及相应的应对策略。在看到一个动态规划题目,首先去思考是哪类动态规划的问题,然后套入那类题目的公式,再根据题目要求推出状态转移方程。只从笔试面试的角度来看,动态规划逃不出这几种大题型,也就是说等把文章里的题目全部做完,