Manacher 算法找到最长回文子字符串
今天在LeetCode刷题的时候恰好碰到了相关的一道题(647. 回文子串)。想了很久,没有什么特别有效的思路。看答案发现使用了一个没有听说过的算法:Manacher,可以将时间复杂度从O(N^2)
降到O(N)
。恰逢面试在即,特此写一篇博客记录下来对算法的理解,以备复习之需。
算法过程分析
相对于中心拓展算法找到最长回文SubString,Manacher算法的优点就是其时间复杂度为O(N)
。当然,快速的代价就是理解起来相对难一些。
预处理
在中心拓展算法中,我们需要对奇数长度的回文子字符和偶数长度的回文子字符分开分析。为了规避这个问题,Manacher算法对输入进行预处理,使得所有的回文子字符都为奇数长度:
- 将一个输入不包含的的字符(一般为
$
)加到输入前。 - 将另一个输入不包含的字符(一般为
!
)加到输入后。 - 将另一个输入不包含的字符(一般为
#
)加到修改后的输入中每两个字符之间。
举个例子来讲,我们有字符串"aaaa"
,那么预处理之后,就会得到"$#a#a#a#a#!"
。所以我们可以看出来之前最长的回文子字符"aaaa"
就变为了#a#a#a#a#
。也就是说,从偶数长度的子字符变为了奇数长度的子字符。
为什么还要分别将两个输入不包含的字符
放在输入的首尾呢?别急,我们接着看。
遍历所有回文中心位
由于我们在输入预处理的步骤中,将所有的回文子字符已经转为奇数长度。所以在下面的操作中,我们只需要将输入的每一个字符,都当做一个回文子字符的中心位即可。不需要考虑偶数长度的回文子字符。
现在,我们用一个数组radius
来记录输入中每一个字符是回文中心位时,它的最长回文半径的长度-1。radius[5] = 3
就代表当输入的第5位为中心位时,最长的回文半径为3 - 1 = 2
,也就是说:当输入的第2位为中心位时,其最长的回文长度为2 + 1 + 2 = 5
。这一步我们可以用一个while
循环来计算每一个中心位的最长回文长度:
'''
@author: Yizhou Zhao
'''
# 设置 radius[i] = 1, 因为字符本身也是一个回文数
radius[i] = 1
while(string[i-radius[i]] == string[i+radius[i]]):
radius[i] += 1
可以看到,在上面的while
循环中,我们并没有为其设置边界。原因就在于我们在前面提出的问题:“分别将两个输入不包含的字符
放在输入的首尾”。这样一来,我们就可以确保修改过后的输入本身("$#a#a#a#a#!"
),并不是一个回文字符串。所以也就不用担心数组越界的问题了。
之后,再通过一个for
循环填满radius
数组内所有的项之后,我们就可以找到输入中最长的回文子字符的长度是多少了。
'''
@author: Yizhou Zhao
'''
# s: String - 已经过初始化的字符串
# return: String - 给定输入的最长回文子字符,需要post process去掉 #、$等helper符号
def findLongestPalinSubStr(s):
# 初始化
maxLenCenter = 0
maxLen = 1
radius = [1 for i in range(len(s))]
# 将输入的每一个字符都当做是回文中心位
for i in range(1, len(s) - 1):
while(s[i-radius[i]] == s[i+radius[i]]):
radius[i] += 1
# 更新最长回文子字符的信息
if radius[i] > maxLen:
maxLenCenter = i
maxLen = radius[i]
return s[maxLenCenter - maxLen + 1: maxLenCenter + maxLen]
优化 - 真正的 Manacher 算法
那么上述做法,有没有可以优化的余地呢?答案是肯定的。我们假设一个奇数长度的回文字符a = "312111213"
。在这个例子中我们可以看到如果我们把第5位设为中心位时,通过上述算法,我们必定会得到其本身就是一个回文字符串。
那么这个能给我们一个什么样的信息呢?
记得回文字符串的定义吗?对于奇数长度的回文字符串,中心位的左右是对称的。也就是说,如果中心位到其左边界内包含另一个回文子字符,那么中心位到其右边界也一定有一个回文子字符。
在上面的例子中,当我们遍历到i=5
时,我们有如下的信息:
i | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
radius[i] | 1 | 1 | 2 | 1 | 5 |
那么,当我们遍历到i=7
,由于已知当中心位等于4时从0到8都是回文数,我们就可以用radius[2]
的值来作为radius[7]
的基数(index为2和index为7,对于4是中心位时是对称的)。在此基础上,我们只需要检查当7是中心位时,radius[2]
之外还符不符合回文属性就可以了。
利用这个特性,我们就可以对上面的算法提出一个优化方向:
- 我们是不是可以利用之前已经计算出来的回文子字符,来减少我们计算当前中心位回文子字符串的计算量呢?
有了方向之后,就该落实方案了。在Manacher算法中,我们会记录一个目前已知右边界最靠右的回文子字符串pivotRight
及其中心位pivot
。这样一来,在我们继续计算pivot
右边字符的回文属性时,我们就可以利用pivot
左边已知的回文长度来减少我们的工作量了。
源代码 (Python 3.7)
'''
@author: Yizhou Zhao
'''
# s: String - 输入字符串,不可以包含['#', '$', '!']
# return: String - 添加 helper 符号之后的输入字符串
def preprocess(s):
rawList = list(s)
newStr = "#".join(rawList)
newStr = '$#' + newStr
newStr += '#!'
return newStr
# s: String - 输入字符串
# return: String - 去掉 helper 符号的字符串
def postprocess(s):
result = ""
for c in s:
if c != '#':
result += c
return result
# s: String - 输入字符串,不可以包含['#', '$', '!']
# return: String - 输入字符串内包含的最长回文子字符串
def manacher(s):
s = preprocess(s)
print(list(s))
# 初始化
pivot = 0
pivotRight = 0
maxLenCenter = 0
maxLen = 1
radius = [1 for i in range(len(s))]
# 将输入的每一个字符都当做是回文中心位
for i in range(1, len(s) - 1):
# j = 当 pivot 为中心位与 i 对应的index
j = pivot - (i - pivot)
# 如果 i 包含在pivot和其右边界内
if i < pivotRight:
radius[i] = min(radius[j], 1)
# 只需要更新已知范围之外的
while(s[i - radius[i]] == s[i+radius[i]]):
radius[i] += 1
# 更新 pivot 以及 pivotRight 的信息。使其一直保持在目前右边界最大的值。
if pivotRight < i + radius[i]:
pivot = i
pivotRight = i + radius[i]
# 更新最长回文子字符的信息
if radius[i] > maxLen:
maxLenCenter = i
maxLen = radius[i]
tempResult = s[maxLenCenter - maxLen + 1: maxLenCenter + maxLen]
result = postprocess(tempResult)
return result
未经本人同意,禁止转载