KMP算法
介绍:KMP 算法是一种改进的字符串匹配算法,名称由三个发明者姓名的首字母组成。算法的核心是利用匹配失败后的信息,尽量减少模式串 P 与主串 S 的匹配次数以达到快速匹配的目的。具体实现就是通过一个 next[] 数组实现,数组本身包含了模式串的局部匹配信息(算法思想参考b站视频)。KMP 算法的时间复杂度为 O(m+n),其中m、n分别是模式串、主串的长度。
1. 什么是 next[] 数组?
前缀/后缀:指除了自身以外,一个字符串的全部头部/尾部组合。例如字符串 china,前缀包括 c、ch、chi、chin,后缀包括 hina、ina、na、a。
最长的相同前后缀:例如 abcjkdabc 最长的相同前后缀是 abc(长度3);cbcbc 最长的相同前后缀是cbc(长度3);abcbc 最长的相同前后缀是不存在的(长度0)。
next[] 数组:表示在模式串 P 中,当前下标对应的字符之前的字符串中,最长的相同前后缀。例如 next [i] = j,表示在模式串 P 中, [0, i - 1] 区段中有最长的相同前后缀为 j。
2. 如何计算 next[] 数组?
规定next[0] = -1,假设已知 next[i] = j,如何求出next[i+1]:
-
情况一:如果 p[i] = p[j],则 next[i+1] = next[i] + 1。
图中红色部分内容相等,表示字符串 [0, i - 1] 区段中最长的相同前后缀为 j。当 p[i] = p[j] 时,红色部分扩展为绿色部分。
-
情况二:如果 p[i] != p[j],则令 j = next[j],然后继续判断 p[i] = p[j]。
当 p[i] != p[j] 时,需要递归前缀索引,next[j] 表示 [0, j - 1] 区段中最长的相同前后缀,即图中第一和第二个浅绿色椭圆内容相同。因为红色部分内容相等,所以四个浅绿色椭圆内容相等。令 j = next[j],即利用第一和第四个浅绿色椭圆内容相同来加快得到 [0, i - 1] 区段的相同前后缀的长度。
也可以这样理解:前面 [0, i - 1] 区段匹配主串成功,此时在 i 处失败,由于第一和第四个浅绿色椭圆内容相同,那么下次可以直接从 next[j] 处开始匹配。这也是 next[] 数组的另一种含义。
3. 为何要改进 next[]数组?
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
模式串 | A | B | C | D | A | B | D |
next[ i ] | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
观察 next[5] = 1,表示当 B 匹配失败时,下次从下标 1 开始匹配,跳过之前的字符匹配。但实际上,下标为 1 的字符仍然还是 B,显然下次匹配一定会失败,于是修改 next[5] = next[ next[5] ] = 0,同理 next[4] = -1。
4. 为何模式串 P 移动的过程中不可能存在匹配?
反证法:如下图所示,M为主串,N为模式串,假设 e 是当前最长的相同前后缀。当蓝色部分匹配失败时,表明 M[y - j, y] = N[0, j],即M、N两竖线间的内容完全相同,下一步应该作出绿色部分的移动。
如果此时存在 f 使得模式串 N 匹配成功,则表明 ① = ②。因为M、N两竖线间的内容完全相同,所以 ② = ③;又因为①是由④移动得到,所以① = ④。从而③ = ④,即 f 也是相同前后缀,显然 f > e,与假设矛盾,所以模式串移动过程中不可能存在匹配。
参考:KMP思想(b站视频)、KMP代码实现、next数组理解、KMP证明
public class KMP {
// KMP算法匹配字符串,匹配成功返回P在S中的首字符下标,匹配失败返回-1
public static int indexOf(String source, String pattern) {
char[] src = source.toCharArray();
char[] ptn = pattern.toCharArray();
int sLen = src.length;
int pLen = ptn.length;
// 模式串为空字符串,返回0
if (pLen == 0) {
return 0;
}
// 主串长度小于模式串长度,返回-1
if (sLen < pLen) {
return -1;
}
int[] next = getNext(ptn);
int i = 0; // 主串S的下标
int j = 0; // 模式串P的下标
while (i < sLen && j < pLen) {
if (j == -1 || src[i] == ptn[j]) {
i++;
j++;
} else {
// 匹配失败时使用next[]数组加快匹配
j = next[j];
}
}
if (j == pLen)
return i - j;
return -1;
}
// 获取模式串P对应的next[]数组
private static int[] getNext(char[] p) {
int pLen = p.length;
int[] next = new int[pLen];
int i = 0; // 后缀下标
int j = -1; // 前缀下标
next[0] = -1; // 初始next[0]为-1
while (i < pLen - 1) {
if (j == -1 || p[i] == p[j]) {
i++;
j++;
// 未优化的next[]数组求法
// next[i] = j;
// 优化的next[]数组求法
if (p[i] != p[j]) {
next[i] = j;
} else {
next[i] = next[j];
}
} else {
j = next[j];
}
}
return next;
}
}
补充:String 类 indexOf() 字符串匹配源码
public class SourceCode {
// 算法思想其实就是暴力匹配,使用双指针依次进行比较
public static int indexOf(String source, String pattern) {
char[] src = source.toCharArray();
char[] ptn = pattern.toCharArray();
int sLen = src.length;
int pLen = ptn.length;
if (pLen == 0) {
return 0;
}
if (sLen == 0) {
return -1;
}
char first = ptn[0];
int max = sLen - pLen;
for (int i = 0; i <= max; i++) {
// 首先查找第一个字符
if (src[i] != first) {
while (++i <= max && src[i] != first) ;
}
// 找到第一个字符,现在匹配剩余字符
if (i <= max) {
int j = i + 1; // 主串和模式串都从下一位开始
int end = j + pLen - 1;
for (int k = 1; j < end && src[j] == ptn[k]; j++, k++) ;
if (j == end) {
// 匹配整个字符串
return i;
}
}
}
return -1;
}
}