Manacher's algorithm 马拉车算法
_
参考
有怎么想出来的思路 非常详细 | https://www.geeksforgeeks.org/manachers-algorithm-linear-time-longest-palindromic-substring-part-1/ |
算法竞赛版 严谨的表达 简明的代码 |
问题描述
求字符串的子串{回文串}{最长}
denote
这里均用Zero-based index
s | 原字符串 |
n | len(s) |
S | 变换后的字符串 |
ns | len(S) |
P | P[i]表示以S[i]为中心的最长回文子串的长度(相对于原字符串s而言) |
expand_around_center | 采用的是leetcode solution里面的说法, 大意是所有回文串的中心只可能有2n-1种, 考虑每个位置作为中心的情况, 从中心往两侧扩宽回文串的长度 |
Prerequisite
python3
expand_around_center
def longestPalindrome(self, s: str) -> str: n=len(s) ans=0 for i in range(n): l = 0 while 0<=i-l<n and 0<=i+l<n and s[i-l]==s[i+l]: l+=1 ans = max(ans, l*2+1) for i in range(n-1): l = 0 while 0<=i-l<n and 0<=i+l+1<n and s[i-l]==s[i+1+l]: l+=1 ans = max(ans, l*2) retur ans |
思路
首先在原字符串每个字符前后插入某个字符,比如#
巧妙的不再需要讨论奇偶回文串, 化虚(间隙)为实(字符'#')
以#为中心是偶回文串(如abba)
否则是奇回文串
也可以在最开始最末尾补上用来终止的两种字符,比如$和^, 作为哨兵, 这样就不用判断边界条件了
不难发现P[i]也是以字符S[i]为中心的 最长回文子串 左边部分的长度(不包括S[i])
也可以不进行变换, 变换是为了便于理解和编程, 不是必须的
相比于找到2n-1的可能的回文串中心位置的算法expand_around_center, manacher算法就做了一处优化
对于这处优化的示例
假设我们用expand_around_center算法, 正在从左往右计算出S各个位置的最长回文子串的长度, 其中P[8]还没有计算
expand_around_center算法没有必要存下各个位置最长回文子串的长度, 这里是为了举例
当判断到S[8]时, 已经知道了P[7]=6,
那么S[1:7]和S[8:14]是以S[7]为中心左右对称的,
又因为P[6]=1, 说明S[4]!=S[8]; S[5]==S[7]
也就是说S[6]!=S[10]; S[7]==S[9]
所以P[8]=1
这种通过在S[7]前面的P[6]来判断P[8]的方法只需要满足一个前提:
8+P[6]<7+P[7]
如果不满足前提, 以S[12]为例,
12+P[2]>=7+P[7]
此时P[12]>=P[2], 那么还需要判断S[12+P[2]+1]和S[12-P[2]-1], 以及离S[12]更远的字符
所以存在一种方法能够由S[7-k]推测S[7+k], 而7+P[7]相当于一个"最远边界", 边界内的字符存在对称性, 可以跳过部分字符的比较过程, 边界外的则用普通的方法比较(参见expand_around_center)
把这种优化方法补充进expand_around_center中就成了manacher算法
算法的细节部分是, 需要维护一个P数组, 同时可以采用维护一个最远边界{当前所有算出了P的字符的"最远边界"}的方法
centerRight := max(t+P[t])
在代码中把最远边界命名为centerRight, 最远边界对应的对称中心处的字符的索引命名为centerIdx
代码
绿色字体的部分代码, 不影响得到P的整个算法, 是一个获取答案的切面
def longestPalindrome(self, s: str) -> str: """ get length of longest palindrome substring of s """ n=len(s) S_bui = [] S_bui.append('^') S_bui.append('#') for i in range(n): S_bui.append(s[i]) S_bui.append('#') S_bui.append('$') S="".join(S_bui) ns=len(S) longestPal=slice(0,0) centerRight=0 centerIdx=0 P=[0]*(ns) for i in range(2,ns-2): if centerRight>i: if P[2*centerIdx-i]+i<centerRight: P[i]=P[2*centerIdx-i] continue else: P[i]=centerRight-i # else P[i]=0 while S[i+P[i]+1]==S[i-P[i]-1]: P[i]+=1
if i+P[i]>centerRight: centerRight=i+P[i] centerIdx=i if longestPal.end-longestPal.start<P[i]: longestPal=slice(i-P[i], i+P[i]+1) return S[longestPal].replace('#','') |
时间复杂度
O(n)
上面的代码由一个for循环, 和里面一个while循环 组成
如果while内进行了k次循环, 新的centerRight 会变成 centerRight+k 或者 k+i(此时i>=centerRight), 而centerRight上限是ns-2, 因此所有的while内循环一共最多发生O(ns)次
综上时间复杂度是O(n)
不进行变换的代码(main)
需要分回文串长度的奇偶进行讨论, 但是速度快很多, 代码基本不用变
下面是一个示例
当参数odd=1时, P[i]保存了以字符S[i]为中心的 (奇数长度)最长回文子串 左边部分的长度(不包括S[i])
当参数odd=0时, P[i]保存了以字符S[i]右侧间隙为中心的 (偶数长度)最长回文子串 左边部分的长度(包括S[i])
def longestPalindrome(s, odd): """ odd is 0 or 1 get longest palindrom substring whose length%2==odd """ n=len(s) P=[0]*n centerRight=0 centerIdx=0 longestPal=slice(0,0) for i in range(n): if centerRight>i: if P[2*centerIdx-i]+i<centerRight: P[i]=P[2*centerIdx-i] continue else: P[i]=centerRight-i while i-P[i]-odd>=0 and i+P[i]+1<n and s[i+P[i]+1]==s[i-P[i]-odd]: P[i]+=1 if i+P[i]>centerRight: centerRight=i+P[i] centerIdx=i
if longestPal.end-longestPal.start<P[i]*2+odd: longestPal=slice(i-P[i]+1-odd:i+P[i]+1) return s[longestPal] |