角色:
甲:abbaabbaaba
乙:abbaaba
乙对甲说:「帮忙找一下我在你的哪个位置。」
甲从头开始与乙一一比较,发现第 7 个字符不匹配。
要是在往常,甲会回退到自己的第 2 个字符,乙则回退到自己的开头,然后两人开始重新比较。
这样的事情在字符串王国中每天都在上演:不匹配,回退,不匹配,回退,……但总有一些妖艳字符串要花出自己不少的时间。
上了年纪的甲想做出一些改变。
于是甲把乙叫走了:「你先一边玩去,我自己研究下。」
甲给自己定了个小目标:发生不匹配,自己不回退。
这基于甲发现的一个事实:当在甲的某个字符 c 上发生不匹配时,甲即使回退,最终还是会重新匹配到字符 c 上。
那干脆不回退,岂不美哉!甲不回退,乙必须回退地尽可能少,并且乙回退位置的前面那段已经和甲匹配,这样甲才能不用回退。
如何找到乙回退的位置?
「不匹配发生时,前面匹配的那一小段 abbaab 于我俩是相同的」,甲想,「这样的话,用 abbaab 的头部去匹配 abbaab 的尾部,最长的那段就是答案。」
具体来说,
abbaab 的头部有 a, ab, abb, abba, abbaa(不包含最后一个字符。下文称之为「前缀」)
abbaab 的尾部有 b, ab, aab, baab, bbaab(不包含第一个字符。下文称之为「后缀」)
这样最长匹配是 ab,乙回退到第三个字符和甲继续匹配。
「要计算的内容只和乙有关」,甲想,「那就假设乙在所有位置上都发生了不匹配,乙在和我匹配之前把所有位置的最长匹配都算出来(算个长度就行),生成一张表,之后我俩发生不匹配时直接查这张表就行。」
据此,甲总结出了一条甲方规则:所有要与甲匹配的字符串,必须先自身匹配:对每个子字符串 [0…i],算出其「相匹配的前缀与后缀中,最长的那个字符串的长度」。
甲把乙叫了回来,告诉他新出炉的甲方规则。
「小 case,我对自己还不了解吗」,乙眨了一下眼睛,「那我回退到第三个字符和你继续匹配就行了」。
以上为 KMP 的基本思想,关键在于如何高效地计算甲方规则,这里有个很好的例子:abababzabababa。
列个表计算一下:(最大匹配数为子字符串 [0…i] 的最长匹配的长度)
子字符串 a b a b a b z a b a b a b a
最大匹配数 0 0 1 2 3 4 0 1 2 3 4 5 6 ?
一直算到 6 都很容易。在往下算之前,先回顾下我们所做的工作:
对子字符串 abababzababab 来说,
前缀有 a, ab, aba, abab, ababa, ababab, abababz, …
后缀有 b, ab, bab, abab, babab, ababab, zababab, …
所以子字符串 abababzababab 前缀后缀最大匹配了 6 个(ababab),那次大匹配了多少呢?
容易看出次大匹配了 4 个(abab),更仔细地观察可以发现,次大匹配的前缀后缀只可能在 ababab 中,所以次大匹配数就是 ababab 的最大匹配数!
OK,直接查我们先前列出的表,可以得出该值为 4。
第三大的匹配数同理,它既然比 4 要小,那前缀后缀也只能在 abab 中找,即 abab 的最大匹配数,查表可得该值为 2。
再往下就没有更短的匹配了。
回顾完毕,来计算 ? 的值:既然末尾字母不是 z,那么就不能直接 6+1=7 了,我们回退到次大匹配 abab,刚好 abab 之后的 a 与末尾的 a 匹配,所以 ? 处的最大匹配数为 5。
上 Java 代码,它已经呼之欲出了:
// 构造 pattern 的最大匹配数表
int[] calculateMaxMatchLengths(String pattern) {
int[] maxMatchLengths = new int[pattern.length()];
int maxLength = 0;
for (int i = 1; i < pattern.length(); i++) {
while (maxLength > 0 && pattern.charAt(maxLength) != pattern.charAt(i)) {
maxLength = maxMatchLengths[maxLength - 1]; // ①
}
if (pattern.charAt(i) == pattern.charAt(maxLength)) {
maxLength++; // ②
}
maxMatchLengths[i] = maxLength;
}
return maxMatchLengths;
}
有了代码后,容易证明它的复杂度是线性的(即运算时间与 pattern 的长度是线性关系):
由 ② 可以看出 maxLength 在整个 for 循环中最多增加 pattern.length() - 1 次,所以让 maxLength 减少的 ① 在整个 for 循环中最多会执行 pattern.length() - 1 次,从而 calculateMaxMatchLengths 的复杂度是线性的。
KMP 匹配的思想和求最大匹配数的过程类似,从 count 值的增减容易看出它也是线性复杂度的:
// 在 text 中寻找 pattern,返回所有匹配的位置开头
List<Integer> search(String text, String pattern) {
List<Integer> positions = new ArrayList<>();
int[] maxMatchLengths = calculateMaxMatchLengths(pattern);
int count = 0;
for (int i = 0; i < text.length(); i++) {
while (count > 0 && pattern.charAt(count) != text.charAt(i)) {
count = maxMatchLengths[count - 1];
}
if (pattern.charAt(count) == text.charAt(i)) {
count++;
}
if (count == pattern.length()) {
positions.add(i - pattern.length() + 1);
count = maxMatchLengths[count - 1];
}
}
return positions;
}
最后总结下这个算法:
1 匹配失败时,总是能够让 pattern 回退到某个位置,使 text 不用回退。
2 在字符串比较时,pattern 提供的信息越多,计算复杂度越低。(有兴趣的可以了解一下 Trie 树,这是 text 提供的信息越多,计算复杂度越低的一个例子。)
作者:逍遥行
链接:https://www.zhihu.com/question/21923021/answer/37475572
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。