【数据结构与算法】KMP算法详解

1,938 阅读9分钟

KMP算法

1. 引入

KMP算法中最著名的应用就是 "求子串问题"。

题目

现在有str1="abcd1234efg" 和str2="1234",如何判断str2是不是str1的子串?

注意,子串必须是连续一段,比如 "1234f" 就不是 "abcd1234efg" 的子串,但是 "1234e" 是。

分析

本题如果使用暴力解法,则尝试方法是从左往右尝试str1中每一个字符是否能够配出str2。

暴力解法在一种极端的情况下,时间复杂度会非常高。比如:str1="1111111112",str2="11112"。如果要用暴力解法,则str1从第一个字符1向后配了5次发现配不上,再从第二个字符1向后又配了5次发现配不上,以此类推,直到str1最后一段才配上。如果str1长为N,str2长为M,则时间复杂度为O(NM),相当于str1每走一步就需要遍历整个str2。

如果使用KMP算法解决该问题,则返回类型不是boolean,而是返回子串在str1中第一个字符的下标,如果不包含返回-1。

KMP和暴力解法核心思想是一样的,都是从左往右尝试str1中每一个字符是否能够配出str2,但是KMP有加速。

暴力解法

public static boolean findSubStr(String str1, String str2) {
    if (str1 == null || str2 == null || str1.length < str2.length) {
        return false;
    }

    return process(str1.toCharArray(), str2.toCharArray());
}

public static boolean process(char[] c1, char[] c2) {
    // 标记默认为false,这样如果str1和str2一个相交字符都没有也会返回false
    boolean flag = false;

    // 遍历整个str1
    for (int i = 0; i < c1.length; i ++) {
        // 尝试str1中每一个字符和str2相匹配
        if (c1[i] == c2[0]) {
            // 只要有一个字符匹配上,我们就期望这次匹配是成功的
            flag = true;
            // str1和str2挨个字符进行匹配
            int k = i + 1;
            for (int j = 1; j < c2.length; j ++, k ++) {
                // 在匹配过程只要有一个字符不一样匹配就失败,直接终止匹配
                if (c1[k] != c2[j]) {
                    flag = false;
                    break;
                }
            }
        }
    }

    return flag;
}

2. 最大匹配长度

在将KMP算法解决 "求子串问题",我们首先要理解一个概念,就是最长前缀和后缀的匹配长度

最长前缀和后缀的匹配长度是子串(str2)中每一个字符都需要携带的信息,该信息与对应的字符无关,但是与对应字符的前面所有字符有关

比如说有一个字符串 "abbabbk",我们想要知道k字符的最长前缀后后缀的匹配长度。

首先我们需要获取k字符前面的字符串 "abbabb",然后求出 "abbabb" 字符串前缀和后缀的最大匹配长度为3,k字符携带的信息就是3。

20211009091225.png

前缀和后缀都不能取到字符串整体。

如果该字符前面没有字符串,则该字符携带的信息就是 -1。

人为规定字符串0位置的字符携带的是 -1,1位置的字符携带的是0。

3. 加速流程

使用KMP加速该问题的关键是:str2中每一个字符要携带前缀和后缀的最大匹配长度

假设str1="......oabbcefabbckbc......",str2="abbcefabbcg......"。str1中显示的首字符o是第 i - 1 位,假设当前str1从第 i 位开始开始匹配str2(前面匹配过程忽略)。

使用经典方法

20211010165614.png

str1的第 i 位之后的字符依次和str2进行字符匹配,在str1匹配到第 i + 10 位时,发现和str2第 10 位的字符匹配不上,从第 i 位开始的匹配操作失败。因此,str1就会放弃第 i 位能匹配子串成功的可能性,匹配指针会从str1的第 i + 10 位跳到第 i + 1位,第 i + 1之后的字符依次再和str2第 0 位字符进行字符匹配(相当于str2向右滑动一位)。以此类推。

使用KMP算法加速

20211012105616.png

统计str2中每个字符的前后缀最大匹配长度: [ -1,0,0,0,0,0,0,1,2,3,4 ...... ]。

str1的第 i 位之后的字符依次和str2进行字符匹配,在str1匹配到第 i + 10 位时,发现和str2第 10 位的字符匹配不上,从第 i 位开始的匹配操作失败。

根据统计的前后缀最大匹配长度可知,其中前 11 位中最大匹配长度为第 10 位字符对应的4,因此可以确定在str2第 10 位字符g之前最大前缀和最大后缀是 "abbc"。

str1的匹配指针不动,继续和str2的第 4 位(最大前缀的后一位)进行字符匹配。

4. 剖析

要想理解KMP的第二部操作到底什么意思?通过查看两次的比较对象就可以得知。

20211012105712.png

和经典方法一样,在str1第 i 位开始的匹配操作失败之后,str1也不会再去尝试第 i + 1 位能够匹配成功的可能性,但是str1不会去尝试第 i + 2 位能够匹配成功的可能性,而是直接尝试第 i + 6 位能够匹配成功的可能性(相当于str2向右滑动(最大后缀中第一位的下标 - 最大前缀第一位的下标)位),也就是说str1会直接从str1中的str2的最大后缀开始匹配

但是str1第 i + 6 位和str2第 0 位开始往后4位在上一轮匹配中就确定是重合的了,都是最大前 / 后缀的内容为 "abbc"。为了加速效果更好(常数加速),在实际比较时可以直接跳过重合部分进行开始比较。

因此str1并没有从第 i + 6开始尝试匹配,而是直接从第 i + 11位开始匹配,因为str1的第 i + 6 位到第 i + 9 位和str2的第 0 位到第 3 位是重合的,只有到str1的第 i + 10 位之后才可能会出现匹配失败的情况。

那么现在需要证明一点:为什么从str1的第 i + 1 位到第 i + 5 位开始匹配就一定会匹配失败?

5. 证明

为什么从str1的第 i + 1 位到第 i + 5 位开始匹配就一定会匹配失败?

还是和str2统计的第 0 位到第 10 位字符每个字符的前后缀最大匹配长度这个数组 [ -1,0,0,0,0,0,0,1,2,3,4 ...... ] 有关。

20211011165344.png

将上述证明问题抽象:证明str1的第 i 位到第 j 位 [ i+1,j-1 ] 中间任何一位 k 开始匹配都一定会失败。

该证明有一个前提条件:从str1的第 i 位到第 (X - 1) 位这一段和从str2的第 0 位到第 (Y - 1) 位这一段完全相等。

使用反证法,假设str1的第 k 位开始匹配,结果匹配成功。

已知,str1无论是从哪一位开始匹配都是从str2的第 0 位开始匹配的,如果匹配成功,意味着从str1的第 k 位开始一直到第 X 位这一段一定和从str2第 0 位开始到第 (X - k) 位这一段完全一致。

因此,从str1的第 k 位开始一直到第 (X -1) 位这一段一定和从str2的第 0 位开始到第 (X - k - 1) 位这一段完全一致。

又因为上一轮顺位匹配时只有str1第 X 位和str2第 Y 位不同,因此从str1的第 k 位到第 (X -1)位这一段一定和从str2的第 (Y - X + K) 位到第 (Y - 1) 位这一段一定完全一致。

由等价交换,从str2的第 0 位开始到第 (X - k - 1) 位这一段和从str2的第 (Y - X + K) 位到第 (Y - 1) 位这一段一定完全一致。

因为str1的第 k 位小于第 j 位,因此从str1的第 k 位到第 X 位这一段一定比从第 j 位到第 X 位这一段要大。

因此对应的从str2的第 0 位开始到第 (X - k - 1) 位这一段一定比原第 Y 位的最长前缀要长。

因为从str2的第 0 位开始到第 (X - k - 1) 位这一段和从str2的第 (Y - X + K) 位到第 (Y - 1) 位这一段等长。

因此对应的从str2的第 (Y - X + K) 位到第 (Y - 1) 位这一段一定比原第 Y 位的最长后缀要长。

又因为从str2的第 0 位开始到第 (X - k - 1) 位这一段也相当于是第 Y 位的前缀,从str2的第 (Y - X + K) 位到第 (Y - 1) 位这一段也相当于是第 Y 位的后缀。

所以原str2的第 Y 位出现了一个比最长前缀和后缀更长的前缀和后缀,因为已经给出了最长前缀和后缀,所以相互矛盾,假设不成立。

6. 完整流程

实际上KMP的整个流程就是str2一直右移的过程。

我们抛开KMP常数优化的过程,仔细分析一下KMP加速的本质流程。

20211011204348.png

KMP为什么很快?拿str1中a~e举例子,使用经典方法需要比较17次,而使用KMP只需要比较4次,如果加上KMP的常数加速,那么在4次比较中将会更快。

其中,①、②、③和④是4次匹配开始时str1和str2的比较位置,实际上代表了KMP对str1中a~e这一段完整的加速流程。

在匹配指针指向str1的①位置开始匹配时,直到发现 e 和 w 匹配不上,因此①位置匹配失败。

然后找出str2中 w 的最长前后缀为:abbsabb。匹配指针指向②位置开始匹配,直到发现 e 和 t 匹配不上,因此②位置匹配失败。

然后找出str2中 t 的最长前后缀为:abb。匹配指针指向③位置开始匹配,直到发现 e 和 s 匹配不上,因此③位置匹配失败。

然后找出str2中 s 的最长前后缀为:无。匹配指针指向④位置开始匹配,发现 e 和 a 匹配不上,因此④位置匹配失败。

str1中a~e已经全部和str2匹配完,任何一个位置开始匹配都匹配不出str2,因此匹配指针指向 e 的后面一位⑤开始匹配,继续循环执行①位置的操作,周而复始,直到str1最后一位结束。

7. 实现

该实现过程包含了常数加速过程。

// 返回值是子串在str1中的起始下标
public static int findSubStr(String str1, String str2) {
    if (str1 == null || str2 == null || str1.length() < str2.length()) {
        return -1;
    }

    return process(str1.toCharArray(), str2.toCharArray());
}

public static int process(char[] str1, char[] str2) {
    // i1是str1的匹配指针,i2是str2的匹配指针
    int i1 = 0;
    int i2 = 0;

    // 获取str2所有字符的最长前缀和后缀
    int[] next = getMaximalPrefixAndSuffix(str2);

    // 匹配过程只要i1越界或者i2越界匹配都会终止(i1和i2也可能同时越界)
    while (i1 < str1.length && i2 < str2.length) {
        // KMP加速过程中只有三种情况
        if (str1[i1] == str2[i2]) {
            // 对应位置一样,str1和str2并行向后继续匹配
            i1 ++;
            i2 ++; // 只有匹配字符相等时i2才会往后走
        } else if (i2 == 0) { // next[i2] == -1
            // 对应位置不一样,但是str2的匹配指针在0位置,说明i2跳到0位置了,意味着str1前面一整段没有位置能和str2匹配成功
            // str1匹配指针向后移一位开始下一段与str2的匹配
            i1 ++;
        } else {
            // 对应位置不一样,且str2的匹配指针不在0位置,此时i2需要跳到最长前缀的下一位,进行下一次比较
            // i2跳的这个过程就是KMP常数加速的操作
            i2 = next[i2];
        }
    }

    // 如果i2越界,那么说明str1中匹配成功了str2,那么就返回str1中子串的首位
    // 如果i1越界,那么说明str1中没有任何一位能够与str2匹配成功,返回-1
    return i2 == str2.length ? i1 - i2 : -1;
}

// 统计最大前后缀的本质就是确定str2每一位的最大前缀,预估的最大前缀在没有匹配成功时每一轮都会缩小,直到收缩到0,则表示该位置没有最大前缀
public static int[] getMaximalPrefixAndSuffix(char[] str2) {
    // 如果str2中只有一个字符,那么一定是next[0]
    if (str2.length == 1) {
        return new int[] {-1};
    }

    // 如果不止一个字符,那么手动给next[0]和next[1]赋值
    int[] next = new int[str2.length];
    next[0] = -1;
    next[1] = 0;

    // str2的游标,从str2[2]后开始给next填值
    int i = 2;

    // prefix是c[i]目前最有可能的最长前缀的后一位的下标
    int prefix = 0;

    // 当i越界时表示next已经全部填满
    while (i < str2.length) {
        if (str2[i - 1] == str2[prefix]) {
            // str2[i]的前一位和str2[i]当前最有可能的最长前缀的后一位的下标相同,说明最长前缀还能延长,需要包含str2[prefix]
            // 同时当前第i位匹配成功
            next[i ++] = ++ prefix;
        } else if (prefix > 0) {
            // 如果str2[i]的前一位和str2[i]当前最有可能的最长前缀的后一位的下标不相同,说明最长前缀必须缩小,prefix需要向前跳
            // prefix需要跳到c[prefix]最长前缀的后一位
            // 当前第i位匹配失败,下一轮继续匹配第i位
            prefix = next[prefix];
        } else {
            // 当prefix跳到第0位时,还和第i位匹配不上,说明str2[i]没有最长前缀,置为0
            // 同时当前第i位匹配成功
            next[i ++] = 0;
        }
    }

    return next;
}

8. 复杂度

首先证明process方法中的while循环的复杂度:

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

通过观察代码,我们可以发现第一个分支中 i1 和 i2 都增大;第二个分支中 i1 增大;第三个分支中 i2 减小。

估计while的复杂度时,我们需要假设两个量,第一个量是 i1,第二个量是 i1 - i2。

假设str1长度为N,那么 i1 和 i1 - i2 的最大值都是N。

我们要看循环中的三个分支分别对这两个量的影响。

20211012172030.png

循环的第一个分支,i1 和 i2 一起增加。因此 i1 增加,i1 - i2 不变。

循环的第二个分支,i1 增加。因此 i1 增加,i1 - i2 增加。

循环的第三个分支,i2 减少。因此 i1 不变,i1 - i2 增加。

每一次循环只会走一个分支,因此将这两个量的变化范围叠加起来,最大的幅度就是2N(走第二个分支,且都是N)。

三个分支都不会让两个量中任何一个量减少,因此循环发生的次数就和两个量变化范围的叠加绑定在了一起,两个量变化的幅度就是while循环的次数,所以整个while循环的次数不会超过2N。

因此可以证明while的时间复杂度是线性的,为O(N)。

然后证明getMaximalPrefixAndSuffix方法的复杂度:

public static int[] getMaximalPrefixAndSuffix(char[] str2) {
    if (str2.length == 1) {
        return new int[] {-1};
    }

    int[] next = new int[str2.length];
    next[0] = -1;
    next[1] = 0;

    int i = 2;
    int prefix = 0;

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

    return next;
}

估计getMaximalPrefixAndSuffix的复杂度时,我们也需要假设两个量,第一个量是 i,第二个量是 i - prefix。

假设str2长度为M,i 的最大值是M,i - prefix的最大值也是M。

20211012212128.png

循环的第一个分支,i 和 prefix 一起增加。因此 i 增加,i - prefix 不变。

循环的第二个分支,prefix 减少。因此 i 不变,i - prefix 增加。

循环的第三个分支,i 增加。因此 i 增加,i - prefix 增加。

每一次循环只会走一个分支,因此将这两个量的变化范围叠加起来,最大的幅度就是2M(走第三个分支,且都是N)。

因此可以证明getMaximalPrefixAndSuffix的时间复杂度是线性的,为O(M)。

总体时间复杂度为:

因为M一定小于等于N,所以KMP整体时间复杂度为O(N)。