KMP算法原理和代码实现
KMP算法主要解决的的是字符串匹配问题。 给定一个文本串T和一个模式串P,要求在文本串中找到模式串出现的位置。
引入:普通暴力匹配算法
最简单的字符串匹配算法是暴力匹配算法。其基本思想是:从文本串T的第一个字符开始,依次与模式串P的字符进行比较。如果遇到不匹配的字符,则将模式串向右移动一位,继续比较,直到找到匹配的位置或者文本串被扫描完为止。
1 | int BFSearch(string T, string P) { |
这种算法的时间复杂度达到了O(m*n),其中m是文本串的长度,n是模式串的长度。为什么会出现这样的时间复杂度呢?考虑下面的例子:
1 | T = "AAAAAAAAAAAAAB" |
当两个字符串出现大量重复字符的时候,BF算法会反复回溯模式串的指针,导致时间复杂度上升。
而KMP算法通过计算最大前后缀匹配长度,避免了这种回溯,从而提高了匹配效率。
KMP算法原理
如果我们不想直接把指针回溯到零,我们应该如何确定回溯的长度呢?
观察下面的例子:
1 | 012345678 |
可以看到,当我们匹配到T[5]和P[5]时发生了不匹配。但是我们注意到,在P和T已经匹配过的部分中,P[0,2]和T[3,5]是相同的字符串"ABC"。而P[0,2]又是P[0,4]的前缀,T[3,5]与P[0,4]的后缀相同。这样,我们只需要将模式串P向右移动2位,继续比较。
1 | 012345678 |
这样,我们可以看到两个指针后续的字符串是相同的,继续逐个比较字符即可匹配成功。
这说明我们可以利用已经匹配成功的部分来避免不必要的比较。具体来说,我们需要找到模式串P的前缀和后缀的最长匹配长度,这样当发生不匹配时,我们可以将模式串向右移动相应的位数,避免重新匹配前面已经匹配过的部分。
这个最长匹配长度可以通过一个辅助数组next来存储。next[j]表示模式串P的前缀P[0..j-1]中,既是前缀又是后缀的最长子串的长度。
算法实现
先不考虑计算next数组,直接实现KMP匹配算法:
1 | int kmp(string T, string P) { |
接下来实现计算next数组的函数:
定义:next[k] 表示模式串P[0,k-1]的最长前后缀匹配长度。compare表示P[0,k-2]的最长前后缀匹配长度。
- 如果
P[k - 1] = P[compare],那么显然,next[k] = compare + 1(为什么不是next[k] = next[k - 1] + 1呢?) - 如果
P[k - 1] != P[compare],则需要将compare向前回溯。假设更短的前后缀长度为m + 1,这里1表示后缀的最后一个元素,即P[k - 1],那么需要满足前后缀相同的条件:P[0, m - 1] = P[k - m - 1, k - 2]。这样我们就把‘前后缀相等’这个要求向前递推了一位。假若这个时候前一位的前后缀存在匹配的情况,那么我们想要从匹配好的后缀中进一步取出长度为m的后缀,这个取出的‘子后缀’必然和匹配好的前缀的相应‘子后缀’相同,即P[compare - m, compare - 1]=P[k - m - 1, k - 2](这是因为匹配的前后缀相同,所以它们相应位置的子串也相同);同时,这个‘子后缀’还必须和整个P[0, k - 1]的长度为m的前缀相等,也就是上面写的条件。这样,我们发现了一个巧妙之处:P[compare - m, compare] = P[0, m - 1],这表明m正是P[0, compare]的前后缀最大匹配长度!而P[0, compare]的最长前后缀匹配长度为next[compare],因此我们可以将compare更新为next[compare],如果满足条件1,则返回,否则继续,直到找到匹配或者compare为0。因此,前面不能简单地写成next[k] = next[k - 1],因为next[k - 1]表示的是P[0, k - 2]的最长前后缀匹配长度,而我们需要的是在不匹配时,模式串指针应该回溯到的位置。
1 | vector<int> computeNext(string P) { |
总结
- 前后缀最大匹配长度有两层含义:一是表示前后缀的最大匹配长度,二是表示在发生不匹配时,模式串指针应该回溯到的位置。
- next求取时,分两种情况讨论,匹配和不匹配。不匹配时需要回溯compare指针,直到找到匹配或者compare为0。
- KMP算法的时间复杂度为O(m + n),其中m是文本串的长度,n是模式串的长度。相比于暴力匹配算法,KMP算法在处理大量重复字符时表现更优。
参考例题:Leetcode 28. find the index of the first occurrence in a string
2026年1月3日。