KMP算法解决的问题:字符串str1和str2,str1 是否包含str2,如果包含返回str2在str1中开始的位置。如何做到时间复杂度 O ( N ) O(N) O(N)完成?(经典字符串匹配问题)
暴力解法:遍历str1中的每个字符,判断以该字符为首字符时,能否与str2匹配,时间复杂度为 O ( N ∗ M ) O(N*M) O(N∗M)
不难发现经典解法的实质就是,如果i
位置为首的字符串不匹配,就往回跳到i+1
从头开始判断,仔细看整个过程可以知道,每次回跳,都会重复判断很多字符,那是否可以将这些信息收集利用起来,使得每次不用回跳这么多?KMP算法就是这样的,它甚至不用回跳。
讲解KMP之前,先来了解几个概念:
-
最大相同前、后缀长度:对于一个字符串str的第
i
位置的字符str[i]
,其最大相同前后缀长度指的是在这个字符之前的子串(即str[0~i-1]
)所具有的最长相同前后缀的长度,以abbabbk
为例:- 对于
k
这个字符,其对应的子串为abbabb
。- 当取长度为1时,其前缀为
a
,后缀为b
,不相同(不匹配❌); - 当取长度为2时,其前缀为
ab
,后缀为bb
,不相同(不匹配❌); - 当取长度为3时,其前缀为
abb
,后缀为abb
,相同(匹配成功✔); - 当取长度为4时,其前缀为
abba
,后缀为babb
,不相同(不匹配❌); - 当取长度为5时,其前缀为
abbab
,后缀为bbabb
,不相同(不匹配❌); - 不能取长度为6,因为6就是这个子串的长度,一定会匹配,判断它没意义。
- 当取长度为1时,其前缀为
- 这样我们就得出最大前后缀匹配长度为3
- 对于
-
next数组:就是由最大相同前后缀长度(还挺拗口🥴)构成的数组,该数组长度等于字符串长度,字符串中每个字符的最大相同前后缀长度都记录在该数组中。
- 对于0位置处的字符,由于其前面没有子串,所以人为规定其最大相同前后缀长度为-1
- 对于1位置处的字符,其前面只有一个字符,由于其前后缀一定相同,所以我们将其设为0
举个栗子🌰:
字符串:[ a a b a a b s a a b a a b s t] next: [-1 0 1 0 1 2 3 ...]
KMP算法详解:
我们有了next数组,就可以根据next数组的信息,来决定匹配开始的位置,具体过程如下:
-
我们要在str1中查找str2,就计算出str2的next数组。
-
假如现在从
str1[i]
位置开始匹配,匹配到str1[X]
位置发现不相等,即str1[X]≠str2[Y]
,由str2的next数组我们可知Y位置之前字符串的最大前后缀匹配长度,也就是下图中的橙圈,既然前后缀相同,我们就可以直接省略str2前缀的匹配(即从位置j开始的匹配),直接从可能不相等的位置开始验证(即判断str1[X]
和str2[X-j]
是否相同)。
-
如何认定以
[i, j]
范围上任意一个字符为首的字符串一定无法与str2匹配呢?-
在从
i
位置往下匹配的过程中,当我们来到X
,发现str1[X]≠str2[Y]
,这时我们才停止匹配,所以在str1[i, X-1]
和str2[0, Y-1]
范围上的字符串一定相等。 -
假设在
str1[i, j]
范围上存在一个字符str1[k]
,使得以该字符开头,可以得到一个与str2
相匹配的子串。则易知str1[k, X-1]
范围上子串一定与str2
等量的部分相同(即紫色框框的部分)
-
不难发现现在对于
str2[Y]
来说,已经出现了一个更长的最大相同前后缀,就是两个紫色的部分(因为之前我们已经推出了str1[i, X-1]=str2[0, Y-1]
的结论),这是与next的结论相悖的,所以不成立。
-
-
如何求next数组?
-
next[i]
代表什么?0~i-1
字符串的最大相同前后缀长度- 即下图中的下标k:
-
首先,之前已经说过:
next[0] = -1, next[1] = 0
。 -
对于
next[i]
,我们可以利用next[i - 1]
的信息计算,next[i - 1]
是字符str2[i - 1]
之前子串的最大相同前后缀长度,也就是下图中的j
,橙圈代表前/后缀:
-
首先我们要判定
j
位置的字符是否等于i-1
位置的字符-
如果它们相等的话,显然
next[i]=next[i-1] + 1 = j + 1
(上图紫圈) -
如果他们不等的话,则需要让j往前跳:
j = next[j]
,因为两个橙圈的部分是相同的,都是str2[i-1]
的最大相同前后缀,但同时也是str2[j]
之前的字符串,所以next[j]
是什么呢?🧐,没错,就是下图中的k
,绿色圈 标出了str2[j]
之前的字符串的最大相同前后缀。
所以有
j = next[j] = k
,然后判断j
位置的字符(即str2[k]
)是否等于i-1
位置的字符,重复上述过程…… -
当
j
到达0位置时,说明并没有公共前缀,所以next[i] = 0
-
-
JavaCode:
public class Kmp {
/**
* @param s 在哪个字符串中匹配
* @param m 要匹配的字符串
* @return 首个匹配字符串的首字符下标。如果没有匹配,则返回-1
*/
public static int getIndexOf(String s, String m) {
// 要求 N >= M, N = s.length, M = m.length
if (s == null || m == null || m.length() < 1 || s.length() < m.length()) {
return -1;
}
char[] str1 = s.toCharArray();
char[] str2 = m.toCharArray();
int i1 = 0, i2 = 0;
// O(M)
int[] next = getNext(str2);
// O(N)
while (i1 < str1.length && i2 < str2.length) {
if (str1[i1] == str2[i2]) {
i1++;
i2++;
} else if (i2 > 0) {
// 不匹配,i2往前跳
i2 = next[i2];
} else {
// str2中比对的位置已经无法往前跳了
i1++;
}
}
// i2越界,说明匹配成功;i1越界,说明匹配失败;(同时越界也代表匹配成功)
return i2 == str2.length ? i1 - i2 : -1;
}
/**
* 获取ms的next数组
*/
public static int[] getNext(char[] ms) {
if (ms.length == 1) {
return new int[]{-1};
}
int[] next = new int[ms.length];
next[0] = -1;
next[1] = 0;
// i: next数组的位置
int i = 2;
// cn: 要和next[i-1]比较的位置
int cn = 0;
while (i < next.length) {
if (ms[i - 1] == ms[cn]) {
next[i++] = ++cn;
} else if (cn > 0) {
// 当前跳到cn位置的字符,和i-1位置的字符配不上,则cn继续向前
cn = next[cn];
} else {
next[i++] = 0;
}
}
return next;
}
public static void main(String[] args) {
System.out.println(getIndexOf("abbsabb", "sabb")); // 3
}
}
复杂度分析:
- 假设str1和str2的长度分别是N和M
- getNext:
- 首先对于while循环中的内容,存在两个量
i
和i - cn
- 第一个条件分支中,
i
增加,i - cn
不变 - 第二个条件分支中,
i
不变,i - cn
加一 - 第三个条件分支中,
i
和i - cn
都增加
- 第一个条件分支中,
- 这两个量在整个while循环中,要么整体增加,要么只增加一个,而两个量的范围都是
[0, M]
,所以整体复杂度为 O ( 2 M ) = O ( M ) O(2M)=O(M) O(2M)=O(M)
- 首先对于while循环中的内容,存在两个量
- getIndexOf中的while循环:
- 同样存在两个量
i1
和i1 - i2
- 第一个条件分支中,
i1
增加,i1 - i2
不变 - 第二个条件分支中,
i1
不变,i1 - i2
增加 - 第三个条件分支中,
i1
和i1 - i2
都增加
- 第一个条件分支中,
- 可以发现,这两个量在整个while循环中,要么整体增加,要么只增加一个,而两个量的范围都是
[0, N]
,所以整体复杂度为 O ( 2 N ) = O ( N ) O(2N)=O(N) O(2N)=O(N)
- 同样存在两个量
- 所以整个算法的复杂度为 O ( M + N ) = O ( N ) O(M+N)=O(N) O(M+N)=O(N)
实战
-
LeetCode原题:28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)
-
难度:Medium
有一说一,左神讲KMP算法讲的是真的好,不过可能我明天就忘了_(:3」∠)_,还是需要不断巩固啊