详解KMP算法、字典树与AC自动机

详解KMP算法、字典树与AC自动机

摘要

一个经典的字符串问题是:给定一个文本字符串\(Text\) 和若干个模式串\(Mode\) ,询问某个模式串在文本串中出现了多少次。我们可以暴力地解决它,时间复杂度是 \(O(NM)\)的,\(N\)是模式串长度, \(M\)是文本串长度,但\(KMP\)算法给出了一种优化策略,就是先对模式串进行预处理,得到一种信息:模式串前 \(i\) 个字符组成的连续子串中,如果有长度为 \(j\) 的前缀串和长度为 \(j\) 的后缀串相同,最大的 \(j\) 是多少?通常我们用 next 数组来记录它:next[i] = j\(KMP\)算法的时间复杂度是 \(O(M)\) 的,但是一次只能处理一种模式串。如果要对 \(N\)个模式串进行询问,那么时间复杂度也是不理想,AC自动机可以优秀地解决多模式串的匹配问题。AC自动机结合了字典树的存储特性和\(KMP\)算法的"最长相等前后缀"思想,将多个模式串储存在字典树中,将状态机状态转移指针。

单模式串匹配的\(KMP\) 算法

我们约定字符串下标从1开始。

首先考虑暴力匹配的做法,我们将模式串和文本串“对齐”,然后判断对应位置是否相同,再将模式串向右滑动一格。

假设下图中绿色格子代表对应字符相同,红色代表不同。当我们在8号位置遇到一个不相同的字符时,说明当前位置匹配失败了,应该往后移动一格。但是应该注意到,8号位置前方的字符串和文本串是相同的,如果这个部分往后移动一格,那么这个部分很大概率都无法与对应位置匹配,如图移动一格后前8个字符中红色部分大大增加了。

image

这也就意味着,如果我们又重新从模式串第一个字符往后判断,但大概率第一两个字符就无法匹配成功。

最长前后缀

既然我们一格一格地移动很大概率是没有效果的,那我们就应该优化这个过程。

注意到因为文本串的前7个字符和模式串前7个相同,因此,如果移动一格后再进行比较,其实是模式串的前6个字符在和自己的后6个字符先进行着比较,如果前6个和后6个相同,再比较后面的。

那么,如果能够快速移动到前后缀相等的位置,就能省下非常多不必要的时间。

image

我们需要记录下最大的前后缀相等的长度,因为长度越最长的前后缀包含了所有的前后缀的情况(如果最长前j个前缀和后缀相等,那么将前缀串去掉最后一个字符,后缀串去掉第一个字符,前后缀仍然相等且长度为 j-1)。

如上图,当我们将模式串对齐到最长前后缀后,我们保证了最长的前3个字符是匹配的,原来的匹配停在了8号位置,现在就可以继续从8号位置开始匹配了。

如果8号位置还是不相等怎么办?

其实上,上述操作是递归地定义的:

  • 如果匹配到 \(i+1\) 号位置不相同,将模式字符串对齐到以模式串第\(i\)个位置结尾的最长前后缀后继续匹配
  • 如果不存在相同前后缀,将模式字符串第一个字符对齐到当前位置并继续匹配

next数组

算法的关键在如何处理得到最长前后缀的长度,我们将这个信息记录在数组next中,next[i] = j表示以下标为 \(i\) 结尾的后缀最长能与长度为 \(j\) 的前缀匹配。

先给出求next数组的算法:

    for(int i=2, j=0;i<=n;i++){
        while(j && mode[i] != mode[j+1])j = next[j];
        if(mode[i] == mode[j+1])j++;
        next[i] = j;
    }

这个算法很简单但很难理解,下面给出它的原理。

我们将模式串和它自己错位摆放

image

如图,绿色部分代表相等。1号组中绿色部分长度为1,代表了以2号结尾的后缀最长能和长度为1的前缀匹配。2号组中可以知道以3号结尾的后缀最长能与长度为2的前缀匹配。也就是说,我们用i=2,j=1分别指向错位的字符串的上位置和下位置的前一个位置,如果i,j位置上的字符相等,那么就可以可以更新 next[i] = j并两个都增加1。

如果某个位置不同,如4号组所示,那么一般地,我们需要将下方的串再错位一格。但是,由于下方的前3格和上方24格是相同的,再错位的话,就会造成我们一开始所提到的问题。但是,此时我们已经更新了24位置的next[i],我们只需要将其与最长相同前缀对应起来就行了。如4号组第2、3所示,蓝色部分就是以3号位结尾的后缀的最长相同前缀,我们已经将它对应过来了。现在只需要递归地判断对应后相应位置的字符是否相同在进行操作就行了。其代码为while(j && mode[i]!=mode[j+1]) j = ne[j]

如果这个过程中一直都有mode[i]!=mode[j+1],那么j最终会取到0而退出循环,因为我们约定了next[1] = next[0] = 0。这个位置上的next值就为0。

KMP算法

在解决next数组的计算后,KMP匹配过程也就迎刃而解了。和模式串自匹配计算next不同的是,模式串和文本串一开始不需要错位,因此开始时i = 1,j=0。可以发现KMP匹配部分和求next部分是几乎相同的。

    for(int i=1,j=0;i <= m; i++){
        while(j && text[i] != mode[j+1]) j = next[j];
        if(text[i] == mode[j+1]) j++;
        if(j == n){
            printf("%d ", i-n+1);
            j = next[j];
        }
    }
  • 时间复杂度:\(O(N+M)\)
  • 空间复杂度:\(O(2N+M)\)

KMP算法模板

#include<iostream>
using namespace std;

const int N=10010, M = 1000010;
//约定字符串下标从1开始
char s[M],p[N];

//next数组
int ne[N];
int m,n;

int main(){
    cin>>m>>p+1>>n>>s+1;

    //处理next数组
    for(int i=2, j=0;i<=n;i++){
        while(j && p[i] != p[j+1])j = ne[j];
        if(p[i] == p[j+1])j++;
        ne[i] = j;
    }

    // kmp匹配
    for(int i=1,j=0;i <= m; i++){
        while(j && s[i] != p[j+1]) j = ne[j];
        if(s[i] == p[j+1]) j++;
        //匹配结束,模式串整个跳转 j = ne[j]
        if(j == n){
            printf("%d ", i-n+1);
            j = ne[j];
        }
    }
    return 0;
}

多模式串匹配的AC自动机

KMP算法可以以线性时间匹配模式串,但是,如果需要匹配的模式串不止一个,我们只能一个一个地匹配,时间复杂度为\(O(KM)\)\(K\)为模式串个数。当模式串数量规模特别多时,这个时间复杂度是不可接受的。

字典树(Trie)与AC自动机

字典树是一种储存字符串的数据结构,称为Trie树。它的逻辑结构如图所示:

image

这是一棵包含了字符串ask ash shy shely sk he her hey hell hello 的字典树,每个节点都代表一个串,而每个节点的祖先都是它代表的字符串的前缀,它的直接父节点是串的非平凡前缀(abab的平凡前缀),每个节点的出边都是对于它的子节点而言末尾将要增加的一个字符。

每个节点会记录一个cnt值,表示当前节点代表的串的在储存的串中的出现次数。记录它的意义在于Trie树的储存是有冗余的,例如如果Trie树储存串a abc abc,那么树中实际储存了串 a ab abc,而cnt[a] = 1, cnt[ab] = 0, cnt[abc] = 2真正指明了树储存了一个a和两个abc

Trie树是如何识别串shy是否储存在其中呢?我们从根点root出发,将串shy视为路径root -s-> s -h-> sh -y-> shy,如果树存在这样的路径且终点节点的cnt值不为0,说明shy储存在树中。

Trie树的构造见 数组实现静态数据结构

AC自动机的结构基础是字典树,预处理字典树计算Fail数组就构成了AC自动机。

Fail指针

多模式串匹配的思路和KMP算法相似。

假设我们共有模式串he her she say shr,这是包含模式串he her she say shr的字典树

现在把它想象成一个有屏幕和按钮的黑盒子,按钮有26个,分别代表各个小写英文字符。黑盒子刚开机时,有个指针指向root节点。接下来,你每按一个按钮,指针就会沿着对应的边向下一个节点移动,每个节点屏幕都会输出这个节点代表的字符串。如果没有对应的边,指针回到root节点

现在,你按顺序按下s h e,屏幕上最终输出了字符串she,你知道这是一个模式串,然后你又按下了h e r,屏幕上输出了串her,这也是一个模式串。如果将输入序列视为字符串的话,怎样用这的一个机器检测串中的所有模式串呢?

例如检测串sher,当输入she时,输出串为s sh she,注意到输出为she的所有前缀。但是,模式串he却没有被输出,因为heshe的后缀。如果我们对黑盒内部进行一个修改,使得当黑盒中的指针移动到某个节点时,输出当前节点的字符串以及树中的节点中所有是当前节点字符串的后缀的字符串,输出完成后再处理下一个输入。这样当前输入表示的字符串所包含所有模式串一定都被输出了。下面来证明它。

模式串集合\(M\)和字符串\(T\)。不加证明地说明:字符串\(T\)包含的所有模式串集合为\(MT\)\(T\)的所有连续子串的集合\(ST\),那么一定有\(MT \subseteq ST\) 。又由于任意一个连续子串都一定是\(T\)的某个前缀\(preT\)的后缀,因此所有前缀的后缀串集合的并集一定等于\(ST\),那么就一定包含了\(MT\)

那么,对于某个前缀串,即树中的某个节点,如何找到该节点的所有后缀呢?考虑到串\(S\)的最长非平凡后缀\(S_{back}\),如果它存在一个表示它的节点,那么该节点和它的\(S_{back}\)一定是\(S\)的非平凡后缀。于是,fail指针就来了。(许多人称之为失配指针,但我更愿理解为尾串指针)

一个节点的fail指针将指向表示该节点字符串的最长非平凡后缀的节点。定义单字符串和空串的\(S_{back}\)为空串,空串是任意串的非平凡后缀。

注意到\(S_{back}\)的长度一定比\(S\)小,我们将节点按路径长度(或者字符串长度)分为从上到下若干层,一个节点的fail指针一定指向上面某层的节点而不是该层或下层。

  • fail原理:假设当前节点为\(S_i\),其直接父节点为\(fS_{i}\),且父到子的边为\(c\)。直接父节点的最长非平凡后缀节点为\(fS_{i,back}\),如果\(fS_{i,back}\)边为\(c\)的子节点,那么,该子节点一定是\(S_i\)的最长非平凡后缀。

image

图中,红色箭头表示fail指针,指向root的fail指针未标出。不难证明一个节点只有一个指出的fail指针,沿着fail指针一定会终止在root节点,且经过的所有节点的字符串集合为每个节点的后缀集合的并集。

你可能会注意到上面的fail原理有个明显的漏洞:

我们添加两个模式串a y

image

我们发现,say的父节点的\(S_{back}\)a,但a根本就没有出边y!实际上,除了fail指针外,AC自动机中还有一种指针:状态机状态转移指针(我起的名字,暂且不知道专业称呼叫啥),简记为move指针。

状态机状态转移指针

回到一开始的那个神奇的黑盒子,当我们检测文本串sher,输入到s h e的时候,由于指针已经指向叶子节点,任意输入都会导致指针重回root节点,可以理解为状态机重启了,也就是说我能压根就没办法完全将sher输入进去,只能这样办:

  • 当输入某个字符时重启了,我们就放弃已经输入的字符的第一个字符再重新按顺序输入,直到所有字符都输入了。

也就是说,第一次输入she,在r时重启,第二次就输入her。这非常地蛋疼,怎么解决呢?

对于上一张图,考虑字符串sherhershrsa的输入:

0 1 2 3 4 5 6 7 8 9 10 11
s h e r h e r s h r s a
输入:
s h e r
  h e r h
    e 
      r
        h e r s
          e
            r
              s h r s
                    s a
                      a

我们发现了一个性质:

  • 如果因为走到某个节点的下一个不存在节点而重启,当前输入是合法字符串+非法字符,例如第4行的输入she + r,如果它的合法输入有个非平凡的最长的合法后缀,例如shehe,那么,再次输入它就是累赘的,例如第5行的输入her+h,he是累赘的。

注意到每个崭新的合法输入都将导致指针停留在某个节点,例如输入he导致指针停留在节点he, 那么我们这样做:

  • 如果遇到了想要前往一个非法节点,不让指针直接回到root,而是将它指定为它自己的fail指针所指向的那个节点的对应子节点。
  • 定义root节点的所有非法子节点都指向自己

这样,我们在来看上图的情况:在求say的fail指针时,父节点sa的fail指针指向aa没有节点ay,于是指向a的fail指针对应的root节点的相应节点,而root节点有合法节点y,于是fail指针指向节点y。这其实等价于节点a的出边为y的“子节点”为y,也可以视作节点ay连接了一条为y的出边,这个指针就叫做状态机状态转移指针。显然,状态机转移指针使得对于每个点而言所有按钮都能走向合法节点!

构造fail指针和状态机状态转移指针

void build(){
    queue<int> q;
    for(int i = 0;i<26;i++){
        if(trie[0][i])q.push(trie[0][i]);
    }
    while(q.size()){
        int p = q.front();q.pop();
        //对于每一个可能的出边都要构造
        for(int i = 0;i<26;i++){
            if(trie[p][i]){
                //fail指针
                fail[trie[p][i]] = trie[fail[p]][i];
                q.push(trie[p][i]);
            }else{
                //状态机状态转移指针
                trie[p][i] = trie[fail[p]][i];
            }
        }
    }
}

在AC自动机上运行文本串

fail指针和move指针建好后,AC自动机就建好了,运行文本串时我们只需要关心什么时候运行到了模式串节点就行了。

int qurey(char str[]){
    int u = 0, ans = 0;
    for(int i = 0;str[i];i++){
        //状态机状态转移,进入出边节点
        u = trie[u][str[i]-'a'];
        //从该节点开始沿着fail指针跑前缀
        for(int j = u; j; j = fail[j]){
            //e[] 是每个节点对应的值,将模式串节点的值记为1
            // 将所有的值收集起来,就是文本串中模式串的个数
            ans += e[j];
            //如果多次出现只记录一次的话需要置0
            e[j] = 0;
        }
    }
    return ans;
}
  • 时间复杂度:\(O(kM)\)\(M\)是文本串长度,\(k\)是一个小常数,代表从沿着fail跑到root的路径长度,一般很小,一定不大于最长模式串长度
  • 空间复杂度:\(O(NL)\)\(N\)为模式串个数,\(M\)为模式串长度

AC自动机模板

模板题出处:[Acwing1282](1282. 搜索关键词 - AcWing题库)

#include<iostream>
#include<algorithm>
#include<queue>
using namespace std;
const int N = 10010;
const int M = 1000010;
int trie[50*N][26],idx;
int fail[50*N],e[50*N];
void insert(char str[]){
    int p = 0;
    for(int i = 0;str[i];i++){
        int u = str[i]-'a';
        if(!trie[p][u])trie[p][u] = ++idx;
        p = trie[p][u];
    }
    e[p]++;
}
//BFS
void build(){
    queue<int> q;
    for(int i = 0;i<26;i++){
        if(trie[0][i])q.push(trie[0][i]);
    }
    while(q.size()){
        int p = q.front();q.pop();
        for(int i = 0;i<26;i++){
            if(trie[p][i]){
                fail[trie[p][i]] = trie[fail[p]][i];
                q.push(trie[p][i]);
            }else{
                trie[p][i] = trie[fail[p]][i];
            }
        }
    }
}

int qurey(char str[]){
    int u = 0, ans = 0;
    for(int i = 0;str[i];i++){
        u = trie[u][str[i]-'a'];
        for(int j = u; j; j = fail[j]){
            ans += e[j];
            e[j] = 0;
        }
    }
    return ans;
}
#include<cstring>
char model[55],txt[M];
int main(){
    int t;
    scanf("%d", &t);
    while(t--){
        int n;
        scanf("%d",&n);
        for(int i = 0;i<n;i++){
            scanf("%s",model);
            insert(model);
        }
        //建fail指针
        build();
        scanf("%s",txt);
        printf("%d\n",qurey(txt));
        

        idx = 0;
        memset(trie,0,sizeof trie);
        memset(fail,0,sizeof fail);
        memset(e,0,sizeof e);
        memset(model,0,sizeof model);
        memset(txt,0,sizeof txt);
    }
    return 0;
}

Fail树

AC自动机的内容远不止这些,下面来讲讲Fail树。

我们将除了root节点的Fail指针外,其余节点的Fail指针反向。可以证明,除去root节点的fail指针外,剩下的fail指针和节点将构成一棵树!

image

我们发现,Fail树将所有点组织为了一种特殊的形式(定义空串是任意串的后缀):

  • 父节点是所有子节点的后缀

Fail树有个很好用的性质:如果某个节点在文本串中出现了一次,那么它的所有祖先代表的字符串也出现了一次。

AC自动机的退化问题

在前面给出AC自动机检测操作的时间复杂度时,我们给出了一个假设:模式串最大长度足够小。

实际上还有个隐藏假设:每个节点沿着Fail指针向上走很少步就能到达根节点。即,Fail树的深度很小。

我们考虑一个非常毒瘤的情况:

  • 模式串是:a aa aaa aaaa ....
  • 文本串是:aaaaaaaaaaaaaaaa.....

保证模式串长度总和和文本串长度(都是\(\leq 10^5\))足以接受,请统计每个模式串的出现次数。

如果按照传统的AC自动机的匹配方法,那么时间复杂度将是\(O(NM)\)\(N\)是模式串最大长度,\(M\)是文本串长度。时间复杂度从线性退化为了近乎平方级。导致这种退化的原因是这些模式串构成的字典树像一条长为\(N\)的链,每个节点的Fail指针都指向了直接父节点,Fail树也成了一条长为\(N\)的链。如果我们每次走到一个节点都要回爬Fail的话,深度为\(K\)的节点就要爬\(K\)次,因此花在爬Fail树上的时间就不可忽略了。

于是Fail树就派上用场了。

强壮的AC自动机

上述情况的时间复杂度退化了,因为每次转移到一个节点都要更新自己和向上爬Fail树更新路径上的所有节点。我们考虑借助Fail树的性质优化这个过程。

考虑到我们向上爬Fail树的原因是我们除了更新当前串的出现次数外还要更新所有是当前串的后缀串的模式串。当AC自动机有move指针转移到某个串时,代表这个串出现在文本串,而在Fail树上,当前节点的所有祖先都是它的后缀,既然这个串出现了一次,那么它的所有后缀也必然出现了一次!

现在每匹配到一个串我们都不向上爬Fail,而是将该串出现的次数增加1。

当文本串匹配结束时,我们就得到了每个串在文中出现且不作为其他串的后缀串出现的次数。这时,每个串的实际出现次数都是其直接子节点的实际出现次数 + 该节点不作为子串的后缀串出现的次数

还是考虑检测串sher,前面的输入she让指针遍历了节点root s sh she,而输入r则直接将指针转移到了节点her,而没有遍历到she的所有后缀。

我们在Fail树上从叶子节点开始按层向上将自己的实际出现次数累加到直接父节点即可。规定叶子节点的实际出现次数等于其不作为其子节点的后缀串出现的出现次数(废话,因为叶子节点没有子节点)

累加完成后,每个节点的出现次数就是实际的出现次数了。

自底向上逐层爬Fail树

实际上由建Fail指针的过程的可以发现,如果我们将搜索点的入队顺序逆序过来,那么对于Fail树的任意层的节点,它的下一层的节点全部都在它前方,即入队顺序的逆序就是按层自底向上爬Fail树的顺序。

vector<int> ins;//记录入队顺序
void failTree(){
    for(auto i : ins){
        cnt[fail[i]]+=cnt[i];
    }
}

强壮的AC自动机模板

模板题

//AC自动机

#include<iostream>
#include<queue>
#include<stack>
using namespace std;
const int N = 220, M = 1000010;
int tr[M][26],idx;
int fail[M],cnt[M],id[N],k;

void insert(char str[]){
    int p = 0;
    for(int i = 0;str[i];i++){
        int u = str[i]-'a';
        if(!tr[p][u])tr[p][u] = ++idx;
        p = tr[p][u];
        cnt[p]++;
    }
    
    id[k++] = p;
}
stack<int> inQ;
void build(){
    queue<int> q;
    for(int i = 0;i<26;i++){
        if(tr[0][i]){q.push(tr[0][i]);inQ.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]);
                inQ.push(tr[u][i]);
            }
            else tr[u][i] = tr[fail[u]][i];
        }
        
    }
}


void fail_tree(){
    while(inQ.size()){
        int u = inQ.top();
        cnt[fail[u]] += cnt[u];
        inQ.pop();
    }
}

char ins[M];
int main(){
    int n;
    scanf("%d",&n);
    for(int i = 0;i<n;i++){
        scanf("%s",ins);
        insert(ins);
    }
    build();
    fail_tree();
    for(int i = 0;i<k;i++){
        printf("%d\n",cnt[id[i]]);
    }
    return 0;
}

要注意到这个题是模式串间相互匹配,回看query操作中,状态指针的转移是和Trie树的转移是一样的,即u= tr[u][i],因此,原本的query计数操作可以和insert合并,即第17行cnt[p]++

  • 时间复杂度 \(O(M)\)
  • 空间复杂度 \(O(M)\)
posted @ 2022-02-27 15:11  Sarfish  阅读(973)  评论(0)    收藏  举报