字符串匹配算法:KMP算法

156 阅读4分钟

今天学习了KMP算法,觉得有必要记录一下。

Brute-Force 暴力算法

暴力算法就是当每一次字符串匹配不成功的时候,将模式串往后移一个单位,这样时间复杂度为O(mn)O(m * n) 代码如下:

class Solution {
    public boolean match(String str, String pattern) {
        int n = str.length();
        int m = pattern.length();
        for (int i = 0; i < n; i++) {
            if (str.substring(i, m).equals(pattern))) return true;
        }
        return false;
    }
}

KMP算法

每次字符串匹配失败都会提供一个有用的信息,就是当前模式串和主串匹配字符错误前面的前缀是相同的,那么我们只要利用这个信息避免一些不必要的计算。

具体来说,当我们知道了这一公共前缀后,我们要在这一公共前缀字符串里找到最长的前缀能匹配其某一后缀的,通俗来讲,就是使分别从首尾开始的子字符串能够相同的最大长度。

举个例子,abcab的符合上述描述的最长前缀是ab,因为它能匹配后缀ab,且abc不能匹配后缀cab,所以它长度最长。

计算出每个公共前缀的最长前缀后,因为其性质,我们可以将模式串的公共前缀的最长前缀与刚匹配的那一段主串的相应后缀对齐,然后再进行下一轮的匹配。

所以我们需要一个next数组,计算每个前缀的最长前缀所对应的长度。

每当我们在pattern[pos]匹配失败的时候,模式串就从next[pos-1]的地方开始匹配,这样使得主字符串里的公共前缀的后缀能和模式字符串里的前缀对齐。

这里我们先假定next数组已经计算得出,代码如下:

class Solution {
    public boolean match(String str, String pattern) {
        int n = str.length();
        int m = pattern.length();
        int[] next = buildNext();
        int tar = 0; // 主串里匹配的当前位置
        int pos = 0; // 模式串里匹配的当前位置
        while (tar < n) {
            if (str.charAt(tar) == pattern.charAt(pos)) {
                // 匹配成功,主串和模式串同时推进一个字符
                tar++;
                pos++;
            }else if (pos != 0) {
                // 匹配不成功,有相同的公共前缀,pos移动到公共前缀的最长前缀处,使得与主串公共前缀的最长后缀处对齐
                pos = next[pos-1];
            }else{
                // 匹配不成功,pos已为0,说明没有公共前缀,直接主串推进一位
                tar++;
            }
            
            if (pos == m){
                // 最后一位匹配成功,整个模式串匹配成功
                return true;
            }
            
        }
        return false;
    }
}

next数组

接下来我们讲如何计算next数组,next数组我们只需要依赖模式串。暴力直观的计算方法,是直接一个个比较字符串的前缀和后缀。

但这样的时间复杂度会达到O(m2)O(m^2)

那么如何通过之前已经计算得到的next[0], next[1], .... , next[i-1] 来得到next[i]呢? 我们观察到如果i处的字符如果和next[i-1]处的相同,那么next[i]可以直接有next[i-1]+1得到。

而如果不同,则需要迭代找到某个能使i处的字符与next[k]处的字符相同的k,然后next[i] = next[k] + 1。 加上next数组构建实现的代码如下:

class Solution {
    public boolean match(String str, String pattern) {
        int n = str.length();
        int m = pattern.length();
        int[] next = buildNext(pattern);
        int tar = 0; // 主串里匹配的当前位置
        int pos = 0; // 模式串里匹配的当前位置
        while (tar < n) {
            if (str.charAt(tar) == pattern.charAt(pos)) {
                // 匹配成功,主串和模式串同时推进一个字符
                tar++;
                pos++;
            }else if (pos != 0) {
                // 匹配不成功,有相同的公共前缀,pos移动到公共前缀的最长前缀处,使得与主串公共前缀的最长后缀处对齐
                pos = next[pos-1];
            }else{
                // 匹配不成功,pos已为0,说明没有公共前缀,直接主串推进一位
                tar++;
            }
            
            if (pos == m){
                // 最后一位匹配成功,整个模式串匹配成功
                return true;
            }
            
        }
        return false;
    }
    public int[] buildNext(String p){
        int x = 1; // 一个字符的最长前缀自然是0
        int now = 0; // now = next[x-1]
        int[] next = new int[p.length()];
        
        while (x < p.length()) {
            if (p.charAt(x) == p.charAt(now)) {
                next[x++] = ++now;// 当前位置的字符与x位置的字符相同,直接右移一位
            }else if (now != 0) {
                now = next[now-1];// 寻找最大的能使x处的字符与now处的字符的now
            }else{
                next[x++] = 0; // 不能再缩小k,直接赋0
            }
        }
        return now;
    }
}