Aho-Corasick Automaton · AC自动机

Aho-Corasick Automaton · AC自动机

AC自动机是一个高效的字符串多模匹配算法,它的核心想法是把KMP的失配指针做到Trie上,从而实现对于所有模板字符串而言的单次文本串扫描出结果。由此可见,若要整下AC自动机,必须先掌握Trie和KMP。另外,AC自动机真的不是自动帮你AC的机子 其实你可以找AK自动机
注意,下文涉及的Trie以及KMP主要为AC自动机做铺垫,详细算法介绍请见相应文章!

·字典树(Trie)

其实字典树、前缀树什么的是同一个东西。
这里写图片描述
不难看出,树上每个节点都对应了一个单词。Trie就是这样一颗多叉树(26叉较常用,图中省略了空子树),它的边用一个大小为26的数组记录,这样就可以实现O(1)向下查找。例如单词“tea”,它对应树中路径root (第零层) > 1-1(第一层左往右第一个) > 2-2 > 3-1 。
插入:从根开始,顺着边往下走(O(1)递进,实现见代码),当走到词的末位时标记当前节点
查询:从根开始,顺着边往下走(中途走不通视为查询失败),当走到词的末位时检查当前节点,确认是否被标记。
实现方法:市面上 有两种:数组模拟和充斥着指针的动态实现,建议初学者使用静态数组,dalao们请随意

代码一言不合就封装

class Trie
{
private:
    class Nodes 
    {
    public:
        Nodes* nxt[26];
        bool end;
        Nodes()
        {
            for(int i=0;i<26;++i)
                nxt[i]=NULL;
            end=false;
        }
        void init()
        {
            for(int i=0;i<26;++i)
                nxt[i]=NULL;
            end=false;
        }
    };
    Nodes root,*now;
public:
    Trie()
    {
        this->root.init();
        this->now=NULL;
    }
    void init()
    {
        this->root.init();
        this->now=NULL;
    }
    int insert(const char *s)
    {
        if(!s)
            return 0;
        this->now=&this->root;
        for(int i=0;s[i];++i)
        {
            char ch=(s[i]>='a'&&s[i]<='z'?s[i]-'a':s[i]-'A');//假定大小写通用
            //一般情况下这样写: char ch=s[i]-'a';
            if(!this->now->nxt[ch])
            {
                if((this->now->nxt[ch]=new Nodes)==NULL)
                {
                    return -1;//申请内存失败,返回错误信息
                }
            }
            this->now=this->now->nxt[ch];
        }
        this->now->end=true;//标记单词末尾
        return 1;
    }
    bool find(const char *s)
    {
        this->now=&this->root;
        for(int i=0;s[i];++i)
        {
            char ch=(s[i]>='a'&&s[i]<='z'?s[i]-'a':s[i]-'A'); 
            if(!this->now->nxt[ch])
            {
                return false;
            }
            this->now=this->now->nxt[ch];
        }
        return this->now->end;
    }
    ~Trie()//析构函数?并不会写qwq
    {
    }
};
·看毛片(KMP)

看毛片算法至关重要,请务必仔细阅读并理解相应的内容。

部分匹配值

· 我也不知道是不是这么叫,反正实际上是最长公共前后缀。
· 最长公共前后缀:(前缀和后缀的定义就不说了,如果不会,自行Ctrl+w) 找到max(len),使得字符串长度为len的前缀和后缀完全匹配。第三个栗子: ABCAB的最长公共前后缀为AB,len=2
算了还是说一下前缀和后缀吧:

ABCBCD
len     前缀         后缀
 0     (空串)       (空串)
 1     A            D
 2     AB           CD
 3     ABC          BCD
 4     ABCB         CBCD
 5     ABCBC        BCBCD
 ......
 6     ABCBCD       ABCBCD
部分匹配值的获取(get_next函数)

· 惯例:如果你还不知道部分匹配值,请按Ctrl+w
next数组的意义:nxt[i]表示搜索词从开头(第零位)到第i位的部分匹配值

void mknxt(int nxt[],int lw,char w[])
{
    int k=0,i=1;//k是上一次的匹配值,i务必从1开始
    for(k=0,i=1;i<lw;++i)
    {
        while(k>0&&w[i]!=w[k])//k>0不解释,w[i]!=w[k]即当前匹配不成功
            k=nxt[k-1];//我也不想解释,可就是难解释啊
        if(w[i]==w[k])//当前位置匹配成功
            k++;
        nxt[i]=k;
    }
}

当前位置匹配成功的操作很好理解,k+1即可。
关键在于不相等的情况:k=nxt[k-1]是什么鬼!


w[k]与w[i]不等的处理方法:显然长度为k的前后缀是不能用了,所以应当去考虑短一点的前后缀。展开图中大条子,出现上下两个小条子,小条子的结构也是绿黄绿,而且(1-1)=(1-2),(2-1)=(2-2)。我们知道(大条子绿色前缀)=(大条子绿色后缀),所以(1-1)=(1-2)=(2-1)=(2-2)。然后执行k=nxt[k-1],原图变为:
这里写图片描述
现在,大条子中的绿色部分已经缩短,k的值改变为原来的nxt[k-1]。再次检查w[k]和w[i]。

另外:
如果你实在看不懂我说的,或许这个能帮到你(下文的P数组即w数组):
这里写图片描述
上图转自博客园的 c_cloud ,他的文章参考
http://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html

查找
void KMP()
{
    for(int i=0,k=0;i<ls;++i)
    {
        while(k>0&&w[k]!=s[i])
            k=nxt[k-1];
        if(w[k]==s[i])
            k++;
        if(k==lw)
            printf("Pattern occurs with shift:%d\n",(i-lw+1));
    }
}

如果你看懂了next数组的构造,这里也就不需要解释了

·正片-AC自动机

当需要找的东西太多时,KMP就显得很磨叽(需要对每一个模板串构造一次nxt数组并在所有文本串中找一次),因此引入AC自动机。
这里写图片描述

# 构造Trie

将模板串全部读入并构造Trie,这里需要在之前的Trie基础上新增last和fail指针,前者叫做后缀链接(后头再解释),后者就是失配边。

# 画图构造失配边与后缀链接

失配边类似于KMP中的next,实际上指向了一个串的后缀(只需要满足后缀的关系就行),有了这玩意儿可以极大的方便查找;

后缀链接指向串的后缀(废话)且该后缀是一个完整的单词。后缀链接的作用很显然,如果有{“fubuki”,”ki”},某时刻已经找到了”fubuki”,那么顺着后缀链接就能找到”ki”,这样一来,后缀链接就可以进行递归累计。

void ACA()//BFS构造失配边
{
    queue<Trie*>que;
    for(int i=0;i<26;++i)
    {
        now=root.nxt[i];
        if(now)
        {
            que.push(now);
            now->f=&root;
        }
    }//初始化队列
    while(!que.empty())
    {//逐层构造
        Trie *hed=que.front();
        que.pop();
        for(int i=0;i<26;++i)
        {//构造对于now->nxt[i]的fail与last
            Trie *j=hed->f;
            now=hed->nxt[i];
            if(!now)
            {
                if(j)
                    hed->nxt[i]=j->nxt[i];//这句过会儿解释,请暂忽略
                continue;
            }
            que.push(now);
            while(j&&!j->nxt[i])//从hed->f的基础上获取hed->nxt[i]->f,找一个hed的后缀,且这个后缀的后一个字符为i
                j=j->f;
/* <???>"*********"'字符' hed->nxt[i]对应的串(双引号内为已经确定fail的部分)和当前字符
   "*********"'未知字符' hed->f对应的串及其下一个字符(未知)
   显然,两端中双引号内字符串完全匹配,只需考虑二者的下一个字符是否相等
*/
            if(j)//数组版无需判断,这里是指针防越界(空指针)
            {
                now->f=j->nxt[i];
                now->lst=now->f->flg?now->f:now->f->lst;//如果失配边指向一个单词就直接记录lst,否则看看失配边指向的lst
            }
            if(!now->f)//空串是任意串的后缀,无法构造fail的就指回root Ps.数组版还是不需要。。。
                now->f=&root;
        }
    }
}
# 匹配(查询)

与KMP的Find相差无几,只不过由推动下标变为推动节点指针

void find()
{
    now=&root;
    for(int i=0;s[i];++i)
    {
        int j=s[i]-'a';
        while(now&&!now->nxt[j])//如果当前节点没有儿子i就顺着失配边走直到新节点有儿子i或走回root
            now=now->f;
        if(now)//再次防越界(空指针)
        {
            now=now->nxt[j];//相当于KMP里的指针后移
            if(now->flg)//当前节点记录了单词
                count(now);//递归累加出现次数
            else if(now->lst)//当前串的后缀可能是个单词
                count(now->lst);
        }
        else//now为空意味着走回root
            now=&root;
    }
    return;
}
/*构造失配边时有一句:hed->nxt[i]=j->nxt[i];
实际上是加上一条不存在的边,把nxt[i]变成了另一个fail指针
如果构造时写了这句话,匹配的时候就无需写while(now&&!now->nxt[j])了*/
void count(Trie *now)
{
    if(!now)
        return;
    cnt[now->flg]++;
    count(now->lst);//当前串的后缀可能又是一个单词
}

最后贴上完整代码,写的很粗鲁大家见谅
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<queue>

using namespace std;

int n,m,cnt[1005];
char s[1005];//不要在意大小

class Trie
{
public:
    Trie *nxt[26],*f,*lst;
    int flg;
    Trie()
    {
        for(int i=0;i<26;++i)
            nxt[i]=NULL;
        f=lst=NULL;
        flg=0;
    }
}root,*now;//Trie

void count(Trie *now)
{
    if(!now)//
        return;
    cnt[now->flg]++;
    count(now->lst);
}//累计答案用的,不同题目会要求不同答案

void find()
{
    now=&root;
    for(int i=0;s[i];++i)
    {
        int j=s[i]-'a';
        while(now&&!now->nxt[j])
            now=now->f;
        if(now)
        {
            now=now->nxt[j];
            if(now->flg)
                count(now);
            else if(now->lst)
                count(now->lst);
        }
        else
            now=&root;
    }
}

void ACA()//BFS构造失配边
{
    queue<Trie*>que;
    for(int i=0;i<26;++i)
    {
        now=root.nxt[i];
        if(now)
        {
            que.push(now);
            now->f=&root;
        }
    }
    while(!que.empty())
    {
        Trie *hed=que.front();
        que.pop();
        for(int i=0;i<26;++i)
        {
            Trie *j=hed->f;
            now=hed->nxt[i];
            if(!now)
            {
                if(j)
                    hed->nxt[i]=j->nxt[i];
                continue;
            }
            que.push(now);
            while(j&&!j->nxt[i])
                j=j->f;
            if(j)
            {
                now->f=j->nxt[i];
                now->lst=now->f->flg?now->f:now->f->lst;
            }
            if(!now->f)
                now->f=&root;
        }
    }
}

int main()
{
    freopen("test.in","r",stdin);
    freopen("test.out","w",stdout);
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;++i)
    {
        now=&root;
        scanf("%s",s);
        for(int j=0;s[j];++j)
        {
            if(!now->nxt[s[j]-'a'])
                now->nxt[s[j]-'a']=new Trie;
            now=now->nxt[s[j]-'a'];
        }
        now->flg=i;
    }
    ACA();
    for(int i=1;i<=m;++i)
    {
        scanf("%s",s);
        find();
    }
    for(int i=1;i<=n;++i)
        printf("%d ",cnt[i]);
    return 0;
}
·写在后边

如果哪一位dalao会 AK自动机 请尽快联系本蒟蒻,必有重谢!
本文多有不妥之处,欢迎批评指正!
本文不会有人转的。。。转载请注明出处。

参考:
刘汝佳《算法竞赛入门经典(训练指南)》
徐玄長(好吧我自己)《KMP·看毛片》

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Aho-Corasick算法是一种高效的字符串匹配算法,它可以在一次扫描文本的过程中同时查找多个模式串。该算法通过将模式串预处理为确定有限状态自动机,从而实现快速的匹配过程。与其他算法相比,Aho-Corasick算法的时间复杂度为O(n),与模式串的数量和长度无关。 以下是Aho-Corasick算法的一种Java实现示例: ```java import java.util.*; class AhoCorasick { static class Node { Map<Character, Node> children; Node fail; List<String> outputs; Node() { children = new HashMap<>(); fail = null; outputs = new ArrayList<>(); } } static void buildTrie(Node root, List<String> patterns) { for (String pattern : patterns) { Node curr = root; for (char c : pattern.toCharArray()) { curr.children.putIfAbsent(c, new Node()); curr = curr.children.get(c); } curr.outputs.add(pattern); } } static void buildFailure(Node root) { Queue<Node> queue = new LinkedList<>(); for (Node child : root.children.values()) { child.fail = root; queue.add(child); } while (!queue.isEmpty()) { Node curr = queue.poll(); for (Map.Entry<Character, Node> entry : curr.children.entrySet()) { char c = entry.getKey(); Node child = entry.getValue(); queue.add(child); Node failNode = curr.fail; while (failNode != null && !failNode.children.containsKey(c)) { failNode = failNode.fail; } child.fail = failNode != null ? failNode.children.get(c) : root; child.outputs.addAll(child.fail.outputs); } } } static List<String> search(Node root, String text) { List<String> matches = new ArrayList<>(); Node curr = root; for (char c : text.toCharArray()) { while (curr != null && !curr.children.containsKey(c)) { curr = curr.fail; } curr = curr != null ? curr.children.get(c) : root; for (String output : curr.outputs) { matches.add(output); } } return matches; } public static void main(String[] args) { List<String> patterns = Arrays.asList("he", "she", "his", "hers"); String text = "ushers"; Node root = new Node(); buildTrie(root, patterns); buildFailure(root); List<String> matches = search(root, text); System.out.println(matches); // 输出:[she, he, hers] } } ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值