基本思想
KMP 基本思想:匹配失败时,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,继续把它向后移,移到第一个可能匹配的位置。
比如:
母串:ABCDABC......
子串:ABCDABD......
当第 7 位 C 与 D 不匹配时,根据已知信息将子串移动到有可能可以匹配的位置,移动位数 = 已匹配的字符数 - 对应的部分匹配值。
搜索词 | A | B | C | D | A | B | D |
---|---|---|---|---|---|---|---|
部分匹配值 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
- 已匹配的字符数 = 6
- 对应的部分匹配值 = 2
- 移动位数 = 已匹配的字符数 - 对应的部分匹配值 = 4
部分匹配值
"部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。
- "A" 的前缀和后缀都为空集,共有元素的长度为 0;
- "AB" 的前缀为 [A],后缀为 [B],共有元素的长度为 0;
- "ABC" 的前缀为 [A, AB],后缀为 [BC, C],共有元素的长度 0;
- "ABCD" 的前缀为 [A, AB, ABC],后缀为 [BCD, CD, D],共有元素的长度为 0;
- "ABCDA" 的前缀为 [A, AB, ABC, ABCD],后缀为 [BCDA, CDA, DA, A],共有元素为 "A",长度为 1;
- "ABCDAB" 的前缀为 [A, AB, ABC, ABCD, ABCDA],后缀为 [BCDAB, CDAB, DAB, AB, B],共有元素为 "AB",长度为 2;
- "ABCDABD" 的前缀为 [A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为 [BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为 0。
部分匹配实质
有时候,字符串头部和尾部会有重复。比如,"ABCDAB" 之中有两个 "AB",那么它的"部分匹配值"就是 2("AB" 的长度)。搜索词移动的时候,第一个 "AB" 向后移动 4 位(字符串长度 - 部分匹配值),就可以来到第二个 "AB" 的位置。
部分匹配值的选取实质上是基于贪心思想
移动后的母串与子串的匹配部分("ABCDAB")的关系必须保证:母串的后缀等于子串的前缀,且尽量长。
比如,母串 ("ABCDABC") 与子串 ("ABCDABD") 在第 7 位不匹配时,要把子串移到第一个可能匹配的位置。在已匹配的 "ABCDAB" 中,第一个可能匹配的位置便是第 5 位 "A" 。
第一个可能匹配的位置实质上就是匹配部分(子串前缀 "ABCDAB")"前缀"和"后缀"的最长的共有元素的首部。推导如下:
移动后的母串与子串的关系必须保证:母串的后缀 X 等于子串的前缀 Y,且尽量长 --- (1)
既然属于匹配部分,那么母串的该后缀 X 也就等于子串的后缀 Z --- (2)
由 (1) (2) 联合可得,子串的前缀 Y 也就等于子串的后缀 Z
所以,匹配本身与母串无关,只与子串有关
KMP 实现
设 P[j] 为子串匹配部分 B[1 ... j] 的部分匹配值,则 P[j] 应该是所有满足 B[0 ... P[j] - 1] = B[j - P[j] + 1 ... j] 的最大值。由上面的推导可知,P[j] 与母串 A 无关,可单纯从子串 B 推导而来。
那么,如何快速地预处理 P 数组呢?我们可以通过 P[0],P[1],…,P[j - 1] 的值来获得 P[j] 的值。
对于 B = "ababacb",假如我们已经求出了 P[0],P[1],P[2] 和 P[3],看看我们应该怎么求出 P[4] 和 P[5]
0 1 2 3 4 5 6
B = a b a b a c b
P = 0 0 1 2 ? ?
P[3] = 2,那么 P[4] 显然等于 P[3] + 1,因为由 P[3] 可以知道,B[0, 1] 已经和 B[2, 3] 相等了,现在又有 B[2] = B[4],所以 P[4] 可以由 P[3] 后面加一个字符得到。
0 1 2 3 4 5 6
B = a b a b a c b
P = 0 0 1 2 3 ?
B[ P[4] + 1 ] != B[5] 。那么,我们要考虑“退一步”了。我们考虑 P[5] 是否有可能由 P[4] 的情况所包含的子串得到,即是否 P[5] = P[ P[4] ] + 1。P[4] = 3 是因为 B[0 ... 2] 和 B[2 ... 4] 都是 "aba";而 P[2] = 1 则告诉我们,B[0]、B[2] 和 B[4] 都是 "a" 。既然 P[5] 不能由 P[4] 得到,或许可以由 P[2] 得到(如果 B[1] 恰好和 B[5] 相等的话,P[5] 就等于 P[2] + 1 了)。显然,P[5] 也不能通过 P[2] 得到,因为 B[1] != B[5] 。事实上,这样一直推到 P[0] 也不行,最后,我们得到,P[5] = 0 。
- 如果匹配成功,P[j] = P[j - 1] + 1
- 如果匹配失败,j 倒退到 P[j - 1],再继续匹配,直到 j = 0 为止
// getNext P[i]: 满足 B[0 ... P[i] - 1] = B[i - P[i] + 1 ... i] 的最大值(即匹配值),P[i] = 0 时,表示匹配值为 0,不能再倒退
let j = 0, P = [];
P[0] = 0;
for (let i = 1; i < B.length; i ++) {
while (j && B[j] !== B[i]) j = P[j - 1];
if (B[i] === B[j]) j ++;
P[i] = j;
}
// j 表示已经匹配的长度,即 A[i - j + 1 ... i] = B[0 ... j - 1],如果匹配失败,倒退到 P[j - 1]
j = 0;
for (let i = 0; i < A.length; i ++) {
while (j && A[i] !== B[j]) j = P[j - 1];
if (A[i] === B[j]) j ++;
if (j === B.length) {
console.log('Pattern occurs with shift ', i - B.length + 1);
j = P[j - 1];
}
}
时间复杂度
KMP 的时间复杂度分析可谓摊还分析的典型。我们从上述程序的 j 值入手。每一次执行 while 循环都会使 j 减小(但不能减成负的),而另外的改变 j 值的地方只有一行。每次执行了这一行,j 都只能加1;因此,整个过程中 j 最多加了 n 个 1。于是,j 最多只有 n 次减小的机会(j 值减小的次数当然不能超过 n,因为 j 永远是非负整数)。这告诉我们,while 循环总共最多执行了 n 次。按照摊还分析的说法,平摊到每次 for 循环中后,一次 for 循环的复杂度为 O(1) 。整个过程显然是 O(n) 的。这样的分析对于后面 P 数组预处理的过程同样有效,同样可以得到预处理过程的复杂度为 O(m) 。