整篇以主串为 ababcabcacbab
,模式串为 abcac
为例。
模式串与主串做匹配时,如果是暴力匹配,在主串某趟匹配失败后,模式串要移动第一位,而主串也有苦难需要回退。
在KMP算法中,如果在匹配过程中,主串不需要回退,当匹配失败后,会从当前位置开始继续匹配。而模式串会滑动到某一位开始比较,而不是没都回退到第一位开始比较。
1. 前缀表:不能不了解
KMP算法中,在写代码之前, 先了解一下KMP算法。首先看一下模式串子串的前后缀。
前缀:除最后一个字符以外,字符串的所有头部子串。后缀:除第一个字符以外,字符串的所有尾部子串。
需要找的是每个子串前缀和后缀相等的最长的前缀和后缀的长度。
求前缀表
以 abcac
为例:
子串 | 前缀 | 后缀 | 最长相等前后缀长度 |
---|---|---|---|
a | - | - | 0 |
ab | a | b | 0 |
abc | ab、a | bc、c | 0 |
abca | abc、ab、a | bca、ca、a | 1 |
abcac | abca、abc、ab、a | bcba、cac、ac、c | 0 |
下标 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
字符串 | a | b | c | a | c |
前缀表 prefix | 0 | 0 | 0 | 1 | 0 |
所以,字符串 abcac
的最长相等前后端长度是 00010
,将这个长度写成数组形式,得到
对应的部分匹配值 [0,0,0,1,0]
,换成另一个名字是,前缀表 prefix = [0,0,0,1,0]
模拟匹配过程
下面,将模式串 abcac
与 主串 ababcabcacbab
进行匹配
第一趟:
主串指针 len = 2
,模式串指针 i = 2
时,模式串的 c
和主串的 a
匹配失败。已经匹配的字符串是 ab
,查看前缀表,prefix[1] = 0
,说明 ab
前缀和后缀没有相等的,所以下一趟模式串要回退到第一个字符重新比较,也就是回退到模式串 pattern
的下标为 0 的位置。
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
主串 main | a | b | a | b | c | a | b | c | a | c | b | a | b |
模式串 pattern | a | b | c |
第二趟:
主串指针 len = 6
,模式串指针 i = 4
时,模式串的 c
和主串的 b
匹配失败。已经匹配的字符串是 abca
,查看前缀表,prefix[3] = 1
,说明 abca
前缀和后缀有一个字符相等,所以下一趟模式串要回退到第二个字符开始重新比较,也就是回退到模式串 pattern
的下标为 1 的位置。
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
主串 main | a | b | a | b | c | a | b | c | a | c | b | a | b |
模式串 pattern | a | b | c | a | c |
第三趟:
主串指针 len = 6
,模式串指针 i = 0
时,模式串的 a
和主串的 b
匹配失败。查看前缀表,prefix[0] = 0
,说明前缀和后缀没有相等的,因为当前与主串比较的就是模式串的第一个字符,所以,将主串移到下一个位置,与模式串的第一个字符比较。
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
主串 main | a | b | a | b | c | a | b | c | a | c | b | a | b |
模式串 pattern | a | b | c | a | c |
模式串全部比较完成,匹配成功。整个匹配过程中,主串始终没有回退,所以,KMP算法的时间复杂的是 O(n+m)
。
某趟发生匹配失败时,如果对应部分的前缀表是0,也就是说已匹配的相等序列中没有相等的前后缀,此时模式串移到第一个字符开始比较。
这个前缀表,似乎和写代码时用的next数组没有关系诶。
2. 前缀表和next数组:是我认识的那个东东
我之前学KMP算法,只知道求next数组,可能书上也有写前缀表,但是从来都是跳过不看,导致我现在才知道前缀表这个东西。然后,才发现,弄懂前缀表才是几年之后再求解next数组时不用翻资料重新学的要点所在。而next数组的公式知识为了实现代码,不是为了我们记住怎么求解next数组的而存在的。毕竟公式那么多字母,那么复杂,不用几年,几个月后就背不出来了。
以 abcac
为例:
前缀表 prefix = [0,0,0,1,0]
,
用求next数组的方法求出来的数组是 next = [0,1,1,1,2]
,
这样看起来,prefix
和 next
的关系,好像并不明显,这么隐晦的吗?
next数组 还有一种表达方式 next = [-1,0,0,0,1]
,这样看来来,prefix
和 next
好像有点关系了。
将前缀表整体右移一位,然后将空出来的第一位用 -1
填充,就得到了next 数组:
下标 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
字符串 | a | b | c | a | c |
前缀表 prefix | 0 | 0 | 0 | 1 | 0 |
next | -1 | 0 | 0 | 0 | 1 |
这样,当模式串和主串匹配失败时,直接查看当前匹配失败的字符的前缀表就可以了,而不是查看匹配失败字符前一个字符的前缀表了。
还是以模式串 abcac
与 主串 ababcabcacbab
匹配为例:
当第一趟匹配失败时,主串指针 len = 2
,模式串指针 i = 2
时,模式串的 c
和主串的 a
匹配失败。查看前缀表,prefix[2] = 0
,说明 ab
前缀和后缀没有相等的,所以下一趟模式串要回退到第一个字符重新比较,也就是回退到模式串 pattern
的下标为 0 的位置。
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
主串 main | a | b | a | b | c | a | b | c | a | c | b | a | b |
模式串 pattern | a | b | c |
我这里是从-1开始存储和计算next数组的,所以此时next数组的含义是:当模式串的第 i
个字符与主串发生匹配失败时,就回退到模式串的 next[i]
重新与主串匹配。有的地方是从0开始存储和计算next数组的。我后面的代码实现中next数组也是从-1开始计算的。
可以将next 数组整体 +1
,所以next数组也可以写成以下形式:
下标 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
字符串 | a | b | c | a | c |
next | 0 | 1 | 1 | 1 | 2 |
3. next数组公式:为了实现代码而已
用于求解next数组的公式如下(没有记录书中的那种数学公式):
i = 0 ==> next[i] = -1
当最长相等前后缀长度不为0 ==> next[i] = 最大长度
其他情况 ==> next[i] = 0
4. 代码实现:java版
这里的代码中,求解出来的next数组, 下标是从0开始的,数组的第一个值也是 -1。用 java 实现的KMP算法代码如下:
/**
* KMP 算法
*
* @param main 主串,是一个字符串
* @param pattern 模式串,是一个字符串
* @return 返回模式串 pattern 与主串 main 匹配时的第一个字符的下标
*/
public static int match(String main, String pattern) {
int[] next = getNext(pattern);
int i = 0, j = 0;
// 模式串与主串匹配
while (i < main.length() && j < pattern.length()) {
if (main.charAt(i) == pattern.charAt(j)) {
i++;
j++;
} else {
j = next[j];
if (j == -1){
j = 0;
i++;
}
}
}
return j == needle.length() ? i - needle.length() : -1;
}
/**
* 求解next数组 [-1, ... , ...]
* 利用java的字符串函数比较前缀和后缀是否相等
*
* @param s 字符串
* @return 该字符串的next数组
*/
private int[] getNext(String s) {
int[] next = new int[s.length()];
next[0] = -1;
// 求next数组
for (int index = 1; index < s.length(); index++) {
// 当前index字符之前的子串[0, index-1]的相等的最长前后缀
for (int i = index - 1, j = 1; i > 0 && j < index; i--, j++) {
String pre = s.substring(0, i); // 前缀
String post = s.substring(j, index); // 后缀
if (pre.equals(post)) { // 前缀和后缀相等的最大长度
next[index] = i;
break;
}
}
}
return next;
}