kmp和ac自动机

摆烂很久了,康复训练到kmp和ac自动机的时候突然发现很容易就能理解其中的原理(之前甚至没写过ac自动机)。果然算法也是需要时间沉淀的东西,其中的原理网上有很多优质的博文,这里就不献丑了。要看懂本篇请先看看网上其他大神写的全面的内容,这里只说明重点核心部分,浅浅讲下我的理解供复习用

kmp

算法讲解中反复提到的核心:nextp[]数组,和一个反复出现的关键字:回溯。

首先要清楚回溯的对象是模板p,一直遍历的是s,而next[]是用来回溯p的。

浅谈一下算法原理:

  1. 设i遍历s,j遍历p

  2. i和j只要一致就不断遍历下去,当出现不一致的时候就匹配失败(显然的),这个时候回溯的是p而不是s,问题就来到了 为什么只回溯p?,观察下面的图,当C-D匹配失败后,i的位置不动,j的位置向前移动(注意!!效果上其实是p向右移动)四个单位后i对应C,j也对应C,C-C匹配,ij又可以不断遍历下去直到j遍历完。

kmp和ac自动机_第1张图片

  1. 理解2的过程之后,就能发现kmp的一个关键回溯原理,p移动四个单位之后,前面还有两个字符“AB”根本就没有去管它,却神奇的能和s匹配成功,这一原理大佬叫它 “最长前缀后缀”,解释起来很复杂,大概说的就是字符串存在一个后缀它和它的前缀一模一样,观察上面的图,C-D匹配失败后,"ABCDAB"这个字符的前缀"AB"同时也是后缀,在C-D之前,后缀"AB"能匹配成功,那么前缀"AB"一定也能匹配成功,所以就不用管"AB"了。

  2. kmp就是像上面介绍的那样,匹配的时候下标就+1,不匹配就只让j回溯。

  3. 最后来解释怎么计算p该位移多远,实际上我们不是真的位移,而是计算”跳跃“的位置(效果一样),这里就是构造next[]的过程。上图中 下标从0开始,j=6的时候匹配失败,匹配成功的"ABCDAB"的最长前后缀长度是2,那么next[6]=2; 下标从1开始的话就是next[7]=3;

  4. 计算next[]数组,牢记next[]是针对p构造的,根据最长前后缀得到的。

    ne[1]=0;
    for(int i=1,j=0;i<=n;i++)
    {
        j=ne[i];//j要知道当前p应该到哪个位置了,此时i-1和j-1是匹配过的,ne[i-1]=j-1,p[i-1]=p[j-1](j>0),
        
        while(j&&p[i]!=p[j]) j=ne[j];//当i和j不匹配时,j回溯,直到匹配成功或者j=0;
        
        ne[i+1]=(p[i]==p[j])? j+1:0;//0可以用j替换
        //等价于:if(p[i]==p[j]) j++;ne[i+1]=j;
        
        //这里最难理解,细说:假设p[i]!=p[j],那么j一定等于0,那么i+1一定回溯到0这个位置;
        //假设p[i]==p[j],那么i+1就应该回溯到j+1这个位置,即ne[i+1]=j+1,因为p[i]==p[j],所以要在下一层循环i+1时判断p[i+1]是否匹配j+1,匹配的话j再次+1,不匹配j+1就回溯到匹配为止
    }
    
  5. 然后这是下标从1开始的另一种写法,让i和j+1匹配,也许更好理解:

    ne[1]=0;
    for(int i=2,j=0;i<=n;i++)
    {
        j=ne[i-1];//j要知道上一个循环p遍历到个位置了,此时i-1和j是匹配过的,p[i-1]=p[j],ne[i-1]=j
        while(j&&p[i]!=p[j+1]) j=ne[j];//i和j+1进行匹配
        ne[i]=(p[i]==p[j+1])?j+1:0;//假设p[i]==p[j+1],那么i回溯到j+1的位置,否则j一定是等于0
    }
    
  6. 完整算法:

    char p[N],s[M];    
    int ne[N];
    int main()
    {
        int n,m;
        cin >> n >> p+1 >> m >> s+1;//下标从1开始
        
        ne[1]=0;
        for(int i=2,j=0;i<=n;i++)//这是求next数组的,上文已解释过
        {
            j=ne[i-1];
            while(j>0&&p[i]!=p[j+1]) j=ne[j];
            ne[i]=(p[i]==p[j+1])?j+1:0;
        } 
        for(int i=1,j=0;i<=m;i++)//这里求匹配位置的下标
        {
            while(j>0&&s[i]!=p[j+1]) j=ne[j];//同next,i和j+1匹配,不匹配则回溯
            if(s[i]==p[j+1]) j++;
            if(j==n) cout<<i-n<<' ';//完成匹配,输出起点坐标
        }
        return 0;
    }
    

ac自动机

ac自动机是kmp的升级版,可以在一个文本串中找到多个不同的模式串。

kmp通过查找p对应的next[]数组实现快速匹配,如果把所有p做成一个字典树,然后匹配的时候查找p对应的next[]数组,就可以实现快速匹配的效果。

trie
       O
     s/ \h
     O   O
   h/ \a  \e
   O   O   O
 e/ \r  \y  \r
 O   O   O   O
类比 KMP 的 next[i] trie里的next数组代表最长的trie"相同前后缀",如果不存在相同前缀则next[i]指向0
比如 she 中在trie树中最长存在相同前缀的后缀为he,其前缀和her的he 
此时 she 的 e 通过next指向 her的e

KMP中 由于while(j && p[i] != p[j+1]) j = next[j]; 
        直到 if(p[i]==p[j+1]) j++;
             next[i] = j;

        所以while这一行开始的时候的j是上一轮赋给next[i-1]时的j
        所以KMP:
        先求next[0] -> next[i-1]:前i-1组的信息 
        在前i-1组的信息上再去求next[i]

for(i=2;i<=m;i++)
{
    int j = next[i-1];
    while(j && p[i]!=p[j+1]) j = next[j];
    if(s[i]==p[j+1]) j++;
    next[i] = j;
}

类比KMP:
        自动机在trie中则是先求前i-1层的信息,再求第i层的信息
        那么逐层向下就可以用BFS来做
        while(hh<=tt)
        {
            t = q[hh++];//取队列头节点 其值为自动机第i-1层某个结点的idx -> t对应的是next[i-1]中的i-1
            //遍历头节点t所有的儿子
            26个字母
            for(i=0;i<26;i++)
            {
                p = tr[t][i];//字母p为字母t的下一个字母,t对应的是next[i-1]中的i-1 那么p就对应i
                j = next[t];// j为前i轮循环后停在子串的下标 对比母串字母p[i] 和 子串j下标对应的字母的下一个字母p[j+1] 
                while(j && !tr[j][i]) j = next[j];// j的下一个字母(p[j+1])是不存在字母i(p[i])的 p[i]!=p[j+1]
                if(tr[j][i]) j=tr[j][i]; //tr[j][i]代表idx为j的结点的儿子结点i的idx 
                next[p] = j; //next[]中存的即为字母p为后缀最后一个所对应的trie中最长相同前缀的
            }
        }
类比符号:
            自动机       KMP
        t = q[hh++]; -> i-1
        p = tr[t][i] -> i    for i in (0,25)
        j = next[t]  -> j=next[i-1]  这里的j是指刚经过前i-1个点循环后第i次循环while还没开始的j
        在自动机里则表示刚经过前i-1层循环后第i层循环while还没开始的j
        所以其值等同于刚赋值给的next[i-1],而自动机中t = i-1 所以j = next[i-1] = next[t]
tr[t][i]中代表字母的i-> p[i]         一个字母p[i]->trie可能有的26个字母
           !tr[j][i] -> p[i] != p[j+1]  tr[j][i] tr[j][i]表示j的下一个字母p[j+1]是不是这里的i(KMP中的p[i])

优化为trie图 
while(j && !tr[j][i]) j = next[j];
优化思路 在没有匹配时 把while循环多次跳 优化为 直接跳到ne指针最终跳到的位置
数学归纳法
假定在循环第i层时,前i-1层都求对了
那么ne[t]就是ne指针最终跳到的位置
#include
#include
#include
#include

using namespace std;

const int N=10010,S=55,M=10000010;

int n;
int tr[N*S][26],cnt[N*S],idx;
char str[M];
int q[N*S],ne[N*S];
void insert()
{
    int p = 0;
    for(int i =0;str[i];i++)
    {
        int t = str[i]-'a';
        if(!tr[p][t]) tr[p][t] = ++idx;//如果儿子不存在 创建一个新的节点
        p = tr[p][t];// 沿字符串字母idx继续往下走
    }
    cnt[p] ++;
}
void build()
{
    int hh=0,tt=-1;
    for(int i=0;i<26;i++)//根节点以及第一层结点都是指向根节点,所以直接从第一层开始搜,也就是根的所有儿子开始搜
    {
        if(tr[0][i])
            q[++tt] = tr[0][i];
    }

    while(hh<=tt)
    {
        int t = q[hh++];//队列popleft
        for(int i=0;i<26;i++)
        {
            int p = tr[t][i];//p:自动机中某个第i层结点的idx -> KMP中的i 
            // if(p)
            // {
            //     int j = ne[t];
            //     while(j && !tr[j][i]) j = ne[j];
            //     if(tr[j][i]) j = tr[j][i];
            //     ne[p] = j;
            //     q[++tt] = p;
            // }

            // 优化思路 在没有匹配时 把while循环多次跳 优化为 直接跳到ne指针最终跳到的位置
            // 数学归纳法
            // 假定在循环第i层时,前i-1层都求对了
            // 在第i层没找到字母i,那么去第i-1层父结点t的next指针的位置就是它最终应该跳到的位置
            if(!p) tr[t][i] = tr[ne[t]][i];//ne[t]:j  如果不存在儿子tr[t][i]的话
            // 如果存在儿子节点 则对儿子节点的next指针赋值为tr[ne[t]][i]
            else
            {
                ne[p] = tr[ne[t]][i];
                q[++tt] = p;
            }
        }
    }

}
int main()
{
    int T;
    scanf("%d", &T);
    while (T -- )
    {
        memset(tr, 0, sizeof tr);
        memset(cnt, 0, sizeof cnt);
        memset(ne, 0, sizeof ne);
        idx = 0;

        scanf("%d", &n);
        for (int i = 0; i < n; i ++ )
        {
            scanf("%s", str);
            insert();
        }

        build();

        scanf("%s", str);

        int res = 0;
        for (int i = 0, j = 0; str[i]; i ++ )
        {
            int t = str[i] - 'a';
            /*
            while(j && !tr[j][t]) j = ne[j];
            if(tr[j][t]) j=tr[j][t];
            int p = j;
             // she 和 he 的 e结点都有cnt[e]=1
                遍历到she的后缀he的时候 her的相同前缀he肯定是逐层遍历到了的 len(he)
            j = tr[j][t];

            int p = j;
            while (p)
            {
                res += cnt[p];
                cnt[p] = 0;//she he 把cnt[e]的用过了之后 res=2 此时再进来一个her 就不能再+he的cnt了,所以cnt[e]用过之后要置0
                p = ne[p];
            }
        }

        printf("%d\n", res);
    }

    return 0;
}

你可能感兴趣的:(算法)