最长回文 - Manacher‘s Algorithm

简介

这篇文章是关于Manchaer’s Algorithm的学习感想,记录个人的学习成果以及自己的理解。

Mancher’s Algorithm 是用于最长回文子串的求解,具体的背景本小白也不是很清楚,可以去百度或者维基看看。其时间复杂度是O(N),是一个非常简洁的算法。要介绍Manacher’s Algorithm,一定得先介绍回文(Palindrome)。那就从回文开始吧。

回文/Palindrome

回文是正着读过去和反着读过来都一样的字符串,比如“abba” 或者“aba”。回文有三个性质:

  1. 回文字符串正向和反向是一样的,例如“abcba”
  2. 回文字符串中,位置关于中心对称的字符是一样的,例如第一个和倒数第一个,第二个和倒数第二个…
  3. 回文字符串的任意中心相同的子串也是回文,例如“abcba”的子串“bcb”也是回文

其实三点性质的核心就是第二点性质。

相应解法

目前有好多种解法,力扣也有相应的题目。最容易想到的方法便是暴力列举,但其时间复杂度会达到O(N3),消耗太多的时间,并不是一种很好的方法。有地方会提到将最长回文子串转换成最长相同子串的求法,因为回文的反向字符串和本身是一样的。但是这种方法是行不严谨的,例如“accxycca”的逆向字符串是“accyxcca”,用最长相同字符子串来求解的结果是“acc”,很明显这不符合回文特点。除此之外,动态规划(O(N2)),中心扩张(O(N2)),binary search (O(N2logN))都是最长回文子串求解的方法。然而Manacher’s Algorithm / 马拉车算法做到了将时间复杂度进一步降低至O(N)。

算法介绍

预处理

在介绍算法之前,有一个问题需要注意。回文长度有两种可能,奇数“aba”和偶数“abba”。对于奇数长度回文,中心很好找。但偶数长度回文的中心位于两个字符的中间,并不方便查找。为解决这种情况,第一步是对回文进行预处理:在字符之间插入特殊符号,例如“#”或者其他不会出现在字符串中的字符。同时再在最开始插入一个其他字符,用于后续中心和长度的计算,例如“@”。(最后有没有无所谓)

"ababz"->"#a#b#a#b#z#"->"@#a#b#a#b#z#"

回文半径

现在开始介绍Manacher’s Algorithm。在这个算法中,引入了一个和字符串等长的数组,记录以各个字符为中心的回文半径长度。例如“a” 的回文半径是1,“aba”的回文半径是2。

//假设输入字符串为 s
vector<int> p (s.size(),0);

镜像点,中心,右边界

算法会遍历整个字符串,所以有变量 i 表示当前遍历的位置。在此基础上,还增加了三个变量,分别是c, R,mir(当前回文的中心,当前回文的右边界,i 关于中心 c的镜像位置,如图)。现在结合之前的半径数组 p ,来解释设置这些变量的缘由。

变量设置

算法精髓

为了找出最长回文子串,找到最大的回文半径和相应的位置即可。因此,算法的目的便是完成数组 p 的赋值。在赋值的时候,引入之前设置的三个变量来减少计算量。在具体介绍算法步骤前,需要解释一下算法的精髓:回文半径和镜像点。镜像点的设立是为了更方便快速的计算每一个字符串的回文半径。在暂不考虑镜像点半径超过当前回文的范围的情况下,下图介绍了镜像点的用法。

镜像点的应用
如图所示,镜像点( mir )的回文长度是3(半径是2),通过回文关于中心点对称的性质,遍历点( i )的回文长度至少是镜像点( mir )的长度。因此,在寻找遍历点的回文半径的时候,就可以从镜像点的长度开始寻找,从而减少了算法的计算量。

"#a#  b#b  #a#"
  ^    ^    ^
 mir center i

因为在当前回文的外面,可能是个以 i 为中心更长的回文, 所以需要对于遍历点的字符进行新一轮的半径寻找。例如:(位置有限,“#”暂时未被画出)
新回文
这种情况下,以 i 为中心的回文半径已经不在当前回文的范围内(i + p[i] > R),意味着以遍历点为中心的回文已经不是以c为中心回文的子串了。当前回文中心点需要更新,以当前遍历点为中心。
对于下一个遍历的字符,可以看作是新回文中的一个点,这也是马拉车算法的更新机制。


之前有一个问题暂时没有考虑,也就是镜像点的半径超过当前回文的左边界的情况,这里讲一下解决方案。举个例子:
在这里插入图片描述
图中镜像点回文的长度超过了边界,如果将镜像点半径直接赋值给遍历点的话,遍历点回文最右边的字符“x”是不符合回文的条件的。如若这个字符和最左边的字符相等,当前回文的边界还会往两边扩。正是因为不相等,所以才会有当前格局。
在这种情况下,根据回文的第三个性质,一定能找到镜像点为中心,半径为mirror-L的子回文。因为回文的对称性质,R - i = mirror - L。所以,遍历点的半径在镜像点回文长度超过当前回文边界的情况下,应该被赋值为R - i。这样在设计程序的时候,遍历点的半径应该取镜像点半径p[mir]和 R - i 中小的那一个,即:

p[i] = min(p[mir], R - i);

至此,马拉车算法的基本思路已经讲完了。

代码细节说明

现在假设一个回文“axabaxa”为例子来解释算法过程
第一步,预处理的字符串,得到“@#a#x#a#b#a#x#a#”。
在算法的最开始,先初始化变量。

int mir = 0;
int c = 0;
int R = 0;

镜像点的计算:

if (i <= R) mir = 2 * c - i;

if 函数是为了防止c在0的时候,出现mir等于负数导致数组越界的情况。而且 i 在右边界外的时候是没有计算镜像点的必要的。

现在进入对字符串的遍历(for循环)。
在通过while 循环来找回文半径之前,通过镜像点来减少运算量:

p[i] = min(R - i, p[mir]);

然后使用while循环来寻找遍历点回文半径:

/* 
三个判断条件
1.i + p[i] 不能超过 s 的长度
2.i - p[i] 不能小于0
3.往两边扩展的字符相同
如若满足,回文半径+1, 如若不满足,结束循环
*/
while (i + p[i] < s.size() && i - p[i] >= 0 && s[i - p[i]] == s[i + p[i]]){
	p[i]++;
}

第一个字符的回文半径 p[0] = 1。这时会有一个判断,判断当前遍历位置对应回文的右边界(i + p[i])是否已经超过以为c为中心的回文的右边界(R )。如果大于,则需要重新定义c和r。

if (i + p[i] > R) {
	c = i;
	R = i + p[i];
}

至此,马拉车算法的代码核心都已经讲完了,剩下的就是输出相应子串了。
对于子串的长度,计算方式为:

// maxlen 为程序中得到的最大回文半径
// len 为最长回文长度
int len = maxlen - 1;

其原因如图所示:

在这里插入图片描述
不管是奇数长度还是偶数长度,在调整“#”和字符的位置之后,回文半径会包含一个多余的“#”,所以可以直接将回文半径减一得到最长回文长度。

因为加了另一个符号“$”的缘由,起始位置可以如此计算:

// location中心点的位置 
// loc是该回文的起始位置
int loc = (location - maxlen) / 2;

因为奇数长度和偶数长度的中心点不一样,因此“$”的作用是在成对的字符中,垫了半个单位的字符,让偶数长度的中心点落在字符上面,通过int的特性,除以2的时候遍消除了影响。

运行结果:
result 1
result 2
result 3
因为print函数把运行的细节都打印出来了,所以会有点点长

代码

C++,Visual Studio 2019

#include <vector>
#include <iostream>
using namespace std;
//自己写了个print函数,方便查看进程
void print(const int mirr, const vector<int>& p, const int c, const int R, const int i, const string& s){
	cout << "Mirror = " << mirr << "\tc = " << c << "\t R = " << R << endl;
    cout << "i = " << i << endl;
    for (int j = 0; j < s.size(); j++) {
        cout << " " << s[j];
        if (j == i) {
            cout << "<-";
        }
    }
    cout << endl;
    for (int j = 0; j < p.size(); j++) {
        cout << " " << p[j];
        if (j == i) {
            cout << "<-";
        }
    }
    cout << "\n\n/\n\n";
}
// Manacher's Algorithm---马拉车算法 
string Manacher(string str){
	string res;
	//预处理
    string s = "$#";
    for (int i = 0; i < str.size(); i++) {
        s += str[i];
        s += "#";
    }
    int ssize = s.size() + 1;
    s += '@';
 

    int c = 0; // Center
    int R = 0; // Right Boundary
    int mirr = 0; // Mirror Position of Current Position i
    
    vector<int> p(ssize, 0);
    int maxlen = 0, location = 0;
    
    for (int i = 1; i < s.size(); i ++) {
        mirr = 2 * c - i;
        if(i < R) p[i] = min(R - i, p[mirr]);

        while (i + p[i] < s.size() && i - p[i] >= 0 && s[i + p[i]] == s[i - p[i]]) {
            p[i]++;
        }
        
        if (i + p[i] > R) {
            c = i;
            R = i + p[i];
            if (maxlen < p[i]) {
                maxlen = p[i];
                location = i;
            }
        }
        print(mirr, p, c, R, i, s);
    }
    int len = maxlen - 1;
    int loc = (location - maxlen) / 2;

    cout << "len = " << len << "\tloc = " << loc << endl;


    res = str.substr(loc, len);
    return res;
}

int main(){
	string a;
	while (cin >> a){
		string res = Manacher(a);
		cout << res << endl;	
	}
	return 0;
} 

小结

Manacher’s Algorithm 可以说是初学者的我目前接触到最难的算法了,整整花了一天的时间去理解算法的方方面面。这篇博客也是我的第一篇博客,希望和大家分享一下学习结果,如果有帮助当然是最好,如果写的不周也请谅解。我觉得有必要将我的学习经历记录下来,将曾经的努力以及结果保存下来。虽然自己很菜,但是我一直在不断地丰满羽翼,我希望我能一直坚持,人往往在一次又一次的痛苦中悄然进步的。

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 深蓝海洋 设计师:CSDN官方博客 返回首页