前言
最近新学了后缀自动机,回文自动机,感觉以前学的还是掌握的不深,于是总结一下字符串中的几个数据结构,进行一个复习,可能要写几天,待完成(对现在而言)。
AC自动机
Trie树
对于字符串来讲,这应该是最基础的数据结构。

\(Trie\) 树的每一条边代表一个字符,每个节点代表一个字符串,具体指从根节点到该节点经过的所有边的字符的合集,根节点编号为0。
例如上图中,从根节点到9号节点有 \(b\),\(e\),\(e\),三条边,于是9号节点代表的字符串便是 \(bee\)。
每当我们向 \(Trie\) 树中加入一个字符串,我们将加入的初始位置设为根,每加入一个字符,寻找当前位置是否有连该字符的边,比如对于上图,我现在要加 \(beg\),根节点已经向1号节点连边 \(b\),我们就直接挪至3号节点,同理,我们可以继续移至8号节点,然后我们发现8号节点并未向下连一条 \(g\) 的边,因此我们建一个新节点,向它连边 \(g\)。
在判断这方面,我们用 \(go[i][j]\) 来储存 \(i\) 号节点是否练了字符 \(j\) 的边,存储的值就是连边的目标节点。
代码
void push(){
	int t=0;
	for(int i=0;i<len;i++){
		int tt=c[i]-'a';
		if(!go[t][tt])go[t][tt]=++cnt;
		t=go[t][tt];
	}
	isend[t]++;
}
KMP
对于字符串问题,我们通常需要去处理匹配问题,例如在字符串 \(A\) 中找字符串 \(B\),我们可能找了很长一段之后,失配了,我们会迫不得已重新从开头寻找,将当次的起点向后挪一位,然后再从新的起点一个个匹配,但如果我们能找到在当前已匹配的 \(B\)的字串的一段后缀,使它恰好与相同长度的 \(B\) 的前缀相同,那是不是会很方便,我们可以继续在已枚举到的 \(A\) 的位置继续向下匹配,而不是回退很多,从头再来,例如下图。

绿色标出的便是前文所说的那一段后缀,相当于如果我们找到了这样一段,我们就可以仅仅将当前要所寻的 \(B\) 的位置更改,而对 \(A\) 仍照常找下去。
而对于这样一个后缀的寻找,我们就要用到 \(KMP\)。
我们用\(fail[i]\)表示对于 \(B\) 串的位置 \(i\),以 \(i\) 为结尾的最长的满足等于对应长度前缀的后缀,即 \(c[1\) \(to\) \(fail[i]]\) \(=\) \(c[i-fail[i]+1\) \(to\) \(i]\)。
这是可以通过 \(fail[i-1]\) 求得的,如果 \(fail[i-1]\) 的后一个字符等于 \(c[i]\),说明什么,相当于在 \(i-1\) 找到的 \(B\) 的前缀和相同的以 \(i-1\) 的结尾的后缀后面都有一个 \(c[i]\)。这样 \(fail[i]\)就等于 \(fail[i-1]+1\),对吧?那如果没边呢,我们去考虑 \(fail[fail[i-1]]\),这样也还是能执行上一操作,因为前 \(fail[i-1]\) 个字符的后缀,也相当于 \(c[i-1-fail[i-1]+1\) \(to\) \(i-1]\) 的后缀,所以 \(fail[i-1]\) 处理时找到的那个前缀,依旧满足是 \(i-1\) 的一个后缀,所以仍能继续执行该操作。
所以在代码中我们每次通过 \(fail[i]\) 去推 \(fail[i+1]\),在前面的匹配问题中,我们发现 \(fail[i]\) 一定要小于 \(i\),这样才能保证不会一直停留在同一个位置,\(c[1\) \(to\) \(i]\) \(=\) \(c[1\) \(to\) \(i]\) 是没有意义的,对吧,所以我们将 \(fail[1]\) 赋值为0。
代码


复杂度证明(包括构建与匹配)
构建:由于每个位置的 \(fail\) 是从上一个位置的 \(fail\) 开始往回跳,设不停向回跳至无法继续回跳所需要的次数为 \(k\),那么构建时 \(k+=1\) 最多有 \(m\) 次,因为回跳时 \(k-=1\),所以最多回跳 \(m\) 次。证毕。
匹配:最多向右走 \(n\) 次,而每次向左跳 \(fail\)一定会移动至少一个单位,所以最多向左跳 \(n\) 次。
Trie+KMP=AC/xk
如题,相当于 \(Trie\) 树上进行 \(KMP\) 匹配,也就是我们处理了一个带 \(fail\) 边的 \(Trie\) 树。
它通常用于处理一个字符串在多个字符串中寻找匹配。
对于 \(AC\)自动机,\(fail[i]\) 表示以 \(i\) 结点为结尾最长的匹配根缀的后缀,对应的根缀为 \(fail[i]\) 结点对应的字符串。注意 \(fail\) 的定义与 \(KMP\) 稍有区别(\(KMP\) 的 \(fail[i]\) 定义中为以 \(i-1\) 结尾的后缀)。
\(AC\)自动机上跳 \(fail\) 一定会跳到更短的字符串上,所以按 \(BFS\) 顺序推导 \(fail\) 值。
代码
插入与 \(Tire\) 树相同。
构建 \(fail\) 指针

例题
给定 \(n\) 个模式串 \(s_i\) 和一个文本串 \(t\),求有多少个不同的模式串在文本串里出现过。
两个模式串不同当且仅当他们编号不同。
板子题,直接套就可以,给每个字符串在 \(Trie\) 树中的末尾节点打标记即可。
#include<cstdio>
#include<cstring>
#include<iostream>
#include<cmath>
using namespace std;
int n,isend[1000001],go[1000001][26],fail[1000001],cnt,len,t,ans,q[1000001];
char c[1000001];
void push(){
	int t=0;
	for(int i=0;i<len;i++){
		int tt=c[i]-'a';
		if(!go[t][tt])go[t][tt]=++cnt;
		t=go[t][tt];
	}
	isend[t]++;
}
void gget(){
	int head=0,tail=0;
	for(int i=0;i<26;i++)if(go[0][i])q[tail++]=go[0][i];
	while(head<tail){
		int a=q[head++];
		for(int i=0;i<26;i++){
			if(go[a][i]){
				int t=fail[a];
				while(t&&!go[t][i])t=fail[t];
				fail[go[a][i]]=go[t][i];
				q[tail++]=go[a][i];
			}
		}
	}
}
void tiao(int x){
	while(x){
		if(isend[x]==-1)break;
		ans+=isend[x];
		isend[x]=-1;
		x=fail[x];
	}
}
void AC(){
	int t=0;
	for(int i=0;i<len;i++){
		int tt=c[i]-'a';
		while(t&&!go[t][tt])t=fail[t];
		if(go[t][tt])t=go[t][tt];
		tiao(t);
	}
	cout<<ans<<endl;
}
int main()
{
	scanf("%d",&n);
	while(n--){
		scanf("%s",c);
		len=strlen(c);
		push();
	}
	gget();
	scanf("%s",c);
	len=strlen(c);
	AC();
	return 0;
}
回文自动机PAM
和AC自动机一样,回文自动机也要有一颗树把回文串“串起来”。
我们知道一个长的回文串去掉他的两头,能得到一个短回文串,我们利用回文字符串这样性质把他们折叠挂在树上。
对于每个节点表示的字符串,就与\(AC\)自动机有区别了,它是由节点向上读,再反着读回来(当然还是读边所代表的字符),但我们发现奇数长度的回文串显然不行,所以对于回文自动机,我们有两个根,称为奇根和偶根。
这样对于连到奇根,读到奇根的那一条边就只读一次。
将点挂上的同时,我们还可以同时统计其他信息,例如回文串的长度,都等于它的父节点的长度加\(2\)。
fail
在加入第\(i\)位字符后,所有“新产生的”回文子串都是最长新回文子串的一个后缀,那我们如果将这后缀从前面看呢,我们发现它一定包含在了\(s[0\) \(to\) \(i-1]\),因此每加入一个字符,只会至多产生一个新回文串,也就是只会至多新建一个节点。
而这个这个节点\(x\)满足\(s[i-len[x]+1\) \(to\) \(i]\)是是一个回文串,所以新建节点就是俩条件。
1.\(s[i-len[x]+2\) \(to\) \(i-1]\)是一个回文串。
2.\(s[i-len[x]+1]==s[i]\)。
所以我们就是不断地去找\(i-1\)能使\(i\)满足该条件的最长回文后缀。
看到后缀我们就想起了\(fail\)。
\(fail[x]\)表示代表\(x\)的最长回文后缀的节点,这与\(AC\)自动机并无太大区别,只是在由上一位转至下一位时有些区别,就是判断条件。
来个图例吧。

inline int get(int x,int i){
	while(i-len[x]-1<0||c[i-len[x]-1]!=c[i])x=fail[x];
	return x;
}
对每一个跳到的\(fail\),条件1是一定满足的,所以判断条件\(2\)即可。
找到\(fail\)后,一切就都顺理成章的根据题面处理就好啦。
例题
给定一个字符串\(s\)。保证每个字符为小写字母。对于\(s\)的每个位置,请求出以该位置结尾的回文子串个数。
以一个\(num\)数组存储所求,\(x\)的\(num\)就是\(num[fail[x]]\)加\(1\)就可。
代码
#include<bits/stdc++.h>
using namespace std;
char c[500005];
int n;
int len[500005],num[500005],fail[500005];
int las,cur,pos;
int t[500005][26],tot=1;
inline int get(int x,int i){
	while(i-len[x]-1<0||c[i-len[x]-1]!=c[i])x=fail[x];
	return x;
}
int main()
{
	scanf("%s",c);
	n=strlen(c);
	fail[0]=1;len[1]=-1;
	for(int i=0;i<n;i++){
		if(i)c[i]=(c[i]+las-97)%26+97;
		pos=get(cur,i);
		if(!t[pos][c[i]-'a']){
			fail[++tot]=t[get(fail[pos],i)][c[i]-'a'];
			t[pos][c[i]-'a']=tot;
			len[tot]=len[pos]+2;
			num[tot]=num[fail[tot]]+1;
		}
		cur=t[pos][c[i]-'a'];
		las=num[cur];
		printf("%d ",las);
	}
	return 0;
}
 
 
                
            
         浙公网安备 33010602011771号
浙公网安备 33010602011771号