字符串学习笔记
AC自动机
定义:
\(\text{KMP} + \text{Trie} = \text{AC自动机}\)。
根据 KMP 算法的原理为 Trie 树每个节点建立失配指针进行多模式匹配。
Trie 树:
Trie 中的结点表示的是某个模式串的前缀(状态)。Trie 的边就是状态的转移,将 Trie 树构建后的所有状态的集合记作 \(Q\)。
失配(fail)指针:
状态 \(u\) 的 fail 指针指向另一个状态 \(v\),其中 \(v\in Q\),且 \(v\) 是 \(u\) 的最长后缀.
fail 指针与 KMP next 指针的区别:
- 共同点:两者同样是在失配的时候用于跳转的指针。
- 不同点:next 指针求的是最长 Border(即最长的相同前后缀),而 fail 指针指向的是所有模式串的前缀中,匹配当前状态的最长后缀。
算法:
构建 fail 指针:
考虑字典树中当前的结点 \(u\),\(u\) 的父结点是 \(p\),\(p\) 通过字符 \(c\) 的边指向 \(u\),即 \(trie[p,\mathtt{c}]=u\)。假设深度小于 \(u\) 的所有结点的 fail 指针都已求得。
-
如果 \(\text{trie}[\text{fail}[p],\mathtt{c}]\) 存在:则让 \(u\) 的 \(fail\) 指针指向 \(\text{trie}[\text{fail}[p],\mathtt{c}]\)。相当于在 \(p\) 和 \(\text{fail}[p]\) 后面加一个字符 \(c\),分别对应 \(u\) 和 \(fail[u]\)。
-
如果 \(\text{trie}[\text{fail}[p],\mathtt{c}]\) 不存在:那么我们继续找到 \(\text{trie}[\text{fail}[\text{fail}[p]],\mathtt{c}]\)。重复 1 的判断过程,一直跳 fail 指针直到根结点。
-
如果真的没有,就让 fail 指针指向根结点。
如此即完成了 \(\text{fail}[u]\) 的构建。
(如果父亲有对应自己字符指针就随父亲,否则一直找父亲的指针)
void build() {
for (int i = 0; i < 26; i++)
if (tr[0][i]) q.push(tr[0][i]);
while (q.size()) {
int u = q.front();
q.pop();
for (int i = 0; i < 26; i++) {
if (tr[u][i])
fail[tr[u][i]] = tr[fail[u]][i], q.push(tr[u][i]);
else
tr[u][i] = tr[fail[u]][i];
}
}
}
在构建自动机时,每次匹配都会一直向 fail 指针跳边来找到所有的匹配,但是这样的效率较低,需要优化建图。
可以按照 fail 树建图。
queue<int> q;
void bfs(){ //nxt == fail
for(int i=0;i<26;++i) ch[0][i]=1;
q.push(1),nxt[1]=0;
while(!q.empty()){
int x=q.front(),v;q.pop();
for(int i=0;i<26;++i){
if(!ch[x][i]) ch[x][i] = ch[nxt[x]][i];
else{
q.push(ch[x][i]),v=nxt[x];
while(v && !ch[x][i]) v=nxt[v];
nxt[ch[x][i]]=ch[v][i];
}
}
}
}
例题:
P3796 【模板】AC 自动机(加强版)
需要找出哪些模式串在文本串 \(T\) 中出现的次数最多
在文本串 \(S\) 匹配到第 \(i\) 位的时候可以根据 \(fail\) 指针找出当前串 \(S[1,i]\) 的所有后缀是否在树中出现过,并更新对应的模式串出现次数。
之后排序即可输出最大值。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=1e5+105,inf=0x3f3f3f3f;
struct Tree {int fail,end,vis[26];}AC[N];
int cnt;
string s[N];
struct Res{int num,pos;}Ans[N];
bool operator <(const Res &a,const Res &b){
if(a.num!=b.num) return a.num>b.num;
else return a.pos<b.pos;
}
void Clean(int x){memset(AC[x].vis,0,sizeof AC[x].vis),AC[x].fail=AC[x].end=0;}
inline void Build(string s,int Num){
int l=s.size();
int now=0;
for(int i=0,c;i<l;++i){
c=s[i]-'a';
if(AC[now].vis[c]==0)
AC[now].vis[c]=++cnt,Clean(cnt);
now=AC[now].vis[c];
}
AC[now].end=Num;
}
void Get_fail(){
queue<int> Q;
for(int i=0;i<26;++i)
if(AC[0].vis[i]!=0)
AC[AC[0].vis[i]].fail=0,Q.push(AC[0].vis[i]);
while(!Q.empty()){
int u=Q.front(),fail=AC[u].fail;
Q.pop();
for(int i=0,v;i<26;++i){
v=AC[u].vis[i];
if(v!=0) AC[v].fail=AC[fail].vis[i],Q.push(v);
else AC[u].vis[i]=AC[AC[u].fail].vis[i];
}
}
}
int AC_Query(string s){
int l=s.size();
int now=0,ans=0;
for(int i=0;i<l;++i){
now=AC[now].vis[s[i]-'a'];
for(int t=now;t;t=AC[t].fail) ++Ans[AC[t].end].num;
}
return ans;
}
int main(){
int n;
while(1){
cin>>n;if(n==0) break;
cnt=0,Clean(0);
for(int i=1;i<=n;++i) cin>>s[i],Ans[i]={0,i},Build(s[i],i);
AC[0].fail=0,Get_fail();
cin>>s[0],AC_Query(s[0]);
sort(Ans+1,Ans+1+n);
cout<<Ans[1].num<<'\n';
cout<<s[Ans[1].pos]<<'\n';
for(int i=2;i<=n;++i){
if(Ans[i].num == Ans[i-1].num) cout<<s[Ans[i].pos]<<'\n';
else break;
}
}
return 0;
}
是为 AC 自动机模板题。
P5231 [JSOI2012] 玄武密码
对于所有模式串 \(T\),求出其最长的前缀 \(p\),满足 \(p\) 是文本串 \(S\) 的子串。
先跑一边 AC 自动机,处理出 fail 数组,然后再把文本串匹配一下,可以获得 vis 数组,代表着 trie 树上的这个点是文本串的前缀。最后只需要再每个模式串跑一遍 trie 树,就可以得到最长的公共前缀长度了。
#include<bits/stdc++.h>
using namespace std;
const int N=1e7+105,M=1e5+105;
inline int mk(char c){if(c=='E') return 1;if(c=='W') return 2;if(c=='S') return 3;return 4;}
int n,m;
int tr[N][5],tt,fail[N],ep[N];
bool vis[N];
char T[M][105],S[N];
void ins(char *s,int len){
int u=0;
for(int i=0,c=s[0];i<len;++i,c=s[i])
u = tr[u][c] ? tr[u][c] : tr[u][c]=++tt;
++ep[u];
}
queue<int> q;
void build(){
for(int i=0;i<5;++i) if(tr[0][i]) q.push(tr[0][i]),fail[tr[0][i]]=0;
while(!q.empty()){
int u=q.front();q.pop();
for(int i=0;i<5;++i){
if(tr[u][i]) fail[tr[u][i]]=tr[fail[u]][i],q.push(tr[u][i]);
else tr[u][i]=tr[fail[u]][i];
}
}
int p=0;
for(int i=0;i<n;++i){
p=tr[p][S[i]];
for(int k=p;k&&!vis[k];k=fail[k]) vis[k]=1;
}
}
int query(char *s){
int len=strlen(s),p=0,res=0;
for(int i=0;i<len;++i){
p=tr[p][s[i]];
if(vis[p]) res=i+1;
}
return res;
}
int main(){
scanf("%d%d",&n,&m);
scanf("%s",S);
for(int i=0;i<n;++i) S[i]=mk(S[i]);
for(int i=1,l;i<=m;++i){
scanf("%s",T[i]),l=strlen(T[i]);
for(int j=0;j<l;++j) T[i][j]=mk(T[i][j]);
ins(T[i],l);
}
build();
for(int i=1;i<=m;++i)
printf("%d\n",query(T[i]));
return 0;
}
PAM(回文自动机)
待补充
.
.
.
SAM(后缀自动机)
待补充
.
.
.
Lyndon 分解
定义:
定义一个串是 \(\text{Lyndon}\) 串,当且仅当此串的最小后缀为此串本身。
等价于该串为它所有循环表示中字典序最小的。
\(\text{Lyndon}\) 分解将任意串 \(S\) 划分成字符串序列,满足序列中每个串均为 \(\text{Lyndon}\) 串且每个串字典序均大于等于其之后的串。
可以证明这种划分存在且唯一,证明略。
算法:
引理 1:若串 \(u\) 和 \(v\) 都是 \(\text{Lyndon}\) 串且 \(u<v\),则 \(u+v\) 也是 \(\text{Lyndon}\) 串。
证明略。
引理2:若字符串 \(u\) 和字符 \(c\) 满足 \(u+c\) 是某个 \(\text{Lyndon}\) 串的前缀,则对于字符 \(d>c\) 有 \(u+d\) 是 \(\text{Lyndon}\) 串。
证明:
设该 \(\text{Lyndon}\) 串为 \(u+c+t\)。
则根据性质就有 \(\forall i \in [2,len(u)],u[i:]+c+t>u+c+t\),
也就是说 \(u[i:]+c\ge u\),(即为 \(u\) 的后缀加上字符 \(c\) 后字典序仍比 \(u\) 大)。
所以就有 \(u[i:]+d>u[i:]+c\ge u\)。
同时因为 \(c>u[1]\),就有 \(d>c>u[1]\)。
故 \(u+d\) 为 \(\text{Lyndon}\) 串。
\(\text{Duval}\) 算法:
这个算法可以在 $ \mathcal{O}(n)$ 时间复杂度,\(\mathcal{O}(1)\) 空间复杂度内求出一个字符串 \(S\) 的 \(\text{Lyndon}\) 分解。
维护三个变量 \(i,j,k\),\(i,k\) 将字符串分为三段。
\(S[:i−1]\) 为已经分解好且满足字典序非递增的 \(g\) 个 \(\text{Lyndon}\) 串。(\(s_1s_2s_3 \dots s_g\))
\(S[i,k−1]=t^h+v(h>1)\) 尚未分解,满足 \(t\) 是 \(\text{Lyndon}\) 串,且 \(v\) 是 \(t\) 的一个可为空且不等于 \(t\) 的前缀,且有 \(s_g>S[i,k-1]\)
程序实现时按顺序读入字符 \(S[k]\),令 \(j=k-|t|\)。
以 \(S[j]\) 和 \(S[k]\) 为依据分类讨论。
- 当 \(S[k]=S[j]\) 时,直接 \(k\leftarrow k+1,j\leftarrow j+1\),尾部字符串 \(v\) 的周期 \(k-j\) 继续保持
- 当 \(S[k]>S[j]\) 时,由 引理 2 可知 \(v+S[k]\) 是 \(\text{Lyndon}\) 串,由于 \(\text{Lyndon}\) 分解需要满足 \(s_i\ge s_{i+1}\),所以继续向前合并,并且最终整个 \(t^h+v+S[k]\) 会形成一个新的 \(\text{Lyndon}\) 串(所以将 \(j\) 调回 \(i\) 的位置继续判断)。
- 当 \(S[k]<S[j]\) 时,\(t^h\) 的分解被固定下来,算法从 \(v\) 的开头处重新开始,之前的都归到 \(i\) 前的第一部分。
\(i\) 只会单调往右移动,同时 \(k\) 每次移动的距离不会超过 \(i\) 移动的距离,所以时间复杂度是 $ \mathcal{O}(n)$ 的。
核心代码:
for(int i=1;i<=n;){
int j=i,k=i+1;
while(k<=n && s[k]>=s[j]){ //前两种情况
if(s[k]>s[j]) j=i;
else ++j;
++k;
}
while(i<=j){
// 在此处获取字串信息
// 每个字串的长度均为 k-j
i+=k-j;
}
}
一个子串的左端点为 \(i\),右端点为 \(i+k-j-1\)。
例题:
P6114 【模板】Lyndon 分解
本题只需要输出所有右端点的异或和,在 while 循环中直接统计即可。
P1368 【模板】最小表示法
对于长度为 \(n\) 的字符串 \(S\),设 \(T = S + S\),对 \(T\) 进行 \(\text{Lyndon}\) 分解,找到首字符位置 \(\le n\) 且最大的 \(\text{Lyndon}\) 串,这个串的首字符即最小表示法的首字符。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=1e6+105,inf=0x3f3f3f3f;
int n,ans,s[N];
int main() {
scanf("%d",&n);
for(int i=1;i<=n;++i) scanf("%d",s+i),s[i+n]=s[i];
int i=1;
while(i<=n){
int j=i,k=i+1;
while(k<=n*2 && s[j]<=s[k])
j = (s[j]==s[k++]) ? j+1 : i;
while(i<=j)
i+=k-j,ans = i<=n ? i : ans;
}
for(int i=1;i<=n;++i) printf("%d ",s[ans-1+i]);
printf("\n");
return 0;
}

浙公网安备 33010602011771号