AC自动机算法详解以及Java代码实现

Posted 刘Java

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了AC自动机算法详解以及Java代码实现相关的知识,希望对你有一定的参考价值。

详细介绍了AC自动机算法详解以及Java代码实现。

文章目录

1 概念和原理

AC自动机(Aho-Corasick automaton)算法于1975年产生于贝尔实验室,是一个多模式字符串匹配算法,在多模式匹配领域被广泛应用,例如违禁词查找替换、搜索关键词查找等等。

关于Trie树和KMP算法,我们此前已经讲解过了:

  1. 前缀树Trie的实现原理以及Java代码的实现
  2. KMP算法详解以及Java代码实现

AC自动机算法常被认为是Trie树+KMP算法的结合体,为什么呢?我们先看看它的构建步骤:

  1. 对所有的关键词构建Trie前缀树。
  2. 为Trie树上的所有节点构建fail失配指针。

第一步,对所有的关键词构建Trie前缀树。这一步利用Trie的特点构建快速前缀查找结构,trie树的特点是可以从字符串头部开始匹配,并且相同前缀的词共用前面的节点,因此它可以避免相同前缀pattern的重复匹配,但是对于相同的后缀无能为力。

第二步,为Trie树上的所有节点构建fail失配指针节点,某个节点的失配指针节点即表示当前节点匹配失败后应该跳往的继续匹配的节点。fail失配指针是AC自动机能够匹配多个关键词的关键。

所谓节点的失配指针节点,就是当前节点表示的路径字符串中的最长真后缀位置的指针节点。这里需要理解KMP的next数组以及最长匹配长度的前缀和后缀概念,这一步就是利用KMP前后缀匹配的思想,实现利用相同的后缀信息快速跳转到另一个关键词继续前缀匹配,不会出现关键词遗漏的现象。

  1. 如果当前节点没有匹配到,则跳转到此节点继续匹配。
  2. 如果当前节点匹配到了,那么可以通过此指针找到该节点的模式串路径中包含的最长后缀模式串。

这里的失配指针所谓最长匹配长度的前缀和后缀的和KMP的next数组中的概念区别是:

  1. 在KMP算法中,是针对单个关键词匹配,求出的最长匹配长度的前缀和后缀都位于同一个关键词内。例如关键词abcdabc,最长匹配前后缀为abc,他们都属于该关键词。
  2. 在AC自动机算法中,是针对多个关键词匹配,对于某个关键词路径求出的“最长匹配长度的前缀”是该关键路径的后缀串,它对应的“最长匹配长度的后缀”是另一个关键路径的前缀串。
  3. 另外,一个节点的失配指针节点,只能是它的某个上层节点,不可是它的下层节点。因为某个节点匹配失配之后只能够向上跳转,无法向下跳转(因为向下跳转就说明失配指针节点路径长度超过当前匹配的路径长度了)

例如3个关键词“普工电焊工”,“电焊工人”,“焊工”。

  1. 第一个关键路径“普工电焊工”中第2个“工”的节点的失配指针应该指向第二个关键路径 “电焊工人”中的“工”节点。因为关键路径字符串“普工电焊工”的后缀子串,与 另一个关键路径“电焊工人”的前缀子串“电焊工”相匹配,并且能够得到最长匹配路径。
    1. 注意,“普工电焊工”虽然有后缀“焊工”能与第三个关键路径“焊工”进行前缀匹配,但由于它不是最长匹配路径,因此不能指向“焊工”这一条路径中的“工”节点。
  2. 而关键路径“电焊工人”中的“工”节点的失配指针才应该指向第三个关键路径“焊工”中的“工”节点。如下:


假设基于三个关键词要匹配“普工电焊工”文本,那么当我们匹配到最后一个字符“工”节点的时候,首先匹配到了一个关键词“普工电焊工”,然后我们可以直接根据“工”节点的失配指针,找到“电焊工人”的“工”节点,然后又根据这个“工”节点的失配指针,找到“焊工”这个关键词。这样所有的关键词都找到了,不会出现遗漏“焊工”关键词的情况。

我们需要为所有节点构建失配指针节点,具体的构建策略下面会讲到。

2 节点定义

在这里,我们给出一个比较简单的节点的定义。

  1. next,表示经过该节点的模式串的下层节点,这是Trie树结构的保证,存储着子节点的值到对应的节点的映射关系。
  2. depth,表示以当前节点结尾的模式串的长度,也是节点的深度,默认为0。
  3. failure,失配指针节点,其指向表示另一个关键词前缀的最长后缀节点。用于实现利用相同的后缀信息快速跳转到另一个关键词继续前缀匹配,不会出现关键词遗漏的现象。
    1. 如果当前节点没有匹配到,则跳转到此节点继续匹配。
    2. 如果当前节点匹配到了,那么可以通过此指针找到该节点的模式串包含的最长后缀模式串继续匹配。
class AcNode 
    /**
     * 经过该节点的模式串的下层节点
     */
    Map<Character, AcNode> next = new HashMap<>();

    /**
     * 模式串的长度,也是节点的深度
     */
    int depth;

    /**
     * 失配指针,如果没有匹配到,则跳转到此状态。
     */
    AcNode failure;

    public boolean hashNext(char nextKey) 
        return next.containsKey(nextKey);
    

    public AcNode getNext(char nextKey) 
        return next.get(nextKey);
    

3 构建Trie前缀树

构建AC自动机的Trie的方法和构建普通Trie的方法几乎一致。

在添加每个模式串成功后,会为最后一个节点的depth赋值为当前模式串的长度,也就是说depth值不为0,则表示当前节点是一个关键词的结尾。

/**
 * trie根节点
 */
private AcNode root;
/**
 * 加入模式串,构建Trie
 *
 * @param word 模式串,非空
 */
public void insert(String word) 
    AcNode cur = root;
    for (char c : word.toCharArray()) 
        if (!cur.next.containsKey(c)) 
            cur.next.put(c, new AcNode());
        
        cur = cur.next.get(c);
    
    cur.depth = word.length();

3.1 案例演示

假设我们有如下关键词:电焊、电焊工、电焊工人、电焊学员、电焊学徒、电焊学徒工、普工电焊工、普工电商、普工

那么我们构建的前缀树结构如下,红色圈表示该节点是某个关键词的结束位置:

4 构建fail失配指针

构建fail失配指针的一种常见的方法如下,实际上是一个BFS层序遍历的算法

  1. Trie的root节点没有失配指针,或者说失配指针为null,其他节点都有失配指针,或者说不为null。
  2. 遍历root节点的所有下一层直接子节点,将它们的失配指针设置为root。因为这些节点代表着所有模式串的第一个字符,基于KMP的next数组定义,单个字符没有最长真后缀,此时直接指向root
  3. 继续循环向下遍历每一层的子节点,由于bfs的遍历,那么上一层父节点的失配指针肯定都已经确定了。基于next数组的构建思想,子节点的失配指针可以通过父节点的是失配指针快速推导出来。设当前遍历的节点为c,它的父节点为p,父节点的失配指针为pf。
    1. 如果pf节点的子节点对应的字符中,包含了当前节点的所表示的字符。那么基于求最长后缀的原理,此时c节点的失配指针可以直接指向pf节点下的相同字符对应的子节点。
    2. 如果pf节点的子节点对应的字符中,没有包含了当前节点的所表示的字符。那么继续获取pf节点的失配指针节点,继续重复判断。直到满足第一种情况,或者pf指向了根节点,并且根节点的子节点也没有匹配,那么此时直接将c节点的失配指针指向根节点。
/**
 * 为所有节点构建失配指针,一个bfs层序遍历
 */
public void buildFailurePointer() 
    ArrayDeque<AcNode> queue = new ArrayDeque<AcNode>();
    //将所有root的直接子节点的failure设置为root,并且加入queue
    for (AcNode acNode : root.next.values()) 
        acNode.failure = root;
        queue.addLast(acNode);
    
    //bfs构建失配指针
    while (!queue.isEmpty()) 
        //父节点出队列
        AcNode parent = queue.pollFirst();
        //遍历父节点的下层子节点,基于父节点求子节点的失配指针
        for (Map.Entry<Character, AcNode> characterAcNodeEntry : parent.next.entrySet()) 
            //获取父节点的失配指针
            AcNode pf = parent.failure;
            //获取子节点
            AcNode child = characterAcNodeEntry.getValue();
            //获取子节点对应的字符
            Character nextKey = characterAcNodeEntry.getKey();
            //如果pf节点不为null,并且pf节点的子节点对应的字符中,没有包含了当前节点的所表示的字符
            while (pf != null && !pf.hashNext(nextKey)) 
                //继续获取pf节点的失配指针节点,继续重复判断
                pf = pf.failure;
            
            //pf为null,表示找到了根节点,并且根节点的子节点也没有匹配
            if (pf == null) 
                //此时直接将节点的失配指针指向根节点
                child.failure = root;
            
            //pf节点的子节点对应的字符中,包含了当前节点的所表示的字符
            else 
                //节点的失配指针可以直接指向pf节点下的相同字符对应的子节点
                child.failure = pf.getNext(nextKey);
            
            //最后不要忘了,将当前节点加入队列
            queue.addLast(child);
        
    

4.1 案例演示

首先,根据我们的构建BFS方法,从上层向下层依次构建。

首先是第1层root节点,root节点没有fail指针节点,它的failure属性为null。

然后是root节点的所有下一层直接子节点,即第2层节点,将它们的失配指针设置为root。因为这些节点代表着所有模式串的第一个字符,基于KMP的next数组定义,单个字符没有最长真后缀,此时直接指向root。

然后遍历下一层子节点,此时需要使用到规律。设当前遍历的节点为c,它的父节点为p,父节点的失配指针为pf,当前节点的失配指针为cf。

  1. 如果pf节点的子节点对应的字符中,包含了当前节点的所表示的字符。那么基于求最长后缀的原理,此时c节点的失配指针可以直接指向pf节点下的相同字符对应的子节点。
  2. 如果pf节点的子节点对应的字符中,没有包含了当前节点的所表示的字符。那么继续获取pf节点的失配指针节点,继续重复判断。直到满足第一种情况,或者pf指向了根节点,并且根节点的子节点也没有匹配,那么此时直接将c节点的失配指针指向根节点。

利用上面的规律,第3层节点的失配指针构建如下(从左到右):

首先找到第3层“焊”节点,它的父节点是“电”,“电”的pf为root,而root节点的子节点对应的字符中,不包含了当前节点的所表示的字符,所以此时“焊”节点的cf指向root节点。

同理,第3层“工”节点的cf指向root节点。

利用上面的规律,第4层节点的失配指针构建如下(从左到右):

首先找到“学”节点,它的父节点是“焊”,“焊”的pf为root,而root节点的子节点对应的字符中,不包含了当前节点的所表示的字符,所以此时“学”节点的cf指向root节点。

同理,“工”节点的cf指向root节点。

最后是“电”节点,,它的父节点是“工”,“工”的pf为root,而root节点的子节点对应的字符中,包含了当前节点的所表示的字符“电”,所以此时“电”节点的cf指向root节点的“电”子节点。

利用上面的规律,第5层节点的失配指针构建如下(从左到右):

首先找到“员”节点,它的父节点是“学”,“学”的pf为root,而root节点的子节点对应的字符中,不包含了当前节点的所表示的字符,所以此时“员”节点的cf指向root节点。

同理,“徒”节点的cf指向root节点,“人”节点的cf指向root节点。

随后是,“焊”节点,它的父节点是“电”,“电”的pf为第2层的“电”节点,而该pf节点的子节点对应的字符中,包含了当前节点的所表示的字符“焊”,所以此时“焊”节点的cf指向第3层的“焊”节点。

最后是“商”节点,它的父节点是“电”,“电”的pf为第2层的电节点,而该pf节点的子节点对应的字符中,不包含了当前节点的所表示的字符“商”,此时,继续找pf节点的pf节点,最后找到root节点,并且root节点的子节点不包含当前节点的字符,所以此时“商”节点的cf指向root节点。

利用上面的规律,最后一层第6层节点的失配指针构建如下(从左到右):

首先找到“工”节点,它的父节点是“徒”,“徒”的pf为root,而root节点的子节点对应的字符中,不包含了当前节点的所表示的字符,所以此时“工”节点的cf指向root节点。

随后是,第二个“工”节点,它的父节点是“焊”,“焊”的pf为第3层的“焊”节点,而该pf节点的子节点对应的字符中,包含了当前节点的所表示的字符“工”,所以此时“工”节点的cf指向第4层的“工”节点。

最终的数据结构如下。整个Trie树的失配指针构建完毕,AC自动机构建完毕。

5 匹配文本

构建完AC自动机之后,下面我们需要进行文本的匹配,匹配的方式实际上比较简单。

  1. 遍历文本的每个字符,依次匹配,从Trie的根节点作为cur节点开始匹配:
  2. 将当前字符作为nextKey,如果cur节点不为null且节点的next映射中不包含nextKey,那么当前cur节点指向自己的failure失配指针。
  3. 如果cur节点为null,说明当前字符匹配到了root根节点且失败,那么cur设置为root继续从根节点开始进行下一轮匹配。
  4. 否则表示匹配成功的节点,cur指向匹配节点,获取该节点继续判断:
    1. 如果该节点是某个关键词的结尾,那么取出来,也就是depth不为0,那么表示匹配到了一个关键词。
    2. 继续判断该节点的失配指针节点表示的模式串。因为失配指针节点表示的是当前匹配的模式串的在这些关键词中的最长后缀,且由于当前节点的路径包括了失配指针的全部路径,并且失配指针路径也是一个完整的关键词,需要找出来。
/**
 * 匹配文本
 *
 * @param text 文本字符串
 */
public List<ParseResult> parseText(String text) 
    List<ParseResult> parseResults = new ArrayList<>();
    char[] chars = text.toCharArray();
    //从根节点开始匹配
    AcNode cur = root;
    //遍历字符串的每个字符
    for (int i = 0; i < chars.length; i++) 
        //当前字符
        char nextKey = chars[i];
        //如果cur不为null,并且当前节点的的子节点不包括当前字符,即不匹配
        while (cur != null && !cur.hashNext(nextKey)) 
            //那么通过失配指针转移到下一个节点继续匹配
            cur = cur.failure;
        
        //如果节点为null,说明当前字符匹配到了根节点且失败
        //那么继续从根节点开始进行下一轮匹配
        if (cur == null) 
            cur = root;
         else 
            //匹配成功的节点
            cur = cur.getNext(nextKey);
            //继续判断
            AcNode temp = cur;
            while (temp != null) 
                //如果当前节点是某个关键词的结尾,那么取出来
                if (temp.depth != 0) 
                    int start = i - temp.depth + 1, end = i;
                    parseResults.add(new ParseResult(start, end, new String(chars, start, temp.depth)));
                    //System.out.println(start + " " + end + " " + new String(chars, start, temp.depth));
                
                //继续判断该节点的失配指针节点
                //因为失配指针节点表示的模式串是当前匹配的模式串的在这些关键词中的最长后缀,且由于当前节点的路径包括了失配指针的全部路径
                //并且失配指针路径也是一个完整的关键词,需要找出来。
                temp = temp.failure;
            
        
    
    return parseResults;


class ParseResult 
    int startIndex;
    int endIndex;
    String key;

    public ParseResult(int startIndex, int endIndex, String key) 
        this.startIndex = startIndex;
        this.endIndex = endIndex;
        this.key = key;
    

    @Override
    public String toString() 
        return "" +
                "startIndex=" + startIndex +
                ", endIndex=" + endIndex +
                ", key='" + key + '\\'' +
                '';
    

5.1 案例演示

基于我们上面构建的AC自动机。假如,此时文本为:“你好我想找一个普工电焊工相关的工作”,下面我们来看看AC自动机匹配的过程:

cur=root,遍历文本的每一个字符进行匹配:

当前字符nextKey=“你”,cur.next不包含“你”,且cur.failure=null,此时进入下一轮。cur=root。

当前字符nextKey=“好”,cur.next不包含“好”,且cur.failure=null,此时进入下一轮。cur=root。

后续的字符“我想找一个”,都是上面的判断逻辑,此时还没有找到任何关键词。cur=root。

当前字符nextKey=“普”,cur.next包含“普”节点,表示这是一个匹配成功的节点,那么cur指向该节点“普”,temp=cur,继续循环判断(temp!=null):

  1. temp不是某个关键词的结尾,temp=temp.failure=root。
  2. 最终结束本次查找,没找到任何关键词,cur=“普”。


当前字符nextKey=“工”,cur.next包含“工”节点,表示这是一个匹配成功的节点,那么cur指向该节点“工”,temp=cur,继续循环判断(temp!=null):
3. temp是某个关键词的结尾,此时找到了第1个匹配的关键词“普工”。temp=temp.failure=root。
4. 最终结束本次查找。cur=“工”。


当前字符nextKey=“电”,cur.next包含“电”节点,表示这是一个匹配成功的节点,那么cur指向该节点“电”,temp=cur,继续循环判断(temp!=null):

  1. temp不是某个关键词的结尾,temp=temp.failure,即temp指向第2层的电节点。
  2. temp不是某个关键词的结尾,temp=temp.failure,即temp=root。
  3. 最终结束本次查找,没找到任何关键词,cur=“电”。


当前字符nextKey=“焊”,cur.next包含“焊”节点,表示这是一个匹配成功的节点,那么cur指向该节点“焊”,temp=cur,继续循环判断(temp!=null):

  1. temp不是某个关键词的结尾,temp=temp.failure,即temp指向第3层的“焊”节点。
  2. temp是某个关键词的结尾,此时找到了第2个匹配的关键词“电焊”。这里就能看出失配指针的重要作用,它可以在不同的关键词之间跳转,避免了关键词匹配的遗漏。temp=temp.failure=root。
  3. 最终结束本次查找。cur=“焊”。


当前字符nextKey=“工”,cur.next包含“工”节点,表示这是一个匹配成功的节点,那么cur指向该节点“工”,temp=cur,继续循环判断(temp!=null):

  1. temp是某个关键词的结尾,此时找到了第3个匹配的关键词“普工电焊工”。temp=temp.failure,即temp指向第4层的“工”节点。
  2. temp是某个关键词的结尾,此时找到了第4个匹配的关键词“电焊工”。这里就能看出失配指针的重要作用,它可以在不同的关键词之间跳转,避免了关键词匹配的遗漏。temp=temp.failure=root。
  3. 最终结束本次查找。cur=“工”。


当前字符nextKey=“相”,cur.next不包含“相”节点,cur=cur.failure,即cur指向第4层“工”节点。

cur.next不包含“相”节点,cur=cur.failure=root。

cur.next不包含“相”节点,且cur.failure=null,最终进入下一轮。cur=root。

当前字符nextKey=“关”,cur.next不包含“关”节点,且cur.failure=null,最终进入下一轮。cur=root。

当前字符nextKey=“的”,cur.next不包含“的”节点,且cur.failure=null,最终进入下一轮。cur=root。

后续的字符“相关的工作”,都是上面的判断逻辑,此时没有找到任何关键词。到此字符串遍历完毕,查找完毕!

最终文本“你好我想找一个普工电焊工相关的工作”,匹配到关键词如下:

[startIndex=8, endIndex=9, key='普工', 
startIndex=10, endIndex=11, key='电焊', 
startIndex=8, endIndex=12, key='普工电焊工', 
startIndex=10, endIndex=12, key='电焊工']

6 完整实现

public class ACTrie3 
    /**
     * trie根节点
     */
    private AcNode root;


    public ACTrie3() 
        this.root = new AcNode();
    

    class AcNode 
        /**
         * 经过该节点的模式串的下层节点
         */
        Map<Character, AcNode> next = new HashMap<>();

        /**
         * 模式串的长度,也是节点的深度
         */
        int depth;

        /**
         * 失配指针,如果没有匹配到,则跳转到此状态。
         */
        AcNode failure;

        public boolean hashNext(char nextKey) 
            return next.containsKey(nextKey);
        

        public AcNode getNext(char nextKey) 
            return next.get(nextKey);
        
    

    /**
     * 加入模式串,构建Trie
     *
     * @param word 模式串,非空
     */
    public void insert(String word) 
        AcNode cur = root;
        for (char c : word.toCharArray()) 
            if (!cur.next.containsKey(c)) 
                cur.next.put(c, new AcNode());
            
            cur = cur.next.get(c);
        
        cur.depth = word.length();
    

    /**
     * 为所有节点构建失配指针,一个bfs层序遍历
     */
    public void buildFailurePointer() 
        ArrayDeque<AcNode> queue = new ArrayDeque<AcNode>();
        //将所有root的直接子节点的failure设置为root,并且加入queue
        for (AcNode acNode : root.next.values()) 
            acNode.failure = root;
            queue.addLast(acNode);
        
        //bfs构建失配指针
        while (!queue.isEmpty()) 
            //父节点出队列
            AcNode parent = queue.pollFirst();
            //遍历父节点的下层子节点,基于父节点求子节点的失配指针
            for (Map.Entry<Character, AcNode> characterAcNodeEntry : parent.next.entrySet()) 
                //获取父节点的失配指针
                AcNode pf = parent.failure;
                //获取子节点
                AcNode child = characterAcNodeEntry.getValue();
                //获取子节点对应的字符
                Character nextKey = characterAcNodeEntry.getKey();
                //如果pf节点不为null,并且pf节点的子节点对应的字符中,没有包含了当前节点的所表示的字符
                while (pf != null && !pf.hashNext(nextKey)) 
                    //继续获取pf节点的失配指针节点,继续重复判断
                    pf = pf.failure;
                
                //pf为null,表示找到了根节点,并且根节点的子节点也没有匹配
                if (pf == null) 
                    //此时直接将节点的失配指针指向根节点
                    child.failure = root;
                
                //pf节点的子节点对应的字符中,包含了当前节点的所表示的字符
                else 
                    //节点的失配指针可以直接指向pf节点下的相同字符对应的子节点
                    child.failure = pf.getNext(nextKey);
                
                //最后不要忘了,将当前节点加入队列
                queue.addLast(child);
            
        
    

    public void parseText1(String text) 
        char[] chars = text.toCharArray();
        AcNode p = root;
        //遍历字符串的每个字符
        for (int i = 0; i < chars.length; i++) 
            char c = chars[i];
            while (!p.hashNext(c) && p != root) 
                p = p.failure;
            
            p = p.getNext(c);
            if (p == null) 
                p = root;
             else 
                AcNode temp = p;
                while (temp != null) 
                    if (tempAC自动机

Aho-Corasick automaton

AC自动机

HDU2222 Keywords Search

P3796 模板AC自动机(加强版) 题解(Aho-Corasick Automation)

AC自动机