原题
Given a string S, find the longest palindromic substring in S. You may assume that the maximum length of S is 1000, and there exists one unique longest palindromic substring.
题目大意
给定一个字符串S,找出它的最大的回文子串,你可以假设字符串的最大长度是1000,而且存在唯一的最长回文子串
解题思路
动态规划法,
假设dp[ i ][ j ]的值为true,表示字符串s中下标从 i 到 j 的字符组成的子串是回文串。那么可以推出:
dp[ i ][ j ] = dp[ i + 1][ j - 1] && s[ i ] == s[ j ]。
这是一般的情况,由于需要依靠i+1, j -1,所以有可能 i + 1 = j -1, i +1 = (j - 1) -1,因此需要求出基准情况才能套用以上的公式:
a. i + 1 = j -1,即回文长度为1时,dp[ i ][ i ] = true;
b. i +1 = (j - 1) -1,即回文长度为2时,dp[ i ][ i + 1] = (s[ i ] == s[ i + 1])。
有了以上分析就可以写出代码了。需要注意的是动态规划需要额外的O(n^2)的空间。
代码实现
public class Solution {
/**
* 005-Longest Palindromic Substring(最长回文子串)
*
* @param s 输入的字符串
* @return 最长回文子串
*/
public String longestPalindrome(String s) {
if (s == null || s.length() < 2) {
return s;
}
int maxLength = 0;
String longest = null;
int length = s.length();
boolean[][] table = new boolean[length][length];
// 单个字符都是回文
for (int i = 0; i < length; i++) {
table[i][i] = true;
longest = s.substring(i, i + 1);
maxLength = 1;
}
// 判断两个字符是否是回文
for (int i = 0; i < length - 1; i++) {
if (s.charAt(i) == s.charAt(i + 1)) {
table[i][i + 1] = true;
longest = s.substring(i, i + 2);
maxLength = 2;
}
}
// 求长度大于2的子串是否是回文串
for (int len = 3; len <= length; len++) {
for (int i = 0, j; (j = i + len - 1) <= length - 1; i++) {
if (s.charAt(i) == s.charAt(j)) {
table[i][j] = table[i + 1][j - 1];
if (table[i][j] && maxLength < len) {
longest = s.substring(i, j + 1);
maxLength = len;
}
} else {
table[i][j] = false;
}
}
}
return longest;
}
}
最长回文子串把原字符串S倒转过来成为S‘,以为这样就将问题转化成为了求S和S’的最长公共子串的问题,而这个问题是典型的DP问题,但是非常可惜,这个算法是不完善的。
S=“c a b a” 那么 S' = “a b a c”, 这样的情况下 S和 S‘的最长公共子串是aba。没有错误。
但是当 S=“abacdfgdcaba”, 那么S’ = “abacdgfdcaba”。 这样S和S‘的最长公共子串是abacd。很明显abacd并不是S的最长回文子串,它甚至连回文都不是。
所以最长回文子串不能转化成为最长公共子串问题了。当原串S中含有一个非回文的串的反序串的时候,最长公共子串的解法就是不正确的。正如上一个例子中S既含有abacd,又含有abacd的反串cdaba,并且abacd又不是回文,所以转化成为最长公共子串的方法不能成功。除非每次我们求出一个最长公共子串的时候,我们检查一下这个子串是不是一个回文,如果是,那这个子串就是原串S的最长回文子串;如果不是,那么就去求下一个次长公共子串,以此类推。
最长回文子串有很多方法,分别是1暴力法,2 动态规划, 3 从中心扩展法,4 著名的manacher算法。
方法一 暴力法
遍历字符串S的每一个子串,去判断这个子串是不是回文,是回文的话看看长度是不是比最大的长度maxlength大。遍历每一个子串的方法要O(N2),判断每一个子串是不是回文的时间复杂度是O(N),所以暴利方法的总时间复杂度是O(N3)。
方法二 动态规划 时间复杂度O(N2), 空间复杂度O(N2)
动态规划就是暴力法的进化版本,我们没有必要对每一个子串都重新计算,看看它是不是回文。我们可以记录一些我们需要的东西,就可以在O(1)的时间判断出该子串是不是一个回文。这样就比暴力法节省了O(N)的时间复杂度。
P(i,j)为1时代表字符串Si到Sj是一个回文,为0时代表字符串Si到Sj不是一个回文。
P(i,j)= P(i+1,j-1)(如果S[i] = S[j])。这是动态规划的状态转移方程。
P(i,i)= 1,P(i,i+1)= if(S[i]= S[i+1])
string longestPalindromeDP(string s) {
int
n = s.length();
int
longestBegin = 0;
int
maxLen = 1;
bool
table[1000][1000] = {
false
};
for
(
int
i = 0; i < n; i++) {
table[i][i] =
true
; //前期的初始化
}
for
(
int
i = 0; i < n-1; i++) {
if
(s[i] == s[i+1]) {
table[i][i+1] =
true
; //前期的初始化
longestBegin = i;
maxLen = 2;
}
}
for
(
int
len = 3; len <= n; len++) {
for
(
int
i = 0; i < n-len+1; i++) {
int
j = i+len-1;
if
(s[i] == s[j] && table[i+1][j-1]) {
table[i][j] =
true
;
longestBegin = i;
maxLen = len;
}
}
}
return
s.substr(longestBegin, maxLen);
}
方法三 中心扩展法
这个算法思想其实很简单啊,时间复杂度为O(N2),空间复杂度仅为O(1)。就是对给定的字符串S,分别以该字符串S中的每一个字符C为中心,向两边扩展,记录下以字符C为中心的回文子串的长度。但是有一点需要注意的是,回文的情况可能是 a b a,也可能是 a b b a。
string expandAroundCenter(string s,
int
c1,
int
c2) {
int
l = c1, r = c2;
int
n = s.length();
while
(l >= 0 && r <= n-1 && s[l] == s[r]) {
l--;
r++;
}
return
s.substr(l+1, r-l-1);
}
string longestPalindromeSimple(string s) {
int
n = s.length();
if
(n == 0)
return
""
;
string longest = s.substr(0, 1);
// a single char itself is a palindrome
for
(
int
i = 0; i < n-1; i++) {
string p1 = expandAroundCenter(s, i, i);
if
(p1.length() > longest.length())
longest = p1;
string p2 = expandAroundCenter(s, i, i+1);
if
(p2.length() > longest.length())
longest = p2;
}
return
longest;
}
方法四 传说中的Manacher算法。时间复杂度O(N)
这个算法做了一个简单的处理,很巧妙地把奇数长度回文串与偶数长度回文串统一考虑,也就是在每个相邻的字符之间插入一个分隔符,串的首尾也要加,当然这个分隔符不能再原串中出现,一般可以用‘#’或者‘$’等字符。例如:
原串:abaab
新串:#a#b#a#a#b#
这样一来,原来的奇数长度回文串还是奇数长度,偶数长度的也变成以‘#’为中心奇数回文串了。
接下来就是算法的中心思想,用一个辅助数组P 记录以每个字符为中心的最长回文半径,也就是P[i]记录以Str[i]字符为中心的最长回文串半径。P[i]最小为1,此时回文串为Str[i]本身。
我们可以对上述例子写出其P 数组,如下
新串: # a # b # a # a # b #
P[] : 1 2 1 4 1 2 5 2 1 2 1
我们可以证明P[i]-1 就是以Str[i]为中心的回文串在原串当中的长度。
证明:
1、显然L=2*P[i]-1 即为新串中以Str[i]为中心最长回文串长度。
2、以Str[i]为中心的回文串一定是以#开头和结尾的,例如“#b#b#”或“#b#a#b#”所以L 减去最前或者最后的‘#’字符就是原串中长度 的二倍,即原串长度为(L-1)/2,化简的P[i]-1。得证。 依次从前往后求得P 数组就可以了,这里用到了DP(动态规划)的思想, 也就是求P[i] 的时候,前面的P[]值已经得到了,我们利用回文串的特殊性质可以进行一个大大的优化。
先把核心代码贴上:
[cpp] view plain copy
- for (i = 0; i < len; i++){
- if (maxid > i){
- p[i] = min(p[2*id - i], maxid - i);
- }
- else{
- p[i] = 1;
- }
- while (newstr[i+p[i]] == newstr[i-p[i]])
- p[i]++;
- if (p[i] + i > maxid){
- maxid = p[i] + i;
- id = i;
- }
- if (ans < p[i])
- ans = p[i];
- }
为了防止求P[i]向两边扩展时可能数组越界,我们需要在数组最前面和最后面加一个特殊字符,令P[0]=‘$’最后位置默认为‘\0’不需要特殊处理。此外,我们用MaxId 变量记录在求i 之前的回文串中,延伸至最右端的位置,同时用id 记录取这个MaxId 的id 值。通过下面这句话,算法避免了很多没必要的重复匹配。
[cpp] view plain copy
- if (maxid > i){
- p[i] = min(p[2*id - i], maxid - i);
- }
那么这句话是怎么得来的呢,其实就是利用了回文串的对称性,如下图,
j=2*id-1 即为i 关于id 的对称点,根据对称性,P[j]的回文串也是可以对称到i 这边的,但是如果P[j]的回文串对称过来以后超过MaxId 的话,超出部分就不能对称过来了,如下图,
所以这里P[i]为的下限为两者中的较小者,p[i]=Min(p[2*id-i],MaxId-i)。算法的有效比较次数为MaxId 次,所以说这个算法的时间复杂度为O(n)。
题目:给一个字符串,找出最长的回文的长度(或求这个回文)。
分析:
寻找字符串中的回文,有特定的算法来解决,也是本文的主题:Manacher算法,其时间复杂度为O(n)。
首先在每两个相邻字符中间插入一个分隔符,当然这个分隔符要在原串中没有出现过。一般可以用‘#’分隔。这样就非常巧妙的将奇数长度回文串与偶数长度回文串统一起来考虑了。
然后,我们需要一个辅助数组rad[],用rad[i]表示第i个字符的回文半径,rad[i]的最小值为1,即只有一个字符的情况,现在问题转变成如何求出rad数组。
假设现在求出了rad[1, ..., i],现在要求后面的rad值,再假设现在有个指针k,从1循环到rad[i],试图通过某些手段来求出[i + 1, i + rad[i] - 1]的rad值,其分析如下:
如图1所示,黑色的部分是一个回文子串,两段红色的区间对称相等。因为之前已经求出了rad[i - k],所以可以避免一些重复的查找和判断,有3种情况:
图1
① rad[i] - k < rad[i - k]
如图1,rad[i - k]的范围为青色。因为黑色的部分是回文的,且青色的部分超过了黑色的部分,所以rad[i + k]肯定至少为rad[i]-k,即橙色的部分。那橙色以外的部分就不是了吗?这是肯定的,因为如果橙色以外的部分也是回文的,那么根据青色和红色部分的关系,可以证明黑色部分再往外延伸一点也是一个回文子串,这肯定是不可能的,因此rad[i + k] = rad[i] - k。
② rad[i] - k > rad[i - k]
如图2,rad[i-k]的范围为青色,因为黑色的部分是回文的,且青色的部分在黑色的部分里面,根据定义,很容易得出:rad[i + k] = rad[i - k]。根据上面两种情况,可以得出结论:当rad[i] - k != rad[i - k]的时候,rad[i + k] = min(rad[i] - k, rad[i - k])。
图2
③ rad[i] - k = rad[i - k]
如图,通过和第一种情况对比之后会发现,因为青色的部分没有超出黑色的部分,所以即使橙色的部分全等,也无法像第一种情况一样引出矛盾,因此橙色的部分是有可能全等的。但是,根据已知的信息,我们不知道橙色的部分是多长,因此就需要再去尝试和判断了。
图3
以上就是Manacher算法的核心思想。POJ上有一道关于回文的题目POJ3974,读者了解Manacher算法之后有兴趣可以做做,下面给出该题的Java代码,可以通过。
import java.io.FileNotFoundException; import java.util.Scanner;
public class Main {
public static int getPalindromeLength(String str) { // 1.构造新的字符串 // 为了避免奇数回文和偶数回文的不同处理问题,在原字符串中插入'#',将所有回文变成奇数回文 StringBuilder newStr = new StringBuilder(); newStr.append('#'); for (int i = 0; i < str.length(); i ++) { newStr.append(str.charAt(i)); newStr.append('#'); }
// rad[i]表示以i为中心的回文的最大半径,i至少为1,即该字符本身 int [] rad = new int[newStr.length()]; // right表示已知的回文中,最右的边界的坐标 int right = -1; // id表示已知的回文中,拥有最右边界的回文的中点坐标 int id = -1; // 2.计算所有的rad // 这个算法是O(n)的,因为right只会随着里层while的迭代而增长,不会减少。 for (int i = 0; i < newStr.length(); i ++) { // 2.1.确定一个最小的半径 int r = 1; if (i <= right) { r = Math.min(rad[id] - i + id, rad[2 * id - i]); } // 2.2.尝试更大的半径 while (i - r >= 0 && i + r < newStr.length() && newStr.charAt(i - r) == newStr.charAt(i + r)) { r++; } // 2.3.更新边界和回文中心坐标 if (i + r - 1> right) { right = i + r - 1; id = i; } rad[i] = r; }
// 3.扫描一遍rad数组,找出最大的半径 int maxLength = 0; for (int r : rad) { if (r > maxLength) { maxLength = r; } } return maxLength - 1; }
public static void main(String[] args) throws FileNotFoundException { int caseNum = 0; Scanner sc = new Scanner(System.in); while (true) { String str = sc.nextLine(); if (str.equals("END")) { break; } else { caseNum ++; System.out.println("Case " + caseNum + ": " + getPalindromeLength(str)); } } }
} |