KMP算法

139 阅读5分钟

虽然我很喜欢她,但是一直没有跟她讲,因为我知道,得不到的东西永远是最好的。 --《东邪西毒》

KMP算法是个非常优秀的算法,但是不是很好理解,我尽力而为,如果下面我不能让你彻底明白,也不必太在意,因为,得不到的永远是最好的。

从问题开始,现在有两串字符串str1(长度为N)、str2(长度为M),N大于等于M,求两串字符串的最大公共子串。最自然的,我们会想到,循环遍历一遍str1,每个位置和str2比较,记录最大的记录。很容易的知道,这样做的时间复杂度为O(N*M),现在问题来了,有没有存在一种可能,提升下性能,于是,D.E.Knuth、J.H.Morris和V.R.Pratt三位算法大神想到了一种解决的方法,时间复杂度可以做到O(N),KMP算法取名就是根据三位大神英文名的首字母而来。

KMP算法有个核心概念,就是nextArray,这是一个长度和str2一样的数组,存放的是str2每个位置前缀子串等于后缀子串的最大长度。这个概念不太好描述,还是举个例说明(原始串str2="abcdabck"):

image.png

下面给出nextArray的每个位置的计算规则

* 0位置规定为-1 
* 1位置规定为0 
* 2位置由于a != b,因此为0 
* 3位置 a != c,ab != bc,因此为0 
* 4位置 a != d,ab != cd,abc != bcd,因此为0 
* 5位置 a == a,ab != da,abc != cda,abcd != bcda,因此为1 
* 6位置 a != b,ab == ab,abc != dab,abcd != cdab,abcda != bcdab,因此为2 
* 7位置 a != c,ab != bc,abc == abc,abcd != dbac,abcda != cdabc,abcdab != bcdabc,因此为3

因此通过计算后nextArray存放的内容为:

image.png

在例子中,我们每个位置都是前后一个一个字符的对比,时间复杂度似乎没有O(M)这么好,实际上,这只是为了直观了解nextArray数组存放的是什么,实际代码可以做到O(M)。关键在于,当求i位置时,可以利用i-1位置作为指导,如果str2[i-1]==str2[nextArray[i-1]],那么nextArray[i] = nextArray[i-1] + 1,否则继续比较str2[i-1]和nextArray[nextArray[i-1]]关系,直到nextArray[...]小于0结束。以下面的字符串为例:

image.png

假设目前已经来到了i=17位置,求此位置的前缀子串等于后缀子串的最大长度。

第一次比较str2[16]和str[7]是否相等,发现不等,继续比较str2[16]和str2[3],发现相等,则nextArray[17] = 3 + 1。比较流程如下图所示:

image.png

下面证明如果str[i-1] == str[nextArray[i-1]],那么nextArray[i] = nextArray[i-1] + 1,反证法,假设nextArray[i] = nextArray[i-1] + 2,当初在i-1位置时,求得nextArray最大值为7,说明0-6位置和i-8到i-2位置字符一一相等,现在来到i,此刻nextArray等于9,那么说明在i-1时求得最大值为8,0-7位置和i-9到i-2位置字符一一相等,但是显然和上面的矛盾,同理可推出前向比较。

铺垫了这么多,下面开始讲str1和str2的比较过程(遍历str1,从0开始):

    1. 准备两个数组下标变量i1、i2,分别表示str1、str2当前来到的位置,如果str1[i1] == str2[i2],则都++比较下一个;
    1. 出现了str1[i1] != str2[i2],则利用nextArray数组从前项的最大前后缀下标开始和i比较,如果仍不相等,则继续拿前项的前项比较,直到如果前项为-1了,说明str2的第一个字符都不能和str1的i1位置字符相等,则i需要++从下一个位置开始了
    1. 整个比较过程结束,如果出现了i2==str2.length的情况,表示已经越界了,说明str1里面包含了str2。

代码如下:

/** 
* 查找str1是否包含str2 
* @param str1 
* @param str2 
* @return 返回第一次匹配的首位置,找不到返回-1 
*/ 
public static int findFirst(String str1, String str2) { 
    if (str1 == null || str2 == null || str2.length() < 1 || str1.length() < str2.length()) {
        return -1; 
     } 
     // 记录了str2每个字符最长前缀和后缀相等时候的下标 
     int[] nexts = nextArray(str2); 
     char[] arr1 = str1.toCharArray(); 
     char[] arr2 = str2.toCharArray(); 
     int i1 = 0; int i2 = 0; 
     while (i1 < arr1.length && i2 < arr2.length) { 
         if (arr1[i1] == arr2[i2]) { 
             // 字符串中字符一样,同时++ 
             i1++; 
             i2++; 
         } else if (nexts[i2] < 0) { 
             // next数组来到第一个了,还不相等,那只能源字符串+1进行比较了 
             i1++; 
         } else { 
             // 看前一个最大前后缀能不能匹配上,也就是从0到i2-1是否和源字符串的0到i1-1个字符是否相等 
             // 因此从i1 和 i2开始比较 
             i2 = nexts[i2]; 
         } 
     } 
     return i2 == arr2.length ? i1 - i2 : -1; 
} 
/** 
* next数组包含了每个位置字符 前缀子串等于后缀子串的最大长度,eg., 
* a b c d a b c k: * 0 1 2 3 4 5 6 7 
* 0位置规定为-1 * 1位置规定为0 
* 2位置由于a != b,因此为0 
* 3位置 a != c,ab != bc,因此为0 
* 4位置 a != d,ab != cd,abc != bcd,因此为0 
* 5位置 a == a,ab != da,abc != cda,abcd != bcda,因此为1 
* 6位置 a != b,ab == ab,abc != dab,abcd != cdab,abcda != bcdab,因此为2 
* 7位置 a != c,ab != bc,abc == abc,abcd != dbac,abcda != cdabc,abcdab != bcdabc,因此为3 
* @param str2 
* @return 
*/ 
private static int[] nextArray(String str2) { 
    char[] array = str2.toCharArray(); 
    int[] nexts = new int[array.length]; 
    nexts[0] = -1; 
    int cn; 
    for (int i = 2; i < nexts.length; i++) { 
        cn = nexts[i - 1]; 
        while (cn > -1) { 
            if (array[cn] == array[i - 1]) { 
                nexts[i] = cn + 1; break; } 
            else { 
                cn = nexts[cn]; 
            } 
        } 
    } 
    return nexts; 
}

通过代码,我们分析下时间复杂度,首先看findFirst方法,里面有三个分支,循环过程中,一次只可能进入其中的一个分支,因此,如果能证明i1、i2两个变量整体是不回退的,那时间复杂度必然为O(N),因此,我们比较i1和i1-i2的关系(数学上常用的技巧,如果两个变量单独比较不了,通常采用做差或做除的方式),可能的情况如下:

  • 1)、arr1[i1] == arr2[i2]时,i1增大,i2也增大,i1-i2不变
  • 2)、nexts[i2] < 0时,i1增大,i2不变,i1-i2增大
  • 3)、其他情况,i2减小,i1不变,i1-i2增大

因此,i1、i1-i2存在单调性,时间复杂度为O(N)。

下面就只剩下nextArray方法了,如果能证明其时间复杂度也为O(N),则说明整个KMP算法时间复杂度就是O(N)。同样的道理,直接看i和cn的关系看不出,我们看i和i-cn的关系,这里只存在两种可能,并且一次只能命中其中之一:

  • 1)、array[cn] == array[i - 1]时,跳出内层循环,cn不变,i增大,i-cn增大
  • 2)、其他情况,内层循环会使cn减小,i不变,i-cn增大

因此,i、i-cn存在单调性,时间复杂度为O(N),综上所述,总时间复杂度为O(N)。