13字符串匹配之KMP算法、Trie树、AC自动机

1. KMP算法

KMP算法框架代码

  • 时间复杂度:o(n+m),其中m为next数组的长度,n为主串的长度。
  • 空间复杂度:o(m),其中m为next数组的长度。
class Solution{
    public static int KMP(char[] a, int n, char[] b, int m){
        int[] next = getNexts(b, m);
        int j = 0;
        for(int i=0; i<n; i++){
            while(j>0 && a[i]!=b[j]){
                j = next[j-1] +1;
            }
            if(a[i]==b[j]){
                j++;
            }
            if(j==m){
                return i-m+1;
            }
        }
        return -1;
    };
    //此为KMP函数的关键
    public static int[] getNexts(char[] b, int m){
       int[] next = new int[m];            //求得模式串的next数组
       next[0] = -1;
       int k = 0;
       for(int i=0; i<m; i++){
            while(k!=-1 && b[k+1]!=b[i]){  //求得b[k+1]与b[i]相等的具体k值
                k = next[k];
            }
            if(b[k+1] == b[i])
                k++;
            next[i] = k;
        }         

        return next;
    };

};

2. Trie树(字典树,多模式串匹配算法)

  • 一种树形结构,它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题(本质是利用字符串之间的公共前缀,将重复的前缀合并在一起)。
  • Trie树的构造过程,经典的存储方式:借助散列表的思想,我们通过一个下标与字符——映射的数组,来存储子节点的指针。
  • 假设我们的字符串只有a-z这26个小写字母,我们在数组下标为0的位置,存储指向子节点a的指针,下标为1的位置,存储指向子节点b的指针,以此类推,下标为25的位置,存储指向子节点z的指针。如果某个字符的子节点不存在,我们就在对应的下标的位置存储null.

其具体的代码如下(构建过程的时间复杂度为o(n)):

class TrieNode{
    char val;
    public TrieNode[] children = new TrieNode[26];
    public boolean isEndingChar = false;
    public TrieNode(){
    };
    public TrieNode(char val){
        this.val = val;
    };
}

public class Trie{
    private TrieNode root = new TrieNode('/');  //根节点存储无意义的字符
    
    //向Tire树中插入一个字符串
    public void insert(char[] text){
        TrieNode p = root;
        for(int i=0; i<text.length; i++){
            int now = text[i] - 'a';
            if(p.children[now] == null){
                TrieNode newTrieNode = new TrieNode(text[i]);
                p.children[now] = newTrieNode; 
            }else{
                p = p.children[now];
            }
        }
        p.isEndingChar = true;
    }

    //在Trie树中查找一个字符串
    pubic boolean find(char[] pattern){
        TrieNode p = root;
        for(int i=0; i<pattern.length; i++){
            int now = pattern[i] - 'a';
            if(p.children[now] == null){
                return false;   //不存在pattern
            }
            p = p.children[now];
        }
        if(p.isEndingChar == false) return false;  //不能完全匹配,只是前缀
        else return true;  //找到pattern
    }
}
  • 构建完成后如果需要查找某个字符串pattern,其长度为k,则查找的时间复杂度为o(k)
  • Trie树是非常耗内存的,是一种空间换时间的操作。在重复前缀不多的情况下,Tire树不但不能节省内存,还有可能浪费更多的内存。
  • 为了节省Trie树所占用的内存,可以稍微牺牲一点查询的效率,将每个节点中的数组换成其他数据结构,来存储一个节点的子节点指针,比如有序数组、跳表、散列表、红黑树等;还可以使用缩点优化法,就是对只有一个子节点的节点,而且此节点不是一个串的结束节点,可以将此节点与子节点合并。这样可以节省空间,但却增加了编码难度。

选两种数据结构,散列表和红黑树,跟 Trie 树比较一下,看看它们各自的优缺点和应用场景。在一组字符串中查找字符串,Trie 树实际上表现得并不好。它对要处理的字符串有极其严苛的要求。

  1. 第一,字符串中包含的字符集不能太大。我们前面讲到,如果字符集太大,那存储空间可能就会浪费很多。即便可以优化,但也要付出牺牲查询、插入效率的代价。
  2. 第二,要求字符串的前缀重合比较多,不然空间消耗会变大很多。
  3. 第三,如果要用 Trie 树解决问题,那我们就要自己从零开始实现一个 Trie 树,还要保证没有 bug,这个在工程上是将简单问题复杂化,除非必须,一般不建议这样做。
  4. 第四,我们知道,通过指针串起来的数据块是不连续的,而 Trie 树中用到了指针,所以,对缓存并不友好,性能上会打个折扣。

综合这几点,针对在一组字符串中查找字符串的问题,我们在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,我们都不需要自己去实现,直接利用编程语言中提供的现成类库就行了。那 Trie 树是不是就没用了呢?实际上,Trie 树只是不适合精确匹配查找,这种问题更适合用散列表或者红黑树来解决。Trie 树比较适合的是查找前缀匹配的字符串。

3. AC自动机(多模式串匹配算法)

Trie树跟AC自动机之间的关系,就像单串匹配中朴素的串匹配算法与KMP算法一样,只不过前者针对的是多模式串。所以AC自动机实际上就是在Trie树之上,加了类似KMP的next数组,只不过此处的next数组是构建在树上。

public class AcNode{
    public char data;
    public AcNode[] children = new AcNode[26]; //字符集只包含a-z这26个字符
    boolean isEndingChar = false;   //结尾字符为true
    public int length = -1;  //当isEndingChar = true时,记录模式串的长度
    public AcNode = fail;   //失效指针
    public AcNode(char data){
        this.data = data;
    }; 
}

 

AC自动机的构建,包含两个操作:

  • 将多个模式串构建成Tire树:这部分内容在2中已经详细介绍
  • 在Trie树上构建失效指针(相当于KMP中的失效函数next数组)
    • (1)用例子进行说明:有四个模式串,分别是c, bc, bcd, abcd;主串是abcd.在Trie树中每一个节点都有一个失效指针,它的作用和构建过程和KMP中的next数组极为相似。
    •   
    • (2)假设我们沿着Trie树走到p节点,也就是下图中的紫色节点,那p的失效指针就是从root走到紫色节点形成的字符串abc,跟所有模式串前缀匹配的最长可匹配后缀子串(例如字符串abc的后缀子串有bc, c,我们拿它们和其他模式串匹配,如果某个后缀子串可以匹配某个模式串的前缀,那么我们就把这个后缀子串叫做可匹配后缀子串),就是箭头指的bc模式串,我们将p节点的失效指针指向那个最长匹配后缀子串对应的模式串的前缀的最后一个节点,就是下图中箭头所指向的节点。
      • 1)可以像2中KMP算法求解next数组那样,当我们要求某个节点的失效指针的时候,我们通过已经求得的、深度更小的那些节点的失效指针来推导,也就是说我们可以逐层依次来求解每个节点的失效指针。所以,失效指针的求解过程是一个层次遍历树的过程。
      • 2)具体过程描述:
        • 1>首先root指针的失效指针为NULL,也就是指向自己。
        • 2>当我们已经求得某个节点p的失效指针之后,求解p的子节点的失效指针:
          • 假设节点 p 的失效指针指向节点 q,我们看节点 p 的子节点 pc 对应的字符,是否也可以在节点 q 的子节点中找到。如果找到了节点 q 的一个子节点 qc,对应的字符跟节点 pc 对应的字符相同,则将节点 pc 的失效指针指向节点 qc
          • 如果节点 q 中没有子节点的字符等于节点 pc 包含的字符,则令 q=q->fail(fail 表示失效指针,这里有没有很像 KMP 算法里求 next 的过程?),继续上面的查找,直到 q 是 root 为止,如果还没有找到相同字符的子节点,就让节点 pc 的失败指针指向 root。
      • 构建失效指针的具体代码如下:
      • public void buildFailurePointer(AcNode root){
            Queue<AcNode> queue = new LinkedList<>();  //层次遍历树,需要使用队列实现
            root.fail = null;
            queue.add(root);
            while(!queue.isEmpty){
                AcNode p = queue.remove();             //出队一个元素p
                for(int i=0; i<p.children.length; i++){  //遍历p中所有子元素
                    AcNode pc = p.children[i];        
                    if(pc == null)                   //只考虑非空元素
                        continue;
                    if(p == root){                   //第一层元素的fail值都为root(第一层元素不会重重复)
                        pc.fail = root;
                    }else{                           //从第二层开始
                        AcNode q = p.fail;           
                        while(q!=null){              //回溯的查找最长匹配前缀子串
                            AcNode qc = q.children[pc.data-'a']; 
                            if(qc!=null){          //qc与pc的data值是否相等  
                                pc.fail = qc;
                                break;
                            }
                            q = q.fail;
                        }
                    }
                    if(q == null){                  //一直找到根节点都没有找到与pc节点值匹配的节点,说明无法匹配,pc.fail=root
                        pc.fail = root;
                    }
                }
                queue.add(pc);                    //查找pc子树的fail值需要用到pc
            }
        }

在失效指针构建完成后,需要在AC自动机上匹配主串(匹配的过程中是要找到主串中存在的所有模式串),其具体代码实现如下:

public void match(char[] text, AcNode root){
    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 temp = p;
        while(temp != root){            //打印出可以匹配的串
            if(temp.isEndingChar == true){
                int pos = i-temp.length + 1;
                System.out.println("匹配起始下标" + pos + "; 长度" + tmp.length);
            }
            temp = temp.fail;    
        }
    }
}

在匹配过程中,主串从 i=0 开始,AC 自动机从指针 p=root 开始,假设模式串是 b,主串是 a。

  • 如果 p 指向的节点有一个等于 b[i]的子节点 x,我们就更新 p 指向 x,这个时候我们需要通过失效指针,检测一系列失效指针为结尾的路径是否是模式串。这一句不好理解,你可以结合代码看。处理完之后,我们将 i 加1,继续这两个过程;
  • 如果 p 指向的节点没有等于 b[i]的子节点,那失效指针就派上用场了,我们让 p=p->fail,然后继续这 两个过程。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值