KMP算法
名字的由来
是三个作者的名字缩写,没有特殊含义。
这篇文章是学习了邓俊辉老师的《数据结构与算法 c++》版本。
解决的问题
主要用于解决字符串串匹配的问题,例如在文本串 abcdefg 中寻找是否存在 cde 这个子串。
其中查找的目标字符串做模式串( pattern ),在文本串(text)中查找这个目标串,如果存在这个子串,就返回在文本串中第一次出现的下标,如果不存在就返回-1。
28. 实现 strStr()
暴力解法
暴力解法的思路很简单:
1)定义两个指针,分别用于遍历文本串和模式串。tIndex 和 pIndex 并且都初始化为 0
2)同时前进两个指针,如果对应的字符匹配就继续前进;如果不匹配,模式串指针回退到0。文本串指针回退到 i - j + 1。
3)退出循环。while (i < n && j < m)。n和m分别为文本串和模式串的长度
3.1)如果模式串索引超出边界,则意味着找到这个子串,匹配成功
3.2)如文本串索引超出边界,则意味着匹配失败
3.3)返回值。如果 i - j 小与等于 n - m ,说明匹配成功,并且i - j 就是匹配成功的位置,否则就是匹配失败。
class Solution {
public int strStr(String haystack, String needle) {
String text = haystack;
String pattern = needle;
if (text == null || pattern == null)
return -1;
int tIndex = 0;
int pIndex = 0;
while (tIndex < text.length() && pIndex < pattern.length()) {
if (text.charAt(tIndex) == pattern.charAt(pIndex)) {
tIndex++;
pIndex++;
}
else {
tIndex = tIndex - pIndex + 1;
pIndex = 0;
}
}
if (tIndex - pIndex > text.length() - pattern.length())
return -1;
return tIndex - pIndex;
}
}
暴力解法的缺点
暴力解法在匹配失败时,文本串回退到 i - j + 1,模式串回退到 0 。实际的复杂度是 O(n * m),但是暴力解法的框架是可以引出KMP算法的,只需要避免不必要的回退。
KMP 的思路
为了解决暴力解法在匹配失败时过多的回退。KMP的主要思路是:匹配失败时,文本串原地不动,模式串尽可能少的回退。
假设在 T[i] 和 P[j] 时匹配失败:
失败之后,按照KMP的思想,文本串的指针 i 保持不变;模式串指针回退,即模式串向右移动,用新的 P[t] 和 T[i] 去进行匹配。能够选择 t 这个索引的必要条件是:P[ 0, t ) == T[ i - t, i )。又因为原本直到 T[i] 才和 P[j] 失配,因此前面元素都是匹配完成的,因此有 T[ i - t, i ) = P[ j - t, j ]。综合两个表达式,从而有 P[0, t) == P[j - t, j),即下图中蓝色方框所示。
P[0, t) == P[j - t, j) 表达式的意思是,子串P[0, j) 有一个长度为 t 的相等的真前缀 和 真后缀。如下图所示:
也就是说:如果文本串和模式串在 T[i] 和 P[j] 时匹配失败,KMP的操作是:保持文本串指针不变,模式串向右移动,使用 P[t]和T[i] 进行比较,其中 t 是
子串 P[0, j) 的相等前后缀的长度。t 显然不止一个,但是 t 满足集合 N(P, j) = { 0 <= t < j | P[0, t) == P[j - t, j) }。
为了在移动后减少模式串匹配的次数,图中P( t, m )的长度要小一点,因此 t 要尽可能大,所以kmp算法是是取 集合 N(P, j) 中的最大值作为 t 的值。
这个值表示了:当我们在 P[ j ] 匹配失败时,可以转向 P[t],将P[t] 作为新的值与 T[i]进行比较。
也就是说,对于一个模式串P,给定一个匹配失败位置 j ,就有一个对应的 t,使得可以用 P[t] 代替 P[j] 继续进行匹配。因此习惯把 j 和 t 的对应关系存在一个叫 next 的数组中,暗含了下一个匹配的位置的意思。
next 数组
next[j] 的语义是:当模式串在索引 j 处匹配失败时,可以将索引 next[j] 作为下一个位置和 文本串 T[i] 重新进行匹配。
next[j] = max { N(P, j) }。
N(P, j) = { 0 <= t < j | P[0, t) == P[j - t, j) }。
next 数组的取值
从集合的N的定义可以看出,当 j > 0时,集合N有一个固定的元素 0。必然可以取到最大值。
举例说明,对于模式串 bcc,如果在 j = 1 的位置失效,就得看子串b
,子串b
的最长相等真前后缀的长度显然是0。
bcbcc,如果在索引4的位置匹配失败,就得看子串 bcbc,子串的最长相等真前后缀的长度是2
如果当前位置匹配成功,模式串和文本串都加1
但是如果在 j = 0时,匹配失效,集合N为空,此时next[ 0 ]应该取多少呢?
这种情况什么时候会发生?也就是在模式串第一个字符的时候就匹配失败了,这种情况下,指针应该按照下图变动:
这个时候实质上是模式串保持不变,文本串前进了一步。
这个有点类似上面匹配成功的情况,匹配成功的时候,模式串和文本串索引都向前前进了一步。如果我们先把模式串索引后退一步(此时模式索引在0位置,退后一步在-1位置),然后再和文本串索引同时前进一步,那么在第一个字符就失配的情况就等价于匹配成功的情况。
因此,如果我们定义:next[0] = -1。假想在模式串的最左边 -1 的位置 有一个通配符 * 。和任何字符都是匹配的,这样子等效的结果为:
这和上一幅图的效果是一样的,只不过如果不设置这个-1通配符,就得单独把这个情况拿出来分析。设置了通配符,就保持了与其他情况的一致性。
因此,通过将next[0] 定义为 -1,同时假想在模式串左边有一个通配符,将原来在索引为0的时候就失配的情况,转化为2步进行。先将模式串索引退后一步,用 -1 位置的通配符与T[i]进行匹配,然后将由于通配符与所有字符都匹配,所以再同时+1,达到原先匹配成功的效果。
KMP算法主程序
最后完成KMP算法的主程序,假设已经获得了 next数组。
重申一下next数组的语义:
next[ j ] 表示模式串在索引 j 处,与文本串T[ i ] 匹配失败后,可以将 新的索引 next[j] 作为下一个与 T[i] 进行匹配的索引。
特别的 next[0] 定义为 -1。当模式串第一个字符就匹配失败,就假想用 -1 作为下一个与T[i] 进行匹配的索引,-1位置上有一个通配符。这等价于 T[i] == P[j] = P[-1]
class Solution {
public int strStr(String haystack, String needle) {
String text = haystack;
String pattern = needle;
if (text == null || pattern == null)
return -1;
int tIndex = 0; // 文本串当前进行匹配的索引
int pIndex = 0; // 模式串当前进行匹配的索引
while (tIndex < text.length() && pIndex < pattern.length()) {
// 文本串当前索引小于0,其实就是-1,pattern[-1]上面是一个通配符,等价于匹配成功
if (pIndex < 0 || pattern.charAt(pIndex) == text.charAt(tIndex)) {
pIndex++;
tIndex++;
}
// 如果匹配失败,那么文本串索引不变,模式串索引更新为 next[pIndex]
else {
pIndex = next[pIndex];
}
}
if (tIndex - pIndex <= text.length() - pattern.length()) {
return tIndex - pIndex;
}
return -1;
}
}
next数组程序实现
重申一下next数组的语义(用法):
next[ j ] 表示模式串在索引 j 处,与文本串T[ i ] 匹配失败后,可以将 新的索引 next[j] 作为下一个与 T[i] 进行匹配的索引。
特别的 next[0] 定义为 -1。当模式串第一个字符就匹配失败,就假想用 -1 作为下一个与T[i] 进行匹配的索引,-1位置上有一个通配符。这等价于 T[i] == P[j] = P[-1]
重申下next数组的逻辑含义(计算方法)
next[j] 表示模式串子串 P[0, j)中相等真前后缀的最大长度。next[0] = -1。
假设我们已经知道了 next[0,…j],在此基础上,求next[j+1]。
next[j+1] 表示,现在P[j+1]匹配失效了,需要得到子串P[0, j+1) 的相等前后缀的最大长度。
子串P[0, j+1) 相等的前后缀的最大长度,就是图中的绿色方框和红色方框所示。从红色方框可以看出,后缀包括了两个部分,一是子串P[0, j) 的后缀,二是字符P[j]。由next[j] 的语义可知,若蓝色箭头指向的方框为子串P[0, j) 的相等前后缀长度,则,蓝色箭头指向的两个方框是自然匹配的。
在此基础上,要看绿色框框和红色框框是不是完全匹配,只要看 索引 j 和 索引 next[j] 上的元素是不是匹配的。如果匹配,那么 next[j + 1] = next[j] + 1
如果不匹配:就得看下一个嵌套的next 索引:
也就是说 next[j+1]的值,在以下几个值中选取
next[j] + 1;
next[next[j]] + 1;
next[next[next[j]]] + 1;
next[0] + 1;
从上到下,依次去判断,直到 P[j] 和 P[ next […] ] 相等为止,如果一直不相等,最后会终止于 P[j] 和 P[-1] 比较,假设在 -1 处有一个通配符。等价于匹配成功。
这个情况就是,在 j+1 处失配,但是子串P[0, j+1) 的最长相等前后缀长度为0,就是没有,这个时候,应该把P[0] 和P[j] 进行比较,在-1处匹配成功之后,需要加上1才等于next[j + 1],也就是说next[ j+1 ] == 0;
为了方便进行迭代,如果要计算 next[j+1] 那么 t 就初始化为 next[j]。进行匹配的就是P[j] 和 P[t],也就是说 t 是 按照下边这个顺序进行尝试的当前的next[…]
next[j] + 1;
next[next[j]] + 1;
next[next[next[j]]] + 1;
next[0] + 1;
要计算next[j+1],首先把 t = next[j], 然后对 P[j] 和 P[t] 进行匹配,如果匹配不成功,t = next[t] = next[next[j]]。如果还不成功,t = next[t]。直到最后,t = next[0] = -1,到达通配符,一定会匹配成功。
如果 t < 0 ,是假设模式串P的最左侧-1的位置有一个通配符,他和所有的字符都能匹配成功,这种情况下:
if (t < 0) {
next[j+1] = t + 1; // 说明:next[j+1] == 0
t = next[j+1]; // 因为要计算下一个next了,所以要维护一下t的定义,方便进行迭代
j ++; // 要计算一下next了
}
如果 t >= 0 ,也就是说还没有到达-1那个通配符。如果匹配成功了
if (P[j] == P[t]) {
next[j+1] = t + 1;
t = next[j+1];
j ++;
}
如果 t >= 0 ,并且匹配失败了,t 更新为下一层嵌套的next,在下一次循环中,先判断 t 是不是通配符索引,如果是直接匹配成功,如果不是,用P[t] 和 P[j] 进行匹配。
if (t >= 0 && P[j] != P[t]) {
t = next[t];
}
和主程序不同的是:
匹配成功时候:
next程序,是获取next[j+1]的值,并且维护 j 和 t的语义。j 表示当前已经获取的next数组的最大索引,所以得++。t 表示下一步要计算next[j+1+1],那么t 就得维护为 next[j+1]。
而主程序,在匹配成功后,模式串索引和文本串索引都加1。
匹配失败的时候:
next程序,将t 更新为 next[t],在下一步循环中,用新的P[t] 和 P[j] 进行匹配。
而Kmp主程序,将j 更新为next[j],在下一步循环中,用新的P[j] 和T[i] 进行匹配。
完整程序
class Solution {
public int strStr(String haystack, String needle) {
String text = haystack;
String pattern = needle;
if (text == null || pattern == null)
return -1;
// next表的语义(用法):当P[j] 与 T[i]匹配失败时,用索引为next[j]的那个模式串字符代替P[j],重新与T[i]进行匹配
// next表的逻辑语义(求法):next[j]表示子串P[0, j) 的相等的真前缀和真后缀的最大长度,规定next[0] == -1
int[] next = buildNext(pattern);
int tIndex = 0; // 文本串当前进行匹配的索引
int pIndex = 0; // 模式串当前进行匹配的索引
while (tIndex < text.length() && pIndex < pattern.length()) {
// 小于0,pattern[-1]上面是一个通配符,等价于匹配成功
if (pIndex < 0 || pattern.charAt(pIndex) == text.charAt(tIndex)) {
pIndex++;
tIndex++;
}
// 如果匹配失败,那么文本串索引不变,模式串索引更新为 next[pIndex]
else {
pIndex = next[pIndex];
}
}
if (tIndex - pIndex <= text.length() - pattern.length()) {
return tIndex - pIndex;
}
return -1;
}
private int[] buildNext(String pattern) {
int m = pattern.length();
int[] next = new int[m];
if (m == 0) {
return next;
}
next[0] = -1; // 如果在第一个字符就失效,就假设在模式串左边有一个通配符
int t = next[0]; // next[i+1] 是等于next[...] + ,t 就代指当前进行匹配的那个next[...]
int j = 0; // next数组的索引,已知next[0, j] 求next[j+1]
while ( j < m - 1 ) {
if (t < 0 || pattern.charAt(t) == pattern.charAt(j)) {
next[j+1] = t + 1;
t = next[j+1]; // 已经求出了next[j+1],下一步要求next[j+2],根据t的语义,将t更新为next[j+1]
j++;
}
else {
t = next[t];
}
}
return next;
}
}
总结
KMP主程序和next表的程序是大致类似的。
因为KMP主程序中:是用模式串以及模式串的子串和文本串进行匹配。如果模式串P[j]和文本串T[i]匹配失败,就将索引j替换为 t = next[j],保证了P[0, t) 和 T[ i - t, i ) 相等,只需要比较 P[ t ] 和 T[ i ]。实际上就是拿模式串的子串和文本串进行匹配。如果继续失败,就把这个 t 替换为 next[t]。在kmp主程序中,这个 t 表示为 tIndex
在next表程序中:是用模式串子串以及子子串和模式串进行匹配。如果要计算next[j+1]。首先将 t = next[j] ,保证了P[ 0, t ) 和 P[ j - t, j) 是相等的 ,然后将 P[t] 和 P[j] 进行匹配,如果匹配成功,next[j+1] = t + 1。如果匹配失败,就将t = next[t]。
next程序是根据next[0…j] 进行递推计算出 Next[j+1]。
依次用 next[j],next[next[j]],next[next[next[j]]]…,next[0],上的数值去和 P[j] 进行匹配。
主程序是根据模式串当前匹配索引对应的next表数值进行更换匹配的。例如,当前匹配的是P[j] 并且失败了,那么就取索引 j 对应的 next[j],用 P[next[j]] 替换 P[j]进行下一轮匹配。