前言:
KMP是数据结构里较为复杂的一类,能够高效地从一个字符串中找到另一个字符串第一次出现的位置,时间复杂度为O(m+n),其中m为目标字符串的长度,n为模式字符串的长度。和传统的暴力匹配O(m*n)相比,时间复杂度大大减少,特别是在目标字符串和模式字符串较长的情况下,性能优势更加明显。
在学KMP算法时,难点在于Next数组的寻找,在写本文之前也曾浏览过很多文章,翻看过很多视频,在这些基础之上用一种较为新的方式来寻找Next数组。希望能对你有所帮助。
1.暴力匹配
何为暴力匹配?就是两个字符串都一个一个碾过去,遇到相同的就同时右移,不同的时候主串回溯到(该次匹配开始时的位置 + 1),直到目标字符串搜寻完毕。图示如下:
暴力匹配很好理解,在这里就不过多阐述,以下是暴力匹配的代码:
private static int count(String str1, String str2){
int str1len = str1.length(); // str1的指针
int str2len = str2.length();
int count = -1; // 返回值
int i = 0; // str2的指针
int j = 0; // str2的指针
while ((i != str1len) && (j != str2len)){
if (str1.charAt(i) == str2.charAt(j)){ // 如果匹配成功
i++;
j++;
} else{ // 匹配失败
i = (i - j) + 1; // i回溯到匹配开始的下一个位置
j = 0;
}
if (j == str2len){ // 如果匹配完成,返回匹配成功的索引值,失败则-1不变
count = i - j;
}
}
return count;
}
整体来说,暴力匹配是新手解决这一问题的最佳途径,但是时间效率较为低下,因此可以考虑KMP算法。
2.KMP算法
KMP算法和暴力匹配的最大区别就在于回溯,我们可以看到,暴力匹配如果失败,每次都要回溯到第一次匹配成功的下一位,字符串少了还好说,如果目标和模式字符串都很长,那么暴力匹配的时间花费就太大了!KMP则是在已经匹配过的字符串里寻找有用条件来进行回溯,大大减少了回溯的次数,并且借助"字符匹配表"使回溯的效率更高。
2.1 字符匹配表(Next数组)
2.1.1 前缀
在搞清楚字符匹配表之前,我们先来搞明白前缀。拿单词"monologue"来说,前缀就是含m但不含e的部分,即"m","mo","mon","mono"......"monologu".
2.1.2 后缀
后缀与前缀相背,同样拿上面的例子,后缀就是含e但不含m的部分,即"e","ue","gue","ogue"......"onologue".
2.1.3 共有前后缀
在理解了前缀和后缀之后,共同前后缀的概念就很好理解了,就是二者取交集。
在搞清楚上面三个概念之后,我们就可以开始着手于寻找我们的字符匹配表了。对字符匹配表而言,就是共有前后缀的最大长度。我们以"ABACABAB"为例来给大家仔细地讲述如何去找到字符匹配表。
上图就是“ABACABAB”的字符匹配表,我们首先要明白一点,字符匹配表是共有前后缀的最大长度,所以当只有一个字母时,是没有共有前后缀,所以next[0] = 0毋庸置疑,并且字符匹配表的长度=字符串的长度。
(1) 字符匹配表是从头走到尾得到的,我们这里用prefix来代表共有前后缀的最大长度。以上图为例,当走到AB时,前缀为“A”,后缀为"B",没有共同的前后缀,那么prefix = 0,即next[i = 1] = 0,这里的i就相当于指针。
当我们走到ABA时,前缀为”A“,”AB“,后缀为"BA","A",所以prefix = 1,即next[i = 2] = 1。
(2) 此时我们已经走到了ABAC,我们发现,如果索引为3的”C“如果和索引为1的”B“相同,那么是不是就在ABA的基础之上构成了一个更长的前后缀?很显然他们不同,这里也没有共同的前后缀,所以很遗憾ABAC在C的位置,prefix = 0,即next[i = 3] = 0。
(3) 当我们接下来走到第五位的时候,不是正好和走到第三位一样的吗?新的一位都和第一位相同,均为A,而且此时prefix是为0的,注意这里prefix为0,有伏笔。所以在进行第五位的相同前后缀查找时,我们发现next[i = 4] == next[prefix] = "A",所以可以得到第五位的prefix = 1。
(4) 走到第六位第七位的时候,我们发现第六位和第二位相同,第七位和第三位相同。即第六位和第二位均为"B",第七位和第三位均为”A“,并且我们注意到,走到第六位时,prefix为1,此时因为第六位和第二位相同,不刚好在1的基础之上构成了一个更长的前后缀了吗?我们直接+1即可,则第六位的prefix为2。同理,第七位的prefix为3。
这里我们不难发现,如果prefix != 0,那么就找prefix的下一位,如果它和最新的字母相同,那不就刚好在之前的基础上有更长的前后缀了吗?但如果下一位不同呢?
(5)走到最后,即第八位时,prefix = 3,我们很容易发现第八位 != 第四位,意味着无法构成更长的前后缀,那么我们就没有简单的方法来得到第八位的匹配值吗?
如得!我们不难发现,prefix为3,代表着已经成功匹配过三个数,同时我们能清楚的看到,左边框无法与右边框一起构成prefix = 4的前后缀,但是左边框的前缀"AB"能和右边框的后缀"AB"构成长度为2的相同前后缀,那么我们在两个共有的部分寻找共同的前后缀,使得左边框的前缀能和右边框的后缀相同不就好了吗?如何回到共有的部分呢?我们有prefix = 3,不就是代表成功匹配三次吗?那我们要做的就是回到第三次匹配的位置。根据索引从0开始的特性,new_prefix = next[prefix - 1] 是不是就代表这共有部分的相同前后缀长度呢?我们根据之前得到的表可以得到,new_prefix = 1。
在得到新的prefix = 1后,我们容易惊讶的发现,这就是我们指针走到第三位的情况!按之前的表格我们有"ABA"最长的共有前后缀长度为1,即prefix = 1,所以这又回到了之前的想法,因为第八位和第二位相同,所以能在第一位和第七位相同的基础上构成更长的前后缀,不就是2了吗!
至此,我们得到了完整的字符匹配表,综合之前的过程我们能够明白:
(以下的prefix都是在比较之前的数值,比较之后就要在指针的位置变化prefix的值)
(1)如果next[prefix] == next[指针(>0)],那么prefix(共有前后缀) + 1,next[指针] = prefix
(2)如果next[prefix] != next[指针(>0)],我们又分为两种情况:
(a)如果prefix = 0,那么next[指针] = 0 是顺理成章的事情,因为prefix = 0 就表明在之前没有能构成共同前后缀的字母。
(b)如果prefix != 0,就代表在已匹配的部分存在两个更小的部分完全相同,那么我们就要去在这两个小部分去查找共同前后缀,然后重新比较 next[new_prefix] 和 next[指针] 找出是否存在以下情况,即共同前后缀"A",分别能和第二位和第八位的"B"构成一个更长的前后缀。
而这一步,也就又回到了上面的条件判断中去,即如果next[new_prefix] == next[指针],那么prefix + 1,如果next[prefix] != next[指针(>0)],就继续上述操作,直至next[new_prefix] == next[指针]或者prefix == 0。
将以上的思路整理成代码如下:
private static int[] NextArr(String str){ // 得到str的字符匹配表
// 计算next数组
int strLen = str.length(); // 记录字符串的长度
int[] next = new int[strLen]; // next数组的容量就是字符串的长度
next[0] = 0;
int i = 1;
int prefix_len = 0; // 前后缀共有的最长长度
while (i != strLen){ // 结束条件
if (str.charAt(prefix_len) == str.charAt(i)){ // 如果相同
prefix_len++; // 共有前后缀+1
next[i] = prefix_len;
i++;
} else{ // 没有共同的前后缀
if (prefix_len == 0){ // 之前没有共同的前后缀
next[i] = 0;
i++;
} else{
prefix_len = next[prefix_len - 1]; // 寻找两个小部分的共同前后缀
// 这一行的代码是核心!很多教程没有讲清楚这一点,请结合上述例子反复食用揣摩!
}
}
}
return next;
}
2.2 第一次匹配成功的索引值
2.2.1 移动位数 = 已匹配长度 - 对应的部分匹配值
这是KMP算法的使用公式,具体的推导比较复杂繁琐,而且不是核心点,大家知道有这样的一个公式并且会使用即可。
当我们明白字符匹配表和公式之后,我们就可以开始着手匹配了,之前讲过,KMP的精髓在于回溯,至于匹配的方法还是相同的,所以匹配这部分就不用细说了,直接给大家上代码。
private static int kmpSearch(String str1, String str2){
int i = 0; // str1的指针
int j = 0; // str2的指针,也可以理解为共同前后缀的长度
int count = -1; // 记录最终匹配成功的索引
int[] next = NextArr(str2); // 得到字符匹配表
while (i != str1.length() && j != str2.length()){ // 循环体结束条件
if (str1.charAt(i) == str2.charAt(j)){ // 如果成功匹配,就继续下一个匹配
i++;
j++;
if (j == str2.length()){ // 如果str2的指针已经到最后了,就代表匹配完成
count = i - j; // 返回匹配开始的指针位置,即索引值
}
} else{ // 如果匹配不成功,分两种情况讨论
if (j != 0){ // 如果之前匹配成功,就跳过 [起始的i + (已匹配的长度 - 表[j])]
i = (i - j) + (j - next[j - 1]); // 这里是写出了完整表达式,也可以简化成i - next[j - 1]
j = 0;
} else{ // 如果之前匹配没有成功,即没有相同的前后缀,那么i++,j不动
i++;
}
}
}
return count; // 返回结束的索引值
}
2.3 匹配
至此KMP的代码全部完成,输入目标字符串和模式字符串后就可以得到匹配成功的索引值了(如果你的result = -1当我没说......)
public static void main(String[] args) {
String target = "BBC ABCDAB ABCDABCDABDE";
String parse = "ABCDABD";
int result = kmpSearch(target,parse);
if (result != -1){
System.out.println("从索引为" + result + "开始匹配成功...");
} else{
System.out.println("匹配失败...");
}
}
3. 感悟
KMP算法的核心点在于Next数组的寻找,各种方法的核心都是一样的,只是在寻找这个核心的过程中不同道罢了,没有真正意义上的优劣,选择你自己能够明白的方法,才是最适合自己的方法。也正如生活中路,适合他人的路可能并不合适你,总要找到一条属于自己的道路。
KMP算法是我们在算法路上必须要经过的一道坎,当然如果要实现相同的功能,暴力匹配和正则都是可以实现的,真正想要明白一个算法原理需要花费一定的时间,所以不要厌倦算法的复杂,相信自己一定可以的!