KMP 匹配算法用于快速从字符串中查找某子串。其由 Knuth、Morris、Pratt 三人于 1977 年联合发表(1974 年 Knuth 和 Pratt 共同构思出,同一时间 Morris 也独立设计出该算法,最终三人于 1977 年联合发表),因此被称为 KMP 算法。
朴素匹配算法及其问题
在讲解 KMP 算法之前,我们先看看朴素匹配算法。所谓朴素匹配算法,就是在没有 KMP 算法的情况下,大多数人在第一时间会想到的最直观计算方法。KMP 算法也就是在朴素匹配法的基础上,所进行的改进。
如上图所示为朴素匹配算法的匹配步骤。图中黄色的为主串「abcdabcdef」,粉色的为子串「abcde」,我们可以把主串和子串看作是两把尺子,而匹配过程就是固定主串不动,逐步移动子串,然后比较它们的重叠部分,如果重叠部分对应字符全部相等的话,则匹配成功,否则的话子串继续往后移一位,然后重新比较重叠部分。
我们分步来描述一下运算步骤:
- 子串处于位置 0,我们欣喜地发现前 4 个字符都相等,但是等我们比较到第 5 个字符的时候,遗憾发现这一位并不相等,匹配失败。
- 子串移动到位置 1,首字符即不相等。
- 子串移动到位置 2,首字符即不相等。
- 子串移动到位置 3,首字符即不相等。
- 子串移动到位置 4,发现重叠部分所有字符都相等,匹配成功!
以上就是朴素匹配算法,如它的名字指出的那样,它很朴素很直观,且有效,但这也往往意味着其在效率上会存在存在很大的问题。
在步骤 1 中,我们已经比较发现主串和子串的前 4 位是相等的,而第 5 位不相等。在之后的步骤中,我们不断地进行移位和比较。大家有没有想过,我们已经将主串的前 5 位和子串的前 5 位逐一比较过了,我们是不是可以更好地来利用这个比较结果,达到减少步骤的目的呢。
具体来说,我们看步骤 2,此时子串移动到位置 1,然后我们将子串的第 1 位和主串的第 2 位进行比较,从步骤 1 的比较中我们已知主串的第 2 位和子串的第 2 位是相等的,因而步骤 2 中的操作也就等同于将子串的第 1 位和子串的第 2 位进行比较。同理,步骤 3 是将子串的 1、3 位相比较,步骤 4 是将子串的 1、4 位相比较。假如说,我们已经预先已经知道了子串「abcde」中就没有重复字符,那么像步骤 2 到步骤 4 的比较就不用做了,我们直接把子串移动到位置 4,进行后面的比较即可。
这样的话,步骤就直接简化到如下图所示的两步。
当然,以上举的是一个极端情况,子串「abcde」中没有重复字符,那么如果子串中有重复字符又怎么说呢?我们看如下图的例子:
子串为「ababe」,是有重复字符的,显然,第2 步中我们不能直接把子串移动到位置 4。因为在子串的前 4 位字符中,前两位和末两位是相等的,我们只能将子串移动到位置 2,将子串的前两位和主串的第 3、4 位对齐(此时我们已经知道它们是相等的了),然后来比较主串的第 5 位和子串的第 3 位,希冀于后面的几位都能匹配成功。
通过以上对朴素匹配算法问题的分析,对于如何解决其问题,我们可以总结出:
- 比较过程中,主串的游标没必要往回走,比如过现在主串的前 4 位都已经跟子串比较过了,那就没必要再将这前面 4 位再去跟子串比较了。主串的游标只往右走,不往回走。
- 既然确定主串的游标只要往右移动就行了,剩下来要确认的就是子串的游标问题。我们可以看到,当子串已比较字符中没有重复字符时,我们直接将子串的头部移动到当前主串游标所在位置,又从子串的第一位开始比较;而当子串已比较字符的头尾存在重复的话,我们则没必要回到第一位,而是回到重复字符的下一位继续比较。这个子串游标应该回到第几位的问题,我们称之为回溯。研究清了回溯问题,KMP 算法也就自然出来了。
next 数组
我们已经了解到,KMP 的关键在于子串的回溯,确切来说是根据已比较的那部分子串来回溯。具体回溯到哪一位,跟主串并没有关系,它只取决于已比较的那部分子串的首位字符重复度。因此,对于给定的一个子串,比如「abcabcef」,我们关心的是:如果子串的前 3 位「abc」已经匹配到跟主串相等了,匹配第 4 位的时候没相等,这个时候我的子串应该回溯到哪一位。如果已经匹配了 4 位、5 位呢?显然,我们应该为此构建一个数组。
由于这个数组解决的是下一步比较从哪一位开始的问题,因此我们把它命名为 next 数组。
上图演示了对字符串「ababae」求 next 数组的过程。描述如下。
- next[0]
a 前面没有字符,因而 next[0] = 0
- next[1]
b 前面只有一个字符 a,因而 next[1] = 0 。注意,咱们计算 next[i],是根据第 i 位之前的字符来计算的,不包括第 i 位本身,即图中蓝色背景的部分。
- next[2]
b 前面有两个字符「ab」,没有相等的,因而 next[2] = 0
- next[3]
b 前面有 3 个字符「aba」,其中首部的 a 和尾部的 a 是相等的,因而 next[3] = 1
- next[4]
「abab」中,首部的 ab 和尾部的 ab 重合,因而 next[4] = 2
- next[5]
「ababa」中,首部的 aba 和尾部的 aba 重合,因而 next[5] = 3
- next[6]
「ababab」中,首部的 abab 和尾部的 abab 重合,因而 next[6] = 4
- next[7]
「abababe」首尾没有重合,因而 next[7] = 0
通过以上的步骤讲解,大家对于 next 数组的概念,即字符串首位重合度,已经理解了。但是如何如何通过程序来计算呢?其实 next 数组的计算过程,也是字符串和字符串的比较过程,只不过比较的双方是自己和自己。合着绕了一圈,还是回到两个字符串的比较上来了,不过不要急, 「两个相同的串比较」和「两个陌生的串比较」毕竟是不一样的,耐心把这个问题解决了,咱们的 KMP 算法就出来了。
如上图右侧所示,我们把每次步骤中两个串(蓝串和粉串)的位置关系画了出来。整个匹配过程仍然是蓝串不动,粉串往右移。由于我们要比较的是首和尾,至少要达到两个字符,因而前两步可以跳过,其 next 值必然等于零。达到两个字符的长度后,我们把粉串的头放在蓝串的尾,发现这两个字符不相等,因而 next 值为 0。之后粉串和蓝串的长度都加一,同时我们把粉串继续往后移一位,发现首尾字符相等,next 值为 1。两个串的长度再加一,此时我们没必要移动粉串,因为上一步的两个字符已经匹配上了,我们继续匹配下一个字符,果然这个字符也匹配上了,next 的值又比之前加一,变成了 2。之后两步由于一直匹配成功,所以我们继续保持粉串不动。知道最后一步,上一步的匹配失败了,这个时候我们 next 值继续增长的美梦破灭了,我们只得将粉串往右移。但是应该往后移几位呢?我们自然是希望少移一点,多匹配几位,但是如果一位一位移的话,也未免效率太低了。这个时候智慧就体现出来了。
我聚焦到这一步来看:
已经有四位匹配成功了,如红框中所示。此时我们要将粉串往右移动,面对的问题是该移多少位。朋友们,有没有点似曾相识的感觉了?这又是一个 KMP 啊,这是一个递归有没有!关键是,红框中串的 next 数组我们已经有了啊!还等什么,赶紧拿出来用。我们取出 next[4] = 2,说明我们应该再往后移 2 位。同时蓝串和子串长度加一,就是如下所示了:
若比较仍是不相等,则按照刚刚的方法继续递归,直到匹配成功或 next 的值为零。
整个过程其实很简单,只有最后递归这一步是有点绕的。
无论怎样,我们已经得到了 next 数组,离成功不远了。喘口气,把这部分的代码实现了。
//------ 计算字符串的回溯下标数组 -------- // str: 要计算的字符串 // next:用于存储回溯下标的数组 //---------------------------------------- void getNextArr(char * str, int *next) { int length = strlen(str); int index1 = 1; // 固定串的游标 int index2 = 0; // 移动串的游标 next[0] = 0; while (index1 < length) { if (index2 == 0 || str[index1] == str[index2]) { next[index1] = index2; ++index1; ++index2; } else { index2 = next[index2]; } } }
KMP 算法的实现
其实到这里,KMP 算法的核心我们已经掌握了。
我们已经得到了 next 数组,因而接下来的代码实现也变得非常简单。实现思路也就如咱们上面所说的,固定主串不动,移动子串去比较。在这个过程中,主串的游标只增大不减小,而子串的游标则会在匹配失败时减小,至于减少到多少,取决于 next 数组。
好,下面我们便贴上代码。
//---------------------------- // KMP 字符匹配算法 // author: huangqiang // date: 2020-09-13 //---------------------------- #include<stdio.h> #include<string.h> #include<stdbool.h> //------ 计算字符串的回溯下标数组 -------- // str: 要计算的字符串 // next:用于存储回溯下标的数组 //---------------------------------------- void getNextArr(char * str, int *next) { int length = strlen(str); int index1 = 1; // 固定串的比较下标 int index2 = 0; // 移动串的比较下标 next[0] = 0; while (index1 < length) { if (index2 == 0 || str[index1] == str[index2]) { next[index1] = index2; ++index1; ++index2; } else { index2 = next[index2]; } } } void main() { // 主串 char *mainStr = "abaaabababebabf"; int mainStrLen = strlen(mainStr); // 子串 char *subStr = "abababe"; int subStrLen = strlen(subStr); // 回溯下标数组 int next[subStrLen]; // 调用 getNextArr 方法计算回溯下标数组 getNextArr(subStr, next); // matched 变量用于存储匹配结果 bool matched = false; int mainIndex = 0, subIndex = 0; // 配对生成了,退出; 到主串尾了,退出。 while(!matched && mainIndex < mainStrLen) { // 对当前字符进行匹配 if (mainStr[mainIndex] == subStr[subIndex]) { // 子串游标到尾了,说明匹配成功了 if (subIndex >= subStrLen-1) { matched = true; } else { subIndex++; mainIndex++; } } else { if(subIndex > 0) { // 字串回溯的时候,主串的游标保持不动 subIndex = next[subIndex]; } else { subIndex = 0; mainIndex++; } } } printf("主串: %s\n子串: %s\n", mainStr, subStr); if (matched) { printf("匹配成功,起始下标: %d\n", mainIndex-subStrLen+1); } else { printf("匹配失败\n"); } return; }
KMP 算法的改进
上面的 KMP 算法其实还是有改进空间的。我们来看下面一种情况。
在第 1 步中,已经匹配了主子串的前 4 位,匹配第 5 位时发现不相等。此时主串和子串的游标(数组下标)均等于 4。
这个时候我们要回溯了,回溯到多少呢?next[4] = 3,即图中步骤 2 的位置,然后继续回溯 next[3] = 2,next[2] = 1,next[1] = 0。然而你看前几位都是 a,咱们忙活了半天其实就是反复地在将 a 和 b 进行比较,这显然是多余的。
如何改进呢?方法也很直接。在我们计算 next 数组的时候,若发现回溯到的那个位置的字母和当前的字母是一样的,那意味着比较结果仍将是不相等,继续往前回溯吧。根据这个规则,我们重新来改写 next 数组。
在此我们只贴上修改后的 getNextArr 函数,代码的其他部分不变。
//------ 计算字符串的回溯下标数组 -------- // str: 要计算的字符串 // next:用于存储回溯下标的数组 //---------------------------------------- void getNextArr(char * str, int *next) { int length = strlen(str); int index1 = 1; // 固定串的比较下标 int index2 = 0; // 移动串的比较下标 next[0] = 0; while (index1 < length) { if (index2 == 0 || str[index1] == str[index2] && str[next[index2] != str[index2]] ) { next[index1] = index2; ++index1; ++index2; } else { index2 = next[index2]; } } }