一篇文章带你理解KMP,扩展KMP,AC自动机

1,412 阅读4分钟

KMP算法详解

前言

KMPKMP算法是一种非常高效地解决字符串匹配算法,算法思想主要是动态规划,如果你想要真正的理解并能够灵活运用需要对动态规划有一定的理解,并且在接下来的扩展KMPKMPACAC自动机的讲解都是基于动态规划这种算法思想的基础上,如果你还不是很懂动态规划,建议可以先从背包开始。

KMP能够解决什么问题?

例如求解一个字符串是否在文本串中出现过,或者出现了多少次?问题的定义很简单,通过程序解决这个问题也没有难度,难度在于我们如何高效的来解决这个问题,好了,我们先来描述一下暴力解法,遍历文本串,如果当前字符于字符串ss的首字母相等,那么我们就判断一次是否包含,很显然这种做法非常简单,但是相应的复杂度也很高

/**
 * 判断字符串是否在文本串t中出现
 * @param s 待判断的字符串
 * @param t 文本串
 * @return true / false
 */
public boolean isContainSubStr(String s, String t) {
    for (int i = 0; i < t.length(); i++) {
        if (t.charAt(i) != s.charAt(0)) {
            continue;
        }
        int ti = i, si = 0;
        while (ti < t.length() && si < s.length() && t.charAt(ti) == s.charAt(si)) {
            ti++; si++;
        }
        if (si == s.length()) {
            return true;
        }
    }
    return false;
}

KMP究竟优化了什么?

在我们的暴力程序中,如果t[i]!=s[j]t[i] != s[j]的时候,我们是将ii指向下一个与s[0]s[0]相同的位置上,同时jj的下标又是从00开始遍历,想象一种场景,假设t="ABCABCABB"s="ABCABB"t = "ABCABCABB", s = "ABCABB",在遍历t[5]t[5]的时候发现t[5]!=s[5]t[5] != s[5],面对这种情况,kmpkmp算法能够将jj直接跳到下标为22的位置,也就是j=2i=5j=2,i=5,这个时候发现t[i]=s[j]t[i] = s[j]的,相比于暴力,这块就少遍历了下标为0011两次。我们现在只要知道它的是怎么优化的,具体是怎么做的,继续往下看!

KMP是怎么优化的?

上面那个例子,为什么在t[5]!=s[5]t[5] != s[5]的时候,ss的下标jj能够直接跳到下标22呢,仔细观察的同学应该就发现了,ABCABABCAB的最长前缀等于后缀的长度为22,也就是ABAB,我们只需要把ss字符串以jj为下标s[0,j]s[0,j]的前后缀最长长度求出来,我们用nextnext数组存储,在指针失配的时候,我们就可以获取当前的前一个下标的最长前后缀长度,如next[4]=2next[4] = 2;这就是为什么ss字符串失配后直接跳到下标22的原因。

如何求字符串前后缀相等的最长长度?

动态规划思想,我们先定义状态,next[i]next[i]表示s[0,i]s[0,i]字符串中最长前后缀的长度,如何求解next[i]next[i]呢,求解next[i]next[i]之前我们假设已经知道了next[i1]next[i-1]的结果,那么我们就可以通过next[i1]next[i-1]递推出next[i]next[i],定义j=next[i1]j = next[i-1],也就是我们知道了s[0,j1]=s[ij2,i1]s[0,j-1]=s[i-j-2,i-1],可能这样描述比较抽象,我们举个例子,拿s="ABCABB"s = "ABCABB"来举例,next[3]=1next[3]=1,现在求next[4]next[4],此时 i=4j=1i=4,j=1,如果s[i]!=s[j]s[i] != s[j]怎么办,那么我们继续把j指向next[j1]next[j-1]的位置,直到匹配,为什么这么做呢,就是尽可能的减少重复计算,这块比较抽象,需要自己理解,如果s[i]==s[j]s[i] == s[j],那么next[i]=j+1next[i] = j + 1;

程序实现

public static int[] getNext(String s) {
    int[] next = new int[s.length()];
    for (int i = 1; i < s.length(); i++) {
        int j = next[i - 1];
        while (j > 0 && s.charAt(i) != s.charAt(j)) j = next[j - 1];
        if (s.charAt(i) == s.charAt(j)) j++;
        next[i] = j;
    }
    return next;
}

扩展KMP算法详解

扩展KMP是用来解决什么问题的?

扩展KMPKMP主要是用来求字符串SS的所有后缀匹配字符串TT前缀的最长长度,举个例子,S="ABCABB"T="ABCABCABB"S = "ABCABB",T = "ABCABCABB",求SS的所有后缀匹配TT的最长前缀长度,结果为[5,0,0,2,0,0][5, 0, 0, 2, 0, 0];

扩展KMP的算法思想

我们首先声明两个数组nextnextzznextnext记录单个字符串的所有后缀与本身的前缀最长长度,zz数组则记录字符串SS的所有后缀匹配字符串TT前缀的最长长度。

维护最右区间匹配段

在该算法中,我们从11n1n-1顺次计算next[i]next[i]的值(next[0]=0)(next[0] = 0)。在计算next[i]next[i]的过程中,我们会利用已经计算好的next[0],..,next[i1]next[0],..,next[i-1]; 对于i,我们称区间[i,i+next[i]1][i, i+next[i]-1]ii的匹配段,我们要维护s[0,i1]s[0, i-1]最长的匹配段,记作[l,r][l, r],根据定义我们可知s[l,r]s[l,r]是字符串ss的前缀。记录这个有什么用呢?比如我们已经记录了s[0,i1]s[0,i-1]的最右区间匹配段[l,r][l, r],在求解next[i]next[i]时,如果l<=i<=rl <= i <= r的话,那么 s[i,r]=s[il,rl]s[i, r] = s[i-l, r-l],如果next[il]<ri+1next[i-l] < r - i + 1的话,那么next[i]=next[il]next[i] = next[i-l],为什么呢,因为后缀s[il,n]s[i-l,n]匹配的最长前缀长度就是next[il]next[i-l],加上next[il]<ri+1next[i-l] < r-i+1,那么next[i]=next[il]next[i] = next[i-l],这里比较抽象最好动手画一画哦

算法详解

求解字符串SS的所有后缀匹配字符串TT前缀的最长长度步骤如下:

  • 第一步获取字符串TTnextnext数组
  • 第二步判断next[il]<ri+1next[i-l] < r - i + 1是否成立,如果成立 z[i]=next[il]z[i] = next[i - l]
  • 第三步,如果第二步next[il]>ri+1next[i - l] > r - i + 1,说明next[i]next[i]的长度可能比 ri+1r - i + 1的长度要大,那么就要进行暴力匹配计算z[i]z[i]
  • 第四步,如果计算到新的 i+z[i]+1i + z[i] + 1大于rr,那么更新[l,r][l, r]区间

程序实现

public static int[] getZNext(String s) {
    int[] next = new int[s.length()];
    for (int i = 1, l = 0, r = 0; i < s.length(); i++) {
        if (i <= r && next[i - l] < r - i + 1) {
            next[i] = next[i - l];
        } else {
            next[i] = Math.max(0, r - i + 1);
            while (i + next[i] < s.length() && s.charAt(next[i]) == s.charAt(i + next[i])) ++next[i];
        }
        if (i + next[i] - 1 > r) {
            l = i; r = i + next[i] - 1;
        }
    }
    next[0] = s.length();
    return next;
}

public static int[] getZFunction(String s, String t) {
    int[] z = new int[s.length()];
    int cnt = 0;
    while (cnt < Math.min(s.length(), t.length()) && s.charAt(cnt) == t.charAt(cnt)) cnt++;
    z[0] = cnt;
    int[] next = getZNext(t);
    for (int i = 1, l = 0, r = 0; i < s.length(); i++) {
        if (i <= r && next[i - l] < r - i + 1) {
            z[i] = next[i - l];
        } else {
            z[i] = Math.max(0, r - i + 1);
            while (i + z[i] < s.length() && s.charAt(i + z[i]) == t.charAt(z[i])) ++z[i];
        }
        if (i + z[i] - 1 > r) {
            l = i; r = i + z[i] - 1;
        }
    }
    return z;
}

AC自动机

前言

如果想要学习ACAC自动机,建议真正的理解了字典树与KMPKMP,特别是KMPKMP在指针失配时是如何做的,如果没有掌握这些前置知识,不建议直接学习ACAC自动机

AC自动机是用来解决什么问题的?

ACAC自动机是用来解决多字符串匹配问题的,简单讲就是判断多个字符串在文本串中是否出现过,或者每个字符串在文本串中出现过多少次的问题,是不是感觉与KMPKMP有点相似的感觉,没错,就是一样的,区别就在于KMPKMP是针对单个字符串匹配的,如果一次要判断多个字段串是否匹配的话,那就得建树了,由于是字符串,那么最合适的就是字典树了,不懂字典树的建议去学习一波,很简单,至少比KMPKMP简单!

AC自动机算法思想之Fail指针

这个与KMPKMPNextNext指针如出一辙

如何求Fail指针

FailFail指针用BFSBFS来求的,对于直接与根节点相连的节点来说,如果这些节点失配,他们的FailFail指针直接指向rootroot即可,其他节点其FailFail指针求法如下:
假设当前节点为fatherfather,其孩子节点记为childchild。求childchildFailFail指针时,首先我们要找到其fatherfatherFailFail指针所指向的节点,假如是tt的话,我们就要看tt的孩子中有没有和childchild节点所表示的字母相同的节点,如果有的话,这个节点就是childchildfailfail指针,如果发现没有,则需要找father>fail>failfather->fail->fail这个节点,然后重复上面过程,如果一直找都找不到,则childchildFailFail指针就要指向rootroot

程序实现

static class Tire {
    
    static final int MAXN = 1000010;
    static int siz = 0;
    static int[] val = new int[MAXN];
    static int[] fail = new int[MAXN];
    static int[][] ch = new int[MAXN][26];
    
    public static void tire_clear() {
        siz = 1; Arrays.fill(ch[0],0);
    }
    
    public static int idx(char c) {
        return c - 'a';
    }

    /**
     * 往树中插入字符串
     * @param s 字符串
     * @param v 权值
     */
    public static void tire_insert(String s, int v) {
        int u = 0, len = s.length();
        for (int i = 0; i < len; i++) {
            int c = idx(s.charAt(i));
            if (ch[u][c] == 0) {
                Arrays.fill(ch[siz],0);
                val[siz] = 0;
                ch[u][c] = siz++;
            }
            u = ch[u][c];
        }
        val[u] = v;
    }

    /**
     * 构建Fail指针
     */
    public static void tire_build() {
        Queue<Integer> queue = new ArrayDeque<>();
        for (int i = 0; i < 26; i++) {
            if (ch[0][i] != 0) queue.add(ch[0][i]);
        }
        while (!queue.isEmpty()) {
            int u = queue.poll();
            for (int i = 0; i < 26; i++) {
                if (ch[u][i] != 0) {
                    fail[ch[u][i]] = ch[fail[u]][i];
                    queue.add(ch[u][i]);
                } else {
                    ch[u][i] = ch[fail[u]][i];
                }
            }
        }
    }

    /**
     * 求出现在文本串中的字符串数量
     * @param s 文本串
     * @return 出现数量
     */
    public static int tire_query(String s) {
        int u = 0, res = 0;
        for (int i = 0; i < s.length(); i++) {
            u = ch[u][idx(s.charAt(i))];
            for (int j = u; j > 0 && val[j] != -1; j = fail[j]) {
                res += val[j]; val[j] = -1;
            }
        }
        return res;
    }
}