2017-12-30 19:25:03
Knuth-Morris-Pratt 字符串查找算法(常简称为“KMP算法”)可在一个主文本字符串内查找一个词的出现位置。此算法通过运用对这个词在不匹配时本身就包含足够的信息来确定下一个匹配将在哪里开始的发现,从而避免重新检查先前匹配的字符。
这个算法是由高德纳(Donald Ervin Knuth)和沃恩·普拉特在1974年构思,同年詹姆斯·H·莫里斯也独立地设计出该算法,最终由三人于1977年联合发表。
KMP算法的处理核心是在匹配失败后改如何应对,如果从头开始重新匹配,那么算法的时间复杂度会达到O(mn),能不能够不从头开始匹配呢,高德纳等人为此设计出了大名鼎鼎的KMP算法。
一、引出问题
举个例子来说明这个算法:
text : abcdabxabcdabcdabcy
pattern:abcdabc
开始查找:
text :abcdabxabcdabcdabcy
pattern:abcdabc
这里的x和c失配,常规的暴力检索的方法是,此时将pattern后移一位进行匹配,但是这样无疑抛弃了很多有用的信息,比如说这里其实表明了,c字符的前面的字符已经顺利匹配了。
那么如果能够找到一个最长的前后缀相等的字串,那么就可以直接将pattern移动到相应位置继续进行比较,而不用再一个个的试错。
从本质上来说,暴力搜索不就是再找一个最长匹配的前后缀么?那么既然都是在pattern里找最长匹配前后缀,我们为什么不事先就找好了呢?
具体来说第二次查找的位置是从cdabc开始进行逐个匹配,因为abcdab字符串的最长前后缀是ab,因此,我可以放心的将pattern的首部ab对准text的尾部ab,因为之前已经判断了,这两者是匹配的。
继续查找:
text :abcdabxabcdabcdabcy
pattern:abcdabc
发现再次失配,同理,判断一下c前面的字串ab是否存在最长公共前后缀,发现并没有则从a开始和x匹配,则x移到下一位进行匹配。
继续查找:
text :abcdabxabcdabcdabcy
pattern:abcdabc
那么,到这里我们发现核心问题其实是寻找pattern上的最长公共前后缀。
二、失配数组的计算
通过上文的解释我们发现最终不管是暴力检索,还是使用KMP本质上都需要用到最长公共前后缀的信息,那么这里就介绍一下KMP中高效获得该数组的方法。
text:abcdabca
a 0
ab 0
abc 0
abcd 0
abcda 1
abcdab 2
abcdabc 3
abcdabca 1
为什么说可以高效完成呢?因为我们注意到每一次的最长公共前后缀都与之前已经计算好的息息相关。比如abcda->abcdab,我们已经知道abcda的最长公共前后缀是1了,那么如果下一个新出现的字符和b相等,那么直接长度加一即可。
但是很多情况下,会出现不相等的情况,比如abcdabc->abcdabca,通过上一步,我们已经知道了abcdabc的最长公共前后缀长度为3,只需要比较d和新出现的字符,如果一致,则直接加一即可,可惜,这里并不相等。但我们已经知道abc是最长公共前后缀,可以利用这个信息来更快的找到新的匹配点,具体来说就是继续寻找abc的最长公共前后缀,然后将待比较的字符进行移动即可。其实就是转化成一个弱化的简单加一,还是比较容易理解的。
下面是Java的实现代码:
int kmp(String A, String B) { if (A == null | B == null) return -1; if (A.equals(B)) return 0; int[] prefix = new int[B.length()]; for (int i = 1, j = 0; i < B.length(); ) { if (B.charAt(i) == B.charAt(j)) prefix[i++] = ++j; else if (j == 0) i++; else j = prefix[j - 1]; } for (int i = 0, j = 0; i < A.length(); i += j - prefix[j - 1], j = prefix[j - 1]) { while (j < B.length() && (i + j) < A.length() && A.charAt(i + j) == B.charAt(j)) j++; if (j == B.length()) return i; if (j == 0) j++; } return -1; }
代码解析:
Java实现代码非常的consice,下面讲解一下其中的实现思路。首先是失配数组的计算:
// i 为prefix开始填写的位置,由于idx = 0必为0,所以i从1开始 // j 为 i - 1的最长公共前后缀的长度,初始值为长度0 for (int i = 1, j = 0; i < B.length(); ) { // 比较i位置的字符和最长公共前后缀长度位置的字符,如果相等直接更新prefix[i] = ++j,并i++ if (B.charAt(i) == B.charAt(j)) prefix[i++] = ++j; // 若j == 0还没有匹配,则prefix[i] = 0,并i++ else if (j == 0) i++; // 若j != 0,则将j更新为prefix[j - 1] else j = prefix[j - 1]; }
在看匹配函数的实现之前我们不妨看一下BruteForce的实现代码:
public int bruteForce(String A, String B) { for (int i = 0, j = 0; i < A.length(); i++, j = 0) { while (j < B.length() && (i + j < A.length() && A.charAt(i + j) == B.charAt(j))) j++; if (j == B.length()) return i; } return -1; }
KMP算法中相比传统的BF算法,改进的地方是在发生失配的时候,j不再只是简单的更新为0,而是更新为prefix[j - 1],这里需要注意的是,如果j = 0,则只能更新为0。另外,i的更新也不是只是从i到i + 1,而是从失配的位置继续进行匹配,即原先在i + j位置失配,则之后依然在这个位置继续比较。
// 初始化i = 0,j = 0 for (int i = 0, j = 0; i < A.length(); i += j - prefix[j - 1], j = prefix[j - 1]) { // 开始比较,按字符进行比较,i中保存的是text的起始位置,j可以理解为offset while (j < B.length() && (i + j) < A.length() && A.charAt(i + j) == B.charAt(j)) j++; // j == B.length()的时候,表示A中存在B,返回i if (j == B.length()) return i; // trick,当j == 0时,j应该保持0,又j会被更新为prefix[j - 1],所以将j置为1 // 另外如果j = 0,此时退化为普通的BF,i需要自增,而i += j - prefix[j - 1]此时等价于i++ if (j == 0) j++; }