字符串学习笔记

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 指针都已求得。

  1. 如果 \(\text{trie}[\text{fail}[p],\mathtt{c}]\) 存在:则让 \(u\)\(fail\) 指针指向 \(\text{trie}[\text{fail}[p],\mathtt{c}]\)。相当于在 \(p\)\(\text{fail}[p]\) 后面加一个字符 \(c\),分别对应 \(u\)\(fail[u]\)

  2. 如果 \(\text{trie}[\text{fail}[p],\mathtt{c}]\) 不存在:那么我们继续找到 \(\text{trie}[\text{fail}[\text{fail}[p]],\mathtt{c}]\)。重复 1 的判断过程,一直跳 fail 指针直到根结点。

  3. 如果真的没有,就让 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;
}
posted @ 2023-08-17 23:20  RoFtaCD  阅读(53)  评论(0)    收藏  举报