虽然我很喜欢她,但是一直没有跟她讲,因为我知道,得不到的东西永远是最好的。 --《东邪西毒》
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"):
下面给出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存放的内容为:
在例子中,我们每个位置都是前后一个一个字符的对比,时间复杂度似乎没有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结束。以下面的字符串为例:
假设目前已经来到了i=17位置,求此位置的前缀子串等于后缀子串的最大长度。
第一次比较str2[16]和str[7]是否相等,发现不等,继续比较str2[16]和str2[3],发现相等,则nextArray[17] = 3 + 1。比较流程如下图所示:
下面证明如果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开始):
-
- 准备两个数组下标变量i1、i2,分别表示str1、str2当前来到的位置,如果str1[i1] == str2[i2],则都++比较下一个;
-
- 出现了str1[i1] != str2[i2],则利用nextArray数组从前项的最大前后缀下标开始和i比较,如果仍不相等,则继续拿前项的前项比较,直到如果前项为-1了,说明str2的第一个字符都不能和str1的i1位置字符相等,则i需要++从下一个位置开始了
-
- 整个比较过程结束,如果出现了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)。