最长回文子串——Manacher 算法
1. 问题定义
最长回文字符串问题:给定一个字符串,求它的最长回文子串长度。
如果一个字符串正着读和反着读是一样的,那它就是回文串。
举个 :
-
s="ababa"
, 最长回文长度为5
;即ababa
-
s="abccb"
, 最长回文长度为4
,即bccb
。
2.暴力解法:
对于最长回文子串问题,最简单粗暴办法是:找到所有字符串的子串,遍历每一个子串以验证它们是否为回文串。一个子串由子串的起点和终点确定,因此对于一个长度为n
的字符串,共有n^2
个子串。这些子串的平均长度大约为n/2
,因此这种解法的时间复杂度是O(n^3)
。
解法:
string findLongestPalindrome(string &s){
if (s.empty()) {
return "";
}
if (s.size() == 1) {
return s;
}
// 字符串 长度
unsigned long length = s.size();
// 最长 回文 字符串 长度
int maxLength = 0;
// 最长 回文 字符串 起始地址
int start = 0;
for(int i = 0; i < length; i++){
for (int j = i + 1; j < length; j++) {
int tmp1, tmp2;
// 判断 是不是 回文
for (tmp1 = i, tmp2 = j; tmp1 < tmp2; tmp1++, tmp2--) {
if(s.at(tmp1) != s.at(tmp2)){
break;
}
}
// 如果 遍历 到中间 说明这是一个 回文 字符串
if (tmp1 >= tmp2 && (j-i) >= maxLength) {
maxLength = j - i + 1;
start = i;
}
}
}
if (maxLength > 0) {
return s.substr(start, maxLength);
}
return "";
}
3. 动态规划
回文字符串的子串也是回文,所以对于母串s
,我们用p[i][j] = 1
(表示以i
开始以j
结束的子串)是回文字符串
,那么p[i+1][j-1]
也是回文字符串
。
这样当
s[i] = s[j]
时,如果p[i+1][j-1]
是回文子串
,则p[i][j]
也是回文子串。如果
p[i+1][j-1]
不是回文子串或者s[i] != s[j]
,那么p[i][j]
就不是回文子串。-
特别地,对于这样的字符串——只包含单个字符、或者两个字符重复,其均为回文串
p[i][i] = 1; c[i][i+1] = 1, if(s[i] == s[i+1])
-
这样需要额外的空间
o(N^2)
,算法复杂度也是o(n^2)
.
解法:static int const arrayLength = 100; string findLongestPalindrome(string &s){ if (s.empty()) { return ""; } if (s.size() == 1) { return s; } // 字符串 长度 unsigned long length = s.size(); // 最长 回文 字符串 长度 int maxLength = 0; // 最长 回文 字符串 起始地址 int start = 0; // 存储 所有 子字符串 bool p[arrayLength][arrayLength] = {false}; for(int i = 0; i < length; i++){ // 单个 字符 为 回文串 p[i][i] = true; // 判断 两个字 重复 情况 if ((i < length - 1) && s.at(i) == s.at(i+1)) { p[i][i + 1] = true; start = i; maxLength = 2; } } // 子串 长度(因为已经计算了单个或两个字重复的回文字符串,所以子串长度最低从3开始) // 计算 3 - length 所有子串中 所有最长子串 for(int len = 3; len <= length; len++){ // 子串 起始 地址 // 在 字符串 中 找到 所有 长度为 len的子串并判断 for (int i = 0; i <= length - len; i++) { int j = i + len - 1; if (p[i+1][j-1] && s.at(i) == s.at(j)) { p[i][j] = true; maxLength = len; start = i; } } } if (maxLength >= 2) { return s.substr(start, maxLength); } return ""; }
这种解法,当最大的回文子串有多个时,取最后一个,如果要取第一个,则在 start = i
后面加上break;
即可。
C语言解法:
static int const arrayLength = 100;
/**
找到 最长 回文 子串 (动态 规划 方法)
@param string 字符串
@param stringLength 字符串 长度
*/
void findLongestPalindromeTwo(char *string, int stringLength) {
if (string == NULL || stringLength == 0) {
return;
}
if (stringLength == 1) {
printf("%s\n", string);
return;
}
// 回文串 长度
int maxLength = 0;
// 起始 位置
int startPosition = 0;
// 辅助 数组(存储 所有 字符串)
int helperArray [arrayLength][arrayLength] = {false};
// 循环 遍历
for (int tmpIndex = 0; tmpIndex < stringLength; tmpIndex++) {
// 单个 字符 为 回文串
helperArray[tmpIndex][tmpIndex] = true;
if (string[tmpIndex] == string[tmpIndex + 1]) {
// 相同 字符 比如 aa 也是 回文串
helperArray[tmpIndex][tmpIndex + 1] = true;
startPosition = tmpIndex;
maxLength = 2;
}
}
// 循环 遍历
// 子串 长度(因为已经计算了单个或两个字重复的回文字符串,所以子串长度最低从3开始)
// 计算 3 - length 所有子串中 所有最长子串
for (int len = 3; len <= stringLength; len ++) {
for (int i = 0; i <= stringLength - len; i ++) {
int j = i + len - 1;
if (helperArray[i + 1][j - 1] == true && string[i] == string[j]) {
helperArray[i][j] = true;
maxLength = len;
startPosition = i;
}
}
}
if (maxLength > 1) {
for (int tmpIndex = 0; tmpIndex < maxLength; tmpIndex++) {
printf("%c", string[startPosition]);
startPosition++;
}
printf("\n");
}
}
4.中心扩展
很明显所有的回文字符串都是对称的;
长度为奇数回文字符串以最中间字符位置为对称轴左右对称。
长度为偶数的回文串以中间两个字符的空隙为对称轴对称。
因此,整个字符串中所有字符,以及字符间的空隙都有可能是某个回文子串的对称轴位置。可以遍历这些位置,在每个位置上同时向左向右扩展,直到左右两边字符不同或者到达边界。
对于一个长度为n的字符串,这样的位置一共有
n+n-1=2n-1
个,在每个位置上平均要进行大约n/4
次比较,此算法的时间复杂度为o(n^2)
.
解法:
string findLongestPalindrome(string &s) {
if (s.empty()) {
return "";
}
if (s.size() == 1) {
return s;
}
unsigned long length = s.size();
int maxlength = 0;
int start = 0;
string tmpStr = s;
for(int i = 0,k = 0; i <= length; i++){
s.insert(k, "#");
k = k + 2;
}
for (int i = 0 ; i < length; i++) {
// 间隔 两个 字符
int j = i - 1, k = i + 1;
while (j >= 0 && k < length && s.at(j) == s.at(k)) {
if ((k - j + 1) > maxlength) {
maxlength = k - j +1;
start = j;
}
j--;
k++;
}
}
if(maxlength > 0){
int tmpMaxLength = (maxLength - 1)/2;
int tmpStartPostion = start/2;
return tmpStr.substr(tmpStartPostion,tmpMaxLength);
}
return "";
}
C语言解法:
/**
找到 最长 回文 子串 (中心 对称 方法)
@param string 字符串
@param stringLength 字符串 长度
*/
void findLongestPalindrome(char *string, int stringLength) {
if (string == NULL || stringLength == 0) {
return;
}
if (stringLength == 1) {
printf("%s\n", string);
return;
}
// 插入 特殊字符 后字符串 长度
int tmpStringLength = stringLength * 2 + 1;
// 开辟 新的字符串
char *tmpString = malloc(sizeof(char) * tmpStringLength);
// 新字符串 复制 旧字符串 并在空隙插入 '#'
tmpString[0] = '#';
for (int tmpIndex = 0; tmpIndex < stringLength; tmpIndex ++) {
tmpString[tmpIndex * 2 + 1] = string[tmpIndex];
tmpString[tmpIndex * 2 + 2] = '#';
}
// 回文串 长度
int maxLength = 0;
// 起始 位置
int startPosition = 0;
// 遍历 字符串
for(int i = 0; i < tmpStringLength; i++) {
int j = i - 1;
int k = i + 1;
while (j >= 0 && k < tmpStringLength && tmpString[j] == tmpString[k]) {
if (k - j + 1 > maxLength ) {
maxLength = k - j + 1;
startPosition = j;
}
j --;
k ++;
}
}
if (maxLength > 1) {
int tmpMaxLength = (maxLength - 1)/2;
int tmpStartPostion = startPosition/2;
for (int tmpIndex = 0; tmpIndex < tmpMaxLength; tmpIndex++) {
printf("%c", string[tmpStartPostion]);
tmpStartPostion++;
}
printf("\n");
}
}
5. Manacher 算法
中心扩展的算法是存在缺陷的:
由于回文字符串的奇偶性造成了不同性质的对称轴位置,因此要分两种情况进行处理。
-
很多子串被重复多次访问,造成较差的时间效率。
举个 :s : a b a b a i : 0 1 2 3 4
当i == 1
和i == 2
时,左边的子串aba分别被遍历了一次。
A. 解决长度奇偶性带来的对称轴位置问题
Manacher
算法首先对字符串做一个预处理,在所有的空隙位置(包括首尾)插入同样的符号,要求这个符号是不会出现在原串中出现的,这样会使得所有的串都是奇数长度的。已插入#
号为例。
aba ———> #a#b#a#
abba ———> #a#b#b#a#
插入的是同样的符号,且符号不存在于原串,因此子串的回文性不受影响,原来是回文的串,插完之后还是回文的,原来不是回文的,依然不是回文的。
B. 解决重复访问的问题
我们把一个回文中最左或最右位置的字符与其对称轴的距离称为回文半径。Manacher定义了一个回文半径数组RL,用RL[i]表示以第i个字符为对称轴的回文串的回文半径。我们一般对字符串从左往右处理,因此这里定义RL[i]为第i个字符为对称轴的回文串的最右一个字符与字符i距离。对于上面插入分隔符之后的两个串,可以得到RL数组。
s: # a # b # a #
RL : 1 2 1 4 1 2 1
RL-1: 0 1 0 3 0 1 0
i : 0 1 2 3 4 5 6
s: # a # b # b # a #
RL : 1 2 1 2 5 2 1 2 1
RL-1: 0 1 0 1 4 1 0 1 0
i : 0 1 2 3 4 5 6 7 8
上面我们还求了一下RL[i]-1
。通过观察可以发现,RL[i]-1
的值,正是在原来那个没有插入过分割符的串中,以位置i为对称轴的最长回文串的长度。那么只要我们求出RL数组,就能得到最长回文子串的长度。
那么问题就变成了,怎样高效地求RL
数组,基本思路是利用回文串的对称性,扩展回文串。
我们再引入一个辅助变量MaxRight
,表示当前访问到的所有回文子串,所能触及的最右一个字符的位置。另外还要记录下MaxRight
对应的回文串的对称轴所在位置,记为pos
,它们的位置关系如下。
我们从左往右地访问字符串来求RL,假设当前访问到的位置是i,即要求RL[i],在对应上图,i必然在pos右边,但是我们更关注的是,i是在MaxRight的左边还是右边,我们分情况分析。
- ** 当i在MaxRight的左边**
如下图所示:
我们知道,图中两个红色块之间(包括红色块)的串是回文的;并且以i
对称轴的回文串,是与红色块间的回文串有所重叠的。我们找到i
关于pos
的对称位置j
,这个j对应RL[i]
我们已经算过的。根据回文串的对称性,以i
为对称轴的回文串和以j
为对称轴的回文串,有一部分是相同的。这里又有两种细分情况。
a. 以j为对称轴的回文串比较短,短到如下图所示:
这时我们知道
RL[i]
至少不会小于
RL[j]
,并且已经知道了部分的以
i
为中心的回文串,于是我们可以令
RL[i]=RL[j]
.但是以
i
对称轴的回文串可能实际上更长,因此我们试着以
i
为对称轴,继续向左右两边扩展,知道左右两边字符不同或者到达边界。
b.以
j
为对称轴的回文串很长,如下图所示:
这时,我们只能确定,两条蓝线之间的部分(及不超过
MaxRight
的部分)是回文的,于是从这个长度开始,尝试以
i
为中心向左右两边扩展,知道左右两边字符不同或者到达边界。
综上,我们只能获取RL[2*pos - i]
和 MaxRight-1
这两者中最小的值,来保证该范围内的字符串是回文字符串,RL[i] = min(RL[2*pos - i], MaxRight-1)
,之后都要尝试更新MaxRight
和pos
,因为有可能得到更大MaxRight
.
具体操作如下:
step 1: 令RL[i]=min(RL[2*pos-i], MaxRight-i)
step 2: 以i为中心扩展回文串,直到左右两边字符不同,或者到达边界。
step 3: 更新MaxRight和pos
-
当
i
在MaxRight
的右边
遇到这种情况,说明以i为对称轴的回文串还没有任何一部分被访问过,于是只能从i的左右两边开始尝试扩展了,当左右两边字符不同或者到达字符串边界时停止更新。然后更新MaxRight和pos。
解法:string findLongestPalindrome3(string &s) { if (s.empty()) { return ""; } if (s.size() == 1) { return s; } unsigned long length = s.size(); int MaxRight = 0; int Maxlen = 0; int pos = 0; string tmpStr = s; for(int i = 0,k = 0; i <= length; i++){ s.insert(k, "#"); k = k + 2; } length = s.size(); int *RL = new int[length](); memset(RL, 0x00, sizeof(length)); for (int i = 0; i < length; i++) { if (i < MaxRight) { RL[i] = min(RL[2*pos - i], MaxRight-1); }else { RL[i] = 1; } while (i - RL[i] >= 0 && i+RL[i] < length && s[i - RL[i]] == s[i + RL[i]]) { RL[i] += 1; } if (RL[i] + i - 1 > MaxRight) { MaxRight = RL[i] + i - 1; pos = i; } Maxlen = max(Maxlen, RL[i]); } if (Maxlen > 0) { return tmpStr.substr((pos+1)/2 - Maxlen/2, Maxlen - 1); } free(RL); return ""; }
C语言解法:
/**
找到 最长 回文 子串 (拉马车 方法)
@param string 字符串
@param stringLength 字符串 长度
*/
void findLongestPalindromeThree(char *string, int stringLength) {
if (string == NULL || stringLength == 0) {
return;
}
if (stringLength == 1) {
printf("%s\n", string);
return;
}
// 插入 特殊字符 后字符串 长度
int tmpStringLength = stringLength * 2 + 1;
// 开辟 新的字符串
char *tmpString = malloc(sizeof(char) * tmpStringLength);
// 新字符串 复制 旧字符串 并在空隙插入 '#'
tmpString[0] = '#';
for (int tmpIndex = 0; tmpIndex < stringLength; tmpIndex ++) {
tmpString[tmpIndex * 2 + 1] = string[tmpIndex];
tmpString[tmpIndex * 2 + 2] = '#';
}
// 记录 最长 半径 范围
int maxRight = 0;
// 记录 最长 回文 字符串 长度
int maxLength = 0;
// 当前 最长 半径 的 对称轴
int currentPosition = 0;
// 记录 每个 位置 最长回文 长度
int *palindromeArray = malloc(sizeof(int) * tmpStringLength);
memset(palindromeArray, 0x00, sizeof(tmpStringLength));
// 遍历 字符串
for (int tmpIndex = 0; tmpIndex < tmpStringLength; tmpIndex++) {
// 当前 字符 在最大 半径 范围 左边
if (tmpIndex < maxRight) {
if (palindromeArray[2*currentPosition - tmpIndex] > maxRight - tmpIndex) {
palindromeArray[tmpIndex] = maxRight - tmpIndex;
}
else {
palindromeArray[tmpIndex] = palindromeArray[2*currentPosition - tmpIndex];
}
}
// 当前 字符 在 最大半径 范围 右边(没有被遍历过)
else {
palindromeArray[tmpIndex] = 1;
}
// 在先前 计算的 回文长度 基础 上 扩展遍历
while (tmpIndex - palindromeArray[tmpIndex] >= 0 &&
tmpIndex + palindromeArray[tmpIndex] < tmpStringLength &&
tmpString[tmpIndex - palindromeArray[tmpIndex]] == tmpString[tmpIndex + palindromeArray[tmpIndex]]) {
palindromeArray[tmpIndex] += 1;
}
if (palindromeArray[tmpIndex] + tmpIndex - 1 > maxRight) {
maxRight = palindromeArray[tmpIndex] + tmpIndex - 1;
currentPosition = tmpIndex;
}
// 更新 长度
if (maxLength < palindromeArray[tmpIndex]) {
maxLength = palindromeArray[tmpIndex];
}
}
if (maxLength) {
int tmpMaxLength = maxLength - 1;
int tmpStartPostion = (currentPosition + 1)/2 - maxLength/2;
for (int tmpIndex = 0; tmpIndex < tmpMaxLength; tmpIndex++) {
printf("%c", string[tmpStartPostion]);
tmpStartPostion++;
}
printf("\n");
}
}
C.复杂度分析:
空间复杂度
:插入分隔符行程新串,占用了线性的空间大小;RL
数组也占用线性 大小的空间,因此空间复杂度是线性的。时间复杂度
:尽管代码里面有两层循环,由于内层循环只是对尚未匹配的部分进行,因此对于每一个字符而言只会进行一次,因此时间复杂度是o(n)
.