给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。
示例 2:
输入: "cbbd"
输出: "bb"
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/longest-palindromic-substring
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
这个问题有几种解法,一种是性能最差的,暴力破解。就是把所有的回文子串一个个的比较获取出来,然后找出一个最大的。第二种是找最长公共子串,就是把字符串倒过来,然后找这两个字符串的公共子串,这种方法求得公共子串并不一定是回文,需要再判断,并且效率没有提升太多。第三种方法是动态规则,就是用一个二维数组保存每一个字符与另外一个是否相同,生成数组后,根据数组判断最长的字符。这三种感觉既占了空间效率也没有明显改善,所以就没实现。
我的解,中心扩散法,回文的特点是两边一样,所以可以根据这个判断,就是一个字符一个字符的遍历,然后判断当前字符与下一个和下下个字符是否相等,如果相等,就是回文字符,然后向两边延伸,直到不相等,与最大的判断,获取最长的位置。
class Solution { public: string longestPalindrome(string s) { int sstart = 0; int send = 0; for(int i = 0; i < s.size(); i++) { int j = i + 1; if(j < s.size() && s[i] == s[j]) { int ti = i; int tj = j; while(ti >= 0 && tj < s.size()) { if(s[ti] != s[tj]) { break; } ti--; tj++; } ti++; tj--; if(send - sstart < tj - ti) { sstart = ti; send = tj; } } j = j + 1; if(j < s.size() && s[i] == s[j]) { int ti = i; int tj = j; while(ti >= 0 && tj < s.size()) { if(s[ti] != s[tj]) { break; } ti--; tj++; } ti++; tj--; if(send - sstart < tj - ti) { sstart = ti; send = tj; } } } return string(s, sstart, send - sstart + 1); } };
这个方法是比较好理解的,并且空间复杂度只有o(1),时间复杂度虽然是o(n^2),但是子串判断的时候,一次跳两个位置,比上面的o(n^2)要少一些步骤。
除了这个方法之外,还有一个求解最长子串的公认算法,就是Manacher。这个算法看了好几个题解,查了很多,最后找到 https://leetcode-cn.com/problems/longest-palindromic-substring/solution/zhong-xin-kuo-san-dong-tai-gui-hua-by-liweiwei1419/ 这个题解讲的比较清楚。感觉作者说的挺对的,是对中心扩散法的修正,为什么呢?因为中心扩散法在判断的索引不断后移中,有很多一开始遍历过的结果又被重新查询了一边。
比如下面的字符串
当我们判断到e的时候,其实是知道当前的回文字符串是abcecba,如果我们判断e的下一个c的时候,我们又需要循环判断一下c作为回文字符串中心的情况,虽然这个例子中没有判断太多,但是实际上,因为是回文字符串,也就像镜子一样,并且c是包含在前面e为中心的回文字符串中,那么c的回文字符串的情况与e左边刚刚判断完的c的情况是一样的,至少有很大一部分是可以利用的。也就是e左边的c是不是回文字符串,字符串有多长,与e的右边的c是一样的,最起码在e的回文字符串里面是一样的,这样就可以利用并且较少判断。
为了更方便计算,Manacher算法会在每一个字符串左右增加一个分隔符,比如'#',然后求一个数组p,p就是当前索引字符串在的位置的回文半径。这样求完之后,直接遍历p找到最大的就可以了。求数组p的时候,就用到了上面的思路,如果某个索引在某一个回文字符串内,那么就可以通过镜像找到对应位置的数据,然后根据边界做一些处理就可以了。我一直在想,不增加分隔符是不是也可以,这样就减少了插入数据的时间和空间消耗,有待测试。
string addboundaries(string s) { string tmp; for (auto& iter : s) { tmp = tmp + "#" + iter; } tmp = tmp + "#"; return tmp; } string longestPalindrome(string s) { string tmpstr = addboundaries(s); int slen = tmpstr.size(); int *p = new int[slen]; memset(p, 0, slen * sizeof(int)); int maxright = 0; int center = 0; int maxlen = 1; int start = 0; for (int i = 0; i < slen; i++) { if (i < maxright) { int mirror = 2 * center - i; p[i] = min(maxright - i, p[mirror]); } int left = i - (1 + p[i]); int right = i + (1 + p[i]); while (left >= 0 && right < slen && tmpstr[left] == tmpstr[right]) { p[i]++; left--; right++; } if (i + p[i] > maxright) { maxright = i + p[i]; center = i; } if (p[i] > maxlen) { maxlen = p[i]; start = (i - maxlen) / 2; } } return string(s, start, maxlen); }
上面代码可以看出,
插入分隔符,方便计算
申请一个数组p,保存数据
保存计算到最有边界的位置索引(针对插入分隔符之后的数组)maxright
保存针对最有边界位置的回文字符串的中心位置center
原字符串中最长回文字符串的长度maxlen
原字符串中最长回文字符串的起始位置start
开始遍历,如果i比maxright小,说明i的一部分数据已经可以通过镜像得到,不需要计算了。只需通过中心法判断基于当前位置i超过maxright的字符串,因为这部分本来也没计算过,所以并没有重复计算,如果满足了回文字符串,就把p[i]实时更新,直到不满足。这时判断最右边界,更新最右边界,中心位置,最大字串长度和起始位置。
当时对于这个算法没仔细想过,一直担心会有逻辑错误,比如,
- 在更新center右边的p数组时,会不会影响到左边的数组
- 会不会有情况使得center左边的回文字符串的最右边界比maxright大
- 如果在center右边的i位置,回文字符串最右边界大于maxright,那么从i到当前maxright之间的字符镜像的位置就会改变,会不会出现更新后镜像位置的数组长度不是最优的
第一条,右边数组修改,并不影响左边,因为从左边遍历过来,每一个位置已经通过中心法找到了最长的位置,所以不管怎么修改其他的数组,如果已经遍历过,肯定是最长的,最优解
第二条,还是上面的解释,因为每个遍历都是最优解,并且记录了遍历过的所有字符串最右边界,所以maxright是最右边的位置,不存在center左边的回文字符串长度超过maxright
第三条,做了好几个符合条件的字符串,发现边界都被处理了,并不会出现矛盾的情况,也就是按照这个逻辑是可以覆盖所有情况,并且是正确的
比如这个字符串,如果当前center是x,maxright是e,就是第二行浅灰色的表示,如果当前走到了i位置,也就是标红的w,那么可以以判断i的最右边界超过了第二行的标记,更新后是第一行的标记,center到i的位置,maxright在原来的基础上增加了1,那么原来的i的下一个字符e(棕色),原来针对x的对应是最左边的e,长度是4,现在更新后变成了w左边的e,长度是0,这样发现确实是0,是正确的,为什么呢,因为在求x的时候到了e的位置就结束了,也就是e的右边与x对称轴e的左边并不一样,所以也不可能是4.
另外我也试着不增加分隔符重写这个编码,立马发现了分隔符的作用,统一计算,减少条件判断,分隔符就是为了把所有的回文字符串都改成奇数的,这样就可以确定center和maxright,如果没有分隔符,那么每一个子回文字符串都要额外判断偶数的情况,太复杂。