leetcode 214. Shortest Palindrome
一看这道题第一反应就是先找出以str[0]为起点的最长的回文子串,然后,将这个回文子串后面的子串反转再拼接到前面。
然后第一步,看看找最长回文子串有哪些方法。
- 暴力搜索,复杂度O(N^2)。
- DP,复杂度也是O(N^2),看来这里用DP的可能性不大了呀。
- manacher’s algorithm,复杂度O(N),看来可行。
首先先试试DP,用二维table记录状态,table[i,j]表示str[i..j]是否回文串,找到递推公式:
table[i,j]是回文串,要满足:
- i==j
- i+1==j && str[i]==str[j]
- str[i+1..j-1]是回文串 && str[i]==str[j]
根据递推公式实现。遇到bad case,非常大的字符串,长度为40002,table似乎爆掉了。然后找相关解答:数组申明在函数内部,属于局部变量,存放在了栈上,栈的默认内存空间为1M左右,改成将table放在堆里面,加入一个bool占1byte,整个表格得用1.6g,实际上运行大概也用了1g左右,明显提交会使得内存爆掉,而且时间花费也很大。结果已提交,TLE,实现代码如下。完美。只能转攻manacher’s algorithm。
class Solution {
public:
string shortestPalindrome(string s) {
const int strSize = int(s.length());
if (s == "") {
return "";
}
bool **table;
table = new bool *[strSize];
for (int i = 0; i < strSize; i++) {
table[i] = new bool[strSize];
}
//bool table[2000][2000];
for (int len = 0; len < strSize; len++) {
for (int i = 0; i < strSize; i++) {
int j = i + len;
if (i == j) {
table[i][j] = true;
} else if (j < strSize) {
if (s[i] == s[j]) {
if (j == i + 1) {
table[i][j] = true;
} else if (i + 1 < strSize && j - 1 >= 0 && table[i + 1][j - 1]) {
table[i][j] = true;
} else {
table[i][j] = false;
}
} else {
table[i][j] = false;
}
}
}
}
int palindromInd = 0;
for (int j = strSize - 1; j >= 0; j--) {
if (table[0][j]) {
palindromInd = j;
break;
}
}
string resultStr = s;
for (int i = palindromInd + 1; i < strSize; i++) {
resultStr = s[i] + resultStr;
}
for (int i = 0; i < strSize; i++) {
delete [] table[i];
}
delete table;
return resultStr;
}
};
记得以前刚刚学数据结构的时候有碰到过求最长回文子串,但是由于当时基础不太好,觉得自己没有啃透,于是借着这个机会复习一下。leetcode上面也有这道题leetcode 5. Longest Palindromic Substring,重新做了一遍。
不经意还看到一种实现,求原字符串和逆转后的字符串的longest common substring,最后再判断一下这个longest common substring的下标是否一致,一致的话这个longest common substring就是Longest Palindromic Substring。用DP求longest common substring的思路比这个好懂,不过DP里面的suffix也是很有趣的概念,这样可以把longest common substring和longest common subsequence区分开来。用DP求longest common substring的算法可以参考:https://en.wikipedia.org/wiki/Longest_common_substring_problem
这里只记录manacher‘s algorithm,参考文章:http://articles.leetcode.com/longest-palindromic-substring-part-ii。manacher‘s algorithm主要是利用回文字符串的对称特性,复杂度据说是线性。我觉得比较巧妙的是,在原字符串每两个字符之间插入一个特殊字符(如’#’)的思路,这样的话无论是奇数长度的字符串还是偶数长度的字符串都可以转化成奇数长度的字符串。用一个跟插入了特殊字符的字符串长度相同的一维计数table,而且table[i]代表的是以字符str[i]为中心寻找回文串所能扩展最远半径,由于str是原字符串插入了’#’的,这样的话还会产生一个规律,就是table[i]实际上就是s.substr((i - table[i]) / 2, table[i])的长度,即(i - table[i])为起点的回文串的长度。非常完美。
用一个例子理解manacher‘s algorithm。
# | b | # | a | # | b | # | c | # | b | # | a | # | b | # | c | # | b | # | a | # | c | # | c | # | b | # | a | # |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
0 | 1 | 0 | 3 | 0 | 1 | 0 | 7 | 0 | 1 | 0 | 9 | 0 | 1 | 0 | 5 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 |
首先需要用到中心点centerInd和右边界rightInd点来利用字符串本身的对称属性,尽量避免已经比较过的字符继续参与比较,节省时间。中心点和有边界点都初始化为0,仅当右边界被超越的时候才更新中心点和右边界点。
当前下标i指向的是当前比对回文字符串的中心点,这个中心点指向的回文字符串只在两种情况下有可能需要用到向外扩展的过程,
当i > rightInd时,这时要向外扩展的原因是因为i已经超过了可以利用对称点的table值的范围,所以table[i]=0,再向外扩展看是否可以增加。如这个例子里面的i=3,和i=7。
当table[2 * centerInd - i] == 2 * centerInd - i && table[2 * centerInd - i] = rightInd - i,这时候说明以2 * centerInd - i为中心点的回文子串只能是table[2 * centerInd - i]这么长,是因为比对已经超过了字符串的起点,没办法继续比对下去,但是对于i就不一样了,它的左边还有多余的字符可以做比对,这些左边的多余字符的信息没办法根据对称特性获得,所以table[i]=rightInd - i,再向外扩展看是否可以增加。如这个例子里面的i=11。而对于table[2 * centerInd - i] > rightInd - i的这种情况是有必要解释一下,这种情况是不需要继续比对下去的,用图表表示比较好理解:
浅绿色的table值是2,根据里面的对称特性,这说明了深绿色的table值只可能是1,如果深绿色的table值大于1的话,右边界值就还要向右移一位,与上一步的计算结果是矛盾的。
所以这种情况不需要继续比对,但是为了省事,不用再写一个判断条件,即使让它继续比对下去,也会在比对遇到第一个字符的时候就停下来了。
其余情况就好办了,直接让table[i] == 2 * centerInd - i
按照这个思路实现kanacher's algorithm,一下子把两道题都过了,代码如下(不过代码写得丑,可优化的地方还有):
class Solution {
public:
string shortestPalindrome(string s) {
/*
为了找到更好的方法,找了了很好的讲解http://articles.leetcode.com/longest-palindromic-substring-part-i
顺便还讲了Longest Common Substring,也是一个感兴趣的问题,于是也去看了wiki。https://en.wikipedia.org/wiki/Longest_common_substring_problem
Longest Common Substring里面DP方法的suffix概念挺有趣的。
试着用Manacher’s algorithm改进O(N^2)的时间和空间复杂度好了,据说是个O(N)时间和空间复杂度的算法。
*/
string newStr = "#";
for (int i = 0; i < s.size(); i++) {
// if (s[i] != 'a') {
// cout << s[i] << endl;
// }
newStr += s[i];
newStr += '#';
}
const int strSize = int(newStr.length());
//cout << strSize << endl;
int centerInd = 0, rightInd = 0, leftInd = 0;
int maxLen = 0, maxInd = 0;
int LPtable[strSize];
LPtable[0] = 0;
for (int i = 1; i < strSize; i++) {
//得到镜像下标
int mirrorInd = 2 * centerInd - i;
//mirrorInd < leftInd这个判断条件只是留给i=1的时候判断的。显得冗余,可以优化
//https://www.felix021.com/blog/read.php?2040
if (mirrorInd < leftInd || LPtable[mirrorInd] >= rightInd - i) {
centerInd = i;
int expandInd = 0;
if (mirrorInd < leftInd) {
expandInd = centerInd + 1;
LPtable[centerInd] = 0;
} else {
expandInd = centerInd + rightInd - i + 1;
LPtable[centerInd] = rightInd - i;
}
while (expandInd < strSize && 2 * centerInd - expandInd >= 0 && newStr[expandInd] == newStr[2 * centerInd - expandInd]) {
expandInd++;
LPtable[centerInd]++;
}
if (LPtable[centerInd] > maxLen && LPtable[centerInd] == centerInd) {
maxLen = LPtable[centerInd];
maxInd = centerInd;
}
rightInd = max(expandInd - 1, rightInd);
} else {
LPtable[i] = LPtable[mirrorInd];
}
}
string suffixStr = s.substr((maxInd - maxLen) / 2 + maxLen);
reverse(suffixStr.begin(), suffixStr.end());
cout << suffixStr << endl;
return suffixStr + s;
}
};
/*
刚刚看完最长回文子串,迫不及待的练练手。
manacher‘s algorithm,参考:http://articles.leetcode.com/longest-palindromic-substring-part-ii
里面的实现比这里简洁多了。
*/
class Solution {
public:
string longestPalindrome(string s) {
string newStr = "#";
for (int i = 0; i < s.size(); i++) {
newStr += s[i];
newStr += '#';
}
const int strSize = int(newStr.length());
int centerInd = 0, rightInd = 0, leftInd = 0;
int maxLen = 0, maxInd = 0;
int LPtable[strSize];
LPtable[0] = 0;
for (int i = 1; i < strSize; i++) {
//得到镜像下标
int mirrorInd = 2 * centerInd - i;
cout << mirrorInd << endl;
//mirrorInd < leftInd这个判断条件只是留给i=1的时候判断的。显得冗余,可以优化
//https://www.felix021.com/blog/read.php?2040
if (mirrorInd < leftInd || LPtable[mirrorInd] >= rightInd - i) {
centerInd = i;
int expandInd = 0;
if (mirrorInd < leftInd) {
expandInd = centerInd + 1;
LPtable[centerInd] = 0;
} else {
expandInd = centerInd + rightInd - i + 1;
LPtable[centerInd] = rightInd - i;
}
while (expandInd < strSize && 2 * centerInd - expandInd >= 0 && newStr[expandInd] == newStr[2 * centerInd - expandInd]) {
expandInd++;
LPtable[centerInd]++;
}
if (LPtable[centerInd] > maxLen) {
maxLen = LPtable[centerInd];
maxInd = centerInd;
}
rightInd = max(expandInd - 1, rightInd);
} else {
LPtable[i] = LPtable[mirrorInd];
}
}
return s.substr((maxInd - maxLen) / 2, maxLen);
}
};
其实这道题还可以用KMP的思想去做,KMP算法的关键是找出pattern里每个前缀中prefix和suffix相等的最长长度。如果将原字符串和逆转后的字符串拼接在一起,以ind=0开始的回文串的顺序是不会变的,那么拼接字符串的prefix和suffix就是回文子串,但是还要注意:
a | a | b | a | a | b | a | a |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| | | | | | | |
0 | 1 | 0 | 1 | 2 | 3 | 4 | 5 |
a | a | b | a | # | a | b | a | a |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| | | | | | | | |
0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 2 |
这种情况会导致回文串长度为5,实际上只是2而已,这样的原因是算prefix和suffix的时候,长度比原字符串的长度还要长了,遇到具有这种特性的字符串就会出错。所以要在两个字符串中间加上没出现过的字符作为分隔符。
接下来介绍KMP算法的关键,参考https://www.youtube.com/watch?v=GTJr8OvyEVQ。有一个待搜索的字符串,还有用作搜索的patter string。
如原字符串是a b c b c g l x,patter string是b c g l
最暴力的方法是一个一个遍历,每次都重复一个一个与pattern string比对,这样的复杂度是O(M*N)。然而KMP算法能在O(N + M)时间复杂度下完成这一任务。暴力方法做了很多冗余的比较。
用一个例子理解KMP算法,原字符串:abcxabcdabxabcdabcdabcypattern:abcdabcy
a | b | c | x | a | b | c | d | a | b | x | a | b | c | d | a | b | c | d | a | b | c | y |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
a | b | c | d | a | b | c | y |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
思路步骤:
第一轮匹配从strInd=0,patternInd=0开始,匹配到strInd=3,patternInd = 3的时候就断掉了,但是已知前面三个字符是匹配的,接下来就要找已经匹配上的子串里有没有suffix==preffix的情况,abc是没有的。strInd = 2, patterInd = 2,明显str[0..strInd] == patter[0..patternInd],明显pattern[0] != str[[1,strInd]],所以下一次匹配只能从strInd=3,patternInd=0开始。
第二轮匹配从strInd=3,patternInd=0开始,不成功
第三轮匹配从strInd=4,patternInd=0开始,匹配到strInd=10,patternInd=6的时候就断掉了,接下来就要找已经匹配上的子串里有没有suffix==preffix的情况,有ab,所以下一次匹配是从strInd=10,patternInd=2开始匹配。
第四轮从strInd=10,patternInd=2开始,不成功。
第五轮从strInd=11,patternInd=0开始,在strInd=18,patternInd=7的时候断掉了,patter[0..patternInd]里有suffix==preffix的情况,abc,所以下一次匹配从strInd=18,patternInd=3开始。
第六轮从strInd=18,patternInd=3开始,匹配成功,返回下标。
于是关键问题是,找出patter所有前缀是否有suffix==preffix,如果有的话,这个公共子串长度是什么。
首先用一个下标维持prefix的长度,接下来用另外一个下标表示prefix的右边界。用一个例子示范计算table的算法:pattern:acacabacacabacacac
a | c | a | c | a | b | a | c | a | c | a | b | a | c | a | c | a | c |
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| | | j | | j | | | | | | j | | | | | | i |
0 | 0 | 1 | 2 | 3 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 4 |
一开始,j和i都初始化为0,很明显table[0]=0,因为此时的前缀长度为j=0,i++;
j=0,i=1,比对patter[j]和pattern[i],不相等,而且j=0,
j=0,i=2,比对patter[j]和pattern[i],相等,table[i]=j(之前前缀的长度)+1=1,i++,j++(想继续看有没有追加字符);
j=1,i=3,比对patter[j]和pattern[i],相等,table[i]=j(之前前缀的长度)+1=2,i++,j++(想继续看有没有追加字符);
j=2,i=4,比对patter[j]和pattern[i],相等,table[i]=j(之前前缀的长度)+1=3,i++,j++(想继续看有没有追加字符);
j=3,i=5,比对patter[j]和pattern[i],不相等,说明当前前缀已经没有可追加的字符了,只好看当前子串有没有嵌套前缀,总比0好。此时说明patter[0..j-1]和pattern[i-j..i-1]是完全匹配的,如果有嵌套前缀,那么在0..j-1间有一个k使得patter[0..k-1]和pattern[j-k..j-1]是完全匹配,那么说明patter[0..k-1]和pattern[i-k..i-1]是完全匹配的,如果patter[k]==patter[i],那么table[i]=k+1;这种嵌套找法会一直继续,直到找到patter[k]==patter[i]的情况或者k==0。
接下来的推导也是按照这个逻辑,可以得到上面的表。