回文串专题总结
本想起个题目叫“关于回文串的一切”,但是水平太有限了,还是叫专题总结好了。
视角主要是Leetcode的视角,进行了浅显的总结,如有疏漏错误还请告知。
回文串题目
目前我接触到的回文串题目有两类。
- 第一类是单纯考寻找或判断以及构造回文串的知识,这种通常比较模板化,比如中心拓展法,Manacher算法,KMP算法,解决这类问题通常需要一定的算法基础以及代码熟练度。
- 第二类是以回文串为背景,在此基础上考察回溯、DP等其他算法,这一类题变化多端,有的难度比较大。解决这类问题通常比较考验对各类算法的理解和变通,需要有比较强的泛化能力。
下面是对做过题目的梳理与知识点归纳:
需要秒出思路的题:
9. 回文数 【Easy,小套路 + 判断回文串】
- 判断是不是回文数
234. 回文链表 【Easy,链表反转 + 判断回文串】
- 判断是不是回文联表
5. 最长回文子串 【Medium,寻找满足条件的回文串】
- 求最长的回文子串长度
647. 回文子串 【Medium,判断回文串】
- 求回文子串的数量
131. 分割回文串 【Medium,判断回文串 + 回溯】
- 返回分割为一到多个回文串的所有方案
需要好好想一想的题
132. 分割回文串 II 【Hard,判断回文串 + DP】
- 返回分割为一或多个回文串的最小分割次数
1278. 分割回文串 III 【Hard,判断回文串 + DP】
- 允许修改以满足回文,问分割成k个回文子串需要修改的最小字符数
1312. 让字符串成为回文串的最少插入次数 【Hard,构造回文串 + DP】
- 返回成为回文串的最少插入次数
214. 最短回文串 【Hard,KMP算法构造回文串】
- ??????待总结
336. 回文对 【Hard,?????】
- ??????待总结
回文串基础
判断回文串
判断的方法很简单,如下面的函数所示,只需知道一个串以及它的开始与结束,从两侧到中心逐个判断即可。
bool isPalindrome(string &s, int begin, int end){
while(begin < end){
if(s[begin] != s[end]) return false;
++ begin;
-- end;
}
return true;
}
中心拓展法计算最长回文串
如果给你一个字符串,问你这个字符串中最长的回文串是哪个(或者问长度),典型例题是5. 最长回文子串。解决这类问题的最常规的方法就是中心拓展法。
中心拓展法唯一需要注意的是,最终回文串的长度可能是奇数,也可能是偶数,因此中心也存在两种,一种是以当前点为中心(对应奇数长度),另一种是以当前点和下一个点为中心(对应偶数长度)。
拓展函数的代码如下:
pair<int, int> expandAroundCenter(string& s, int left, int right){
while(left >= 0 && right < s.size() && s[left] == s[right]){
-- left;
++ right;
}
return {left + 1, right - 1};
}
需要注意的是,这个函数并不会主动寻找两种中心,而是需要调用的时候用两次:
for(int i = 0; i < s.size() - 1; ++i){
auto [left1, right1] = expandAroundCenter(s, i, i);
auto [left2, right2] = expandAroundCenter(s, i, i + 1);
//接着判断哪个最大,或者维护最大长度
}
中心拓展法的时间复杂度是\(O(N^2)\),\(N\)是字符串的长度。空间复杂度是\(O(1)\),即无需额外空间。
Manacher算法
我们仔细观察中心拓展法就可以发现,中间存在很多反复的判断,直觉上造成了一定的浪费。Manacher算法正是通过一个简单的方法利用了前面已经判断完的回文串的信息,来降低复杂度,一举将\(O(N^2)\)降低到了\(O(N)\),不过这种信息需要存储,也就是需要一定的空间代价,空间复杂度也从\(O(1)\)上升到了\(O(N)\)。
Manacher算法的核心是利用已知回文串的中心对称性,对未知中心拓展进行初始化,避免所有的中心拓展都从中心开始。
那它是怎么做的呢,参考【朝夕的ACM笔记】字符串-最长回文子串-Manacher算法中的插图,我给出了自己的理解与总结:
首先给出一个笼统的过程描述:给出一个字符串,假设我们准备寻找以s[i]为中心的回文串时,按照中心拓展的思想,需要从s[i]开始寻找。但是如果在s[i]左侧的,以s[(i+j)/2]为中心的回文串(即下图的“当前回文串”)已知,那么可以根据这个串的对称性,发现以s[i]和以s[j]中心的回文串是对称的,而以j为中心的回文串目前是已知的。因此,可以把以s[j]为中心的长度d[j]作为以s[i]为中心的回文串的长度d[i],即(d[i] = d[j]),在此基础上再进行中心拓展,避免了每一个字符串都从零开始慢慢寻找。这就是Manacher算法的核心思想。
那么实际的实现,需要维护两个东西来达到以上的目的:
- 一个是以每个字符为中心的最大回文半径d[i](它导致了空间复杂度的上升)
- 一个是所有已知回文串能达到的右侧最远的位置max_r,以及对应的回文串中心max_center
Manacher算法开始时就是普通的中心拓展法。不同的是,每次检验时都要注意是否被覆盖在max_center到max_r的范围内,如果在的话就要把它初始化,然后再进行中心拓展,然后再更新所需要记录的值(所以其实很简单)。
这里有一个需要注意的点,即如果我们找到的对应回文串d[j]的范围超过了 j - max_l(这里的max_l是右侧最远回文串的左端点,即max_l = 2*max_center - max_r),这时候不能把d[j]直接赋值给它,因为我们不知道超出右侧范围后的串的内容。这时候用来初始化的不应该是d[j],而是j - l。
最后一个问题,因为存在奇数串和偶数串,为了容易处理,可以把原串的间隙(包括最左端和最右端)插入‘#’,例如[abcd]就变成[#a#b#c#d#],这样操作后,无论怎样,串都是奇数个数,而中心点也只剩一个,上述的操作实现就不必再考虑两种情况了。
最后还是以5. 最长回文子串这个经典题为例子,给出实现代码:
时间复杂度:\(O(N)\)
空间复杂度:\(O(N)\)
class Solution {
public:
pair<int, int> expandAroundCenter(string &s, int left, int right){
while(left >= 0 && right < s.size() && s[left] == s[right]){
-- left;
++ right;
}
return {left + 1, right - 1};
}
string creatString(string& s){
string new_s = "#";
for(auto &x : s){
new_s += x;
new_s += '#';
}
return new_s;
}
// main function
string longestPalindrome(string s) {
string new_s = creatString(s);
int n = new_s.size();
vector<int> d(n + 1, 0);
int max_ceter = -1, max_r = -1;
for(int i = 0 ; i < n ; ++i){
int start_right = i;
int start_left = i;
// Manacher的核心:拓展前的初始化
if(i < max_r){
auto max_l = 2 * max_ceter - max_r;
auto temp_r = d[max_l] < max_r - i ? d[max_l] : max_r - i;
start_right = i + temp_r;
start_left = i - temp_r;
}
auto [left, right] = expandAroundCenter(new_s, start_left, start_right);
// 维护与更新
d[i] = (right - left) / 2;
if(right > max_r){
max_r = right;
max_ceter = i;
}
}
// 返回问题所需结果,根据问题的不同而变化
int max_index = 0;
for(int i = 0 ; i < n + 1 ; ++i){
if(d[i] > d[max_index]) max_index = i;
}
return s.substr(max_index / 2 - d[max_index] / 2, d[max_index]);
}
};
看起来代码长,但是如果理解了关键点其实很简单。
KMP算法
写不动了,参考如何更好地理解和掌握KMP算法 - 知乎,后续会补充我的理解。
回文自动机
又叫回文树,应该是回文问题的最终杀器。有兴趣的话参考:回文自动机实现及模板
回文串与回溯
关于回溯的问题典型是下面这题:
131. 分割回文串 【Medium,判断回文串 + 回溯】
- 返回分割为一到多个回文串的所有方案
与其说是回文串与回溯,不如说是数组回溯。目前常见的两种回溯,一种是数组上回溯,一种是树上回溯。而回文串只不过是一种判断手段,这个题思路可以很简单,即判断不同位置的分割点,如果可行就进入回溯。
核心代码也就这几行:
void backTrack(vector<string>& cur_ret, vector<vector<string>>& ret, string& s, int s_index){
// .......
for(int i = s_index; i<n ; ++i){
if(isPalindrome(s, s_index, i) == true){
cur_ret.push_back(s.substr(s_index, i - s_index + 1));
backTrack(cur_ret, ret, s, i + 1);
cur_ret.pop_back();
}
}
// ......
}
回文串中的DP思想
前面总结的题中,有三道题主要考察的是DP。
-
132. 分割回文串 II 【Hard,判断回文串 + DP】
- 返回分割为一或多个回文串的最小分割次数
-
1278. 分割回文串 III 【Hard,判断回文串 + DP】
- 允许修改以满足回文,问分割成k个回文子串需要修改的最小字符数
-
1312. 让字符串成为回文串的最少插入次数 【Hard,构造回文串 + DP】
- 返回成为回文串的最少插入次数
下面简单分析一下各个题目的dp状态以及转移方程,这些问题都没有固定回答,以下只是我个人的理解,或许有更好的解释与答案,请及时指出。
dp维度应该包含位置信息
其实简单题也是可以用DP解的,例如前面分别用中心扩展法和Manacher算法求解过的5. 最长回文子串 ,也可以用DP的思想,在这个问题中:
-
定义状态为\(dp[i][j]\)表示字符串从 i 到 j 是否为字符串,存的是true或者false。
-
转移方程则为:$dp[i][j] = dp[i - 1][j - 1]*(s[i] == s[j]) $
这样通过遍历可以求得最终的结果,但是这种方法并不好,时间复杂度和空间复杂度都达到了\(O(N^2)\)。不过它DP转移方程的思路很清晰,就是必须与一个串的起始坐标或结束坐标有关(即位置信息)。
带着这种思想去看132. 分割回文串 II,这里要求返回拆分后的最小分割次数,在这个问题中
- 状态为\(dp[i]\)表示前 i 个字符串需要的最小分割次数
- 转移方程为:\(dp[i] = min(dp[i], dp[j] + 1), j \in [0, i - 1]\)。
这个题显然也是满足与串的起始坐标或者结束坐标有关,而前 i 个暗含了起始坐标为0,所以状态空间也就只需要一维变量就可以了。
dp状态的含义可以首先考虑所需求解的问题
接下来需要思考一个问题,为什么在5. 最长回文子串中dp状态所表示含义是“是否为回文串”,而132. 分割回文串 II中就变成了分割次数。面对一个DP问题时,可以首先把DP状态定义成“所需要求解的问题”,如果不行再考虑转化,这也是DP最难的地方了。在5. 最长回文子串中,问的是最长回文子串,而我们已经考虑了用起始坐标与结束坐标去表示dp维度,两个坐标恰好就能算出长度了,因此没必要再去记录长度,只需要在dp计算过程中维护[max, max_x,max_y]就可以了。在132. 分割回文串 II中,需要求解的正是最小分割次数,若dp的含义如果是这个,转移方程也是可以推通的。
- dp维度是位置信息,含义是所要求解的问题:\(dp[i][j]\)表示i到j所需要操作的最少数量
- 转移方程:
- 如果\(s[i] \neq s[j]\),则有\(dp[i][j] = min(dp[i + 1][j] + 1, dp[i][j - 1] + 1)\)
- 如果\(s[i] == s[j]\),则有\(dp[i][j] = min(dp[i + 1][j] + 1, dp[i][j - 1] + 1, dp[i + 1][j - 1])\)
这个题与5. 最长回文子串的状态设置几乎一模一样,唯一的区别是表示的含义变成了所需要的最少数量,这也导致了转移方程的变化。
dp的维度还可以表示问题的条件
接着看1278. 分割回文串 III,问题问的是(如果允许修改以满足回文条件)分割成k个回文子串需要修改的最小字符数,根据前面的思想,dp中需要包含起始位置、结束位置、需要的答案(需要修改的最小字符数),因此:
- 状态为\(dp[i][j]\)表示包括s[i]及以前的字符,分割成j份,所需要的修改最小字符数。
- 转移方程为\(dp[i][j] = dp[j - 1] + cost(k + 1, i), k \in [j - 2, i]\)。这里面cost(i,j)是将i到j修改成回文串所需要的修改次数。
这个题需要想到的话有一定难度。它的含义是所需要求解的问题,维度包括,i是结束位置,起始位置为暗含的0。但是这个问题有一个附加难点,即题目条件包括分割成k个非空且不相交的子串,而不是能分割成任意个。dp的转移一定不能脱离条件,因此需要在另一个维度上增加j表示分割成k份。
联系其他DP的问题,其实这也是一种常见的操作。在0-1背包问题中,我们用\(dp[i][v]\)表示将前 i 件物品放入容量为 v 的背包中。而这里用\(dp[i][j]\)表示前 i 个字符分割成 k 个回文串。两个题完全不同,但是dp维度的含义都是位置信息与问题条件的组合。
参考
都写在文中了。