更加详细的视频讲解请参看视频:
如何进入google,算法面试技能全面提升指南
KMP算法,全称是Knuth-Morris-Pratt 算法,该算法几乎是所有字符串匹配算法中效率最高,实现最简单,思维最巧妙的算法。它的设计充分说明了大道至简的原理,问题的解决办法往往是简单而精致的,就像爱因斯坦的能量方程:E = M C2 .
在分析KMP算法前,我们先看看最简单的暴力匹配法,暴力匹配法的思路很简单,逐个字符比对,当某一个字符比对不上时,往后挪一个字符,然后继续比对。KMP算法的发展,其实构建在简单的暴力匹配算法之上。
暴力匹配法之所以低效,本质原因是因为很多比较操作是没必要的,KMP之所以高效,是因为它去除了暴力匹配法中,那些没必要的无用比较。我们看一个实例,
假定被匹配的文本是T,匹配字符串是P:
T: a b a b a b a c a b a
P: a b a b a c a
不难看到,从P的第六个字符开始,字符匹配不成功,P[5]是’a’, 而对应的字符在T中是’c’. 按照暴力匹配法,我们会将P往后推一个字符,然后再开始比较。但KMP算法不会那么蠢,它会计算往后推几个字符再比较才是最有效的,从而避免那些无效的字符比较。那么怎么知道往后推几个字符才对呢?
我们先把当前匹配上的字符串拿出来分析,当前匹配上的字符串是 :
a b a b a
也就是P[1…5], 我们可以观察到一个事实是,P[1..3] , 也就是 a b a 所形成的字符串是P[1…5]的最长后缀:
P[1…5]: a b a b a
P[1…3]: a b a
KMP算法的结论是,往后推两个字符再比较是最有效的。
于是KMP算法的原理就是,假设当前能匹配上的字符串为P[1….s], 同时存在一个最大值k, 使得P[1…k] 是P[1…s]的最长后缀,那么将P往后挪动s - k 个字符后,再进行字符比对。我们先看个具体例子。
T: a b a b a b a c a b a
P: a b a b a c a
第一次比对时从0开始,从上面比对我们发现,成功匹配上的字符串是P[1…s] = P[1…5] = “a b a b a”, k=3, 也就是P[1..3] = “a b a” 是P[1…5]的最长后缀,于是我们把P往后挪 2 = 5 - 3 个字符后再做比对,做比对时,我们不再比较前3个字符,而是比对后 4 = 7 - 3个字符,7是P的长度:
T: a b a b a b a c a b a
P: a b a b a c a
Duang! 搞定了。例子太简单可能不好说服你,但既然是大牛搞的算法,肯定错不了。接下来的问题是,当我们得到匹配字符串P[1…s] 的时候,我们如何快速的找到最大的k, 0<= k < s, 使得P[1…k] 是 P[1…s]的最长后缀。
我们用一个数组Pi来记录P子串的最长后缀,例如Pi[5] = 3. 所以当前的目标就是要计算数组Pi[1…P.length]的值。对于P[1…s], 如果它的最长后缀的长度是k, 也就是P[1..k]是P[1…s]的最长后缀,于是我们有Pi[s] = k, 如果 P[1+s] == P[1+k],那显然有Pi[1+s] = k + 1, 问题是P[1+s] != P[1+k]时,怎么办。
如果P[1+s] != P[1+k], 那么对于字符串P[1…k], 假设它的最长后缀是P[1…k’], 0<=k’ < k, 那么如果P[1+k’] == P[1+s], 那么我们就有P[1..(s+1)] 的最长后缀就是P[1…(k’+1)], 如果P[1+k’] != P[1+s], 那么我们找到字符串P[1…k’] 的最长后缀,假设是k”, 如果P[k”+1] == P[1+s] 那么P[1…(1+s)]的最长后缀就是k”+1, 依次类推。
举个例子P: a b a b a c a, 我们已经知道s = 5时,k = 3, 那么s = 6时,P[6]对应字符’c’, 而P[1+k]对应字符’b’, 由于P[1+k] != P[1+s], 于是我们看P[1..k]=”aba”的最长后缀k”, 通过观察我们得知k” 等于1. P[1+k”]也就是P[2]对应的字符是’b’, 与P[6]不相等,于是我们接着看P[1…k”]的最长后缀,此时P[1…k”]的最长后缀是0,因为它只有一个字符,它的后缀只能是空,所以我们得到Pi[6]的最长后缀是0.
由此我们可以确定的是Pi[1]一定等于0.于是我们可以通过下面算法来计算Pi数组:
Pi[1…P.length] = {0, -1, -1…..-1};
如果Pi[k] == -1, 表明Pi[k]处的最长后缀还没有被计算,那么就递归的计算Pi[k]的值。
int getLongestSuffix(int s) {
if (s <= 0 || s > Pi.length) {
throw new Exception("Illegal index");
}
if (Pi[s] != -1) {
return Pi[s];
}
Pi[s] = 0;
int k = getLongestSuffix(s-1);
do {
if (P.charAt(k) == P.charAt(s - 1)) {
Pi[s] = k + 1;
return Pi[s];
}
if (k > 0) {
k = getLongestSuffix(k);
}
} while (k > 0);
return Pi[s];
}
for (int i = P.length; i > 0; i--) {
Pi[i] = getLongestSuffix(i);
}
我们需要注意 if (P.charAt(k) == P.charAt(s - 1)), 我们算法描述中,字符的起始下标是从1开始,而在程序中,字符的起始下标从0开始,这就是为何在if判断中,我们获取字符时要使得s-1的原因。
我们以P=”ababaca” 为例,上面代码对Pi的计算过程如下:
a Pi[1] = 0
a b Pi[2] = 0
a b a Pi[3] = 1
a b a b Pi[4] = 2
a b a b a Pi[5] = 3
a b a b a c Pi[6] = 0
a b a b a c a Pi[7] = 1
上面代码中,如果Pi[s] == -1, 那么递归的计算Pi[s-1], 如果Pi[s] != -1 ,函数便直接返回,因此Pi中的每个元素最多会被访问常数次,所以算法的复杂度是O(Pi.length).
上面算法步骤是基础算法设计中一种非常常用的套路,当我想对输入长度为n的数据进行计算时,如果我已经有了长度为n-1的输入数据的计算结果,那么通过对n-1的结果进行简单计算就可以得到n的结果,因此就这么依次递归,通常情况下,当输入长度为0或某个数值时,结果简单易得,因此当递归到某个比较小的数值时,可以简单的得到计算结果,于是递归结束,然后反向计算相应结果,最后一直计算到n-1, n的对应结果。这种思想是动态规划的基础。
有了Pi数值,那么字符串匹配时,我们先看当前能匹配上的子字符串长度,如果当前匹配上的字符串长度恰好为P.length, 那么匹配结束,P 包含在文本T中,如果当前匹配的字符串长度是s, 那么我们把P往后挪Pi[s]个字符后再进行字符匹配,由此代码如下:
public int match(String T) {
int n = T.length();
int m = P.length();
int q = 0;
for (int i = 0; i < T.length(); i++) {
while (q > 0 && P.charAt(q) != T.charAt(i)) {
q = Pi[q]; //获取最长后缀,并判断最长后缀的下一个字符是否能跟当前比对位置的下一个字符匹配
}
if (P.charAt(q) == T.charAt(i)) {
q = q + 1;
}
if (q == m) {
return i - m + 1;
}
}
return -1;
}
我们仍然以P=”ababaca”, T=”abababacab” 为例:
T: a b a b a b a c a b
P: a b a b a c a
match调用后,进入最外层for循环,由于P 和 T 的前5个字符是相同的,所以while循环不进入, if 判断一直成立,所以p一直加1,由于两字符串第6个字符不同,所以最外层循环在i == 5 的时候,进入for 下面的while 循环,此时p == 5, 也就是当前匹配上的子字符串是P[1…5] = “ababa”, 通过查找Pi数值,得知P[1…5]的最长后缀是3:
T: a b a b a b! a c a b
P: a b a b! a c a
于是q被赋值为3,进入下个循环时,字符的匹配从上面!号所标注的字符开始,每匹配一个字符,!就往后挪一位,同时p 加1,由于从!号开始,后面所有的字符都是匹配的,最后当p增加到 P.length 的时候,循环结束,此时表明P包含在T中。最后函数把P在T中的偏移给返回。
match虽然有两个循环间套,但它的时间复杂度仍然是O(m), 复杂性分析将会在下一节分析KMP算法的数学原理时给出说明。
完整代码的获取和调试流程,请上网易云课堂。