647. 回文子串
动规五部曲
**dp数组含义:**dp[i][j]代表s[i,j]是否是回文子串,true或者false;
**递推公式:**当s[i]==s[j]相等,则需要看j和i差多少,要是差的值不超过2,则一定是回文子串;
如果差的值超过了2,则需要看dp[i+1][j-1],也就是区间左右缩减,看里面是不是回文子串,如果里面是,外面也一定是回文子串,如果里面不是外面也不是;
当s[i]和s[j]不相等,一定不是,直接false(初始化时已覆盖,该逻辑可以不写);
**初始化:**全部初始化为false即可,也就是默认全部都不是回文子串,通过逻辑一个个判别。
**遍历顺序:**由于递推公式方向是从右下到左上,所以是i是倒序遍历,j是顺序遍历,由于要组成区间,j>=i,所以j从i开始遍历;
cpp
class Solution {
public:
int countSubstrings(string s) {
vector<vector<bool>> dp(s.size(),vector<bool>(s.size(),false));
int result = 0;
for(int i=s.size()-1; i>=0;i--){
for(int j=i;j<s.size();j++){
if(s[i]==s[j]){
if(j-i<=1){
dp[i][j] = true;
result++;
}else{
if(dp[i+1][j-1] ==true){
dp[i][j] = true;
result++;
}
}
}
}
}
return result;
}
};
516.最长回文子序列
动规五部曲
**dp数组含义:**dp[i][j]代表s[i,j]中的最长回文子序列长度;
**递推公式:**当s[i]==s[j]相等,则直接在dp[i+1][j-1]的基础上+2。因为对回文串两边加上一样的元素,还是回文子串,并且长度加了2;
当s[i]和s[j]不相等,左右可以尝试删除某一个,看删除哪个之后最长回文子序列长度最大,也就是max(dp[i+1][j],dp[i][j-1])
初始化: 由于递推是从左下到右上,左到右,下到上 三个方向递推,而且j是大于等于i 的,所以也就是右上半区域 ,所以最基础的情况就是对角线,所以要初始化对角线,也就是初始化单元素情况,全为1即可。
遍历顺序: 由于递推方向左下到右上,左到右,下到上可得出和上题相同,i倒序遍历,j顺序遍历
cpp
class Solution {
public:
int longestPalindromeSubseq(string s) {
vector<vector<int>> dp(s.size(),vector<int>(s.size(),0));
for(int i=0;i<s.size();i++) dp[i][i] =1;
for(int i=s.size()-1; i>=0;i--){
for(int j=i+1;j<s.size();j++){
if(s[i]==s[j]){
dp[i][j] = dp[i+1][j-1]+2;
}else{
dp[i][j] = max(dp[i+1][j],dp[i][j-1]);
}
}
}
return dp[0][s.size()-1];
}
};
动态规划总结
动态规划章节学习了背包问题、打家劫舍问题、股票问题、子序列问题(包括编辑距离、回文子串)
背包问题:抓住"物品能用几次"和"求什么"
背包问题先判断物品能不能重复使用。物品只能用一次,是 01 背包;物品可以重复使用,是完全背包。二维写法更直观,一维写法更常用,但一维写法必须特别注意遍历顺序。
01 背包一维递推通常是:
cpp
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
因为每个物品只能用一次,所以背包容量 j 必须倒序遍历,避免同一个物品在本轮被重复使用。像 416、1049 本质都是 01 背包,只是value和weight可能都等于物品重量,目标是尽量装满某个容量。
完全背包的递推形式类似,但因为物品可以重复使用,所以容量通常正序遍历。若题目问组合数,例如 518 零钱兑换 II,要先遍历物品、再遍历背包,这样不会把不同顺序重复计算;若题目问排列数,例如 377 组合总和 IV,要先遍历背包、再遍历物品,这样不同顺序会被当成不同方案。
如果题目问最少个数,例如 322 零钱兑换、279 完全平方数,常见递推是:
cpp
dp[j] = min(dp[j], dp[j - coin] + 1);
这类题重点不是方案数,而是"最小代价",所以初始化要特别注意:dp[0]=0,其他位置通常初始化为较大值,表示暂时不可达。
打家劫舍:本质是"当前取不取"
打家劫舍系列的核心判断是:当前位置偷,还是不偷。
198 是线性数组,递推很直接:
cpp
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
意思是第i家不偷,则继承i-1的结果;第 i 家偷,则必须加上i-2的结果。
213 是环形数组,因为首尾不能同时偷,所以拆成两个线性问题:不考虑最后一家,或者不考虑第一家,分别跑一次 198 的逻辑,最后取最大值。
337 是树形打家劫舍,不能再按数组顺序递推,而是用后序遍历。每个节点维护两个状态:偷当前节点、不偷当前节点。偷当前节点时,左右孩子不能偷;不偷当前节点时,左右孩子可以偷也可以不偷,取各自最大值。
股票问题:核心是"第几次交易"和"是否持有"
股票系列不要死记代码,先定义状态。最常见的状态是:第i天,当前是否持有股票,以及已经完成了几次交易。
121 只能买卖一次,维护最低买入价格即可,也可以写成两个状态:持有、不持有。
122 可以买卖无限次,只要上涨就可以累加利润。用动态规划时也是两个状态:持有股票和不持有股票。
123 最多买卖两次,可以扩展成五个状态:无操作、第一次持有、第一次不持有、第二次持有、第二次不持有。
188 最多买卖 k 次,本质是把 123 的状态推广到2k+1个状态。奇数状态通常表示持有,偶数状态表示卖出后不持有。
309 加入冷冻期后,不持有状态要进一步拆开,例如:保持不持有、今天卖出、冷冻期。区别在于今天能不能买入。
714 加入手续费后,本质仍然是持有和不持有,只是在买入或卖出时扣手续费即可。
子序列问题:区分"连续"和"不连续"
子序列问题最容易混的是:连续子数组、普通子序列、编辑距离、回文子序列。判断标准是:题目是否要求连续。
最长连续公共子数组,例如 718,要求连续,所以如果两个元素不相等,当前连续长度直接断掉,不从左边或上边取最大值。递推通常是:
cpp
if (nums1[i - 1] == nums2[j - 1])
dp[i][j] = dp[i - 1][j - 1] + 1;
最长公共子序列,例如 1143,不要求连续,所以不相等时要继承前面的最优结果:
cpp
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
判断子序列,例如 392,本质是看短字符串能不能被长字符串匹配出来。因为只能在长字符串中跳过字符,所以不匹配时通常继承 dp[i][j - 1],而不是像 LCS 那样两边都取最大。
编辑距离类题目,例如 583、72,要围绕"增、删、改"来理解。583 只允许删除,所以本质是让两个字符串变成相同;72 可以增删改,所以状态转移更完整。
回文类题目要注意遍历方向。516 最长回文子序列中,dp[i][j] 表示区间 [i, j] 内的最长回文子序列长度。因为它依赖 dp[i + 1][j - 1]、dp[i + 1][j]、dp[i][j - 1],所以 i 要从后往前遍历,j 从 i + 1 往后遍历。对角线 dp[i][i] = 1,因为单个字符本身就是长度为 1 的回文子序列。
总结:动态规划的主线
动态规划不是背公式,而是先定义状态,再让状态之间发生转移。
背包问题看"物品使用次数"和"组合/排列/最值";打家劫舍看"偷不偷";股票问题看"持有状态和交易次数";子序列问题看"是否连续、是否允许编辑、区间如何缩小"。
只要dp含义、递推公式、初始化、遍历顺序能互相解释,代码就能搞定。