1. 引入
Manacher算法/马拉车算法是LeetCode647. 回文子串所用的算法。该算法可用于寻找回文子串、回文子串数、最长回文子串长度等问题。
回文字符串,其含义是一个字符串正向还是反向读都是一样的。例如“abba
","moon
","level
"等就是回文字符串,可以理解为对称字符串。
回文子串,其含义是一个字符串当中的回文字符串。须要引入三个概念:其中心位置的点为基准点/中心点,其最长的左右边界的字符为左右边界点,左右边界点到基准点的距离称为回文半径。比如"level
",其基准点为’v
’,左右边界点为’l
’,回文半径为3(对于’v
’,其本身就为回文子串,半径为1,以其为基准点的回文子串也为3)。
问题:给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
2. 中心扩展方法
针对该方法,有两种思路:
- 枚举出所有的子串,然后再判断这些子串是否是回文;但是,枚举出所有的子串 s [ l i ⋅ ⋅ ⋅ r i ] s[l_i···r_i] s[li⋅⋅⋅ri]就需要 O ( n 2 ) O(n^2) O(n2)的时间复杂度,然后对于每个子串再使用 O ( r i − l i + 1 ) O(r_i - l_i+1) O(ri−li+1)时间检测其是否为回文子串,整个算法的时间复杂度为 O ( n 3 ) O(n^3) O(n3),这是不可接受的时间复杂度;
- 枚举每一个可能的回文中心,然后用两个指针分别向左右两边扩展,当两个指针指向的元素相同的时候就扩展,否则停止扩展。该种方法,对于字符串中的每个字符,作为基准向两边进行扩展,便可求得该字符为基准,有多少回文子串,其时间复杂度为 O ( n ) O(n) O(n),而对于所有的字符都进行如上操作,总的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
对于枚举每一个可能的回文中心,要注意的是,有些回文子串是奇串,有些是偶串。对于奇回文串而言,进行中心扩展时,基准就是当前字符;而对于偶回文串,基准应当为两个字符,则简单的进行中心扩展就无效了。
为解决奇偶回文子串的问题,可采用加入辅助字符的方式,将偶回文串均变为奇回文串的中心扩展问题。其方法为:对字符串进行修改,将’#
’ 插入字符串中(#只是一个辅助字符,它不会对原字符串造成信息上的干扰)。例如:level
=>#l#e#v#e#l
,moon
=>#m#o#o#n#
。对于旧的字符串长度 n n n,可知我们加入了 n + 1 n+1 n+1个#
,则新字符串长度为 2 ∗ n + 1 2*n+1 2∗n+1,为奇串。转换的代码(c++)如下:
//生成Manacher字符串,避免遇到偶串的问题,可以看到:
//首尾和原字符中间加上#之后可将字符串成为奇串
//而奇串可以使用左右比较的暴力方法求得其所在的最长回文子串
vector<char> manacherString(string& str) {
int strLen = str.size();
vector<char> manacherStr(strLen*2+1);
int idx = 0;
for (int i = 0; i != manacherStr.size(); i++) {
//和1进行位与,凡是偶数都为0,奇数为非0(奇数的最低位为1)
manacherStr[i] = (i & 1) == 0 ? '#' : str[idx++];
}
return manacherStr;
}
string manacherString1(string s) {
if (s.size() == 0) return s;
string ret(s.size() * 2 + 1, 'a');
int idx = 0;
for (int i = 0; i < ret.size(); i++) {
ret[i] = (i & 1) == 0 ? '#' : s[idx++];
}
return ret;
}
可转换为奇串问题后,则中心扩展法的解法代码为:
int countSubstrings(string s) {
string str = manacherString1(s);
int n = str.size(), ans = 0;
for(int i = 0; i < n; i++) {
int r = 1;
while(i - r > -1 && i + r < n) {
if(str[i - r] == str[i + r]) r++;
else break;
}
ans += r/2;
}
return ans;
}
3. Manacher算法
对于中心扩展法而言,其总体时间复杂度为 O ( n 2 ) O(n^2) O(n2),仍不能让人满意,所以就有了马拉车算法,其总体的时间复杂度为 O ( n ) O(n) O(n)。其思想为,在中心扩展法的基础上能够利用之前所计算出的前面字符的基准回文子串长度,使得比较次数大大减少,从而在一定程度上减少时间复杂度(类似于Dynamic Programming,记录之前的计算结果,避免重复进行冗余计算)。
在进行算法介绍之前,要先引入如下概念。
- 已遍历字符串的回文子串的最远右边界,记为 R R R,最远左边界 L L L;
- 已遍历字符串的回文子串的最远右边界对应的基准点,即为 C C C;
- 当前所遍历的字符的下标记为 [ i ] [i] [i],其关于基准点 C C C的对称点 [ i ′ ] [i'] [i′]的下标为 [ 2 ∗ C − i ] [2*C - i] [2∗C−i];
- 对于 [ i ] [i] [i]字符,其最长回文半径左右的字符分别为 X X X、 Y Y Y,边界下标为 L i L_i Li、 R i R_i Ri;
- 对于 [ i ′ ] [i'] [i′]字符,其最长回文半径左右的字符分别为 X ′ X' X′、 Y ′ Y' Y′,边界下标为 L i ′ L'_i Li′、 R i ′ R'_i Ri′;
前述讲过,马拉车算法的思想为避免重复,假设对于 [ i ] [i] [i]其所对应的最长回文半径记录在数组 r a d i u s radius radius中,表示为 r a i d u s [ i ] raidus[i] raidus[i],下面对重复的情况进行分析:
- 当 [ i ] [i] [i]在 R R R的外部,此时我们没法在 [ L , R ] [L,R] [L,R]区间内寻找对称点 [ i ′ ] [i'] [i′],此时 r a d i u s [ i ] radius[i] radius[i]初始值为1,并利用中心扩展法进行扩展得到最终值;
- 当 [ i ] [i] [i]在 R R R的内部,又可分为三种情况:
(1) [ i ′ ] [i'] [i′]的回文区间 [ L i ′ , R i ′ ] [L'_i, R'_i] [Li′,Ri′]在 [ L , R ] [L, R] [L,R]区间内,此时 r a d i u s [ i ] = r a d i u s [ 2 ∗ C − i ] radius[i] = radius[2*C-i] radius[i]=radius[2∗C−i],时间复杂度 O ( 1 ) O(1) O(1);
(2) [ i ′ ] [i'] [i′]的回文区间 [ L i ′ , R i ′ ] [L'_i, R'_i] [Li′,Ri′]不完全在 [ L , R ] [L, R] [L,R]区间内,此时 r a d i u s [ i ] = R − i radius[i] = R-i radius[i]=R−i,时间复杂度 O ( 1 ) O(1) O(1);
(3) [ i ′ ] [i'] [i′]的回文区间 [ L i ′ , R i ′ ] [L'_i, R'_i] [Li′,Ri′]与 [ L , R ] [L, R] [L,R]相切,此时 r a d i u s [ i ] 初 始 值 = r a d i u s [ 2 ∗ C − i ] radius[i]初始值 = radius[2*C-i] radius[i]初始值=radius[2∗C−i],之后再对其进行中心扩展法得到最终值;
(详细图解和证明如下)
理解了Manacher算法的原理和情况之后,可以写出C++代码如下:
int maxLcpsLength(string& str) {
if (str.size() == 0) return 0;
vector<char> charArr = manacherString(str);
vector<int> lcpsArr = vector<int>(charArr.size(), 0);
int C = -1, R = -1; //基准值和该基准值对应的最长回文子串的右边界
int ret = 0; //最终最长回文子串的长度
for (int i = 0; i < charArr.size(); i++) {
//对于下标为i的点,可分为两种大情况:
//1.i > R,此时进行暴力匹配,求以其为基准的最大回文串,起始值为1;
//2.i <= R,此时先确定已有可以不进行匹配的回文串的长度,取
// [该点到此时右边界的距离]和[该点与此时基准的对称点i'(下标为2C-1)的回文半径]的较小值
lcpsArr[i] = R > i ? min(lcpsArr[2 * C - i], R - i) : 1;
//此时对i下标的点的最长回文子串进行求解
while (i + lcpsArr[i] < charArr.size() && i - lcpsArr[i] > -1) {
if (charArr[i + lcpsArr[i]] == charArr[i - lcpsArr[i]]) {
//左右的字符相等
lcpsArr[i]++;
} else {
//左右字符不等,则此时结果即为该点为基准的最长回文子串长度
break;
}
}
//检查新的字符子串右边界是否需要更新
if (i + lcpsArr[i] > R) {
R = i + lcpsArr[i];
C = i;
}
//更新最终最长回文子串的长度,由于回文字符串经过加工,长度为原先二倍+1,且半径即为以该位置为基准的回文子串的个数,所以累加当前半径的一半即可
ret += lcpsArr[i]/2;
}
return ret;
}
4. 衍生类似题目
问题一:给定一个字符串,你的任务是求得这个字符串最长回文子串的长度。
思路:与上述经典例题类似,只是将求得的回文子串半径累加改为记录当前所遍历子串的回文子串中最长的长度。代码如下:
int maxLcpsLength(string& str) {
if (str.size() == 0) return 0;
vector<char> charArr = manacherString(str);
vector<int> lcpsArr = vector<int>(charArr.size(), 0);
int C = -1, R = -1; //基准值和该基准值对应的最长回文子串的右边界
int maxRet = INT_MIN; //最终最长回文子串的长度
for (int i = 0; i < charArr.size(); i++) {
//对于下标为i的点,可分为两种大情况:
//1.i > R,此时进行暴力匹配,求以其为基准的最大回文串,起始值为1;
//2.i <= R,此时先确定已有可以不进行匹配的回文串的长度,取
// [该点到此时右边界的距离]和[该点与此时基准的对称点i'(下标为2C-1)的回文半径]的较小值
lcpsArr[i] = R > i ? min(lcpsArr[2 * C - i], R - i) : 1;
//此时对i下标的点的最长回文子串进行求解
while (i + lcpsArr[i] < charArr.size() && i - lcpsArr[i] > -1) {
if (charArr[i + lcpsArr[i]] == charArr[i - lcpsArr[i]]) {
//左右的字符相等
lcpsArr[i]++;
} else {
//左右字符不等,则此时结果即为该点为基准的最长回文子串长度
break;
}
}
//检查新的字符子串右边界是否需要更新
if (i + lcpsArr[i] > R) {
R = i + lcpsArr[i];
C = i;
}
//更新最终最长回文子串的长度,由于回文字符串经过加工,长度为原先二倍+1,所以返回半径即可
maxRet = max(maxRet, lcpsArr[i]);
}
return maxRet - 1;
}
问题二:给定一字符串,求在其后添加何串使得新得到的字符串成为一回文字符串;
思路:求得以原字符串为结尾的最长回文子串,再将原子串减去所得的最长回文子串,得到所要添加的字符串逆序串,再做一逆序即可。