KMP 和 AC 自动机

引入:字符串匹配

给定字符串 \(S\)\(T\),查询 \(T\)\(S\) 中所有出现的位置。(其中 \(S\) 称为文本串,\(T\) 称为模式串)显然暴力匹配的最坏时间复杂度是 \(O(|S||T|)\) 的。然而在题目中我们需要一种最坏情况 \(O(|S|+|T|)\) 左右的算法。

KMP 模式匹配(Knuth-Morris-Pratt)

字符串相关的某个思路即是某个字符串不可行时考虑其某个后缀是否可以。

考虑 \(S_1\sim S_i\)\(T\) 最多匹配到了 \(P_i\) 位,也就是 \(P_i=\max\limits_{d=1}^i [S_{i-d+1}\sim S_i=T_1\sim T_d]\,d\)。显然在考虑 \(S_1\sim S_{i+1}\)\(T\) 匹配时,有 \(P_{i+1}\le P_i+1\)。而若 \(S_{i+1}\ne T_{P_i+1}\) 时,考虑有哪些长度 \(l\) 满足 \(S_{i-l+1}\sim S_i=T_1\sim T_l\)\(T_{l+1}=S_{i+1}\),此时最大的 \(l\) 即为所求的 \(P_{i+1}-1\)。也就是说我们需要找到所有的满足 \(T_1\sim T_l=S_{i-l+1}\sim S_i=T_{P_i-l+1}\sim T_{P_i}\)\(l\),也就是满足 \(T_1\sim T_{P_i}\) 的长为 \(l\)真前缀(非 \(T\) 本身 的前缀)就是长为 \(l\)真后缀(非 \(T\) 本身 的后缀)的 \(l\)(其中每个 \(i\) 对应的最长的 \(l\) 组成的数组称为 \(T\)前缀函数,此处用 \(nxt\) 表示。特别地,\(nxt_0=nxt_1=0\))。根据定义,最大的 \(l\)\(nxt_{P_i}\),考虑之后的 \(l\) 应该如何选择。

引理:仅次于 \(nxt_{P_i}\)\(l\) 一定是 \(nxt_{nxt_{P_i}}\) 而不会是 \((nxt_{nxt_{P_i}},nxt_{P_i})\) 之间的任意一个值。

证明:若 \(\exists\,l\in(nxt_{nxt_{P_i}},nxt_{P_i})\),则 \(T_1\sim T_l=T_{P_i-l+1}\sim T_{P_i}=T_{nxt_{P_i}-l+1}\sim T_{nxt_{P_i}}\),而 \(nxt_{nxt_{P_i}}=\max\limits_{d=1}^{nxt_{P_i}}[T_{nxt_{P_i}-d+1}\sim T_{nxt_{P_i}}=T_1\sim T_d]\,d\),此时 \(l\) 大于 \(nxt_{nxt_{P_i}}\) 且满足 \(nxt_{nxt_{P_i}}\) 满足的条件,与 \(nxt\) 的定义矛盾。故而仅次于 \(nxt_{P_i}\)\(l\) 一定是 \(nxt_{nxt_{P_i}}\)

此时可以令 \(p=P_i\),若 \(S_{i+1}\ne T_{p+1}\),则 \(p\leftarrow nxt_p\),不断进行此操作直到 \(p=0\)\(S_{i+1}=T_{p+1}\)。若 \(p=0\) 时仍然 \(S_{i+1}\ne T_{p+1}\),则 \(P_{i+1}\)\(0\),否则 \(P_{i+1}\)\(p+1\)。由于 \(\forall i,0\le nxt_i<i\)\(P_{i+1}\le P_i+1\),故最后计算 \(P\) 的时间复杂度为 \(O(|S|)\) 的。

考虑 \(nxt\) 的求法。我们发现 \(nxt\) 就是 \(T\) 和自己进行上述过程(此时 \(P\) 数组即为 \(nxt\) 数组),但是强制从第 \(2\) 位进行匹配。同样进行上述操作即可。故而总的时间复杂度是 \(O(|S|+|T|)\) 的。

模板代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=1000010;
char s1[maxn],s2[maxn];
int i,j,nxt[maxn];
int main(){
    scanf("%s%s",s1+1,s2+1);
    const int siz1=strlen(s1+1);
    const int siz2=strlen(s2+1);
    for(i=2;i<=siz2;++i){
        while(j&&s2[i]!=s2[j+1]) j=nxt[j];
        if(s2[i]==s2[j+1]) ++j;nxt[i]=j;
    }
    j=0;
    for(i=1;i<=siz1;++i){
        while(j&&(s1[i]!=s2[j+1]||j==siz2)) j=nxt[j];
        if(s1[i]==s2[j+1]) ++j;
        if(j==siz2){printf("%d\n",i-siz2+1);j=nxt[j];}
    }
    for(i=1;i<=siz2;++i) printf("%d ",nxt[i]);
    return 0;
}

循环字符串

相关定理 1

对长为 \(n\) 的字符串 \(s\) 跑一遍 KMP 之后,若 \(n\bmod (n-nxt_n)=0\),则 \(s\) 的最小循环节长度为 \(n-nxt_n\)

证明 1

\(s\) 的最小循环节长度为 \(a\),则必有其长度为 \(n-a\) 的前缀和后缀完全相同,也就是 \(nxt_n\) 至少为 \(n-a\)

如果此时 \(nxt_n\) 小于 \(n-a\),(令 \(b=n-nxt_n\))令 \(t\)\(s\) 的长为 \(a\) 的前缀(只有此处下标从 \(0\) 开始),有 \(t_bt_{b+1}\cdots t_{a-1}t_0t_1\cdots t_{b-1}=t_0t_1\cdots t_{a-1}\),也就是 \(\forall i\in[0,a-1],t_i=t_{(i+b)\bmod a}\);由裴蜀定理可得 \(\exists p>0\)\(\forall i\in[1,n]\),都有 \(t_i=t_{(i+pb)\bmod a}=t_{(i+\gcd(a,b))\bmod a}\);故 \(t\)\(s\) 的最小循环节长度均为 \(\gcd(a,b)\),与之前矛盾。

证毕。

相关定理 2

长为 \(n\) 的任意字符串 \(s\) 的所有循环节长度一定都是最小循环节长度的整数倍。

证明 2

思路与 证明 1 相似。

\(s\) 的最小循环节长度为 \(a\);若 \(s\) 存在长度为 \(b\) 的循环节,且 \(b\bmod a\ne 0\),则 \(n\ge \rm{lcm}(a,b)\);由裴蜀定理得 \(\exists j\ge 0,\forall i\in[0,a-1],s_i=s_{i+aj}=s_{(i+aj)\bmod b}=s_{i+\gcd(a,b)}\),且 \(j<\frac{\rm{lcm}(a,b)}a\)。所以 \(s\) 由长度为 \(\gcd(a,b)\) 的循环节,而 \(\gcd(a,b)<a\);与之前矛盾。

证毕。

AC 自动机(Alfred-Corasick Automaton)

原理

AC 自动机结合了 Trie 树能整理多个字符串的特性和 KMP 算法中的前缀函数的性质,常用于对多个字符串的信息的整合(例如多模式串匹配)。

构造和理解

AC 自动机上除了需要对于多个串建出普通的 Trie 之外,每个节点还需要一个 nxt 指针,表示这个节点对应的字符串在 Trie 存在的最长后缀的对应节点。

nxt 指针的求法、原理和原理证明与 KMP 算法中的 nxt 相似。在计算某个节点 \(u\) 的 nxt 时,令其父节点为 \(p\),父节点连向这个点的转移边为字符 \(c\)(令 \(trie_{p,c}=u\)),若 \(trie_{nxt_p,c}\) 存在,则令 \(nxt_u\leftarrow trie_{nxt_p,c}\);否则 \(p\leftarrow nxt_p\),再看 \(trie_{nxt_p,c}\) 是否存在;直到 \(p\) 为根节点且 \(trie_{p,c}\) 不存在,再将 \(nxt_u\) 赋值为根节点。

显然 \(\forall i\)\(nxt_i\) 对应的字符串长度小于 \(i\),故而可以在 Trie 树上 BFS,可以保证在求每个点的 nxt 时其父节点的 nxt、这个 nxt 对应的 nxt …… 这样即可顺利求这个点的 nxt。不难发现 nxt 组成了一棵内向树。(p.s. 这种树被称为 失配树。)

考虑另外一种情况:在模式串为 she、he、her,对文本串 sher 进行匹配。在匹配完 she 之后需要标记 she、he(某个模式串得以完整匹配时,其所有后缀也能完整匹配),同时需要从 Trie 树上的 she 串的节点和其 nxt 上同时走,才能匹配出模式串 her。如果真的在目前经过的节点的所有 nxt 一起根据字符跳转的话,这样会是很低效的。

如果需要更方便地进行跳转,Trie 树边是不够的,我们需要在求出的 nxt 的基础上多连一些转移边。此时需要的就是 \(\forall u,c\),若不存在 \(trie_{u,c}\),则增加从 \(u\)\(trie_{nxt_u,c}\)\(c\) 转移边。这样等效于在从 \(u\) 走到 \(trie_{u,c}\) 时,若这条转移边非树边,则相当于同时在 \(nxt_u\)\(nxt_{nxt_u}\)(因为 \(nxt_u\) 也进行了这样的过程)等节点均进行了转移,在后面补上 \(c\)

最后进行多模式匹配的过程就是将文本串在 AC 自动机上匹配的过程。在文本串进行匹配时,标记每个点被经过的次数。理论上某个点被经过时我们需要标记其 nxt 树上的所有祖先(但是这样会使得时间复杂度升为 \(O(n^2)\),考虑模式串刚好为 \(aa\cdots aa\) 的情况),但是可以在跑完文本串后再进行标记。时间复杂度显然为 \(O(n|\Sigma|)\) 的,其中 \(|\Sigma|\) 为字符集大小。

AC 自动机模板代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=2000010;
const int maxl=2000010;
int i,j,n,k,t,*p,hd,tl,siz,tot,ans,all;
int trie[maxn][26],q[maxn],ed[maxn],nxt[maxn];
int To[maxn],In[maxn],cnt[maxn];
char s[maxn],d[maxl];
int main(){
    scanf("%d",&n);
    for(i=1;i<=n;++i){
        scanf("%s",s+1);
        siz=strlen(s+1);
        p=&k;
        for(j=1;j<=siz;++j){
            p=&trie[*p][s[j]-'a'];
            if(!(*p)) *p=++tot;
        }
        To[i]=*p;
    }
    for(i=0;i<26;++i) if(trie[0][i]) q[++tl]=trie[0][i];
    while(hd!=tl){
        i=q[++hd];
        for(j=0;j<26;++j){
            p=&trie[i][j];
            if(!(*p)) *p=trie[nxt[i]][j];
            else{nxt[*p]=trie[nxt[i]][j];q[++tl]=*p;}
        }
    }
    hd=tl=j=0;
    for(i=1;i<=tot;++i) ++In[nxt[i]];
    for(i=1;i<=tot;++i) if(!In[i]) q[++tl]=i;
    scanf("%s",d+1);
    siz=strlen(d+1);
    for(i=1;i<=siz;++i){
        j=trie[j][d[i]-'a'];
        ++cnt[j];
    }
    while(hd!=tl){
        i=q[++hd];j=nxt[i];
        cnt[j]+=cnt[i];
        if(!(--In[j])) q[++tl]=j;
    }
    for(i=1;i<=n;++i) printf("%d\n",cnt[To[i]]);
    return 0;
}
posted @ 2022-10-10 14:35  Fran-Cen  阅读(59)  评论(0编辑  收藏  举报