乘风破浪:LeetCode真题_005_Longest Palindromic Substring
一、前言
前面我们已经提到过了一些解题方法,比如递推,逻辑推理,递归等等,其实这些都可以用到动态规划上来。动态规划可以说是比较容易理解但是难以写出代码的。究其原因还是我们的分析没有达到细致入微的程度,下面我们看一个可以使用动态规划解决的问题。
二、Longest Palindromic Substring
2.1 问题理解
2.2 问题分析与解答
通过题目,我们可以发现在一个大的字符串中寻找一个回文子序列,还要保证序列的长度最大,我们可以想象如果一个序列A[1...n]是回文序列,那么定义一个数据结构来标记所有的回文序列,dp[i][j]==true,代表字符串从下标i到下标j是一个回文序列,且是最长的,那么dp[i+1][j-1]也必定为一个回文序列,并且A[i]==A[j]以此类推直至我们最开始知道的一个字符是一个回文序列,也就是dp[i][i]==true。这样如果我们能找到一个子串使得这样一路下来一直满足,那么我们就找到了回文序列,根据长度我们可以知道最长的子序列。
于是我们的算法为:
public class Solution { /** * * 题目大意: * 给定一个字符串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)的空间。 * </pre> * * @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; } }
可以看到从长度为1,2开始,将这些作为已知条件,然后进行更高层次的判断,对所有的情况进行遍历之后就得到了我们想要的结果。
那么还有没有其他比较好的解答方案呢?官网给出了一些解释。
首先我们可以使用穷举法,我们遍历完所有的可能,按照长度不断地增加然后尝试,每一个子串都进行比较,这样将会是O(n~3)的时间复杂度。这是一种方法。
其次我们可以使用“中心节点法”,这一种方法非常的巧妙,主要是利用了回文的对称性,分为奇数和偶数对称,因此遍历所有的元素,对于每一个元素,分别进行奇扩展和偶扩展,以此来尝试最大的扩展空间,然后将长度返回,等遍历结束就能找到所有的结果。
回文字符串都是对称的,有两种对称方式,一是关于字符对称,比如a,aba,cabac,这种回文字符串长度都是奇数;二是关于间隔对称,比如aa,abba,cbaabc,这种回文字符串长度都是偶数,所以要分别检测这两种情况。中心结点法,就是遍历整个字符串,分别设为中心结点,然后第二个遍历是分别对设定的中心向左右扩展,所以复杂度为o(n~2)。比如对于字符串abba,先检测关于字符对称,设定中心为a,发现最长回文为a,再检测关于间隔对称,给定中心为ab之间间隔,发现最长回文为空。然后坐标前移,设定中心为b,发现最长回文为b,再设定中心为bb的间隔,发现最长回文为abba,为目前最长,所以最长回文设为abba,然后坐标前移,继续检测。
1 public String longestPalindrome(String s) { 2 if (s == null || s.length() < 1) return ""; 3 int start = 0, end = 0; 4 for (int i = 0; i < s.length(); i++) { 5 int len1 = expandAroundCenter(s, i, i); 6 int len2 = expandAroundCenter(s, i, i + 1); 7 int len = Math.max(len1, len2); 8 if (len > end - start) { 9 start = i - (len - 1) / 2; 10 end = i + len / 2; 11 } 12 } 13 return s.substring(start, end + 1); 14 } 15 16 private int expandAroundCenter(String s, int left, int right) { 17 int L = left, R = right; 18 while (L >= 0 && R < s.length() && s.charAt(L) == s.charAt(R)) { 19 L--; 20 R++; 21 } 22 return R - L - 1; 23 }
2.3 额外扩充:Manacher's Algorithm 马拉车算法
马拉车算法(Manacher‘s Algorithm)是用来查找一个字符串的最长回文子串的线性方法,由一个叫Manacher的人在1975年发明的,这个方法的最大贡献是在于将时间复杂度提升到了线性,这是非常了不起的。对于回文串想必大家都不陌生,就是正读反读都一样的字符串,比如 "bob","level", "noon" 等等,那么如何在一个字符串中找出最长回文子串呢,可以以每一个字符为中心,向两边寻找回文子串,在遍历完整个数组后,就可以找到最长的回文子串。但是这个方法的时间复杂度为O(n*n),并不是很高效,下面我们来看时间复杂度为O(n)的马拉车算法。
由于回文串的长度可奇可偶,比如"bob"是奇数形式的回文,"noon"就是偶数形式的回文,马拉车算法的第一步是预处理,做法是在每一个字符的左右都加上一个特殊字符,比如加上'#',那么
1 bob --> #b#o#b# 2 noon --> #n#o#o#n#
这样做的好处是不论原字符串是奇数还是偶数个,处理之后得到的字符串的个数都是奇数个,这样就不用分情况讨论了,而可以一起搞定。接下来我们还需要和处理后的字符串t等长的数组p,其中p[i]表示以t[i]字符为中心的回文子串的半径,若p[i] = 1,则该回文子串就是t[i]本身,那么我们来看一个简单的例子:
1 # 1 # 2 # 2 # 1 # 2 # 2 # 2 1 2 1 2 5 2 1 6 1 2 3 2 1
为啥我们关心回文子串的半径呢?看上面那个例子,以中间的 '1' 为中心的回文子串 "#2#2#1#2#2#" 的半径是6,而为添加井号的回文子串为 "22122",长度是5,为半径减1。这是个普遍的规律么?我们再看看之前的那个 "#b#o#b#",我们很容易看出来以中间的 'o' 为中心的回文串的半径是4,而 "bob"的长度是3,符合规律。再来看偶数个的情况"noon",添加井号后的回文串为 "#n#o#o#n#",以最中间的 '#' 为中心的回文串的半径是5,而 "noon" 的长度是4,完美符合规律。所以我们只要找到了最大的半径,就知道最长的回文子串的字符个数了。只知道长度无法确定子串,我们还需要知道子串的起始位置。
我们还是先来看中间的 '1' 在字符串 "#1#2#2#1#2#2#" 中的位置是7,而半径是6,貌似7-6=1,刚好就是回文子串 "22122" 在原串 "122122" 中的起始位置1。那么我们再来验证下 "bob","o" 在 "#b#o#b#" 中的位置是3,但是半径是4,这一减成负的了,肯定不对。所以我们应该至少把中心位置向后移动一位,才能为0啊,那么我们就需要在前面增加一个字符,这个字符不能是井号,也不能是s中可能出现的字符,所以我们暂且就用美元号吧。这样都不相同的话就不会改变p值了,那么末尾要不要对应的也添加呢,其实不用的,不用加的原因是字符串的结尾标识为' ',等于默认加过了。那此时 "o" 在 "$#b#o#b#" 中的位置是4,半径是4,一减就是0了,貌似没啥问题。我们再来验证一下那个数字串,中间的 '1' 在字符串 "$#1#2#2#1#2#2#" 中的位置是8,而半径是6,这一减就是2了,而我们需要的是1,所以我们要除以2。之前的 "bob" 因为相减已经是0了,除以2还是0,没有问题。再来验证一下 "noon",中间的 '#' 在字符串 "$#n#o#o#n#" 中的位置是5,半径也是5,相减并除以2还是0,完美。可以任意试试其他的例子,都是符合这个规律的,最长子串的长度是半径减1,起始位置是中心点位置减去半径再除以2。
三、总结
通过对回文字符串的理解,我们可以更加清晰地明白动态规划以及其他方法解决问题的常用技巧,对我们以后理解和分析问题提供了帮助。