Manacher 算法学习笔记

题目传送门

0.前置知识

1.回文半径:指一个回文串的长度的一半(向上取整)。

2.回文中心:字符串以该地方对称,即两边互相回文。如 abcba 的回文中心是 c

1.暴力

这道题暴力很容易实现。找一个回文中心,一直往两边延伸,最大长度就是我们求的答案了。但是复杂度太高,会超时。如何优化呢?

2.回文中心的位置

首先是回文中心的问题。回文中心可能在字母上,也可能在两个字母中间,该怎么办呢?

为了使回文中心都是一个字母,可以选择用“补充”的方式。在两个字母之间都插一个字符 #,这样在两个字母中间的回文中心就是 # 了。需要注意的是,因为防止越界,字符串前后也要加一个字符;又因为不是字符串的组成部分,所以需要不一样的字符。下面演示的用 @!

cin >> t;//输入
t = "@" + t + "!";//首尾加字符
for(int i = 0;i < (int)t.size();i ++)s += t[i],s += '#',n += 2;//两个字符中间插入字符

3.暴力优化

前面一步完了,现在就是 Manacher 算法的精髓。

假设有一个字符串 #d#c#b#c#d#c#b#c#c#(为了方便理解,不添加越界字符)。

我们已经求得 #c#b#c#d#c#b#c# 的回文半径是 \(8\)。既然它们都是回文的,那其他数的值有没有相似点吗?

答案是显然的。当我们枚举下一个回文中心时(即第二个 d 后面的 #)时,因为是回文的,所以等价于字符串的回文对称点 c#d 中的 # 的回文半径的最大值;再比如,枚举到第二个 d 后面的 b 时,因为是回文的,所以等价于求它的回文对称点回文半径的最大值。

但是它可能是错误的。因为第一个 b 的回文半径是 \(6\),而第二个 b 的回文半径只有 4。为什么呢?因为 #c#b#c#d#c#b#c# 的长度不够长,它只能保证中间是回文的,而不能保证两边也是回文的。所以,面对这种情况,只能先把值赋值为回文对称点到边界的距离,然后再暴力枚举。

但是暴力的复杂度高吗?也不见得。因为暴力枚举完之后,可以给后面的借用,等到长度不够了才会继续枚举。所以,暴力至多只会遍历回文串一遍,复杂度即为 \(\mathcal{O(2n)}\)

下面的代码中,用 \(l,r\) 代表之前枚举的最长回文串的两个端点,所以在里面的一个数 \(i\),它离右端点的距离为 \(r - i\),所以它的对称点就为 \(r - i + l\)。用 \(d_i\) 表示最长回文半径。

if(i <= r && r - i + l - d[r - i + l] + 1 >= l)//在长度范围以内,且对称点的最左端点也在范围之内,可直接取出
else if(i <= r)d[i] = r - i + 1;//对称点的最左端点不在范围之内,只能取局部,因为不知道延伸后能不能构成回文
while(i - d[i] > 0 && s[i - d[i]] == s[i + d[i]])d[i] ++;//暴力枚举

4.更新、初始化、答案

遍历时,直接从第三个字符(第一个是判越界字符,第二个是插入字符)开始枚举。因为第二个字符的回文半径肯定只有 \(1\)。所以 \(l,r\) 初始的值也分别为 \(1\)\(1\) (字符串的下标从 \(0\) 开始)。

如果一个字符的回文最右端点超过了之前枚举的最长回文串的右端点,则更新 \(l,r\),确保优化更快。

答案是什么呢?我们发现,如果回文中心是 #,则字符串长度恰好是回文半径 \(-1\);如果是字母也是一样。所以,在枚举的时候直接记录回文半径减去一的最大值即可。

完整代码。

#include <iostream>
using namespace std;
string s,t;
int n,ans = 1;
int d[21000005] ;//2倍空间
int main()
{
    cin >> t;//输入字符串
    t = "@" + t + "!";
    for(int i = 0;i < (int)t.size();i ++)s += t[i],s += '#',n += 2;//s为修改完之后的字符串
    d[1] = 1;//1字符的回文半径为1
    for(int i = 2,l = 1,r = 1;i <= n;i ++)//i从2开始,l,r的初值为1
    {
        if(i <= r && r - i + l - d[r - i + l] + 1 >= l)d[i] = d[r - i + l];
        else if(i <= r)d[i] = r - i + 1;
        while(i - d[i] > 0 && s[i - d[i]] == s[i + d[i]])d[i] ++;
        if(i + d[i] - 1 > r)r = i + d[i] - 1,l = i - d[i] + 1;//超过r,则更新l,r的值
        ans = max(ans,d[i] - 1);//记录回文串长度
    }
    cout << ans ;
}

5.拓展延伸

P1723 高手过愚人节

和模板题大同小异。

P5446 绿绿和串串

这题咋一看很难,实际上就是变形而已。我们先求字符串中每个字符的最长回文半径,然后再递归就行了。我们从后往前依次判断。如果这个下标的位置已经超过了字符串的一半,只需要判断它的回文长度是否超过字符串的长度减去下标加上 \(1\) 即可(这样才能覆盖整个字符串);否则判断它反转后的下标是否满足条件(就是多次翻转)。这里需要注意的是,因为是以最后一个字符作对称轴,所以回文串长度总是为奇数,可以不用插入字符 #

#include <iostream>
#include <cmath> 
using namespace std;
int t,n,d[12000005],cnt,ans[12000005],ji[12000005];
string s;
int main()
{
    cin >> t;
    while(t --)
    {
        cin >> s,cnt = 0,n = (int)s.size();
        if(n == 1)//特判:当长度为1时只有下标为1可行
        {
        	cout << "1\n";
        	continue ;
		}
        s = "$" + s; 
        fill(d,d + (int)s.size() * 2 + 100,0);
        fill(ji,ji + (int)s.size() * 2 + 100,0);//初始化
        d[1] = 1;
        for(int i = 2,l = 1,r = 1;i <= n;i ++)
        {
            if(i <= r && r - i + l - d[r - i + l] + 1 >= l)d[i] = d[r - i + l];
            else if(i <= r)d[i] = r - i + 1;
            while(i - d[i] > 0 && s[i - d[i]] == s[i + d[i]])d[i] ++;
            if(i + d[i] - 1 > r)r = i + d[i] - 1,l = i - d[i] + 1;
        }
        for(int i = n;i >= 1;i --)
        {
            if(i > ceil(n / 2.0))//超过一半
            {
                if(d[i] >= n - i + 1)ji[i] = 1,ans[++cnt] = i;//可行则记录
            }
            else//不超过一半
            {
                if(ji[i * 2 - 1] && d[i] >= i)ji[i] = 1,ans[++cnt] = i;//判断翻转后的位置是否可行
            }
        }
        for(int i = cnt;i >= 1;i --)cout << ans[i] << " ";
        cout << '\n';
    }
}
posted @ 2026-05-22 13:27  wuyixiang  阅读(15)  评论(0)    收藏  举报