回文串相关

回文串系列问题

manacher

manacher 可以在 \(O(n)\) 的时间复杂度内求出以每个点为对称中心的最长回文子字符串

具体思想是用了回文串的性质,贪心地看,我们从前往后去求解每个对称中心的答案,若想要用上前面所求得的答案,那么记录下已得回文串最远的位置和该串的对称中心是最有用的。

因此我们记以 \(i\) 为对称中心的最长回文子串的半径为 \(r(i)\)(此处包含 \(i\) 这个点),而最远的位置记为 \(\max\),其对称中心为 \(p\).

则在求解 \(r(i)\) 时,我们可以有以下分讨,其中核心思想就是尽可能地利用之前求得的解来优化复杂度。

  1. \(\max < i\) 则有 \(r(i) \ge 1\). 该条件说明前面的信息我们不可能能用上,因为求得的字符串根本就没有到达点 \(i\).
  2. 否则,则有 \(r(i) \ge \min(r(2p-i),\max-i+1)\). 此处则取点 \(i\) 关于点 \(p\) 的对称点并利用该对称点的解来求解 \(r(i)\),而关于点 \(p\) 对称总可以使得极大一部分是符合回文的,取这一部分即可。

而后在原有的 \(r(i)\) 上扩展即可。

常见的操作中,为了省去处理边界时的麻烦,常常会在开头和结尾插入不同的特殊字符,并在每两个字符之间插入相同的特殊字符,同时也省去了对于回文串长度奇偶性的讨论。但这并不是必须的。

模板题:LuoguP3805 【模板】manacher

int maxn=0,p=0;
for(int i=1;i<=n;i++) {
	if(maxn>=i) r[i]=std::min(r[(p<<1)-i],maxn-i+1);	
	else r[i]=1;
	while(s[i-r[i]]==s[i+r[i]]) {
		++r[i];
	}
	if(i+r[i]-1>maxn) maxn=i+r[i]-1,p=i;
}

同时稍作修改即可求出以每个位置为开头/结尾的最长回文子串,但不如用 PAM 求,更为简便。

具体的,根据原来求出的以每个位置为对称中心的最长回文子串可以转移到左右两个端点上,然后对于左右两个端点进行递推求出每个子字符串即可。

其中最长回文子串的端点一定在分割符上,假设若其端点不在分隔符上,则向左右的分隔符扩展一定可以得到更长的回文子串。因此我们在递推时也只需要考虑分隔符即可,相当于将左右两边的字符放到分隔符上。

// 将以点 i 为中心的最长回文子串转移到左右两个端点上 
// L[i]: 以点 i 为结尾的最长回文子串
// R[i]: 以点 i 为开头的最长回文子串 
for(int i=1;i<=n;i++) { 
	L[i+r[i]-1]=std::max(L[i+r[i]-1],r[i]-1);
	R[i-r[i]+1]=std::max(R[i-r[i]+1],r[i]-1);
}
// 每向一边跨越两个字符相当于左右跨越了四个字符,其中必定有两个是分隔符 
for(int i=n-2;i>=1;i-=2) L[i]=std::max(L[i],L[i+2]-2);
for(int i=3;i<=n;i+=2) R[i]=std::max(R[i],R[i-2]-2);

应用:P4555 [国家集训队] 最长双回文串


求奇数长度的回文子串相当于不用插入分隔符跑 manacher,因为不需要分类讨论。

然后类似于一个桶进行递推处理即可求解数量。

应用:P1659 [国家集训队] 拉拉队排练

PAM

回文自动机,又称回文树,常用于处理回文计数类问题(事实上 manacher 能做的 PAM 基本上都能做)。

回文自动机是一种树形结构,类似于字典树。在维护时由回文串的特性可以折半进行存储,但由于奇数长度和偶数长度的回文串折半后不同,所以常会用两棵树来存储。通常钦定维护奇数长度的回文树根编号为 \(1\),维护偶数长度的回文树的根编号为 \(0\).

出现死循环时记得检查节点总数是否初始化为 1.

插入

引理 每在字符串的末尾增加一个字符,本质不同的回文子串的数量至多增加 \(1\).

证明可以用反证法,利用回文串的性质易得。

因此我们在插入的时候相当于往字符串末尾添加一个字符,增加的回文子串数量至多为 \(1\).

所以每次插入的时候以 \(s_i\) 结尾的增加的回文子串只有可能是最长的那一个,而这个回文子串是在某一个由 \(s_{i-1}\) 结尾的回文子串的两边同时添加一个相同字符得到的,其中一个即为 \(s_i\).

因此朴素的想法是我们要从长到短地匹配以 \(s_{i-1}\) 结尾的回文子串来插入新的回文子串。而每个回文子串一定是最长的回文子串的一段后缀,因此我们可以引入一个指针,指向比当前回文子串短的后缀回文子串中最长的那一个的节点,我们将这个指针称为 \(fail\) 指针,每次只需要不断地通过该指针进行跳跃即可,我们称为后缀链跳跃。

\(fail\) 指针的求法也类似于插入的方式,因为我们要求的是严格意义上的后缀回文子串,所以只需要从当前节点的父节点的 \(fail\) 指针进行后缀链跳跃即可求得。

模板题:P4555 [国家集训队] 最长双回文串

只需要正序和倒序分别求一次 PAM 即可。


由于 \(fail\) 指针的特性,后缀链跳跃的次数与严格后缀回文子串的个数满足双射关系。

因此我们可以利用这个来求出以当前位置结尾的回文串个数。

应用:P5496 【模板】回文自动机(PAM)

#include <cstdio>
#include <iostream>
#include <cstring>

const int N=5e5+10;

int n;
char s[N];

struct PAM {
	int tr[N][30],cnt[N*30],len[N*30],fail[N*30];
	int idx=1,last;
	
	void init() {
		fail[0]=1;len[1]=-1;
	}
	
	int getfail(int u,int p) { // 后缀链跳跃
		while(p-len[u]-1<=0 || s[p-len[u]-1]!=s[p]) u=fail[u];
		return u;
	}
	
	int insert(char c,int p) {
		int u=getfail(last,p);
		if(!tr[u][c-'a']) {
			fail[++idx]=tr[getfail(fail[u],p)][c-'a'];
			tr[u][c-'a']=idx;
			len[idx]=len[u]+2;
			cnt[idx]=cnt[fail[idx]]+1;
		}
		last=tr[u][c-'a'];
		return cnt[last];
	}
}T;

int main() {
	T.init();
	scanf("%s",s+1);
	n=strlen(s+1);
	int ans=0;
	for(int i=1;i<=n;i++) {
		if(i>1) s[i]=(s[i]-'a'+ans)%26+'a';
		printf("%d ",ans=T.insert(s[i],i));
	}
	return 0;
}

常用技巧:\(trans\) 指针

\(trans\) 指针指向小于或等于当前回文串长度一半的最长回文后缀。

其求法与 \(fail\) 指针类似,均是进行后缀链跳跃进行扩展及判断是否小于当前回文串长度的一半即可,因为后缀链跳跃可以遍历所有的回文后缀。

其中需要特判长度小于或等于 \(2\) 的回文字符串,此时的 \(trans\) 指针即指向 \(fail\) 指针。

应用:P4287 [SHOI2011] 双倍回文

一些性质的利用

P5555 秩序魔咒

题意:求两个字符串中本质不同最长公共回文子串的长度及其个数。

注意到 PAM 是树形结构,因此可以 dfs 来比对两棵 PAM。

因此只需要各跑一次 PAM 然后再进行一次 dfs 即可。

类似的还有:P5685 [JSOI2013] 快乐的 JYY

参考文献

[1] https://www.cnblogs.com/alex-wei/p/Basic_String_Theory.html
[2] https://oi.men.ci/manacher-notes/
[3] https://www.cnblogs.com/candy0014/p/17640292.html
[4] https://www.luogu.com.cn/article/c2dthrmq
[5] https://www.cnblogs.com/hyfhaha/p/13654308.html

posted @ 2025-02-25 22:00  ldh081122  阅读(695)  评论(0)    收藏  举报