字符串的本质就是一个字符数组,串中任意个连续的字符组成的子序列称为该串的子串,包含子串的串相应的称为主串.
子串的定位操作通常被称为串的模式匹配,它求的是子串在主串中的位置. 最简单的方法可以从主串的第一个字符开始匹配,依次迭代,其最坏时间复杂福为 O ( n m ) O(nm) O(nm),其中 n n n 和 m m m 分别是主串和模式串的长度.
在这种暴力匹配中,每趟匹配失败都是模式后移一位再从头开始比较,浪费了大量的时间,KMP 算法改进了这一点,避免了主串指针的回溯.
1. 字符串的前缀、后缀和部分匹配值
- 前缀:除最后一个字符外,字符串的所有头部子串
- 后缀:除第一个字符外,字符串的所有尾部子串
- 部分匹配值:字符串的前缀和后缀的最长相等前后缀长度
以 "ababa"
为例:
a
的前缀和后缀都为空集,最长相等前后缀长度为 0 0 0ab
的前缀为{a}
,后缀为{b}
, a ∩ b = ∅ {a}\cap{b}=\varnothing a∩b=∅,最长相等前后缀长度为 0 0 0aba
的前缀为{a,ab}
,后缀为{a,ba}
, a ∩ b = a {a}\cap{b}={a} a∩b=a,最长相等前后缀长度为 1 1 1abab
的前缀为{a,ab,aba}
,后缀为{b,ab,bab}
, a ∩ b = a b {a}\cap{b}=ab a∩b=ab,最长相等前后缀长度为 2 2 2ababa
的前缀为{a,ab,aba,abab}
,后缀为{a,ba,aba,baba}
, a ∩ b = a b a {a}\cap{b}={aba} a∩b=aba,最长相等前后缀长度为 3 3 3
故字符串 ababa
的部分匹配值为 001023,以表格的形式展示就是:
编号 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
S | a | b | a | b | a |
部分匹配值 | 0 | 0 | 1 | 2 | 3 |
部分匹配值有什么用呢?
![](https://img-blog.csdnimg.cn/20210325102922853.gif)
考虑下图所示的模式匹配情况:
模式串在字符 C
处不匹配,如果是暴力匹配,则会从模式串首字符开始匹配,而 KMP 算法是从部分匹配值之后的字符开始匹配.
显然,这里避免了回溯主串,提高了匹配效率.
那么,每次模式串需要移动多少长度呢?
2. KMP 算法
从前面可以看出来,KMP 算法的核心就是 2 个问题:
- 求出模式串的部分匹配值
- 求出当主串指针 i i i 和模式串指针 j j j 不匹配时,指针 j j j 重新指向的位置(保持 i i i 和 j j j 对应)
在 KMP 算法的描述中,这其实是求 next
数组(注意,下标从
1
1
1 开始,表示第几个字符).
我们规定 next[j]
表示主串第
i
i
i 个字符和模式串第
j
j
j 个字符不匹配时,模式串指针
j
j
j 重新指向的模式串的位置(第几个字符),如上例 next[6]=3
.
当模式串第一个字符(
j
=
1
j=1
j=1 )与主串第
i
i
i 个字符发生失配时,规定 next[1]=0
,表示第一个字符就不匹配时,模式串直接右移,从主串的下一个位置(
i
+
1
i+1
i+1 ) 和模式串的第一个字符继续比较. 其他部分匹配值长度的情况下,
j
j
j 重新指向模式串第一个字符,即从第一个字符开始比较(至少 next[2]=1
).
设模式串为 p
,next[1]=0
, next[j]=k
,此时 next[j+1]
可能有两种情况
-
p[k]=p[j]
此时
next[j+1]=next[j]+1
-
p[k]!=p[j]
此时按照上面的方法行不通了,怎么办了,继续对序列 p 1 p 2 ⋯ p k − 1 p_1p_2\cdots p_{k-1} p1p2⋯pk−1 执行同样的操作,直到与 p j p_j pj 匹配或者递归到终止条件
至此,我们可以得出获取 next
数组的代码:
// Java
public int[] getNext(@NotNull final char[] p) {
int[] next = new int[p.length];
int j = 0, k = -1;
next[j] = k;
while (j < p.length - 1) {
if (k == -1 || p[j] == p[k]) {
++j;
++k;
next[j] = k;
} else {
k = next[k];
}
}
return next;
}
注意,之前为了好理解,设起始下标为 1 1 1,编程实现中数组一般从下标 0 0 0 开始,故之前的位置对应要减 1 1 1.
下面就可以开始看看 KMP 的整体逻辑了:
// Java
public int kmp(@NotNull final char[] s, final @NotNull char[] p, final @NotNull int[] next) {
assert p.length > 0 && s.length > p.length;
int i = 0, j = 0;
while (i < s.length && j < p.length) {
if (j == -1 || s[i] == p[j]) {
++i;
++j;
} else {
j = next[j];
}
}
if (j >= p.length) {
return i - p.length;
} else {
return -1;
}
}
3. KMP算法的进一步优化
优化想法很简单,next
数组还需要递归调来得到发生不匹配时模式串移动到的位置,我们可不可以直接一次性在数组中给出,而不是在调用时反复递归判断?因此我们可以只求出 nextVal
数组.
// Java
public int[] getNextVal(@NotNull final char[] p) {
int[] nextVal = new int[p.length];
int j = 0, k = -1;
nextVal[j] = k;
while (j < p.length - 1) {
if (k == -1 || p[j] == p[k]) {
++j;
++k;
if (p[j] != p[k]) {
nextVal[j] = k;
} else {
nextVal[j] = nextVal[k]; // 为啥呢?因为如果相等了,那和之前比较的就一样了,再比较一次毫无意义
}
} else {
k = nextVal[k];
}
}
return nextVal;
}
PS:有没有感觉求
next
数组和nextVal
数组使用的都是动态规划的思想.