KMP算法的个人理解

148 阅读5分钟

声明:文章内容根据龙书部分内容和网络上相关博客整理,当时随便写的笔记到现在时间太久远了,内容也不是很完善的样子……如果有未标明出处的地方希望大家指正,我会尽快更改

KMP算法

在目标串中匹配模式串的一个最简单、最直观的方法是一个个字符匹配,如果当前字符开始无法找到与模式串匹配的子串,那么向前移动一位,从该位置开始匹配模式串。但是在最坏的情况下,该算法的时间复杂度为O(m×n)O(m \times n)

那么有没有什么更好的办法来匹配字符串呢?——KMP算法。与最原始的逐个匹配的办法不同,KMP算法在匹配失败时会将目标串的指针向后移动合适的位置,而不仅仅是一个位置,从而大大减少了比较的次数。

这一算法的主要思想还是在于串的重复特性,利用串的最大公共前后缀来实现:

设想目标串s和模式串p在目标串的ij位置之间是匹配的,也就是说sisi+1...sj1sjs_is_{i+1}...s_{j-1}s_jp0...pjip_0...p_{j-i}是完全相同的,但是sj+1pji+1s_{j+1} \ne p_{j-i+1}(即这两个串的下一位不匹配),那么这个时候要继续寻找下一个位置进行匹配。

那么下个位置是什么位置呢?假设两个字符串已经完全匹配的部分有最大公共前后缀:

这个时候只要把串p已经匹配部分的前缀和串s已经匹配部分的后缀对齐就可以了,这样就迅速找到了要继续匹配的位置。

next数组及其求解

既然已经知道KMP算法的基本思想,那么剩下的关键就是如何快速找到一个字符串的公共前后缀,这也是next数组要解决的问题。

假设有如下字符串:

ababaaaababaaa

该字符串对应的next数组中的值next[t]为该字符串从首字符开始长度为t+1(因为t从0开始取值)的子串的最长公共前后缀的长度。

对于上面的字符串而言,next数组的值如下:

对应的字串aababaababababaababaaababaaa
next0012311

那么,对于一般的字符串 t0t1...tnt_0t_1...t_n,其next数组应该如何求解?

很显然,当t=0t=0时,next[t]=0next[t]=0,这是一个初始条件。之后,当t=1t=1时,要判断子串t0t1t_0t_1的公共前后缀。这一点比较简单,直接判断t0t_0是否等于t1t_1即可。

现在,我们已经有了初始的条件,可以基于此初始条件不断往后递推。整个过程事实上包含了动态规划的思想。

现在假设我们有ts两个指向字符串s的指针,s始终指向当前正在寻找最长公共前后缀的子串的末尾。

假设在上一步找寻最长公共前后缀的时候,t已经指向了该前缀的最后一个字符,s指向了对应子串的末尾。

b0b1...bt...bs...bnb_0b_1...b_t...b_s...b_n

这意味着子串b0...btb_0...b_t和子串bst+1...bsb_{s-t+1}...b_s(长度为t)是完全相同的(公共前后缀)。接着开始寻找子串b0...bs+1b_0...b_{s+1}的最长公共前后缀。这时候,我们只要比较bt+1b_{t+1}bs+1b_{s+1}是否相同即可,如果相同,很完美,最长的公共前后缀的长度加一,即: next[s+1]=t+1next[s+1]=t+1 要是不同,很显然,t要回溯来找到正确的前后缀。一种办法是t每次回溯一位,然后比较t的下一位和s的下一位是否相同。

整个过程大致如下:

  • t=t-1,再比较在新的t值下b0b1...btb_0b_1...b_tbst+1...bsb_{s-t+1}...b_s是否相同。若相同:比较bt1b_{t-1}的下一位和bsb_s的下一位是否相同,若结果为相同,t=t+1, next[s+1]=t,若不同,重复这一步;

但这种办法显得过于笨重,所以我们需要更好的办法来找到合适的回溯的位置。我们知道,根据上一次计算的结果,子串b0b1...btb_0b_1...b_t和子串bst+1...bsb_{s-t+1}...b_s是完全相同的,我们现在要做的,是在这两个子串内再找到合适的公共前后缀串。

怎么找呢?利用next[t],这之中就包含了长度为t的从头开始的子串的最长公共前后缀串的信息。利用next[t]找到的b0...btb_0...b_t的前缀,恰恰好也是bst+1...bsb_{s-t+1}...b_s的后缀,它们完全相同。所以我们现在要回溯到的位置就是b0...bnext[t]b_0...b_{next[t]}bsnext[t]+1...bsb_{s-next[t]+1}...b_s。这个步骤就是快速回溯,找到正确的公共前后缀。在此基础上,再比较这两个子串后跟着的字符:如果这两个字符相同,那完美了,t=t+1, next[s+1]=t;如果不同,就再重复这个回溯的步骤。

使用java实现的next数组的计算

public static int[] calNext(char[] str) {
    int[] next = new int[str.length];
    // 第一个子串,即首个字符。其前缀为空串,表示为-1
    next[0] = -1;
    // t指示上一个子串的最长公共前后缀的最后一个字符在数组中的索引。初始为-1表示首字符前缀为空
    int t = -1;
    // s表示上一个子串的末尾。初始为0,表示第一个子串为首字符。
    int s = 0;
    for(; s < str.length - 1; s++) {
        while ( t != -1 && (str[t+1] != str[s+1]) ) {
            // 上个前缀的后一个字符和上个子串的后一个字符不匹配,
            // 让t回溯,直到回到-1,表示没有匹配的前后缀串为止
            t = next[t];
        }
        if ( str[t+1] == str[s+1] ) {
            // t前进一位,表示找到了匹配的字符
            t += 1;
            // 更新next数组的值
            next[s+1] = t;
        } else {
            // 没找到匹配的字符
            next[s+1] = -1;
        }
    }
    
    return next;
}