AC自动机:在一篇文章paper字符串中,搜索查找一批matches字符串,看看有哪些字符串能匹配上

AC自动机:在一篇文章paper字符串中,搜索查找一批matches字符串,看看有哪些字符串能匹配上?

提示:字符串匹配算法KMP算法,速度极快,KMP查一个字符串,而AC自动机查一批字符串

要学习今天的内容,必须熟练掌握前缀树的基础知识:
【3】前缀树,查找树,trie数据结构:建前缀树,查字符串,查前缀,删除前缀树中的字符串

之前咱们学过KMP算法必须要预备的预设数组信息:next数组
(1)KMP算法预备知识:字符串match的每一个位置i之前的字符串,前缀与后缀匹配的最大长度是多少?
这个文章你必须学会了,学透了,
【2】KMP算法:在字符串s中搜索匹配查找match字符串,如果能找到返回首个匹配位置i,否则返回-1
当时学习KMP算法,在一个字符串s中快速搜索一个 match字符串,并返回匹配的首个字符位置i

今天要学:在一篇文章paper字符串),搜索一批 matches字符串,看看有哪些字符串能匹配上?


题目

AC自动机:在一篇文章paper字符串中,搜索查找一批matches字符串,看看有哪些字符串能匹配上

和KMP算法的区别就是在字符串中查一个还是一批字符串!
查一个KMP
查一批AC自动机

本质上,KMP算法是AC自动机中的一种特殊情况。


一、审题

示例:
paper=abcdtks
matches=
abcd
bcd
ce

请问你,在paper字符串中,有哪些matches字符串能匹配上
显然ans=
abcd
bcd

没有ce


暴力解:o(nmk)复杂度,不可取

你就遍历字符串paper吧,来到paper的i位置,从i开始
依次去matches字符串数组中,一个一个地查,查第j个字符串
来到j字符串,从k=0位置开始对paper的i字符,一个一个匹配
匹配上了,加入ans
否则让i++,从新来匹配

这样下去得把计算机废了,如此复杂
paper是N长度,matches是M长度,每个字符串内部最大长度为K的话
你要查**o(nmk)**的复杂度

咱们要想办法优化这个复杂度!
类似于KMP,看看能否利用额外空间,换取时间,
咱们是否也可以提前准备一些数据信息,然后加速舍弃那些没必要对比的地方


一批字符串,完全可以用前缀树表示

要学习今天的内容,必须熟练掌握前缀树的基础知识:
【3】前缀树,查找树,trie数据结构:建前缀树,查字符串,查前缀,删除前缀树中的字符串

将matches字符数组,一个一个放入前缀树root
matches=
abcd
bcd
ce
在这里插入图片描述
如何构建前缀树,上文已经说过了,你一定学透了
整体数据结构是这样的:

最普通的前缀树的节点:

public static class Node{
        public int pass;
        public int end;
        public Node[] nexts;//有一个自身类型的节点数组,nexts里面放的是node

        public Node(){
            //一旦生成自己就要初始化,不然没法计数
            pass = 0;
            end = 0;
            nexts = new Node[26];//初始化啥都没有
        }
    }

最普通的前缀树:可以往root上新加字符串,也可以删除,可以查询前缀有哪些,可以查询字符串有没有存在root上

//复习前缀树:
    public static class TrieReview{
        public Node root;

        public TrieReview(){
            root = new Node();
        }

        //add(word)建前缀树,一个一个字符串加
        public void add(String word){
            if (word.compareTo("") == 0) return;

            char[] str = word.toCharArray();//转化为字符数组去一个个找路径,加
            //没有新建一个点,代表这个位置的字符存在0--25==a--z
            Node cur = root;//挂在root上的
            cur.pass++;//新加入,必然root有一个字符串经过
            int path = 0;//索引每一个字符的方向
            for (int i = 0; i < str.length; i++) {
                path = str[i] - 'a';//0--25==a--z
                if (cur.nexts[path] == null) cur.nexts[path] = new Node();//代表存在了
                cur = cur.nexts[path];//然去这个分支,代表加入了str[i]字符了
                cur.pass++;//统计,当前cur字符来一个新的
            }
            //全部加完之后,代表多了一个str
            cur.end++;
        }

        //search(word)
        //查的就是word末尾的end
        public int search(String word){
            if (word.compareTo("") == 0) return 0;
            Node cur = root;//自然是从root开始查
            int path = 0;
            char[] str = word.toCharArray();
            for (int i = 0; i < str.length; i++) {
                path = str[i] - 'a';
                if (cur.nexts[path] == null) return 0;//压根这条路就没有
                cur = cur.nexts[path];
            }
            //查完返回end
            return cur.end;
        }

        //prefixSearch(word)
        //以word为前缀,也就是说word最后一个字符,cur的pass是多少,就有多少个经过这里
        public int prefixSearch(String word){
            if (word.compareTo("") == 0) return 0;
            char[] str = word.toCharArray();
            int path = 0;
            Node cur = root;
            for (int i = 0; i < str.length; i++) {
                path = str[i] - 'a';
                if (cur.nexts[path] == null) return 0;//压根这条路没有
                cur = cur.nexts[path];//沿着路径找
            }
            //前缀玩车个,返回pass
            return cur.pass;
        }

        //delete(word)
        //删除一个字符串,沿途root,cur的pass全部干掉一遍,如过提前发现某一只已经pass=0,则整条路废了
        public void delete(String word){
            if (word.compareTo("") == 0) return;
            //先查,暂时有没有这个字符串,有才需要删除,否则不必
            if (search(word) == 0) return;

            char[] str = word.toCharArray();
            Node cur = root;
            root.pass--;//root必然损失1个字符串
            int path = 0;
            for (int i = 0; i < str.length; i++) {
                path = str[i] - 'a';
                if (--cur.nexts[path].pass == 0) {//减过了pass待会不要多减了
                    //唯独,就一个,则提前删除返回
                    cur.nexts[path] = null;
                    return;
                }
                //这条路如果至少有2个以上,只需要沿途干掉pass,最后干掉end--
                cur = cur.nexts[path];
            }
            //最后必然干end
            cur.end--;
        }
    }

    //测试一波:
    public static void test2(){
        TrieReview trie = new TrieReview();
        String s1 = "abc";
        String s2 = "abd";
        String s3 = "abe";
        trie.add(s1);
        trie.add(s2);
        trie.add(s3);
        System.out.println(trie.search(s3));//1
        System.out.println(trie.prefixSearch("ab"));//3

        System.out.println("--------------");
        trie.delete(s3);
        System.out.println(trie.search(s3));
        System.out.println(trie.prefixSearch("ab"));
    }

    public static void main(String[] args) {
//        test();
        test2();
    }

这个基础数据结构,我们今天会用,拿来改编,增加变量,这个变量是我们解决本题所需的参数。


AC自动机如何利用前缀树加速?

就是类似于KMP,咱们利用前缀树来加速!

我们想干嘛呢?

我们想,不妨设我们正在匹配matches的字符串x,从paper的j位置对应x的0位置,一直开始对比,都能对比上
在这里插入图片描述
现在已经对比到了paper的i位置了,发现i位置和x没法匹配了,
按照暴力解的话,咱们又要从matches中找另一个的字符串y,从y的0位置开始,从新和paper的j再来对比一遍……

有必要么??真的有必要这么损??
没必要暴力再来搞一遍!!!
没必要暴力再来搞一遍!!!
没必要暴力再来搞一遍!!!

你注意,上面x和y,x=abce,以abc作为后缀串,实际上abc也是y=abcdt的前缀
那么你当初x跟paper已经匹配到了abc这个子串了,我就想问问 matches中还有别的哪些字符串,是以这个abc为前缀的,他们没必要再去从新对比abc了

咱们完全可以从y的abc之后的下一个位置k,开始与paper的i位置对比看看能匹配吗?
咱们完全可以从y的abc之后的下一个位置k,开始与paper的i位置对比看看能匹配吗?
咱们完全可以从y的abc之后的下一个位置k,开始与paper的i位置对比看看能匹配吗?

看下图y的k是d字符,而paper的i也是d字符,巧了,相等
继续k++,i++,仍相等,所以y字符串就出现在了paper中,收集y进入答案ans中。
在这里插入图片描述
知道AC自动机想怎么优化了吧?

实际上就想,在paper中查matches的某个字符串x,既然x以i结尾的后缀串str后续没法匹配,我走过的这段后缀串也别浪费了
我想再看看在matches中有谁?比如y,是以str为前缀的字符串?
我们直接在y的这个前缀str的下一个位置,继续跟paper的i位置对比,
这样的话,我就避免了从y的0位置又去和paper重新对比一遍,这个工作没必要重复干了!!
这就是AC自动机的舍弃思想。——就是为了避免重复干相同的事情。

这个事情,谁能做呢?
前缀树!


在前缀树中添加fail指针:指向matches中,另一个以当前匹配串str为后缀串的,最长前缀字符串身上

还是上面那个例子:我们想在x的c那个地方,整一个fail指针,指向y的c,
也就是代表:x以c字符结尾的后缀串abc,c字符的fail指针指向:在y中以最长的以c字符结尾的前缀串
下图直观感受一下,x的c字符为结尾的后缀串,又做y的c字符为结尾的前缀串,最长的那个字符串自然是abc。
在这里插入图片描述
前缀树中放matches字符串们
像这样,字符c的fail指针是这么指的:还是指向了c,为毛呢?因为y是以abc做前缀的,所以下次你对比y,直接来c这里继续对比,看看dt能不能匹配上就行了。
在这里插入图片描述
OK,下面咱们就来看正常情况下,前缀树的fail指针的连接规则:

fail指针的指向规则,就一条,看看cur的fail指向的节点,是否有与cur的子相同字符方向的路

增设fail指针:
(1)人为谁定:root的fail=null
(2)人为谁定:root的第一层节点的fail=root
(3)其余的节点,每一个节点cur的fail指针,由cur的父节点来指定。
其实就(3)一条规则,具体怎么连呢?

(3)来到节点cur,cur的子的字符为c,看cur.fail指向x节点,x的子是字符c吗?
(4)是c?则cur子的fail指向cur.fail的子
(5)否?看看x节点的fail指向的节点y,有c方向的字符吗?去(4)和(5)继续循环
(6)直到x节点的fail指向null,说明一直没找到c字符,则cur的子的fail指向head

看图:解释这(3)–(6)的规则
(3)来到节点cur,cur的子的字符为c,cur的子的fail指针指向谁呢?
看cur.fail指向的x节点,x的子有去字符c方向的路吗?
(4)是c,看黑色那个c,有c方向的路,则cur子的fail指向cur.fail的子,看绿色那个fail指针!
(5)假如把黑色那个c划掉,就是没有去c的路?那cur的子的fail指针指向谁呢?
在这里插入图片描述
看看x节点的fail指向的节点y,y有c方向的字符吗
有,那很OK,cur子的fail指向y的子

如果把黑色c删除呢?就是没有去c的路?那cur的子的fail指针指向谁呢?
看看y节点的fail指向的节点z,z有c方向的字符吗
有,那很OK,cur子的fail指向z的子

如果把黑色c删除呢?就是没有去c的路?那cur的子的fail指针指向谁呢?
z节点的fail指向的节点的请,z的fail竟然指向了null,即进入条件(6)
(6)直到z节点的fail指向null,说明一直没找到c字符,则cur的子的fail指向head

就是这么一条规则!
沿着cur的fail指针搜索一圈,只要能和cur的子的字符方向契合,就让cur的子的fail指向这个节点x的子。
一圈找下来如果没找到,发现最后一个fail指向了null,那不好意思,请你cur的子的fail去指向head吧!

好,AC自动机的类,咱们就可以定义了,它里面有一颗前缀树,可以构建fail指针

//AC自动机数据结构:
    public static class ACAutoMachine{
        //成员变量就一个前缀树
        public Node root;

        public ACAutoMachine(){
            root = new Node();//新建一个前缀树
        }

正常情况下,来了个字符串,填fail之前,先往里面正常构建前缀树:

        //成员方法,往前缀树中插入字符串s,构建前缀树
        public void insert(String s){
            //传统前缀树怎么建,你就怎么建,不过就是end和不同罢了,pass不要,fail先不管,usedEnd不管
            char[] str = s.toCharArray();
            Node cur = root;//从root开始查,有就复用,没有新建
            int path = 0;
            for (int i = 0; i < str.length; i++) {
                path = str[i] - 'a';
                if (cur.nexts[path] == null){
                    Node node = new Node();
                    cur.nexts[path] = node;//path方向加字符
                }
                cur = cur.nexts[path];//沿途往下加
            }
            //加完end变化
            cur.end = s;
        }
        //外部可以循环加入前缀树

再理解一遍fail指针的含义:它指向谁?以cur的子那个字符c为结尾做后缀串str,fail指针指向其他以str为最长的前缀串的字符串

有点绕
以cur的子那个字符c为结尾做后缀串str,
fail指针指向:
其他matches中以str为最长的前缀串的字符串

咱们再画一个图理解这句话:
有这么几个字符串,默认root的fail指向null
第一层所有节点的fail指向root
在这里插入图片描述
然后咱们按照上面的规则填写剩下节点的fail,用BFS遍历前缀树的每一个节点cur,填写cur的子的fail指针
规则就是上面的规则:

沿着cur的fail指针搜索一圈,只要能和cur的子的字符方向契合,就让cur的子的fail指向这个节点x的子。
一圈找下来如果没找到,发现最后一个fail指向了null,那不好意思,请你cur的子的fail去指向head吧!

第一层给第二层的节点填,从左往右BFS遍历看每个节点
看b的fail,因为父a的fail指向root,root确实有b方向的路,所以b.fail指向a.fail的子b
看c的fail,因为父b的fail指向root,root确实有c方向的路,所以c.fail指向b.fail的子c
看d的fail,因为父c的fail指向root,root确实有d方向的路,所以d.fail指向c.fail的子d
看e的fail,因为父d的fail指向root,root确实有e方向的路,所以e.fail指向d.fail的子e
在这里插入图片描述
所以这些fail指向的都是谁呢?不就是以当前字符c结尾的后缀串str,str又是别的字符串中最长的前缀串,fail指向了这个字符串的c字符
看图中第2个fail,对于bc来说,c字符结尾的后缀串可能有:str=c,bc
别的字符串以str为最长的前缀串,那个别的字符串是谁?可不就是cde吗?
因为别的字符串,没有以bc开头的啥字符串,别的字符串,就只有一个c开头的,c=str做前缀(也是最长的前缀了),就cde
这里你要搞清楚,是找别的,str能在其中做一个最长的前缀串的那个字符串哦!

懂不懂?

还没动,看下面,咱们把所有节点的fail填完
在这里插入图片描述
所以这些fail指向的都是谁呢?不就是以当前字符c结尾的后缀串str,str又是别的字符串中最长的前缀串,fail指向了这个字符串的c字符
看图中蓝色的那个个fail,
对于abccde来说,e字符结尾的后缀串可能有:str=e,de,cde,bcde,abced
别的字符串以str为最长的前缀串,那个别的字符串是谁?可不就是bcde吗?
虽然别的字符串有这些:e,de,cde,bcde
但是以str为最长前缀串的,bcde是最长的,所以这个别的字符串就是bcde
因此fail就指向了bcde

理解了吗?
当前串abcde的e的fail指向哪里?
str是当前e字符结尾的后缀串,找其他的字符串中,以str为最长前缀的那个字符串,fail指向这个字符串
具体指向str的末尾就行。
总之就是指向最长前缀为str的这个字符串就行!
在这里插入图片描述
下面图也是这样:这个其他的字符串,有cde,de,e,bcdetfk
但是当前abcde以e结尾的后缀串str最长能做别的字符串前缀的
必定是str=bcde最长
因此别的那个字符串应该是bcdetfk,
因此当前字符串abcde的e字符的fail,应该指向这个bcdetfk,具体就是指向str的结尾字符e呗!
在这里插入图片描述

这下你应该能理解了吧,道理很透彻的。

好,我们来手撕构建fail指针的代码:

        //有了前缀树,调用build,增加好fail指针,BFS遍历root
        public void buildFail(){
            //认为在前缀树中加root和第一层,其实不用额外搞了,因为正常搞,自然第一次必须指向root的
            //那直接把BFS的框架打出来,再改
            Queue<Node> queue = new LinkedList<>();
            queue.add(root);

            Node cur = null;
            Node curFail = null;//cur.fail
            while (!queue.isEmpty()){
                cur = queue.poll();//弹出开始操作cur,然后判断cur的子,不为null,放入queue
                for (int path = 0; path < 26; path++) {//26个字符a--z方向的子
                    if (cur.nexts[path] != null){
                        //看cur的fail,决定cur的子的fail指向
                        cur.nexts[path].fail = root;//如果没法加,最终它是要指向root的
                        curFail = cur.fail;
                        //看curFail有path方向的子吗???
                        while (curFail != null){
                            //一圈走下去,看看有没有path方向的路
                            if (curFail.nexts[path] != null) {
                                cur.nexts[path].fail = curFail.nexts[path];
                                break;//找到了就连接,否则持续找
                            }
                            //找不到,curFail继续跳
                            curFail = curFail.fail;//知道它指向null停止
                        }

                        //cur.nexts[path] != null,BFS将其加入queue
                        queue.add(cur.nexts[path]);
                    }
                }
            }
        }

整个代码,干的就是填fail的事情。BFS为宏观调度,内部来到cur,就给它子填fail,看cur的fail指向的path的情况决定
不行就只能填root了。


fail指针的用处:加速收集答案,当前字符串path没法跟paper继续匹配了,跳到fail指针那接着匹配,舍弃当前后缀str在别的字符串那做前缀的那段搜索

举个例子:
paper=abcdtks
matches=
abcde
bcdf
cdtks
请问哪些matches字符串能与paper匹配上?

(1)建立matches的前缀树root;
(2)用BFS把root的所有节点的fail指针填写好
(3)从paper的i=0位置开始匹配前缀树,准备收集答案
(4)如果发现paper的i位置字符,无法跟root前缀树中的cur字符匹配了,说明前缀树root中目前这条path字符串【从matches中建出来的其中一个字符串】不可能在paper中。下一次一定是对比matches的其他字符串了
(5)直接跳到cur的fail指针指向的字符x,继续与paper后续i+1位置匹配,看看是否与x相同?
所以fail帮助我们干了啥?最开始文章也说了,目的就是跳过当前path后缀串str,str又是其他字符的前缀串的这一段的重复匹配

文章开头咱们就瞅过例子的,我现在再说一遍例子
(1)建立matches的前缀树root;
(2)用BFS把root的所有节点的fail指针填写好
e的父一圈fail最后指向null,没有找到去向e的路,所以e的fail指向了root
其他的指向root值得fail也是同理的
在这里插入图片描述
(3)从paper的i=0位置开始匹配前缀树,准备收集答案
发现paper的0123四个位置的abcd字符,可以在前缀树root中一路匹配下去
当i=3时,root中cur字符为d,paper的i+1位置字符为t,但是root中cur下一个字符是e,匹配不了了
宣告:root总path=abcde这个字符串,不可能在paper中出现!
在这里插入图片描述
(4)下一次一定是对比matches的其他字符串了
(5)直接跳到cur的fail指针指向的字符d,继续与paper后续i+1位置匹配,看看是否与paper的t字符相同?
这其实就是避免了再拿bcdf又去跟paper从头对比 的重复过程,达到加速的目的!
发现cur的子f并不等于paper的t字符,没法匹配
宣告:root总path=bcdf这个字符串,不可能在paper中出现!
在这里插入图片描述
(4)下一次一定是对比matches的其他字符串了
(5)直接跳到cur的fail指针指向的字符d,继续与paper后续i+1位置匹配,看看是否与paper的t字符相同?
这其实就是避免了再拿cdftks又去跟paper从头对比的重复过程,达到加速的目的!
在这里插入图片描述
发现cur的下一个字符t确实与paper的t匹配上了,继续让i++,cur去子t那继续对比
发现paper的ks和root的ks也都匹配上了
注意前缀树中,s字符节点的end=1,代表matches中有一个bcdtks字符串,因此它在paper中,作为答案,收集起来!!!

但是你注意,当初我们在第二个宣告失败的字符串那,我们并没有从头再去匹配bcdtks
而是借助bcdf的d的fail指针,跳过了可能以cd为前缀的其他字符串的这个前缀的重复对比!

这就是AC自动机的fail指针的加速原理!!
牛还是不牛?

牛逼吧!


怎么收集答案:遍历前缀树每个节点cur,每次沿着cur的fail指针们走一圈,哪个字符串是一个结尾,就要了它

还需要在前缀树中添加end字符串,endUse标记

上面的案例中,
收集答案时:s字符节点的end=1,代表matches中有一个bcdtks字符串,因此它在paper中,作为答案,收集起来!!!

正常情况下,end=1代表s就是一个结尾
但是对于本题来说,咱们要整个前缀树这条路径上的字符串,为了避免从root节点从新遍历path组合一次为bcdtks
咱们当初构建前缀树的时候,让end就直接记住bcdtks这个字符串,方便现在收集答案

另外,如果这个答案收集过了,用endUse来标记一下,以后这个答案不需再来收一遍了

故AC自动机节点是这样的:

    //AC自动机的节点
    public static class Node{
        public Node fail;//fail指针
        public Node[] nexts;//所有的0--25代表a--z的方向
        public String end;//普通的end是1,代表来了1个字符串,end结尾
        public boolean usedEnd;//收集过end这个答案了
        
        public Node(){
            fail = null;//默认是null
            nexts = new Node[26];//a--z全null
            end = null;//默认是不为结尾
            usedEnd = false;//没收集过答案
        }
    }

怎么收集答案:伴随fail匹配的过程中,来到前缀树root的每个节点cur,沿着cur的fail指针们走一圈,哪个字符串是一个结尾end,就要了它

(1)随着paper的i位置匹配前缀树的节点cur,图中cur能走下面就走下面,不能走下面就去cur.fail了。
(2)沿着cur的fail指针们走一圈,哪个字符串是一个结尾end,就要了它,如果图中遇到endUse=true,说明这条路,咱们收集过答案了,break就行
举个例子:
paper=abck
matches=
abck
bct
st
tf
在这里插入图片描述
从paper的i=0位置开始匹配,paper的字符是a,cur=root,cur之子a,能匹配,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
从paper的i=1位置开始匹配,paper的字符是b,cur=a,cur之子b,能匹配,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
从paper的i=2位置开始匹配,paper的字符是c,cur=b,cur之子c,能匹配,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
从paper的i=3位置开始匹配,paper的字符是t,cur=c,cur之子k,无法匹配,cur要去cur.fail,看下图cur去了另一个c,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
在这里插入图片描述
从paper的i=3位置开始匹配,paper的字符是t,cur=c,cur之子f,无法匹配,cur要去cur.fail,看下图cur去了root,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
在这里插入图片描述
从paper的i=3位置开始匹配,paper的字符是t,cur=root,cur之子t,能匹配,则沿着cur的fail走一圈,发现没有一个end是有用的,不管
从paper的i=4位置开始匹配,paper的字符是f,cur=t,cur之子f,能匹配,则沿着cur的fail走一圈,巧了,f字符就是一个结尾,所以当前前缀树中tf就是一个答案。
此时i=5越界,答案搜索完成!

fail就是这么用的,答案就是这么收集的。

好,我们手撕收集答案的代码:

        //外围调度,收集答案,一次性搞定
        public List<String> containWordsGetAnswer(String paper){
            //请问matches有哪些字符串,在paper中呢????
            List<String> ans  = new ArrayList<>();
            //反正挨个从paper的位置匹配字符串,来到root中每一个cur,沿着cur一圈走
            //发现cur走不动,去cur的fail指向的字符

            Node cur = root;
            Node follow = null;//配合cur的fail走一圈,收集答案用的
            char[] str = paper.toCharArray();
            int path = 0;
            for (int i = 0; i < str.length; i++) {
                path = str[i] - 'a';
                //进来直接看cur有path方向的路吗?没有,而且cur还有fail就去fail
                //注意,如果cur本身就调到了root,说明要重新去找别的字符串,就别继续跳到fail=null的地方哦
                while (cur.nexts[path] == null && cur != root) cur = cur.fail;//这就是加速点
                //如果有path方向的路,就去,否则cur只能去root重新匹配别的字符串了
                //经过上面while判断,看看cur能去path吗
                cur = cur.nexts[path] != null ? cur.nexts[path] : root;//确实有就去,没有只能会root

                //每次来到一个root中的cur,沿cur的fail走一圈,没收集过就收集
                follow = cur;
                while (follow != root){
                    //圈尾部就是root
                    if (follow.usedEnd) break;//这些节点搞过答案就算了
                    //否则只要是end真的记录结果,就一定出现在paper中,不然fail不会引导我们匹配过来的
                    if (follow.end != null){
                        ans.add(follow.end);
                        follow.usedEnd = true;//标记收过答案了哦!
                    }
                    follow = follow.fail;//沿途走一圈
                }
            }

            return ans;
        }

外部如何调用AC自动机?

综合AC自动机类:

    //AC自动机数据结构:
    public static class ACAutoMachine{
        //成员变量就一个前缀树
        public Node root;

        public ACAutoMachine(){
            root = new Node();//新建一个前缀树
        }

        //成员方法,往前缀树中插入字符串s,构建前缀树
        public void insert(String s){
            //传统前缀树怎么建,你就怎么建,不过就是end和不同罢了,pass不要,fail先不管,usedEnd不管
            char[] str = s.toCharArray();
            Node cur = root;//从root开始查,有就复用,没有新建
            int path = 0;
            for (int i = 0; i < str.length; i++) {
                path = str[i] - 'a';
                if (cur.nexts[path] == null){
                    Node node = new Node();
                    cur.nexts[path] = node;//path方向加字符
                }
                cur = cur.nexts[path];//沿途往下加
            }
            //加完end变化
            cur.end = s;
        }
        //外部可以循环加入前缀树

        //有了前缀树,调用build,增加好fail指针,BFS遍历root
        public void buildFail(){
            //认为在前缀树中加root和第一层,其实不用额外搞了,因为正常搞,自然第一次必须指向root的
            //那直接把BFS的框架打出来,再改
            Queue<Node> queue = new LinkedList<>();
            queue.add(root);

            Node cur = null;
            Node curFail = null;//cur.fail
            while (!queue.isEmpty()){
                cur = queue.poll();//弹出开始操作cur,然后判断cur的子,不为null,放入queue
                for (int path = 0; path < 26; path++) {//26个字符a--z方向的子
                    if (cur.nexts[path] != null){
                        //看cur的fail,决定cur的子的fail指向
                        cur.nexts[path].fail = root;//如果没法加,最终它是要指向root的
                        curFail = cur.fail;
                        //看curFail有path方向的子吗???
                        while (curFail != null){
                            //一圈走下去,看看有没有path方向的路
                            if (curFail.nexts[path] != null) {
                                cur.nexts[path].fail = curFail.nexts[path];
                                break;//找到了就连接,否则持续找
                            }
                            //找不到,curFail继续跳
                            curFail = curFail.fail;//知道它指向null停止
                        }

                        //cur.nexts[path] != null,BFS将其加入queue
                        queue.add(cur.nexts[path]);
                    }
                }
            }
        }

        //外围调度,收集答案,一次性搞定
        public List<String> containWordsGetAnswer(String paper){
            //请问matches有哪些字符串,在paper中呢????
            List<String> ans  = new ArrayList<>();
            //反正挨个从paper的位置匹配字符串,来到root中每一个cur,沿着cur一圈走
            //发现cur走不动,去cur的fail指向的字符

            Node cur = root;
            Node follow = null;//配合cur的fail走一圈,收集答案用的
            char[] str = paper.toCharArray();
            int path = 0;
            for (int i = 0; i < str.length; i++) {
                path = str[i] - 'a';
                //进来直接看cur有path方向的路吗?没有,而且cur还有fail就去fail
                //注意,如果cur本身就调到了root,说明要重新去找别的字符串,就别继续跳到fail=null的地方哦
                while (cur.nexts[path] == null && cur != root) cur = cur.fail;//这就是加速点
                //如果有path方向的路,就去,否则cur只能去root重新匹配别的字符串了
                //经过上面while判断,看看cur能去path吗
                cur = cur.nexts[path] != null ? cur.nexts[path] : root;//确实有就去,没有只能会root

                //每次来到一个root中的cur,沿cur的fail走一圈,没收集过就收集
                follow = cur;
                while (follow != root){
                    //圈尾部就是root
                    if (follow.usedEnd) break;//这些节点搞过答案就算了
                    //否则只要是end真的记录结果,就一定出现在paper中,不然fail不会引导我们匹配过来的
                    if (follow.end != null){
                        ans.add(follow.end);
                        follow.usedEnd = true;//标记收过答案了哦!
                    }
                    follow = follow.fail;//沿途走一圈
                }
            }

            return ans;
        }
    }

(1)拿到paper和matches数组
(2)matches全部加入自动机,然后构建前缀树
(3)然后自动机自动构建fail指针
(4)然后直接查有哪些matches字符串在paper中呢?返回结果即可

    //外部如何用?
    public static List<String> acMatchString(String paper, String[] matches){
        if (paper.compareTo("") == 0 || paper.length() == 0 || matches == null ||
        matches.length == 0) return null;

        ACAutoMachine acAutoMachine = new ACAutoMachine();
        //matches全部加入自动机,然后构建前缀树
        for (int i = 0; i < matches.length; i++) {
            acAutoMachine.insert(matches[i]);
        }
        //然后自动机自动构建fail指针
        acAutoMachine.buildFail();

        //然后直接查有哪些matches字符串在paper中呢?
        List<String> ans = acAutoMachine.containWordsGetAnswer(paper);

        return ans;
    }

测试一把:

    public static void test(){
        String paper = "abctks";
        String[] matches = {
                "abce",
                "bcd",
                "ctks"
        };

        List<String> ans = acMatchString(paper, matches);
        for(String s:ans) System.out.print(s +" ");
    }

    public static void main(String[] args) {
        test();
    }

显然,结果中就一个ctks 在paper中,牛逼!

ctks 

AC自动机看起来复杂,实际上本质和KMP一样,借助额外空间加速换时间
AC自动机就是利用前缀树的fail指针,绕过已经匹配过的后缀前str,这个str在matches中其他的字符串里面做最长的前缀,这个最长的前缀str不要再跟paper重复对比了,这就是加速的本质!!!


总结

提示:重要经验:

1)AC自动机就是利用前缀树的fail指针,绕过已经匹配过的后缀前str,这个str在matches中其他的字符串里面做最长的前缀,这个最长的前缀str不要再跟paper重复对比了,这就是加速的本质!!!
2)你必须熟悉前缀树!构建前缀树的步骤,pass,end的含义,本题中fail,end,usedEnd的含义,就能做文章匹配了。
3)笔试求AC,可以不考虑空间复杂度,但是面试既要考虑时间复杂度最优,也要考虑空间复杂度最优。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

冰露可乐

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值