Manacher算法(建议约上你便宜的草稿本一起理解!)
为了表述方便,我们定义一个新概念臂长,表示中心扩展算法向外扩展的长度。如果一个位置的最大回文字符串长度为 2 * length + 1 ,其臂长为 length。
下面的讨论只涉及长度为奇数的回文字符串。长度为偶数的回文字符串我们将会在最后与长度为奇数的情况统一起来。
思路与算法
在中心扩展算法的过程中,我们能够得出每个位置的臂长。那么当我们要得出以下一个位置 i 的臂长时,能不能利用之前得到的信息呢?
答案是肯定的。具体来说,如果位置 j 的臂长为 length,并且有 j + length > i,如下所示:
情况一:j + length - i = 2 > n
| a | b | a | b | a | b | a | b | e |
j 为中间没有画横线的 a 的索引,length 为左边或者右边画横线元素的个数,此处 length 为 4
| b | a | b | ~~ | b | a | b |
( ‘~’ 仅用于在表达时候分隔两个子串,没有实际意义)
以没画线的 a 拆分开两个 bab 和 bab 子串。
对于左边的子串 bab ,2 * j - i 为 a 的索引,n 为左边子串左边或者右边画线部分元素的个数,此处 n 为 1;
对于右边的子串 bab ,i 为 a 的索引,表示为:当前从 i 进行中心扩展,画线部分是可以跳过不判断的部分元素。
情况二:j + length - i = 1 < n
| b | a | c | a | b | a | c | a | e |
j 为中间没有画横线的 b 的索引,length 为左边或者右边画横线元素的个数,此处 length 为 3
| b | a | c | a | b | ~~ | a | c | a |
( ‘~’ 仅用于在表达时候分隔两个子串,没有实际意义)
以没画线的 b 拆分开两个 bacab 和 aca 子串。(这次拆分保留了这没画横线的 b )
对于左边的子串 bacab ,2 * j - i 为 c 的索引,n 为左边子串左边或者右边画线部分元素的个数,此处 n 为 2;
对于右边的子串 aca ,i 为 c 的索引,表示为:当前从 i 进行中心扩展,画线部分是可以跳过不判断的部分元素。
当在位置 i 开始进行中心拓展时,我们可以先找到 i 关于 j 的对称点 2 * j - i。那么如果点 2 * j - i 的臂长等于 n,我们就可以知道,点 i 的臂长至少为 min(j + length - i, n)。那么我们就可以直接跳过 i 到 i + min(j + length - i, n) 这部分,从 i + min(j + length - i, n) + 1 开始拓展。
我们只需要在中心扩展法的过程中记录右臂在最右边的回文字符串,将其中心作为 j,在计算过程中就能最大限度地避免重复计算。
那么现在还有一个问题:如何处理长度为偶数的回文字符串呢?
我们可以通过一个特别的操作将奇偶数的情况统一起来:我们向字符串的头尾以及每两个字符中间添加一个特殊字符 #,比如字符串 aaba 处理后会变成 #a#a#b#a#。那么原先长度为偶数的回文字符串 aa 会变成长度为奇数的回文字符串 #a#a#,而长度为奇数的回文字符串 aba 会变成长度仍然为奇数的回文字符串 #a#b#a#,我们就不需要再考虑长度为偶数的回文字符串了。
注意这里的特殊字符不需要是没有出现过的字母,我们可以使用任何一个字符来作为这个特殊字符。这是因为,当我们只考虑长度为奇数的回文字符串时,每次我们比较的两个字符奇偶性一定是相同的,所以原来字符串中的字符不会与插入的特殊字符互相比较,不会因此产生问题。
public String longestPalindrome(String s) {
// 这样初始化值为了对第一个 # 在end - start + 1 时候能判定为是回文字符串,并且也默认规定了是非法数组下标作为默认
int start = 0, end = -1;// 最长子串的范围下标标记
// 化解偶数个数的解决问题,使得和奇数方法统一
StringBuffer t = new StringBuffer("#");
for (int i = 0; i < s.length(); ++i) {
t.append(s.charAt(i));
t.append('#');
}
// t.append('#');//这个删除,加了这个后面结尾就 "##" 两个了
s = t.toString();
List<Integer> arm_len = new ArrayList<Integer>();
int right = -1, j = -1; // 右臂可摸到最远地方,和次数最长子串中心点 j
// 从左向右开拓 n 的数据,以 j 区分已知 n 区和搜索区,顺便获取最长子串
for (int i = 0; i < s.length(); ++i) {
int cur_arm_len;// 此时最大子串臂长
if (right >= i) {
// 存在搜索范围,通过对称点记录的 n 值进行开拓右边未知可搜索领域
int i_sym = j * 2 - i;// 对称点
// 取最小臂长搜索优化时间复杂度,因为右边为未知领域,如:bacabacae
int min_arm_len = Math.min(arm_len.get(i_sym), right - i);
// 确定了搜索范围,开始搜索 n 值
// PS:原来传参没有 - 1 和 + 1,这样会导致循环了一次不必要的循环
cur_arm_len = expand(s, i - min_arm_len - 1, i + min_arm_len + 1);
} else {
// 因为需要有已知的 n 进行操作,不存在搜索范围,说明需搜索 n 值
cur_arm_len = expand(s, i, i);
}
arm_len.add(cur_arm_len);// 将臂长加入记录中
// 找到新的更大的臂长去触摸更远的地方(更远的搜索范围),更新触摸范围和对称中心 j 值
if (i + cur_arm_len > right) {
j = i;
right = i + cur_arm_len;
}
// 如果新的臂长形成的子串大于原来记录的最大子串,则更新数据
// PS:原来没有 + 1 也运行成功是因为加了 '#' 的容错性,改后才是正确的标记最长回文子串的元素个数
if (cur_arm_len * 2 + 1 > end - start + 1) {
start = i - cur_arm_len;
end = i + cur_arm_len;
}
}
// 删除 '#' 还原字符串
StringBuffer ans = new StringBuffer();
for (int i = start; i <= end; ++i) {
if (s.charAt(i) != '#') {
ans.append(s.charAt(i));
}
}
return ans.toString();
}
// 求以 i 为中心的n,n为以 i 为中心字串的臂长
public int expand(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
--left;
++right;
}
return (right - left - 2) / 2;// (right - left)/2 - 1 ,因为多循环了一次
}
复杂度分析
时间复杂度:O(n),其中 nnn 是字符串的长度。由于对于每个位置,扩展要么从当前的最右侧臂长 right 开始,要么只会进行一步,而 right 最多向前走 O(n) 步,因此算法的复杂度为 O(n)。
空间复杂度:O(n),我们需要 O(n) 的空间记录每个位置的臂长。
例题参考:5.最长回文子串
----以上为个人思考与见解,有误请指点,有想法也可联系交流!
~~~~~~~~~~~~~~ 谢谢观看!