最长回文子串
最近在 leetcode 上刷算法题,重温一下 ACM 和 javascript 练手,发现了一道有趣的题目,最长回文子串。
开始的想法是反转字符串然后找出最大公共子串(LCS),但是 TLE 了。觉得可能是 js 本身效率太低,然后找了 leetcode 上别人写的算法分析,发现反转后找 LCS 是错误的,而且分析写得非常好,觉得应该好好学习下,记录在此。
回文
回文字符串就是指无论从那个方向读都是一样的,如'aba'
就是一个简单的回文串。
开始的想法是先将字符串反转,然后找出它们的最长公共子串,即是要找最长回文串。
然而这是错误的!!!
比如 'abadfcdaba'
反转后是 'abadcfdaba'
,它们的 LCS 是 'abad'
,这显然不是一个回文串。
而它存在的原因就是原字符串中出现了两个一样的回文串,且有额外相同的部分分别出现在这两个回文串的前后。导致反转后的字符串除了回文部分相同外,还有非回文串也相同。
下面介绍几种不同的方法来计算 LPS。
暴力搜索
最简单的办法是找出字符串中的所有子串,判断其是否为回文串,然后记录下其中最大的回文子串即可。
暴搜无例外的话效率都很低,这个算法是 O(N^3)
的。首先找出所有子串 是 O(N^2)
的,然后判断其是否为回文是 O(N)
的。
1 | var longestPalindrome_bf = function(s) { |
动态规划
DP可能是解这个问题的一个好方法,然而算法复杂度依然是 O(N^2) 的,而且空间复杂度也是 O(N^2)。
我们假设用 P[i][j]
来表示 s[i..j]
是否是一个回文子串。
它的计算公式长这样:
1 | P[i][j] = s[i] === s[j] && P[i + 1][j - 1] ? true : false; |
算法实现如下:
1 | var longestPalindrome_dp = function(s) { |
这个实现在 leetcode 上 TLE 了。几乎跟暴搜一样慢。
ps. 我觉得跟 js 的数组对象有关。在 js 中处理二维数组比静态语言处理要慢的多。
继续优化
降低空间复杂度
DP 的空间复杂度是 O(N^2)
的,主要用来保存二维数组 P[i][j]
,而且只用了一半。
我们可以把空间复杂度降到 O(1)
,只存找到的最长回文串即可。枚举轴心位置,并进行扩展。如果是回文,则轴心两边的字符应该对称相等。
需要考虑到长度奇偶情况的不同,如果是奇数长度,轴心就是一个字符;如果是偶数长度,轴心则不在字符串中。
实现如下:
1 | var longestPalindrome_enum = function(s) { |
这个实现在 leetcode 上 AC 了,用了 236ms
。
降低时间复杂度
相比降低空间复杂度,降低时间复杂度要难得多。这里有一个 O(N)
时间复杂度的算法,叫做 Manacher 算法。
能够从 O(N^2)
降到 O(N)
,这个算法很巧妙。它首先解决了长度奇偶不同的问题。
通过向字符串中加入一些特殊字符来使长度均为奇数。特殊字符即为原字符串的字符集中没有的字符。如 'aba'
中插入 '#'
,变成'#a#b#a#'
。
然后提出了一个回文半径(P)的概念:
1 | T = # a # b # a # a # b # a # |
它代表了以该字符为轴心的回文串对折后的长度。由于插入了特殊字符,如果最长回文字符串的长度为偶数,则轴心会出现在 '#'
上。
容易看出上面的例子中,最大回文子串的轴心就是 P
为 6 的字符。最大回文子串为 'abaaba'
,长度刚好为 6.
这显然不是巧合,接下来就是要计算 P
,记下其最大值及对应下标,即可。目标时间复杂度 O(N)
。当然,这个算法最难的部分,就是计算 P
。
正常计算 P
的话,时间复杂度依然是 O(N^2)
,但是如果利用回文串的对称特性,减少搜索,就可以将复杂度降至 O(N)
。
计算 P
就是以每一个字符为轴心计算回文半径,也就是从每一个字符开始向两边搜索,那么右边必然会搜索到尚未遍历到的字符,如果我们记下最大能搜索到的右边界 R
。在后面的遍历搜索中,如果当前 T[i]
在边界内,即比最大右边界小,那么也就是在一个已搜索的回文子串中,假设 i'
是 i
对应当前最大 R
的轴心 C
的对称位置(即 T[i] == T[i']
), 可以做出下面的结论:
1 | if P[i'] < R-i |
另一种情况,如果当前字符 T[i]
不在边界内,即我们不能得出任何结论,所以 P[i] = 0
。
js 实现:
1 | var longestPalindrome_manacher = function(s) { |
算法效果很好,在 leetcode 上运行时间是 160ms
。
以上。