前言
回文子串,顾名思义,即字符串中满足回文性质的子串。在算法设计与分析中,回文子串经常作为研究和讨论的重点,例如 POJ 3974 题目中的最长回文子串问题,以及 LeetCode 0005 题目中求解最长回文子串长度的问题。
中心扩展算法是一种简单直观的方法,它通过以每个字符为中心向两边扩展来寻找回文子串,然而其时间复杂度为 O ( n 2 ) O(n^2) O(n2),动态规划同样,也存在效率低下的问题。
马拉车算法(Manacher Algorithm)是一种高效解决回文字符串问题的算法。它通过避免重复计算和利用回文字符串的对称性质,实现了线性时间复杂度的求解。马拉车算法能够快速找到给定字符串中的最长回文子串。
一、经典算法
1.1 中心扩展法
中心扩展法是以每个字符为中心,向两边扩展来寻找回文子串,由于回文子串可能是奇数长度或偶数长度:
- 奇数长度的回文串以该字符为中心
- 偶数长度的回文串以该字符右侧的空字符串为中心
面对这种两种情况可以分别处理,也可以向所有字符前后填充特殊符号,例如#,使之变为奇数
下面代码会用填充#处理,至于原字符串前后字符添加#号是因为要保证没有b#b这样的回文子串,否则和#b#比较,长度一样,但是去掉#后,前者是bb,而后者为b,通过长度选择就不正确了,前后都加#就能保证回文串都是以#开始和结束,就不会出现上面那种问题
public String longestPalindrome(String s) {
int start = 0, end = 0;
String joinedStr = "#" + String.join("#", s.split("")) + "#";
for (int i = 0; i < joinedStr.length(); i++) {
int len = expandAroundCenter(joinedStr, i);
if (len > end - start) {
start = i - len / 2;
end = i + len / 2 + 1;
}
}
return s.substring(start / 2, end / 2);
}
public int expandAroundCenter(String s, int center) {
int left = center, right = center, ans = 0;
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right) ) {
ans = right - left + 1;
left--;
right++;
}
return ans;
}
1.2 动态规划法
动态规划的思路是,使用一个二维数组 dp
来记录字符串中每个子串是否为回文子串,使用dp的重要三个条件如下:
-
状态定义:
dp[i][j]
表示字符串s[i...j]
是否是回文子串 -
初始状态:
dp[i][i]
表示字符串单个字符,肯定都是回文串 -
状态转移:
s[i...j]
是否回文子串跟s[i+1...j-1]
是否是回文 和s[i]== s[j]
有关系
d p [ i ] [ j ] = { d p [ i + 1 ] [ j − 1 ] ∧ s [ i ] = = s [ j ] j - i > 1 s [ i ] = = s [ j ] j - i == 1 \begin{equation} dp[i][j]=\begin{cases} dp[i + 1][j - 1] \land s[i] == s[j] & \text{j - i > 1}\\ s[i] == s[j] & \text{j - i == 1} \end{cases} \end{equation} dp[i][j]={ dp[i+1][j−1]∧s[i]==s[j]s[i]==s[j]j - i > 1j - i == 1因为
dp[i][j]
的取值和dp[i+1][j-1]
有关系,需要倒序,先计算i+1,再计算i
public String longestPalindrome(String s) {
boolean[][] dp = new boolean[s.length()][s.length()];
int start = 0, maxLen = 1;
for (int i = 0; i < s.length(); i++) {
dp[i][i] = true;
}
for (int i = s.length() - 1; i >= 0; i--) {
for (int j = i + 1; j < s.length(); j++) {
if (s.charAt(i) == s.charAt(j)) {
if (j - i == 1 || dp[i + 1][j - 1]) {
dp[i][j] = true;
if (j - i + 1 > maxLen) {
maxLen = j - i + 1;
start = i;
}
}
}
}
}
return s.substring(start, start + maxLen);
}
二、马拉车算法
2.1 原理步骤
Manacher 算法是一种利用对称用于解决回文字符串问题的高效算法。其主要用途是在一个字符串中找出最长的回文子串。以下是Manacher算法的详细工作原理:
- 预处理:为了简化问题,我们首先对原字符串做预处理。在每个字符之间插入特殊字符(通常使用’#'),这样可以处理奇偶长度回文字符串的情况。
- 辅助数组和辅助指针:定义一个辅助数组P来保存每个字符为中心的最大回文半径,以及一个辅助指针C和R
- C 表示当前最右边回文子串的中心位置
- R 表示该回文子串的右边界位置
- 遍历字符串:从左到右依次遍历字符串中的每个字符。
- 填充辅助数组:对于当前遍历到的字符,如果其在当前最右边回文子串的右边界之内,则可以利用对称性质,将其对应的辅助数组元素设为与其对称位置的元素相等。这可以通过以下步骤实现:
- 计算当前字符关于C的镜像位置 mirror = 2C - i;
- 检查i位置的回文半径是否在当前最右边回文子串的范围内;
- 若在范围内,将当前字符的辅助数组元素设为与mirror位置的元素相等。
- 扩展回文半径:在填充辅助数组的同时,通过不断扩展回文半径来找到更长的回文子串。具体操作如下:
- 从当前位置开始,向左右两个方向依次检查对应位置上的字符是否相等;
- 若相等,则回文半径增加1;
- 继续向两个方向扩展,直到出现字符不相等的情况为止。
- 更新C和R:如果扩展后的回文子串的右边界超过了当前最右边回文子串的右边界,更新C和R:
- 将当前位置作为新的最右边回文子串的中心C;
- 将当前最右边回文子串的右边界R更新为扩展后的回文子串的右边界。
- 记录最长回文子串:在遍历过程中,记录并更新最长回文子串的长度和起始位置。
- 返回结果:遍历完成后,根据记录的最长回文子串的长度和起始位置,可以得到最终的结果。
通过以上步骤,Manacher算法能够高效地找到给定字符串中的最长回文子串。其时间复杂度为 O ( n ) O(n) O(n),其中n为字符串的长度。
2.2 Java实现
public String longestPalindrome(String s) {
// 预处理字符串,在字符之间插入特殊字符'#'
String processedStr = "#" + String.join("#", s.split("")) + "#";
int[] P = new int[processedStr.length()]; // 辅助数组,保存每个字符为中心的最大回文半径
int C = 0; // 当前最右边回文子串的中心位置
int R = 0; // 回文子串的右边界位置
int maxLen = 0; // 最长回文子串的长度
int centerIndex = 0; // 最长回文子串的中心位置
for (int i = 1; i < processedStr.length() - 1; i++) {
int mirror = 2 * C - i; // 当前位置关于C的镜像位置
// 如果i在R的范围内,则可以利用对称性质快速计算P[i]
if (i < R) {
P[i] = Math.min(R - i, P[mirror]);
}
// 扩展回文半径,检查左右两个位置上的字符是否相等
while (i + 1 + P[i] < processedStr.length() && i - 1 - P[i] >= 0
&& processedStr.charAt(i + 1 + P[i]) == processedStr.charAt(i - 1 - P[i])) {
P[i]++;
}
// 更新最右边回文子串的中心位置和右边界位置
if (i + P[i] > R) {
C = i;
R = i + P[i];
// 更新最长回文子串的长度和中心位置
if (P[i] > maxLen) {
maxLen = P[i];
centerIndex = i;
}
}
}
// 计算最长回文子串的起始位置和结束位置
int start = (centerIndex - maxLen) / 2;
int end = start + maxLen;
return s.substring(start, end);
}
三、LeetCode 实战
3.1 最长回文子串
https://leetcode.cn/problems/longest-palindromic-substring/
给你一个字符串
s
,找到s
中最长的回文子串。如果字符串的反序与原始字符串相同,则该字符串称为回文字符串。
示例 1:
输入:s = "babad" 输出:"bab" 解释:"aba" 同样是符合题意的答案。
示例 2:
输入:s = "cbbd" 输出:"bb"
提示:
1 <= s.length <= 1000
s
仅由数字和英文字母组成
经典的题目, 直接用上面代码就可以
public String longestPalindrome(String s) {
// 预处理字符串,在字符之间插入特殊字符'#'
String processedStr = "#" + String.join("#", s.split("")) + "#";
int[] P = new int[processedStr.length()]; // 辅助数组,保存每个字符为中心的最大回文半径
int C = 0; // 当前最右边回文子串的中心位置
int R = 0; // 回文子串的右边界位置
int maxLen = 0; // 最长回文子串的长度
int centerIndex = 0; // 最长回文子串的中心位置
for (int i = 1; i < processedStr.length() - 1; i++) {
int mirror = 2 * C - i; // 当前位置关于C的镜像位置
// 如果i在R的范围内,则可以利用对称性质快速计算P[i]
if (i < R) {
P[i] = Math.min(R - i, P[mirror]);
}
// 扩展回文半径,检查左右两个位置上的字符是否相等
while (i + 1 + P[i] < processedStr.length() && i - 1 - P[i] >= 0
&& processedStr.charAt(i + 1 + P[i]) == processedStr.charAt(i - 1 - P[i])) {
P[i]++;
}
// 更新最右边回文子串的中心位置和右边界位置
if (i + P[i] > R) {
C = i;
R = i + P[i];
// 更新最长回文子串的长度和中心位置
if (P[i] > maxLen) {
maxLen = P[i];
centerIndex = i;
}
}
}
// 计算最长回文子串的起始位置和结束位置
int start = (centerIndex - maxLen) / 2;
int end = start + maxLen;
return s.substring(start, end);
}
3.2 回文子串
https://leetcode.cn/problems/palindromic-substrings/
给你一个字符串
s
,请你统计并返回这个字符串中 回文子串 的数目。回文字符串 是正着读和倒过来读一样的字符串。
子字符串 是字符串中的由连续字符组成的一个序列。
具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
示例 1:
输入:s = "abc" 输出:3 解释:三个回文子串: "a", "b", "c"
示例 2:
输入:s = "aaa" 输出:6 解释:6个回文子串: "a", "a", "a", "aa", "aa", "aaa"
提示:
1 <= s.length <= 1000
s
由小写英文字母组成
一样可以使用马拉车算法,不需要记录最长回文子串长度和位置,将(半径+1)/2 就能得到子串个数
public int countSubstrings(String s) {
int ans = 0;
// 预处理字符串,在字符之间插入特殊字符'#'
String joinedStr = "#" + String.join("#", s.split("")) + "#";
int joinedStrLen = joinedStr.length();
// 辅助数组,保存每个字符为中心的最大回文半径,半径不包括中心
int [] p = new int[joinedStrLen];
// 当前最右边回文子串的中心位置
int center = 0;
// 当前最右边回文子串的右边界位置
int right = 0;
for (int i = 0; i < joinedStrLen; i++) {
// 如果i在 right 的范围内,则可以利用对称性质快速计算p[i],这部分是马拉车算法的重点
if (i < right) {
// 当前位置i关于center的镜像位置
int minor = 2 * center - i;
// 选择最小半径作为起始
p[i] = Math.min(right - i, p[minor]);
}
// 扩展回文半径,检查左右两个位置上的字符是否相等,注意边界检查
while (i - 1 - p[i] >= 0 && i + 1 + p[i] < joinedStrLen
&& joinedStr.charAt(i - 1 - p[i]) == joinedStr.charAt(i + 1 + p[i])) {
p[i]++;
}
// 更新 center,right
if (i + p[i] > right) {
center = i;
right = i + p[i];
}
ans += (p[i] + 1) / 2;
}
return ans;
}