锲而不舍,金石可镂——理解KMP算法

1,301 阅读5分钟

前言

KMP算法是什么?主要解决的问题是在给定一个字符串template,快速的发现子串pattern是否存在于template中。

KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,所以由3位前辈的名字各取了一个字母得名。在某些版本的《数据结构》这门课中,存在于“串”这一章节。

KMP算法是出了名的难,其思想可能大多数同学都能掌握,但关键是求next数组,很多同学都没有理解为什么剪短的几行代码就可实现神奇的效果。

KMP虐我千百遍,我待KMP如初恋,笔者写代码也有个7-8年了,从来没有见过如此难以理解的算法,不禁感叹:这简短的几行代码里面是包含了多少前人的哲学与智慧啊。

1、蛮力匹配算法

老规矩,在介绍KMP算法之前不得不提蛮力匹配算法,因为了解了蛮力匹配算法才能通过比较知道KMP算法的优势。

先上代码:

function subString(text,pattern){
    let m = text.length;
    let n = pattern.length;
    for(let i = 0;i < m - n;++i){
        let j = 0;
        while(j < n && text[i+j] == pattern[j]){
            ++j;
        }
        if(j == n){
            return i;
        }
    }
    return -1;
}

之所以说它是蛮力匹配算法,设定2个指针,指针表示在主串上移动的位置,j指针表示在目标字符串上的位置,就是通过一位一位的去比较,如果匹配失败,则j指针归0,i指针向后挪动一位。

其大概流程如下: 图片1.png 图片2.png 图片3.png 当遇到不匹配的时候,i指针向后移动一位,j指针回溯 图片4.png 蛮力匹配的问题就出在这个i,j指针的回溯上,因此KMP算法的核心就是解决回溯。

2、KMP算法

在蛮力算法匹配失败的时候,我们来思考此刻的情形 图片5.png 此刻,把模式串往后一位匹配可能匹配上吗?答案是肯定不可能的,为什么呢?明显,在这个匹配失败时候的前缀“abcdab”,只有共同前后缀才有可能匹配的上呀,如果不是共同的前缀,那就只能一直往后找,找到最大的公共前后缀的时刻才匹配的上。 截屏2022-01-30 23.04.35.png 因此,KMP算法的关键就是找匹配失败时,此时最长的公共前后缀,i指针不回溯,直接将j指针挪动到最大公共前后缀的位置开始下一次的匹配。

此刻,我们引入了前缀和后缀的概念。

前缀:除了最后一个字符以外,一个字符串的全部头部组合。

后缀:指除了第一个字符以外,一个字符串的全部尾部组合。

对于我们的模式字符串“abcdabc”,其所有的前缀字符串组合为"a","ab","abc","abcd","abcda","abcdab" 其所有的后缀字符串组合为:"bcdabc","cdabc","dabc","abc","bc","c",所以对于字符串abcdabc,最长的公共前后缀就是3。

我们可以发现,因为模式字符串是给定的,它可能在任何位置匹配失败。那么我们是否可以聪明的先把在每个位置匹配失败的时候最大的公共前后缀先计算出来呢? 学过数据结构的哈希表章节的同学一定知道,通过哈希查找的效率是O(1)。如果我们事先计算出来这个映射关系,那么我们在匹配失败的时候,就相当于可以直接知道下一个开始匹配的位置了。

上述的这个过程就是KMP算法中求解next数组的过程。

我们来看一下我们的模式字符串:"abcdabc"

对于字符串"a", 前缀集合: [],后缀集合:[],最长公共前后缀:0

对于字符串"ab", 前缀集合:["b"], 后缀集合:["a"],最长公共前后缀:0

对于字符串"abc",前缀集合:["a","ab"], 后缀集合:["bc","c"],最长公共前后缀:0

对于字符串"abcd",前缀集合:["a","ab","abc"],后缀集合:["bcd","cd", "d"],最长公共前后缀:0

对于字符串"abcda",前缀集合:["a","ab","abc","abcd"],后缀集合:["bcda","cda", "da", "a"],最长公共前后缀:1

对于字符串"abcdab",前缀集合:["a","ab","abc","abcd","abcda"],后缀集合:["bcdab","cdab", "dab", "ab", "b"],最长公共前后缀:2

对于字符串"abcdabc",前缀集合:["a","ab","abc","abcd","abcda", "abcdab"],后缀集合:["bcdabc","cdabc", "dabc", "abc", "bc", "c"],最长公共前后缀:3

将上述结果整理成表格如下:

abcdabc
0000123

上述推导过程,是我们的思考的过程,但想要将其转化成代码可不是一件容易的事儿,因此我们直接给出KMP算法中求next数组的代码,通过分析代码的执行过程,将其理解。

求next数组代码如下:

/**
 * 生成next数组
 * @param {String} pattern 
 * @param {Number[]} next 
 */
 function genNext(pattern) {
    let m = pattern.length
    let next = []
    // 因为第一个字符串没有前后缀,所以可以直接赋值0
    next[0] = 0;
    //当取一个字符的时候,肯定是一个前后缀都没有的
    for (let i = 1, j = 0; i < m; ++i) {
        // 如果没有匹配到,递归的去求之前的最大前缀
        // 退出循环条件是 k大于0 并且当前位置的字符串要是一样的 
        while (j > 0 && pattern[i] != pattern[j]) {
            // 找到上一次的最大前后缀
            j = next[j - 1];
        }
        // 如果匹配到了,最大的前后缀+1
        if (pattern[i] == pattern[j]) {
            j++;
        }
        // 求出当前字符串的最大公共前后缀
        next[i] = j;
    }
    return next
}

这段代码是我目前见过的最难理解的一段代码没有之一,咋看一下,感觉跟我们之前的推导过程没有任何关系,但是为什么它就能求出正确的next数组呢。请保持耐心继续往下看

首先,我们要知道,当字符串的长度增加1,其最大的公共前后缀的长度只可能增加1 我们将这段代码的执行过程画出来,执行过程如下图所示:

Step1: next-step1.png Step2: 因为前后缀不相等,next[1]=0,此时i指向2 next-step2.png Step3: 因为前后缀不相等,next[2]=0,此时i指向3 next-step3.png Step4: 因为前后缀不相等,next[3]=0,此时i指向4 next-step4.png Step5:因为当前字符串和pattern[0]相等,next[4]=1,此时i指向5 next-step5.png Step6:因为当前字符串和pattern[1]相等,此时next[5]=2,i指向6 next-step6.png Step7:因为当前字符串和pattern[2]相等,此时next[6]=3,i指向7 next-step7.png Step8: i指针越界,next数组生成完成。

在这个过程中,我们可以看到,j指针始终指向的是当前字符之前字符串的最大公共前后缀,因为在前文我们有提到过,字符串的长度增加1,最大公共前后缀的长度只能增加1。

上述这个测试用例还没有覆盖所有可能出现的情况,如果对于字符串"abcdabcaa",我们可以看到,当i=7时,j=2,当i=8时,j=3,因为d和a不匹配,那么j将会回退,j应该回退到哪儿呢,不要忘了,我们next数组可是记住了当前字符串之前的字符串的最大公共前后缀的啊,如果相等,则说明回退到这个位置即可,否则继续递归直到当前字符和待比较字符相等或者j=0,那么,说明j就应该回退到这个位置了,再继续进行会产生越界。 虽然没有在函数里面调用函数,但思想完全是递归的思想。

j指针在这个过程中,始终指向的是最大的公共前后缀。

2.1 KMP算法完整代码

/**
 * 生成next数组
 * @param {String} pattern 
 * @param {Number[]} next 
 */
 function genNext(pattern) {
    let m = pattern.length
    let next = []
    // 因为第一个字符串没有前后缀,所以可以直接赋值0
    next[0] = 0;
    //当取一个字符的时候,肯定是一个前后缀都没有的
    for (let i = 1, j = 0; i < m; ++i) {
        // 如果没有匹配到,递归的去求之前的最大前缀
        // 退出循环条件是 k大于0 并且当前位置的字符串要是一样的 
        while (j > 0 && pattern[i] != pattern[j]) {
            // 回溯,找到上一次的最大前后缀
            j = next[j - 1];
        }
        // 如果匹配到了,最大的前后缀+1
        if (pattern[i] == pattern[j]) {
            j++;
        }
        // 求出当前字符串的最大公共前后缀
        next[i] = j;
    }
    return next
}

/**
 * KMP-Search
 * @param {String} tpl 
 * @param {String} pattern 
 * @returns 
 */
function kmpSearch(tpl, pattern) {
    let n = tpl.length, m = pattern.length;
    let pos = -1;
    let next = genNext(pattern);
    for (let i = 0, q = 0; i < n; ++i) {
         // 如果不是在模式指针指向0的时候匹配失败,则从next数组中读取当前位置之前的最大公共前后缀,模式字符串指针移动至最大公共前后缀的位置。依次回溯,直到找到满意的为止,此处思路和求next数组思路一致。
        while (q > 0 && pattern[q] != tpl[i]){
            q = next[q - 1];
        }
        
        // 如果当前字符和模式字符串指针位上的字符相等, 模式指针后移一位
        if (pattern[q] == tpl[i]) {
            q++;
        }
       
        /*
         *  上述2个if不能交换位置,必须先判断是否匹配失败,才能继续进行匹配,如果交换的话,q指针先向后移动了一位,当前循环并没有结束,i指针还在前一个位置,此刻出现了错位,那么函数将不会正常运行。
         */
       
        // 如果模式字符串指针的位置走到了最后一位,则说明匹配成功了
        if (q == m) {
            // 因为当前匹配的位置实际上是在pattern的length-1的位置上
            pos = i - m + 1
            break
        }
    }
    return pos
}

3、总结

KMP算法的精髓是求解next数组的过程,其代码简洁但逻辑复杂。但大多数人对于求解next数组的过程都一笔带过,我在理解求解next数组的过程中花费了相当多的时间,只要你静下心花时间仔细研读,一定会有所收获的。求next的过程用自然语言的描述大概如下:初始化;前后缀相等;前后缀不等;处置next数组;其中最重要的点是在求解过程中,是一直不停的递归(while),不是仅递归一次就完成的(if)。另外一个重要的点是在遍历字符串的过程中,需要先判断当前模板字符串指针指向的字符和模式指针指向的字符是否相等,相等再将模式字符串指针后移,否则读取next[模式字符串指针-1]数组。

由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。