字符串匹配2——多模式匹配(Trie树 AC自动机)

前言

紧接着上一篇文章字符串匹配1,在上一篇文章里,我们主要总结归纳的是一个字符串和另一个字符串相比较。这篇文章,南国总结归纳的是两种常见的多模式匹配算法Trie树AC自动机
多模式匹配:一个主串和多个模式串中间的匹配问题。
当然,聪明的你一定会问难道之前所学的单模式匹配的算法就不能用;爱解决问题吗? 答案是当然可以,但是用单模式的字符串算法解决这类问题总体的时间开销就会大很多,对于这类问题 我们更多的是采用一下的方法来进行解决。
话不多说,开始我们这篇文章的正文~

Trie树

Trie树,即字典树,它又被称之为单词查找树或者键树。是一种树形结构,也是一种哈希树的变种。
它的树结构模型如下图所示:
《字符串匹配2——多模式匹配(Trie树 AC自动机)》

基本性质

  • 根节点不包含字符,除根节点外的每一个子节点都包含一个字符
  • 从根节点到某一节点。路径上经过的字符连接起来,就是该节点对应的字符串
  • 每个节点的所有子节点包含的字符都不相同

应用场景

典型应用是用于统计,排序和保存大量的字符串(不仅限于字符串),经常被搜索引擎系统用于文本词频统计。

代码实现

Trie树结构的实现代码同时也是leetcode上的208题
《字符串匹配2——多模式匹配(Trie树 AC自动机)》
代码实现总结起来就是:

  • 1.创造节点类用来表示Trie树中每一个节点的信息
  • 2.创建trie树,并实现简单的插入和查询功能

以下是全部代码:

/**
 * leetcode 208 Implement Trie (Prefix Tree)
 * @author xjh 2018.12.25
 */
class TrieNode {
    public char value;
    public boolean isWord;  //这个boolean表示单词结束
    public TrieNode[] children = new TrieNode[26];    //这里测试样例只需要输入a-z

    public TrieNode() {
    }

    TrieNode(char c) {
        TrieNode node = new TrieNode();
        node.value = c;
    }
}

class Trie {
    TrieNode root;

    /**
     * Initialize your data structure here.
     */
    public Trie() {
        root = new TrieNode();
        root.value = ' '; //Trie树的根节点不包含有效字符

    }

    /**
     * Inserts a word into the trie.
     */
    public void insert(String word) {
        TrieNode t = root;
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);  //word的第i个字符
            if (t.children[c - 'a'] == null)    //如果孩子节点中没有该字符 则需要插入进去
                t.children[c - 'a'] = new TrieNode(c);
            t = t.children[c - 'a'];    //树向下迭代
        }
        t.isWord = true;
    }

    /**
     * Returns if the word is in the trie.
     */
    public boolean search(String word) {
        TrieNode t = root;
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);  //word的第i个字符
            if (t.children[c - 'a'] == null)    //如果孩子节点中没有该字符 返回false;
                return false;
            t = t.children[c - 'a'];    //树向下迭代
        }
        return t.isWord;    //search函数和startWith函数最大的区别在于后买你返回的是t.isWord
    }

    /**
     * Returns if there is any word in the trie that starts with the given prefix.
     */
    public boolean startsWith(String prefix) {
        TrieNode tmp = root;
        for (int i = 0; i < prefix.length(); i++) {
            char c = prefix.charAt(i);  //word的第i个字符
            if (tmp.children[c - 'a'] == null)    //如果孩子节点中没有该字符 返回false;
                return false;
            tmp = tmp.children[c - 'a'];    //树向下迭代
        }
        return true;
    }
}

public class test208_ImplementTrie {
    public static void main(String[] args) {
        Trie obj = new Trie();
        obj.insert("word");
//        obj.insert("he");
        obj.insert("hello");
        System.out.println("search word:"+obj.search("word"));
        System.out.println("search he:"+obj.search("he"));
        System.out.println("startwith he:"+obj.startsWith("he"));
    }
}

运行结果:

search word:true
search he:false
startwith he:true

优点

利用字符串的公共前缀来减少查询时间,最大限度的减少无谓的字符串比较,查询效率比哈希树高
实际上,字符串的匹配问题,笼统上讲其实就是数据的查找的=问题。对于支持动态数据高效操作的数据结构,还有散列表,红黑树,跳表等等。在实际应用中,针对一组模式串在主串中的查找问题,我们耿介倾向于使用散列表或者红黑树来进行解决,因为这两种数据结构成熟的编程语言都已经实现为成熟的类库,我们直接拿来运用就行了。
Trie树并不适合精确匹配查找,他更加倾向于前缀匹配的字符串。

性能分析

构建Trie树的过程,需要扫描所有的字符串,时间复杂度是O(n)(n表示所有字符串的长度和)。在构建成功之后,后续的查询操作会非常高效。每次查询时,如果查询的字符串长度是k,那我们只需要比对k个节点后,就嫩那个完成查询操作。和原来那组字符串的长度和个数没有关系。所以在构建好Trie树之后,在其中查找字符串的时间复杂度是O(k),k表示要查找的字符串的长度。
所以我们可以说Trie树是一种典型的空间换时间的思路

AC自动机

AC自动机:Aho-Corasick automation,是著名的多模匹配算法之一。一个常见的例子就是给出n个单词,再给出一段包含m个字符的文章,让你找出有多少个单词在文章里出现过。要搞懂AC自动机,先得有模式树(字典树)Trie和KMP模式匹配算法的基础知识,前面的文章南国已经对这两种算法进行了总结归纳,读者可以查看之前的知识点。
AC自动机实际上就是在Trie树之上进行优化,加了类似KMP算法的next数组,只不过此处的next数组是构建在树结构上。

AC自动机的节点构造

AC自动机实现时树结构中每个节点的类型表示如下:

class AcNode{
    public char data;
    public AcNode[] children=new AcNode[26];    //这里只考虑原始字符集中只包括a-z26个小写字母
    public boolean isEndingChar=false;  //结尾字符位true
    public int length=-1;   //当isEndingChar位true时,记录模式串的长度
    public AcNode fail; //失败指针 功能类似于kmp算法的next数组
    AcNode(){}
    public AcNode(char data){
        this.data=data;
    }
}

Fail指针

在KMP算法中,当我们比较到一个字符发现失配的时候我们会通过next数组,找到下一个开始匹配的位置,然后进行字符串匹配,当然KMP算法试用与单模式匹配,所谓单模式匹配,就是给出一个模式串,给出一个文本串,然后看模式串在文本串中是否存在。
在AC自动机中,我们也有类似next数组的东西就是fail指针,当发现失配的字符失配的时候,跳转到fail指针指向的位置,然后再次进行匹配操作,AC自动机之所以能实现多模式匹配,就归功于Fail指针的建立。
当前节点t有fail指针,其fail指针所指向的节点和t所代表的字符是相同的。因为t匹配成功后,我们需要去匹配t->child,发现失配,那么就从t->fail这个节点开始再次去进行匹配。

Fail指针的求法
Fail指针用BFS来求得,对于直接与根节点相连的节点来说,如果这些节点失配,他们的Fail指针直接指向root即可,其他节点其Fail指针求法如下:
假设当前节点为father,其孩子节点记为child。求child的Fail指针时,首先我们要找到其father的Fail指针所指向的节点,假如是t的话,我们就要看t的孩子中有没有和child节点所表示的字母相同的节点,如果有的话,这个节点就是child的fail指针,如果发现没有,则需要找father->fail->fail这个节点,然后重复上面过程,如果一直找都找不到,则child的Fail指针就要指向root。
《字符串匹配2——多模式匹配(Trie树 AC自动机)》如图所示,首先root最初会进队,然后root,出队,我们把root的孩子的失败指针都指向root。因此图中h,s的失败指针都指向root,如红色线条所示,同时h,s进队。

接下来该h出队,我们就找h的孩子的fail指针,首先我们发现h这个节点其fail指针指向root,而root又没有字符为e的孩子,则e的fail指针是空的,如果为空,则也要指向root,如图中蓝色线所示。并且e进队,此时s要出队,我们再找s的孩子a,h的fail指针,
我们发现s的fail指针指向root,而root没有字符为a的孩子,故a的fail指针指向root,a入队,然后找h的fail指针,同样的先看s的fail指针是root,发现root又字符为h的孩子,所以h的fail指针就指向了第二层的h节点。e,a , h 的fail指针的指向如图蓝色线所示。

此时队列中有e,a,h,e先出队,找e的孩子r的失败指针,我们先看e的失败指针,发现找到了root,root没有字符为r的孩子,则r的失败指针指向了root,并且r进队,然后a出队,我们也是先看a的失败指针,发现是root,则y的fail指针就会指向root.并且y进队。然后h出队,考虑h的孩子e,则我们看h的失败指针,指向第二层的h节点,看这个节点发现有字符值为e的节点,最后一行的节点e的失败指针就指向第三层的e。最后找r的指针,同样看第二层的h节点,其孩子节点不含有字符r,则会继续往前找h的失败指针找到了根,根下面的孩子节点也不存在有字符r,则最后r就指向根节点,最后一行节点的fail指针如绿色虚线所示。

文本串的匹配

匹配过程分两种情况:
(1)当前字符匹配,表示从当前节点沿着树边有一条路径可以到达目标字符,如果当前匹配的字符是一个单词的结尾,我们可以沿着当前字符的fail指针,一直遍历到根,如果这些节点末尾有标记(此处标记代表,节点是一个单词末尾的标记),这些节点全都是可以匹配上的节点。我们统计完毕后,并将那些节点标记。此时只需沿该路径走向下一个节点继续匹配即可,目标字符串指针移向下个字符继续匹配;
(2)当前字符不匹配,则去当前节点失败指针所指向的字符继续匹配,匹配过程随着指针指向root结束。重复这2个过程中的任意一个,直到模式串走到结尾为止。
《字符串匹配2——多模式匹配(Trie树 AC自动机)》
对照上图,看一下模式匹配这个详细的流程,其中模式串为yasherhs。对于i=0,1。Trie中没有对应的路径,故不做任何操作;i=2,3,4时,指针p走到左下节点e。因为节点e的count信息为1,所以cnt+1,并且讲节点e的count值设置为-1,表示改单词已经出现过了,防止重复计数,最后temp指向e节点的失败指针所指向的节点继续查找,以此类推,最后temp指向root,退出while循环,这个过程中count增加了2。表示找到了2个单词she和he。当i=5时,程序进入第5行,p指向其失败指针的节点,也就是右边那个e节点,随后在第6行指向r节点,r节点的count值为1,从而count+1,循环直到temp指向root为止。最后i=6,7时,找不到任何匹配,匹配过程结束。

完整代码实现

package StringMatch;

import java.util.LinkedList;
import java.util.Queue;

/**
 * Ac自动机(Trie树的优化)
 * @author xjh 2018.12.29
 */
class AcNode{
    public char data;
    public AcNode[] children=new AcNode[26];    //这里只考虑原始字符集中只包括a-z26个小写字母
    public boolean isEndingChar=false;  //结尾字符位true
    public int length=-1;   //当isEndingChar位true时,记录模式串的长度
    public AcNode fail; //失败指针 功能类似于kmp算法的next数组
    AcNode(){}
    public AcNode(char data){
        this.data=data;
    }
}

/**
 * 构建基于Ac自动机优化的Trie树
 */
class TrieTree {
    AcNode root;
    /**
     * Initialize your data structure here.
     */
    public TrieTree() {
        root = new AcNode();
        root.data = ' '; //Trie树的根节点不包含有效字符

    }
    /**
     * Inserts a word into the trie.
     */
    public void insert(String word) {
        AcNode t = root;
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);  //word的第i个字符
            if (t.children[c - 'a'] == null)    //如果孩子节点中没有该字符 则需要插入进去
                t.children[c - 'a'] = new AcNode(c);
            t = t.children[c - 'a'];    //树向下迭代
        }
        t.isEndingChar = true;
        t.length=word.length();
    }
    /**
     * Returns if the word is in the trie.
     */
    public boolean search(String word) {
        AcNode t = root;
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);  //word的第i个字符
            if (t.children[c - 'a'] == null)    //如果孩子节点中没有该字符 返回false;
                return false;
            t = t.children[c - 'a'];    //树向下迭代
        }
        return t.isEndingChar;    //search函数和startWith函数最大的区别在于后买你返回的是t.isWord
    }
    /**
     * Returns if there is any word in the trie that starts with the given prefix.
     */
    public boolean startsWith(String prefix) {
        AcNode tmp = root;
        for (int i = 0; i < prefix.length(); i++) {
            char c = prefix.charAt(i);  //word的第i个字符
            if (tmp.children[c - 'a'] == null)    //如果孩子节点中没有该字符 返回false;
                return false;
            tmp = tmp.children[c - 'a'];    //树向下迭代
        }
        return true;
    }

    /**
     * 构建失败指针
     */
    public void buildFailPointer(){
        Queue<AcNode> queue=new LinkedList<>();
        root.fail=null;
        queue.add(root);
        while (!queue.isEmpty()){
            AcNode p=queue.remove(); //出队
            for (int i=0;i<26;i++){
                AcNode pc=p.children[i];
                if (pc==null) continue;
                if (p==root) pc.fail=root;
                else {
                    AcNode q=p.fail;
                    while (q!=null){
                        AcNode qc=q.children[pc.data-'a'];  //子节点
                        if (qc!=null){
                            qc.fail=qc;
                            break;
                        }
                        q=q.fail;
                    }
                    if (q==null){
                        pc.fail=root;
                    }
                }
                queue.add(pc);
            }
        }
    }

    /**
     * 多模式串和主串进行匹配
     */
    public void match(char[] text){
        //text是主串
        int n=text.length;
        AcNode p=root;
        for (int i=0;i<n;i++){
            int idx=text[i]-'a';
            while (p.children[idx]==null&&p!=root){
                p=p.fail;   //失败指针发挥作用的地方
            }
            p=p.children[idx];
            if (p==null) p=root;    //如果没有匹配的话,从root开始重新匹配
            AcNode tmp=p;
            while (tmp!=root){
                //打印出可匹配的模式串
                if (tmp.isEndingChar==true){
                    int pos=i-tmp.length+1;
                    System.out.println("匹配起始下标:"+pos+";长度:"+tmp.length);
                }
                tmp=tmp.fail;
            }
        }
    }
}

总结

AC自动机算法分为3步:构造一棵Trie树,构造失败指针和模式匹配过程。
如果你对KMP算法和了解的话,应该知道KMP算法中的next函数(shift函数或者fail函数)是干什么用的。KMP中我们用两个指针i和j分别表示,A[i-j+ 1…i]与B[1…j]完全相等。也就是说,i是不断增加的,随着i的增加j相应地变化,且j满足以A[i]结尾的长度为j的字符串正好匹配B串的前 j个字符,当A[i+1]≠B[j+1],KMP的策略是调整j的位置(减小j值)使得A[i-j+1…i]与B[1…j]保持匹配且新的B[j+1]恰好与A[i+1]匹配,而next函数恰恰记录了这个j应该调整到的位置。同样AC自动机的失败指针具有同样的功能,也就是说当我们的模式串在Tire上进行匹配时,如果与当前节点的关键字不能继续匹配的时候,就应该去当前节点的失败指针所指向的节点继续进行匹配。

参考文献:
https://www.cnblogs.com/xujian2014/p/5614724.html
https://www.cnblogs.com/cmmdc/p/7337611.html

    原文作者:Trie树
    原文地址: https://blog.csdn.net/weixin_38073885/article/details/86564316
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞