前言
KMP算法是为了解决串的模式匹配问题而发明的。所谓串的模式匹配就是在一个长的串中定位一个子串的位置。通常我们将长的串称为主串,被定位的子串称为模式串。
一个串的模式匹配的例子如下:
主串为"hello,world",模式串为"llo",显然模式串是从主串中索引为2的地方开始的,因此应该返回2。
朴素的模式匹配算法
在了解了需要解决的问题之后,我们可能第一个会想到类似下面这种方法:
- 首先主串和模式串都从第一个位置开始比较:
- 因为第一个位置不同,则从主串的下一个位置继续和模式串的第一个位置比较:
- 因为还是不同,则继续上面的步骤:
- 此时比较的两个元素相同,则继续比较主串和模式串的下一个元素:
- 重复上面的步骤,直到主串或者模式串超出长度为止。
代码如下:
int search(String main, String pattern){
int i = 0, j = 0;
while (i < main.length() && j < pattern.length()){
//如果主串和模式串比较的位置上元素相等,则比较各自的下一个位置
if (main.charAt(i) == pattern.charAt(j)){
i++;
j++;
}else { //否则主串从下一个位置开始,模式串从头开始继续进行比较
i = i - j + 1;
j = 0;
}
}
//如果模式串已经匹配到结尾了,返回其在主串中开始位置的索引
if (j == pattern.length()){
return i - j;
}
//否则说明没有主串中没有找到模式串,返回-1
return -1;
}
我们可以发现,这种朴素的模式匹配算法的时间复杂度是很高的。假设模式串每次都在最后一个位置才不匹配,那么时间复杂度是O((n - m) * m),其中n是主串的长度,m是模式串的长度。
而造成这种现象的原因主要是我们需要不断回溯主串上的指针i,即每次遇到不匹配的情况都需要从主串中刚才开始匹配的下一个位置开始。
KMP算法
KMP算法可以改进朴素的模式匹配算法的缺点,即在不回溯i的情况下,只通过移动j的位置完成匹配。
以下面这个例子来看:假设主串是"AABAABAAA",模式串是"AABAAA",以KMP算法来匹配的步骤如下:
- 每一位进行比较,如果相同就比较主串和模式串的下一位,可以看到这里前五位都是相同的,直到第六位才出现差异:
- 当遇到不同时,仅仅向后移动模式串,然后从主串当前位置继续开始匹配:
可以看出,KMP算法的关键在于第二步,即如何向后移动模式串。
最长公共前后缀
上面的例子中,我们直接将模式串向后移动了3位。因为我们观察模式串可以发现,在第6位的A前面的存在着两个相同的部分:
因为我们已经匹配到了模式串的第六位,说明前五位都是匹配的,而上图中红框内的内容也是相等的,所以我们可以直接将模前一个红框中的内容移动到后一个红框,即把模式串后移三位。
这里可以引出一个公共前后缀的概念,在模式串某个位置之前的子串s中,当s开头的n个元素组成的子串与s的后n个元素组成的子串相等时,我们就称这两个相等的子串为该位置的公共前后缀。(此处n应小于s的长度,即不能自身和自身相等)。
还是以上图为例,当前元素为模式串中最后一个"A",此时有两对公共前后缀:一对是第一位的"A"与第五位的"A";另一对是红框中的部分,即一二位的"AA"与四五位的"AA"。
而在一个位置上的多对公共前后缀中,存在着一对最长的,我们称其为最长公共前后缀,这也是我们真正关心的一对。KMP算法就是根据最长公共前后缀来移动模式串的。
求next数组
我们可以利用一个数组来存储模式串在每个位置的最长公共前后缀的长度,有了这个数组,我们就知道如何移动模式串了。一般我们将这个数组称为next数组。
求next数组的代码如下:
int[] getNext(String pattern){
int length = pattern.length();
int[] next = new int[length];
next[0] = -1; //第一位不存在前后缀
int i = -1, j = 0; //i指向最长公共前缀的下一个位置,j为模式串中当前位置的前一个元素,即新加入上一节所述s子串的那个元素
while (j < length - 1){
//判断i和j的元素是否相等,另外对于没有公共前后缀的情况,使用i==-1处理
if (i == -1 || pattern.charAt(i) == pattern.charAt(j)){
next[++j] = ++i;
}else {
//当不相等时,在当前最长公共后缀中寻找
i = next[i];
}
}
return next;
}
求next数组的关键在于每个位置的最长公共前后缀长度最多比前一个位置增加1,原因也很简单,因为每次最多只增加了一个元素到之前的最长公共后缀中。因此,我们只需要比较每次新加入的那个元素是否和当前最长公共前缀的下一个元素相等即可:相等的情况是最简单的,只需要将最长公共前后缀长度加一即可。
如果不相等的话,情况就稍微复杂一些,但是基本思路就是我们要缩短前后缀的范围去看能不能找到相等的两个子串。那么具体应该缩短多少呢?这也是我一开始学习KMP算法时一个比较困扰的地方,但是其实仔细想想可以发现这个答案也可以在next数组中找到,对应到代码中就是 i = next[i] 那行。
这里的next[i]是现在最长公共前缀中的最长公共前后缀的长度。可能有一点抽象,下面还是通过一个例子来看一下这个思路:
假设i和j分别指向图中红色箭头所示位置,目前的最长公共前后缀为红框中"ABABA"的子串。此时,我们要缩短前后缀的范围,也就是在上图的红框中继续缩小范围,来寻找新的最长公共前后缀。
最直观的方法肯定是一个个元素来缩小,但这样效率太低了,有一种更快的方案,如下图所示:
第一个蓝框部分的子串"ABA"是模式串在i位置的最长公共前缀,因为两个红框内的部分是其实一样的,所以我们在后面红框的结尾处一定也能找到一个长度为3的子串且内容是"ABA"的子串,即第二个蓝框,并且"ABA"是目前第二长的公共前后缀。这时我们只要比较模式串中索引为j的元素和第四个元素,也就是蓝框后的第一个元素是否一致就可以了。而找到"ABA"这个子串也很简单,其实就是next[i]前的内容。这就是为什么在i和j位置上的元素不匹配时要用 i = next[i]。
KMP算法如何实现
得到next数组后,KMP算法的剩余部分就很简单了,其核心思想就是在主串和模式串不匹配时,将模式串后移到最长公共前缀落在原来的最长公共后缀的位置上。代码如下:
int search(String main, String pattern){
//求出next数组
int[] next = getNext(pattern);
int i = 0, j = 0;
while (i < main.length() && j < pattern.length()){
//增加了一个条件来处理模式串第一个元素就不匹配的情况
if (j == -1 || main.charAt(i) == pattern.charAt(j)){
i++;
j++;
}else {
//根据next数组来移动模式串
j = next[j];
}
}
if (j == pattern.length()){
return i - j;
}
return -1;
}
可见,此时我们遇到不匹配的情况,不再回溯主串上的指针i,而只将模式串的最长公共前缀的下一个元素与i对齐,然后继续比较即可,时间复杂度降低为O(m+n)。