KMP算法探究

175 阅读4分钟

现在有这样一个问题:

有一个文本串S,和一个模式串P,要查找P在S中的位置,如何查找? 

用暴力匹配的思路,假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置,则有: 

  • 如果当前字符匹配成功(即S[i] == P[j]),则i++,j++,继续匹配下一个字符;
  • 如果失配(即S[i]! = P[j]),令i = i - (j - 1),j = 0。相当于每次匹配失败时,i 回溯,j 被置为0。

按以上思路很容易就可以实现该算法,遗憾之处在于此算法的时间复杂度较高,为O(N*M),N,M分别为模式串和文本串的长度。那有没有更快的算法呢?当然是有的,其中之一就是KMP算法。

KMP算法相比暴力算法的优化之处以及原因,在这个文章第二段可以看到;KMP算法的流程和原理,在第三段也解释了,这篇文章主要记录下自己学习KMP算法的一些心得。

对于next数组,网上解释的普遍不是很明白,这里记录下自己的理解。

这个文章的3.2的解释比较清楚了,next数组来自于前缀后缀最长公共元素,next数组依赖这个得到。

前缀后缀最长公共元素(以下简称最长公共串)的含义

    其实对于一个给定字符串来说,公共串的求法很简单:把一个串的所有前缀后缀列出来,相同的前缀后缀中最长的那个就是最长公共串。比如字符串ababa,前缀是:a,ab,aba,abab,后缀是a,ba,aba,baba,相同的最长的是aba,所以最长公共元素的长度就是3。

    这个信息在KMP算法里有何意义呢?如果我一个模式串的前五个字符就是ababa,刚好都匹配了文本串,然后下一个字符失配了,这个时候如果按照暴力算法,需要把模式串往前移动一个字符;这个信息在此时就用得上了:最长公共元素意味着,ababa前五个字符组成的字符串,移动后最多只能有三个字符匹配。

    为什么呢?ababa这个字符串,不同长度前缀分别是:a,ab,aba,abab;不同长度后缀分别是:a,ba,aba,baba。如下图所示,移动不同的字符数,就分别对应了不同长度前后缀的比较(比如移动两个字符,就对应了长度为3的前后缀的比较)

所以至少需要移动两个(已匹配的长度5-最长公共元素长度3)字符,才可能出现匹配的情况。

    从这个例子可以很明显看出来,每次失配后,需要移动的字符数=已匹配的长度-最长公共元素长度。而在next数组中,为了方便使用,将最长公共元素长度数组向右移动一位,然后next[0]赋值为-1即可。

实现过程中的问题

(1)next数组的求解

对于next数组的求解,可以参考这个知乎问题中答主恶劣天气的回答,根据这个回答写出计算过程如下(needle是模式串)。

    next[0] = -1;
    int k = 0;
    for (int i = 1; i < next.size();) {
        if (i == 1) {
            next[i] == 0;
            i++;
        }
        while (k >= 0 && i < next.size()) {
            if (needle[i - 1] == needle[k]) {
                k++;
                next[i] = k;
                i++;
            }
            else { k = next[k]; }
        }
        if (i < next.size()) {
            k = 0;
            next[i] = 0;
            i++;
        }
    }

主体思路就是对模式串中每一个字符,循环求它的每个next[i],在循环中,每个next[i]再在一个while循环中求解,如果下一个字符相同,next值增加1,继续求下一个next[i];如果不同,则多移动一些,再尝试求next[i],一直移动到开头还找不到,则说明开头字符到当前位置字符对应的字符串最长公共元素长度为0,故这时next[i]=0。

为什么i=1时要特殊判断?因为没有这个特殊判断,按照后续的判断逻辑,会走到第一个if分支,next[1]就会求解出错。

这种写法逻辑是OK的,但相比标准求解的代码还是繁杂了些:

        next[0]=-1;
        int k=-1;
        int i=0;
        for(;i<next.size()-1;) {
            if(k==-1 || needle[i]==needle[k]) {
                i++;
                k++;
                next[i]=k;
            }else {
                k=next[k];
            }
        }

标准求解代码优化的点:

1.下一个字母匹配的情况和所有字母完全不匹配的情况合入到一个if分支;

2.基于第一点,求next[1]的代码合入到一般next[i]的求解过程;

优化之后的代码看着简洁了很多,但其实也考虑了各种边界条件的情况,确实是很优秀的代码了。

(2)字符串匹配的代码,haystack是字符串,needle是模式串

    int pos = 0;
    for (int subPos = 0; pos < haystack.length();) {
        if (subPos == needle.length()) { return pos - subPos; }
        else if (subPos == -1) {
            pos++;
            subPos = 0;
        }
        else if (haystack[pos] == needle[subPos]) {
            pos++;
            subPos++;
        }
        else if (subPos >= 0) { subPos = next[subPos]; }
    }
    return -1;
    

以上的代码逻辑很清晰,4个分支分别代表:已经匹配到模式串;已经移动到模式串开头,从字符串下一个字符继续匹配;下一个字符匹配;下一个字符失配。看起来所有情况都考虑到了,但实际上还有未考虑到的情况:如果模式串和字符串完全一样,将会返回-1。看看正确的写法:

    int pos = 0, subPos = 0;
    int slen = haystack.length();
    int nlen = needle.length();
    for (; pos < slen && subPos < nlen;) {
        if (subPos == -1 || haystack[pos] == needle[subPos]) {
            pos++;
            subPos++;
        } else if (subPos >= 0) { subPos = next[subPos]; }
    }
    if (subPos == nlen) { return pos - subPos; } else { return -1; }

将是否匹配的代码放在循环之外,避免了之前代码的漏洞。还有值得一提的是先用一个int变量存长度,看起来无关紧要,但因为中间会出现-1和字符串长度比较,如果没有这个赋值而是直接比较,length函数返回的是size_t,与-1比较,类型转换时-1会变成INT_MAX,导致比较结果错误。

结语

KMP算法看起来不难,但实际实现时有很多要注意的细节,自己实际去写一遍会有更多收获