每一种算法都最好看完第一篇再去找要看的博客,因为这样会帮你梳理好思路,看接下来的博客也就更轻松了。当然,我也会尽量在写每一篇时都可以不懂这个算法的人也能边看边理解。
动规的思路有五个步骤,且最好画图来理解细节,不要怕麻烦。当你开始画图,仔细阅读题时,学习中的沉浸感就体验到了。
状态表示
状态转移方程
初始化
填表顺序
返回值
动规一般会先创建一个数组,名字为dp,这个数组也叫dp表。通过一些操作,把dp表填满,其中一个值就是答案。dp数组的每一个元素都表明一种状态,我们的第一步就是先确定状态。
状态的确定可能通过题目要求来得知,可能通过经验 + 题目要求来得知,可能在分析过程中,发现的重复子问题来确定状态。还有别的方法来确定状态,但都大同小异,明白了动规,这些思路也会随之产生。状态的确定就是打算让dp[i]表示什么,这是最重要的一步。状态表示通常用某个位置为结尾或者起点来确定,这点在下面的题解中慢慢领会。
状态转移方程,就是dp[i]等于什么,状态转移方程就是什么。像斐波那契数列,dp[i] = dp[i - 1] + dp[i - 2]。这是最难的一步。一开始,可能状态表示不正确,但不要紧,大胆制定状态,如果没法推出转移方程,没法得到结果,那这个状态表示就是错误的。所以状态表示和状态转移方程是相辅相成的,可以帮你检查自己的思路。
要确定方程,就从最近的一步来划分问题。
初始化,就是要填表,保证其不越界。像第一段所说,动规就是要填表。比如斐波那契数列,如果要填dp[1],那么我们可能需要dp[0]和dp[-1],这就出现越界了,所以为了防止越界,一开始就固定好前两个值,那么第三个值就是前两个值之和,也不会出现越界。初始化的方式不止这一点,有些问题,假使一个位置是由前面2个位置得到的,我们初始化最一开始两个位置,然后写代码,会发现不够高效,这时候就需要设置一个虚拟节点,一维数组的话就是在数组0位置处左边再填一个位置,整个dp数组的元素个数也+1,让原先的dp[0]变为现在的dp[1],二维数组则是要填一列和一行,设置好这一行一列的所有值,原先数组的第一列第一行就可以通过新填的来初始化,这个初始化方法在下面的题解中慢慢领会。
第二种初始化方法的注意事项就是如何初始化虚拟节点的数值来保证填表的结果是正确的,以及新表和旧表的映射关系的维护,也就是下标的变化。
填表顺序。填当前状态的时候,所需要的状态应当已经计算过了。还是斐波那契数列,填dp[4]的时候,dp[3]和dp[2]应当都已经计算好了,那么dp[4]也就出来了,此时的顺序就是从左到右。还有别的顺序,要依据前面的分析来决定。
返回值,要看题目要求。
==这篇博客需要从头开始看,后面的题会用到前面的思路。==回文子串问题,通常以二维数组来做dp表,状态表示通常是一个区间里的回文子串。
647. 回文子串
不同位置为开始或者结束的子串,也是不同子串。
确定状态。这里应当如何确定?我们能否把所有子串是否是回文的信息保存在dp表中?定义i和j,让i从第一个元素开始,j从i开始,一直到末尾,列举出所有的回文子串,然后i往后一步,j再从i开始走,i之前的不需要考虑,因为之前都已经表示过了,有了相同的子串。这样的话,就定义一个二维数组,dp[i][j]表示s字符串[i, j]的子串是否是回文串。
判断回文串,首尾元素不相等,就是false;如果s[i] == s[j],那么也有多种情况,i等于j时,是回文。i + 1 = j时,这个也是回文子串,i和j之间有多个元素时就不仅要判断i和j位置是否相等,还需要中间的这些元素是否也符合要求,也就是dp[i + 1][j - 1]。
不需要初始化,只要填表时保证i < = j就行。填表顺序是从下往上,因为需要dp[i + 1][j - 1]。返回值就是dp表中true的个数。
int countSubstrings(string s) {
int n = s.size();
vector<vector<bool>> dp(n, vector<bool>(n));
int ret = 0;
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;
}
一定要先看第2个,回文子串。以下的题解基于第2题而写。
5. 最长回文子串
这个题要的是最长的回文子串,而不是回文子串个数。
dp[i][j]存储s字符串中[i, j]的子串是否是回文串。s[i] != s[j],false。s[i] 等于s[j],i 等于 j ,true;i + 1 等于 j,true;i + 1 < j,dp[i][j] = dp[i + 1][j - 1]。返回值,找到值为true的最大长度,返回起始位置即可。
string longestPalindrome(string s) {
int n = s.size();
vector<vector<bool>> dp(n, vector<bool>(n));
int len = 1, begin = 0;
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] && j - i + 1 > len)
len = j - i + 1, begin = i;
}
}
return s.substr(begin, len);
}
一定要先看第2个,回文子串。以下的题解基于第2题而写。
1745. 分割回文串 IV
三个部分必须都是回文字符串。
我们设置i和j两个位置来分割成3个字符串,我们得判断3个部分是否是回文子串。回想第2题,我们把子串是否是回文串的信息放在了dp表,那不如先把这个dp表搞出来,然后将分出来的三个区间,依次到dp表中查找是否为true就好了。
bool checkPartitioning(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;
}
}
for(int i = 1; i < n - 1; i++)
{
for(int j = i; j < n - 1; j++)
{
if(dp[0][i - 1] && dp[i][j] && dp[j + 1][n - 1])
return true;
}
}
return false;
}
132. 分割回文串 II
确定状态。惯用以i位置为结尾。如果0 ~ i这个区间的最长的那个子串,也就是起始位置是0 ~ i的子串,它的最少分割次数。如果0~i长度的这个子串本身就是回文串,那么就是0;如果不是,我们设置一个切割点为j,分成两部分,0 ~ j - 1,j ~ i,如果j ~ i是回文串,那么只要考虑前面部分就好,也就是dp[j - 1],再+1,如果j ~ i不是回文串,那么这次切割有问题,就不继续这种情况了。j可以取多种切割点,所以dp[j - 1]也得找最小,再 + 1。
由于上面的分析中,我们需要判断是否是回文,并且不是常规的循环判断,因为这样时间复杂度太大了。根据之前的题解,这里的做法就是创建一个二维dp表,将所有子串是否是回文的消息,保存在dp表中。
初始化。j不可能等于0,因为j等于0,就说明不切割,也就是0 ~ i整个字符串进行判断,这点在上面的分析中出现。但因为我们要求min,为了第一次判断不被干扰,我们需要把dp表初始化为无穷大。填表顺序是从左到右,返回值就是dp[n - 1]。
int minCut(string s) {
int n = s.size();
vector<vector<bool>> isPal(n, vector<bool>(n));
for(int i = n - 1; i >= 0; i--)
{
for(int j = i; j < n; j++)
{
if(s[i] == s[j])
isPal[i][j] = i + 1 < j ? isPal[i + 1][j - 1] : true;
}
}
vector<int> dp(n, INT_MAX);
for(int i = 0; i < n; i++)
{
if(isPal[0][i]) dp[i] = 0;
else
{
for(int j = 1; j <= i; j++)
{
if(isPal[j][i])
dp[i] = min(dp[i], dp[j - 1] + 1);
}
}
}
return dp[n - 1];
}
516. 最长回文子序列
按照做过很多动规题的经验来看,dp[i]可以表示以i位置为结尾,最长的回文子序列。以i位置为结尾,那么必然会用到i - 1,i - 2, i - 3位置的元素,因为题目要求是形成序列,但无论选哪个位置,都可以发现最根本的问题是否是回文字符串,这个没有解决,dp表存的是长度,但是不是回文子序列的长度,我们不知道是不是回文字符串,所以dp表表示长度是不行的,dp表得表示子串是否是回文字符串,然后用起始位置来获得长度,说到这里,就会发现有些思路,代码写法和之前的题相似。
i ~ j之间的字符串是回文的,如果前后各加上一个相同的字符,那么肯定也还是回文串。让dp[i][j]表示s字符串[i, j]区间内的所有子序列,最长的长度。如果s[i] == s[j],那就往里走,s[i + 1]和s[j - 1],但必须得能往里走,如果i 等于 j,那么长度就是1,如果i + 1 = j,那么i和j两个元素构成回文序列,长度为2,都不满足的话,才能是dp[i + 1][j - 1] + 2。如果s[i] != s[j],那么就不能同时以i和j为开头和结尾,那就换成i + 1 ~ j和i ~ j - 1两个区间找,然后取最大值即可。
初始化,其实不用,画二维数组的图来理解一下上面s[i] 等于 s[j]的3个情况和s[i] != s[j]的两个情况,能够发现没有越界的情况,所以不需要初始化。填表顺序是从上到下填每一行,每一行从左到右。返回值是dp[0][n - 1]的最长长度。
int longestPalindromeSubseq(string s) {
int n = s.size();
vector<vector<int>> dp(n, vector<int>(n));
for(int i = n - 1; i >= 0; i--)
{
dp[i][i] = 1;//1个字符肯定为1
for(int j = i + 1; j < n; 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][n - 1];
}
1312. 让字符串成为回文串的最少插入次数
确定状态。经过以上的题解,我们的经验面对这个问题,就不是以i位置结尾了,而是确定一个区间,i <= j,dp[i][j]表示s里面[i, j]区间内的子串,使它成为回文串的最小插入次数。
当s[i] == s[j]时,如果i等于j,那么就是一个字符,次数为0;如果i + 1 = j,那么就是两个字符,次数还是0;如果都不满足,那么ij相等的话,就看i + 1和j - 1位置了,所以此时dp[i][j] = dp[i + 1][j - 1]。如果s[i] != s[j],那么就换成i和j - 1,或者i + 1和j两个区间去找,相当于i左边添加一个和s[j]相同的字符,就变成那么i - 1和j一样,就比较i和j - 1,另一个也是,所以就是dp[i][j - 1] + 1和dp[i + 1][j] + 1,然后两个值取小。
初始化不需要初始化,因为不会有越界情况。i + 1,j - 1,j在i右边。填表顺序,对于二维数组,从上到下,每一行从左到右。返回值dp[0][n - 1]。
int minInsertions(string s) {
int n = s.size();
vector<vector<int>> dp(n, vector<int>(n));
for(int i = n - 1; i >= 0; i--)
{
for(int j = i + 1; j < n; j++)
{
if(s[i] == s[j]) dp[i][j] = dp[i + 1][j - 1];
else dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
}
}
return dp[0][n - 1];
}
结束。