KMP算法,看这一篇就够了

发布于:2022-12-30 ⋅ 阅读:(575) ⋅ 点赞:(0)

        KMP是数据结构与算法课程中比较重要的一课,也是大厂面试算法的基础之一。KMP属于是算法中的几个门槛之一,理解起来会比较困难,今天我们用这篇文章详细讲一下算法的内容,希望能给大家带来帮助。

        KMP算法要解决的主要问题,是一个字符串s2是否是s1的子串的问题。举个例子,假设s1 = "abababc",s2 = "abc",则判断s2是s1的子字符串,并返回s2在s1中的位置4,如果s2 = "abd",则返回-1表示s2不是s1的子字符串。

    这里插一句,要注意子串和子序列的区别,通俗地说,子串是指该串的一个连续的局部。如果不要求连续,则可称为它的子序列。举个例子,对于字符串"abc",它的子串只有"", "a", "b", "c", "ab", "bc", "abc“几种,而子序列则比子串多一个"ac",因为子序列不需要保证连续

    从而可以得出一个结论,对于一个长度为n的字符串,保证字符串中字符各不相同,它的子序列的个数为2的n次方,因为字符串中的每个字符都可以有 要 和不要两种选择,而子串只有n * (n+1) / 2 + 1个

- 长度为0的子串有1个

- 长度为1的子串有n个

- 长度为2的子串有n-1个

- 长度为3的子串有n-2个

- ...

- 长度为n的子串有1个

所以所有子串的长度为1 + n + n-1 + n-2 + n-3 + ... + 1 = (1 + n) * n / 2 + 1

        首先不考虑KMP,先考虑最暴力的解法,不难想到肯定是双层for循环,字符串s2首先从s1的0位置开始比较,即比较s1[0] 和 s2[0],如果相等则继续比较s1[1] 和 s2[1],再相等则再继续比较s1[2]和s2[2]。如果不相等,则将改为从s1的1位置开始比较,即比较s1[1]和s2[0],以此类推。语言描述起来比较难懂,我做了一张演示图,可以帮助大家比较好理解。

        

        然后考虑一下这种做法的时间复杂度。众所周知,时间复杂度应该以算法的最坏情况来估计,这种方法的最坏情况是什么呢?

        我们给出这样的两个字符串:s1 = "aaaaaab",s2 = "aab",考虑一下这两个字符串通过上述解法,比较过程是什么样的呢?

        

        可以看到,在上述的比较过程中,每一次都是s1和s2比较到最后一个字符时才判断出两个字符串不匹配,我们假设s1的长度是n,s2的长度是m,不难得出这种暴力解法的时间复杂度是O(n * m)

        为什么这种暴力解法的时间复杂度这么高?我们先简单提一下,后面我们再和KMP算法对比来看,是因为,s2[0]在s1[0]处的比较结果,对于s2[0]在s1[1]处的比较结果没有任何的帮助。

        学习KMP的第一步,是了解字符串中的一个重要属性,我们用一个数组next来表示。next[i]表示的是:str[0...i-2]和str[1...i-1]位置上,前缀和后缀的最长相等长度。

        举个简单的例子:str = "abcabcz",我们计算一下next[6],即在"abcabc"这个字符串中,寻找前缀和后缀的最长相等长度。

        (为什么要叫next,我们在后面会讲)

        通过上表可以看出,"abcabc"这个字符串中,前缀与后缀的最长相等长度为3,我们就记next[6] = 3

        换一个极端一点的例子,"aaaaab"字符串中,next[5]等于多少?要求这个值,就是要求"aaaaa"字符串中前缀和后缀的最长相等长度,我们可以得到下面的这张表:

        可以看出,"aaaaa"这个字符串中,前缀与后缀的最长相等长度为4,我们计next[5] = 4

        懂了next数组的含义,我们算一下这个字符串"aabaabcaabd"的next数组

  • i = 0时,没有要计算的字符串,我们把此时的数值计为-1;

  • i = 1时,字符串没有要比较的前缀和后缀,我们把此时的数值计为0,也就是说,对于每一个字符串,next[0] = -1和next[1] = 1都是固定的,至于为什么这么规定,我们到了用的时候后面就会知道。

        以上,我们就掌握了KMP算法的中最重要的变量:next数组的含义,下面就是最重要的一步,KMP算法的核心

        KMP算法要求首先计算出s2的next数组,在s1和s2的一次匹配过程中,s1[k] = s2[0],s1[k+1] = s2[1],s1[k+2] = s2[2],...,s1[k+m]  != s2[m],在原来的暴力解法中,下一步是将s2的位置后移一位,也就是匹配s2[0]和s1[k+1],但是在KMP中,我们可以直接匹配s1[k+m]和s2[next[m]]。语言描述比较晦涩,我用如下的动图来做演示。

kmp流程-1

        图中相同颜色的矩形代表相等的字符串,可以看到,因为next[m]的的存在,所以图中蓝色区域一定相等,我们将s1[k+m]和s2[next[m]]做匹配时,会将s2的蓝色区域向后移动,保证和s1的蓝色区域一定是匹配的。

        以上就是KMP算法的核心内容,下面我们尝试对它做一下证明,即为什么一定是s1[k+m]和s2[next[m]]做匹配,s1的k+m之前的位置(即s1[k+m-t]和s2[0]做匹配为什么不能匹配成功呢?

        如果是s1[k+m-t]...s1[k+m]和s2[0]...s2[next[m]+t]匹配,那一定要求图中三个绿色的字符串片段是相等的,但是如果确实相等,那么s2的next[m]一定比现在算出来的更大,那只能说s2的next数组求错了,所以不成立。

        给KMP算法举一个实际的例子,str1 = "aabaadaabaac",str2 = "aabaac",那么两个字符串KMP比较的流程如下

kmp流程实际举例

    先简单看一下这个流程的代码,在文章的最后,我贴了java,c++和js版本的代码,希望前端和后端的同学们都可以看懂。

private static int kmp(String s1, String s2) {
    if (s1 == null || s2 == null || s1.length() <= 0 || s2.length() <= 0 || s1.length() < s2.length()) {
        // 非法参数判断
        return -1;
    }

    char[] str1 = s1.toCharArray();
    char[] str2 = s2.toCharArray();

    int[] next = getNextArr(str2);

    int i1 = 0, i2 = 0;
    while (i1 < str1.length && i2 < str2.length) {
        if (str1[i1] == str2[i2]) {
            i1++;
            i2++;
        } else if (next[i2] >= 0) {
            i2 = next[i2];
        } else {
            // i2 == 0
            i1++;
        }
    }

    return i2 >= str2.length ? i1 - i2 : -1;
}

        如何看这个函数的时间复杂度呢?我们要看while循环中三个分支走的次数。在这个函数中,i1 的取值范围是[0, n],i2 的取值范围是[0, m],又因为m肯定是小于n的,所以我们取最大值,即i2的取值范围肯定也是[0, n]。我们把 i1 和 i2 取一个运算:i1 - i2,那么i1 - i2 的取值范围应该也是[0, n]

        在这个while循环的三个分支中:

  • 第一个分支:i1 增加,i2 - i1 不变

  • 第二个分支:i1 增加,i2 - i1 增加

  • 第三个分支:i1 不变,i2 - i1 增加

        所以整个循环走完,最大只增加了2 * n次,所以整个while循环的时间复杂度是O(N)。

        然后我们看一下next数组的求法。

        我们采取从左到右的方式计算next数组,这样next[i]就可以使用之前的计算结果加速,也就是我们通常说的动态规划。

        首先有一点可以确定的是,i 位置的next数值和他自己是没关系的,因为next数值的算法就是 i 位置之前的字符串前缀后缀相等的最大长度

        如果str[i-1] == str[next[i-1]]的话,那么next[i] = next[i-1] + 1

next数组求法-1

        如上面的演示图所示,已知的是i-1位置的next值即next[i-1],也就是图中两个蓝色区域是相等的,如果str[i-1] 和 str[next[i-1]]相等,相等区域就可以向右扩一位,即next[i] = next[i-1] + 1

        想一下为什么不可能大于这个值?

next数组求法-2-反证

        我们使用反证法,假设next[i]比我们现在算出来的值要大, 也就是图中的黄色区域,那么意味着去掉str[i-1]和str[next[i-1]]的部分肯定也是相等的,那也就意味着我们现在的next[i-1]一定比之前算出来的next[i-1]要大,所以之前算出来的next[i-1]一定是错的

        如果str[i-1] != str[next[i-1]],那么就去检查str[i-1] 和str[next[next[i-1]],看是否相等,如果相等就等于next[next[i-1]] + 1,如果还不相等就继续向前找,如果一直找不到就为0

        语言描述比较抽象,我们举个实际的例子

        我们要算k位置的next值, 即next[22],那我们要参考next[21]

        我们判断出21位置的前缀和后缀的最大相等长度是9,即next[21] = 9。如果把21位置的字符换成和9位置相同的e那将成为绝杀,可惜换不得。如果21位置和9位置相等可以直接判断next[22] = 10

        然后我们继续判断next[9]。图中可以看出,next[9] = 3,我们判断str[21]是否等于str[3],如果相等则可以直接判断next[22] = next[9] + 1 = 4,否则继续向前找。至于为什么不能大于这个值,推法和上面还是一样的,最后会得出next[9]错了的悖论,这里不再次证明。

private static int[] getNextArr(char[] str2) {
    if (str2.length == 1) {
        return new int[] { -1 };
    }
    int[] next = new int[str2.length];

    next[0] = -1;
    next[1] = 0;

    // ne: next[i-1]
    int i = 2, ne = 0;

    while (i < next.length) {
        if (str2[i-1] == str2[ne]) {
            next[i++] = ++ne;
        } else if (next[ne] >= 0) {
            ne = next[ne];
        } else {
            next[i++] = 0;
        }
    }

    return next;
}

        我们再考虑一下求next数组的代码的时间复杂度,我们采用和上面类似的证法,getNextArr方法中有两个变量:i,ne,假设str2的长度是m,那么 i 和 ne 两个变量的取值范围都是[0, m]。while的三个分支中,i,ne有增有减,证明不出时间复杂度,所以我们把 i 和 ne 两个变量做差得到 i - ne,i - ne 的取值范围也是[0, m]。那么在while的三个分支中:

  • 第一个分支:i 增加,i - ne 不变

  • 第二个分支:i 不变,i - ne 增加

  • 第三个分支:i 增加,i - ne 不变

        所以整个while循环的最大执行次数是 2 * m次,即整个方法的时间复杂度是O(m)。

        在整个kmp算法中,kmp方法的时间复杂度是O(n),getNextArr方法的时间复杂度是O(m),在kmp中 m 一定小于 n,所以整个kmp算法的时间复杂度是O(n)。

        回顾一下文章上面留下的两个坑。

        首先next数组为什么叫next,是因为next数组表示了当str1[k]和str2[i]匹配失败时,str2需要匹配的下一个位置,即将str1[k]和str2[next[i]位置做匹配。

        其次,kmp算法相比暴力算法的优势在于,next数组使得 str2[i] 位置的匹配结果对下一次匹配是有帮助的,因为有next数组的存在,使得下一次匹配不需要对str1回退,对str2对回溯也不一定需要回退到0位置。

        以上就是kmp算法的全部内容,各个语言版本的整体代码在下面,如果有需要其他语言版本的,或者对文章内容有疑问的,也欢迎联系作者。

1. Java版

package com.dddf;

public class C01_KMP {

    private static int kmp(String s1, String s2) {
        if (s1 == null || s2 == null || s1.length() <= 0 || s2.length() <= 0 || s1.length() < s2.length()) {
            // 非法参数判断
            return -1;
        }

        char[] str1 = s1.toCharArray();
        char[] str2 = s2.toCharArray();

        int[] next = getNextArr(str2);

        int i1 = 0, i2 = 0;
        while (i1 < str1.length && i2 < str2.length) {
            if (str1[i1] == str2[i2]) {
                i1++;
                i2++;
            } else if (next[i2] >= 0) {
                i2 = next[i2];
            } else {
                // i2 == 0
                i1++;
            }
        }

        return i2 >= str2.length ? i1 - i2 : -1;
    }

    private static int[] getNextArr(char[] str2) {
        if (str2.length == 1) {
            return new int[] { -1 };
        }
        int[] next = new int[str2.length];

        next[0] = -1;
        next[1] = 0;

        // ne: next[i-1]
        int i = 2, ne = 0;

        while (i < next.length) {
            if (str2[i-1] == str2[ne]) {
                next[i++] = ++ne;
            } else if (next[ne] >= 0) {
                ne = next[ne];
            } else {
                next[i++] = 0;
            }
        }

        return next;
    }

    // for test
    public static String getRandomString(int possibilities, int size) {
        char[] ans = new char[(int) (Math.random() * size) + 1];
        for (int i = 0; i < ans.length; i++) {
            ans[i] = (char) ((int) (Math.random() * possibilities) + 'a');
        }
        return String.valueOf(ans);
    }

    public static void main(String[] args) {
        int possibilities = 5;
        int strSize = 20;
        int matchSize = 5;
        int testTimes = 5000000;
        System.out.println("test begin");
        int successNum = 0;
        int failNum = 0;
        for (int i = 0; i < testTimes; i++) {
            String str = getRandomString(possibilities, strSize);
            String match = getRandomString(possibilities, matchSize);
            int kmpRes = kmp(str, match);
            int apiRes = str.indexOf(match);
            if (kmpRes != apiRes) {
                System.out.println(String.format("str:%s, match:%s, kmpRes:%d, apiRes:%d", str, match, kmpRes, apiRes));
                failNum++;
            } else {
                successNum++;
            }
        }
        System.out.println("test finish, successNum: " + successNum + ", failNum: " + failNum);
    }


}

2. C++版

#include<iostream>
#include<vector>
using namespace std;

vector<int> getNext(string str)
{
    int len = str.length();
    vector<int> next(len); 
    next[0] = -1;
    if (len == 1)
    {
        return next;
    }

    next[1] = 0;

    int i = 2;
    int ne = 0;
    while (i < len)
    {
        if (str[i-1] == str[ne])
        {
            next[i++] = ++ne;
        } else if (next[ne] >= 0)
        {
            ne = next[ne];
        } else 
        {
            next[i++] = 0;
        }
    }

    return next;
}

int kmp(string str1, string str2)
{
    if (str1.length() <= 0 || str2.length() <= 0 || str1.length() < str2.length())
    {
        return -1;
    }

    vector<int> next = getNext(str2);

    int i1 = 0, i2 = 0;
    while (i1 < str1.length() && i2 < str2.length())
    {
        if (str1[i1] == str2[i2])
        {
            i1++;
            i2++;
        } else if (next[i2] >= 0)
        {
            i2 = next[i2];
        } else
        {
            i1++;
        }
    }

    return i2 >= str2.length() ? i1 - i2 : -1;
}

int main()
{
    string str1 = "ababababababc";
    string str2 = "aa";
    string str3 = "abc";

    cout << kmp(str1, str2) << endl;
    cout << kmp(str1, str3) << endl;
}

3. javascript版

var getNextArr = function (str) {
    if (str.length == 1) {
        return [ -1 ];
    }

    let next = [];
    next[0] = -1;
    next[1] = 0;

    let i = 2;
    // ne = next[i-1]
    let ne = 0;

    while (i < str.length) {
        if (str[i-1] == str[ne]) {
            next[i++] = ++ne;
        } else if (next[ne] >= 0) {
            ne = next[ne];
        } else {
            next[i++] = 0;
        }
    }

    return next;
}

var kmp = function (str1, str2) {
    if (str1 == null || str2 == null || str1.length <= 0 || str2.length <= 0 || str1.length < str2.length) {
        return -1;
    }

    let i1 = 0;
    let i2 = 0;

    let next = getNextArr(str2);

    while (i1 < str1.length && i2 < str2.length) {
        if (str1[i1] == str2[i2]) {
            i1++;
            i2++;
        } else if (next[i2] >= 0) {
            i2 = next[i2];
        } else {
            i1++;
        }
    }

    return i2 >= str2.length ? i1 - i2 : -1;


}

let str1 = "ababababababc";
let str2 = "aa";
let str3 = "abc";

console.log(kmp(str1, str2));
console.log(kmp(str1, str3));