Java 实现 Manacher 算法
在给定字符串的情况下,欲求最大的回文子串的长度,该如何实现呢?
首先解释一下什么是回文字符串,简单理解就是从左向右读和从右向左读都会得到同样的结果。 比如“32123”和“ababa”是回文字符串,“32143”和“abdca”不是回文字符串。
在字符串“ababa”这个例子中我们会发现,前三个字母“aba”组成的子串也是回文字符串,中间三个字母“bab”组成的子串同样是回文字符串。那么,我们如何求出给定字符串的最大回文子串长度?
一种直接的方法,也许大家都能想到,对字符串中的每一个字符进行处理,利用循环判断该字符左右两边的字符是否相同,直到判断出以该字符为中心时最大回文子串的长度。最终再取出字符串所有字符的最大回文子串长度中最长的那一个。
但是这样的方法效率太低,因为对于字符串中的每个字符我们在一开始都假定它的回文子串长度为1(即该字符本身)。另外我们发现,当输入的子串的长度是偶数的时候,其中心不再是一个字符。
下面我们将引入Manacher算法。
改造字符串:
首先来解决长度奇偶的问题。我们将对给定字符串进行改造,在字符之间加入“#”。
例如处理“abba”,我们将改造成“#a#b#b#a#”。经过改造后,我们发现此时无论原回文字符串长度为奇数还是偶数时,改造后的字符串都可以按照奇数来处理。改造代码:
String rs = "#";
for(int i = 0; i < s.length(); i++) rs = rs + s.charAt(i) + "#";
并且我们将引入一个辅助数组p[i] 来记录以i个字符为中心的(包含i这个字符)回文串半径长。例如“#a#b#a#”,b的半径为从左边第一个“#”到b的长度。
给定字符串 a b a
回文字符串 1 3 1
改造字符串 # a # b # a #
辅助数组p 1 2 1 4 1 2 1
我们发现原回文字符串长度为辅助数组相应位置i的值减去1,即 p[i] - 1。这是因为根据p[i]半径的定义,我们可知改造后字符串的长度为 2 * p[i] - 1。对于改造后回文字符串,原有字符(不为“#”)左边一定会有 p[i]/2 个“#”,右边同理。所以原长度等于p[i] - 1。
Manacher算法的优点就在于他对p[i]的赋值不再是从1开始,根据回文字符串的对称性,在对p[i]赋初始值时我们可以参考前面已有的点,从而找出p[i]的最小值而不再是直接赋值p[i]=1再进行循环。
Manacher算法核心:
定义两个变量: maxLen是数组之前已定义的 i-1 个元素中回文串中能延伸到的最右端的位置,所对应的元素的序号为mid。即maxLen = p[mid]+mid。
下面我们将分情况讨论 i 与 mid 和 maxLen 的关系。
i >= maxLen
这说明至今为止没有任何一个回文字符串能包含i,所以只能将p[i]赋值为1并按照之前的方法进行查找。-
i < maxLen
这说明i在某个回文字符串之中,而这个回文字符串的中心便是mid。
根据回文字符串的对称性,我们可以根据i关于mid的对称点j来进行p[i]的赋值。根据对称点的定义,我们可以得到 j = 2*mid – i。p[j]又可分为三种情况。-
p[j] > maxLen – i
即 j 点的回文子串有一部分已经在 mid 的半径之外。这时我们根据回文子串对称性可知i的长度一定为maxLen – i。由回文子串最大范围为 maxLen 处可知,maxLen后面的字符不可能与 mid - p[mid] 前面的字符相同,所以对于i点来说,它的回文字符长度一定为maxLen – i。 -
p[j] < maxLen – i
由于i与j对称,p[i] = p[j]。
-
p[j] = maxLen – i
这时情况较为特殊,我们无法判断maxLen后面的情况,则赋值p[i]为maxLen – i,再根据循环来判断是否还需更新p[i]。
-
由此我们可以得到p[i]的赋值方法:
if(i < maxLen) p[i] = Math.min(p[2*mid-i], maxLen);
else p[i] = 1;
Manacher 算法代码:
public static int countSubstrings(String s) {
String rs = "#";
for(int c=0; c<s.length(); c++){
rs = rs + s.charAt(c) + "#";
} //改造字符串
int[] p = new int[rs.length()]; //初始化辅助数组
int mid = 0;
int maxLen = 0; //初始中心为第一个字符,半径为0
for(int i=0;i<rs.length();i++){
if(i < maxLen) p[i] = Math.min(p[2*mid-i], maxLen);
else p[i] = 1;
while(i-p[i]>=0 && i+p[i]<rs.length() && rs.charAt(i-p[i])==rs.charAt(i+p[i])){
p[i]++;
}
if(i+p[i] > maxLen){
mid = i;
maxLen = i + p[i];
} // 当以第i个字符为中心的回文字符串的最右字符在maxLen右边,更新中心和半径
}
int max = p[0];
for(int i=0;i<p.length;i++){
System.out.println(p[i]);
max = Math.max(max, p[i]);
} //寻找最大值
return max-1; // 原回文字符串长度等于数组p中元素减一
}
综上,我们发现Manacher算法的优点就是对p[i]处的初始值可以利用回文字符串的对称性,根据i的对称点j的特性来给i提供参考,从而将复杂度降低至O(n)(因为位置小于maxLen的字符可以直接根据对称点 j 的值进行赋值,实际在更新maxLen直到改造字符串 rs 的末尾),避免了大量的循环。运行后发现如果最大回文字符串长度为偶数,则p的最大值会出现在对应的“#”处,而实际给定字符串这个“#”是不存在的,这也是Manacher算法改造字符串后方便之处。