一 题目
Given a string s, find the longest palindromic substring in s. You may assume that the maximum length of s is 1000.
Example 1:
Input: "babad"
Output: "bab"
Note: "aba" is also a valid answer.
Example 2:
Input: "cbbd"
Output: "bb"
二 分析
这是一道Medium 难道的题目,也是一道面试常见的问题,毕竟网上的解法多种多样。
题目就是求最大回文的子串,回文就是从左右两边读都一样的字符串,或者说中心对称的字符串。
有两种case:aba、bb。都算回文。
暴力法就是穷举所有子字符串的可能,然后依次按位判断其是否是回文,并更新结果。显然有C(N,2)(组合)个子串。检测每个子串都需要O(N)的时间,所以此方法的时间复杂度为O(N3)。
2.1 动态规划法
记得之前看动态规划(Dynamic Programming)的时候,有过类似的题目,跟据动态规划的两个特点:第一大问题拆解为小问题,第二重复利用之前的计算结果,,忘了的可以看这篇扫盲下:《算法图解》-9动态规划 背包问题,行程最优化
思路如下:首先可以知道单个字符和两个相邻字符是否回文,然后检测连续三个字符是否回文以此类推。那么结合动态规划的特点:先从左到有每个字符为1的都是回文,然后计算所有长度为2的子字符串(前面相当于初始化),到长度为3的时候,就可以利用上次的计算结果:如果中心对称的短字符串不是回文,那长字符串也不是,如果短字符串是回文,那就要看长字符串两头是否一样。
我们以字符串s=aXXXXXa为例,分别用i,j表示字符串的起止,如果字符串s会为回文,则s[i+1]=s[j-1].
那么我们用二维数组:二维数组dP[i,j]用以表示Si…Sj是回文(true)或不是回文(false)
- 当 s[i] != s[j] 的时候, dp[i][j] 直接就是 false。
- 如果s[i] == s[j] 那么是否是回文决定于 dp[i+1][ j - 1], 也就是dP[i,j] = (P[i + 1, j - 1] && Si ==Sj)
代码如下:我写的有些啰嗦。
public static void main(String[] args) {
// TODO Auto-generated method stub
String s ="babad";
String res = longestPalindrome(s);
System.out.println(res);
}
public static String longestPalindrome(String s) {
if(s == null || s.length()==0){
return "";
}else if(s.length()==1 ){
return s;
}
int n = s.length();
boolean[][] dp = new boolean[n ][n ];
//初始化
for(int i=0;i<n;i++ ){
dp[i][i] = true;
}
int longest = 1, start = 0;
//长度为2的判断
for(int i=0;i<n-1;i++){
if(s.charAt(i)== s.charAt(i+1)){
dp[i][i+1] =true;
start =i;
longest =2;
}else{
dp[i][i+1] =false;
}
}
//字符串的长度
for(int len=3;len<=n;len++){
//字符串起始位置
for(int i=0;i<n-len+1;i++){
int j= i+len-1;
if(s.charAt(i)== s.charAt(j)&&dp[i+1][j-1]){
dp[i][j] =true;
start =i;
longest = Math.max(longest,len);
}
}
}
return s.substring(start,start+longest);
}
Runtime: 35 ms, faster than 43.20% of Java online submissions for Longest Palindromic Substring.
Memory Usage: 37.8 MB, less than 54.44% of Java online submissions forLongest Palindromic Substring.
时间复杂度:时间复杂度 O(n^2),空间复杂度O(n^2)
2.2 中心扩散法
动态规划虽然优化了时间,但也浪费了空间。实际上我们并不需要一直存储所有子字符串的回文情况,我们需要知道的只是中心对称的较小一层是否是回文。所以如果我们从小到大连续以某点为个中心的所有子字符串进行计算,就能省略这个空间。 这种解法中,外层循环遍历的是子字符串的中心点,内层循环则是从中心扩散,一旦不是回文就不再计算其他以此为中心的较大的字符串。由于中心对称有两种情况,一是奇数个字母以某个字母对称,而是偶数个字母以两个字母中间为对称,所以我们要分别计算这两种对称情况。
static int start=0;
static int max= 0;
public static String longestPalindrome(String s) {
if(s == null || s.length()==0){
return "";
}else if(s.length()==1 ){
return s;
}
for (int i = 0; i < s.length() - 1; i++) {
//处理两种不同的核心
findPalindrome(s, i, i);
findPalindrome(s, i, i+1);
}
return s.substring(start,start+max);
}
private static void findPalindrome(String s, int i, int j) {
while(i>=0&&j<s.length()&& s.charAt(i)==s.charAt(j) ){
//移动指针
i--;
j++;
}
if(j-(i+1)>max){
max = j-(i+1);
start = i+1;
}
}
Runtime: 5 ms, faster than 96.62% of Java online submissions for Longest Palindromic Substring.
Memory Usage: 36.5 MB, less than 100.00% of Java online submissions forLongest Palindromic Substring.
复杂度: 时间 O(n^2) 空间 O(1)
我觉得这个方法更容易理解,虽然不是时间最快的。
2.3 Manacher's algorithm
https://en.wikipedia.org/wiki/Longest_palindromic_substring
算法实现参考:https://www.felix021.com/blog/read.php?2040
由一个叫 Manacher 的人在 1975 年发明的,这个方法的是降维了,把时间复杂度提升到了O(N),很厉害就是难理解。
我看了上面大神的文章,看了一晚上感觉地看懂了,可是照着写代码最后代码提交LeetCode还是报了数组越界。这个算法网上能搜出来很多。不见得每个都能通过测试,建议你亲自动手试试。下面是LeetCode官网solution的最后解释:
Approach 5: Manacher's Algorithm
There is even an O(n)O(n) algorithm called Manacher's algorithm, explained here in detail. However, it is a non-trivial algorithm, and no one expects you to come up with this algorithm in a 45 minutes coding session. But, please go ahead and understand it, I promise it will be a lot of fun.
原理部分,leetcode上面的链接已经打不开了,可见wiki或者felix021上面的。下面的内容就是基于felix021的,我主要是用Java实现了,并加了必要的注释,方便以后自己在看有个印象。
由于回文串的长度可奇可偶,比如 "bab" 是奇数形式的回文,"abba" 就是偶数形式的回文,马拉车算法的第一步是预处理,做法是在每一个字符的左右都加上一个特殊字符,比如加上 '#',就变成了“#b#a#b#”,“#a#b#b#a#”.这样做的好处就是处理之后得到的字符串的个数都是奇数个,不在区分之前的字符串是奇数还是偶数。
然后用一个数组 P[i] 来记录以字符S[i]为中心的最长回文子串向左/右扩张的长度(也可以认为s[i] 字符为中心的回文子串的半径),若 p[i] = 1,则该回文子串就是s[i] 本身.
S # 1 # 2 # 2 # 1 # 2 # 3 # 2 # 1 #
P 1 2 1 2 5 2 1 4 1 2 1 6 1 2 1 2 1
(p.s. 可以看出,P[i]-1正好是原字符串中回文串的总长度)
找规律:如果我们找到最大的半径,就知道最长的回文子串的字符个数了。只知道长度无法定位子串,我们还需要知道子串的起始位置。 起始位置是中间位置减去半径再除以2。(需要自己去找规律验证)
接下来就是如何确定p[i]的值,也是算法的核心,这块我不太理解。贴一下大神的解释:
该算法增加两个辅助变量(其实一个就够了,两个更清晰)id和mx,其中 id 为已知的 {右边界最大} 的回文子串的中心,mx则为id+P[id],也就是这个子串的右边界。注意:这个 mx 位置的字符不属于回文串,所以才能用 mx-i 来更新 p[i] 的长度而不用加1。
如果mx > i,那么P[i] >= MIN(P[2 * id - i], mx - i)
拆开来看,优雅的代价就是不好理解。
//记j = 2 * id - i,也就是说 j 是 i 关于 id 的对称点(j = id - (i - id))
if (mx - i > P[j])
P[i] = P[j];
else /* P[j] >= mx - i */
P[i] = mx - i; // P[i] >= mx - i,取最小值,之后再匹配更新。
当然,即时这样解释还是不容易理解,大神有画了图:
当 mx - i > P[j] 的时候,以 S[j] 为中心的回文子串包含在以 S[id] 为中心的回文子串中,由于 i 和 j 对称,以 S[i] 为中心的回文子串必然包含在以 S[id] 为中心的回文子串中,所以必有 P[i] = P[j],其中 j = 2*id - i,因为 j 到 id 之间到距离等于 id 到 i 之间到距离,为 i - id,所以 j = id - (i - id) = 2*id - i,参见下图。
当 P[j] >= mx - i 的时候,以S[j]为中心的回文子串不一定完全包含于以S[id]为中心的回文子串中,但是基于对称性可知,下图中两个绿框所包围的部分是相同的,也就是说以S[i]为中心的回文子串,其向右至少会扩张到mx的位置,也就是说 P[i] >= mx - i。至于mx之后的部分是否对称,就只能老老实实去匹配了。
public static void main(String[] args) {
// TODO Auto-generated method stub
String s ="cbbd";
String res = longestPalindrome(s);
System.out.println(res);
res = longestPalindrome("babad");
System.out.println(res);
}
private static String longestPalindrome(String s) {
if(s == null || s.length()==0){
return "";
}else if(s.length()==1 ){
return s;
}
//formatstring : abc => #a#b#c#
StringBuilder sb = new StringBuilder();
for (int i = 0, len = s.length(); i < len; i++)
sb.append("#").append(s.charAt(i));
sb.append("#");
String str = sb.toString();
// idx是当前能够向右延伸的最远的回文串中心点,随着迭代而更新
// max是当前最长回文串在总字符串中所能延伸到的最右端的位置
// maxIdx是当前已知的最长回文串中心点
// maxSpan是当前已知的最长回文串向左或向右能延伸的长度
int idx = 0, max = 0;
int maxIdx = 0;
int maxSpan = 0;
int[] p = new int[str.length()];
for(int i=0;i<str.length();i++ ){
// 找出当前下标相对于idx的对称点
int imirror = idx-(i-idx);
//核心赋值逻辑:如果当前已知延伸的最右端大于当前下标,我们可以用对称点的P值,否则记为1等待检查
p[i] = max>i?Math.min(p[imirror], max-i):1;
// 检查并更新当前下标为中心的回文串最远延伸的长度
while((i+p[i])<str.length()&&(i-p[i])>=0 ){
if(str.charAt(i+p[i] )==str.charAt(i-p[i]) ){
p[i]++;
}else{
break;
}
}
// 检查并更新当前已知能够延伸最远的回文串信息
if((i+p[i])>max){
max = i+p[i];
idx =i;
}
//寻找最大值及中心点
if(p[i]>maxSpan){
maxSpan = p[i];
maxIdx = i;
}
}
maxSpan =maxSpan-1;
int beginIndex = (maxIdx - maxSpan) / 2;
return s.substring(beginIndex, beginIndex+maxSpan );
}
Runtime: 6 ms, faster than 87.05% of Java online submissions for Longest Palindromic Substring.
Memory Usage: 36 MB, less than 100.00% of Java online submissions forLongest Palindromic Substring.
仅以此文加深印象,感谢以上文章大神,再次膜拜。
参考:
https://segmentfault.com/a/1190000002991199
https://www.jiuzhang.com/solution/longest-palindromic-substring/
https://www.cnblogs.com/bitzhuwei/p/Longest-Palindromic-Substring-Par-I.html
https://segmentfault.com/a/1190000016239464