一、从暴力匹配到KMP:为什么需要它?
在日常的文本处理中,我们经常会遇到一个很基础的问题:在一段较长的文本里找到某个特定的短字符串。比如说,我们有一段文本 “ABABABCAA”,想看看其中是否包含 “ABABC” 这个小片段。
最容易想到的办法就是一个一个字符去比较,也就是暴力匹配法。从文本的第一个字符开始,和小片段的第一个字符比较,如果一样,就继续比较下一个字符;要是不一样,就把小片段往后挪一个字符,重新从第一个字符开始比较。这个过程中,一旦发现不匹配,文本的指针就得回溯,重新开始新的一轮比较。这样做虽然能实现功能,但效率很低,时间复杂度高达 O(n×m),其中n是文本的长度,m是小片段的长度。
KMP算法的出现,很好地解决了这个效率问题。它引入了一个很关键的概念:前缀函数(next数组) 。这个数组记录了小片段自身的匹配信息,有了它,在匹配过程中文本的指针就不用回溯了,大大提升了匹配速度,时间复杂度降低到 O(n+m)。
二、KMP的核心:前缀函数与next数组
1. 什么是前后缀?
- 前缀:就是从小片段开头开始,取一部分字符组成的子串,但不能包含最后一个字符。比如对于 “ABAB” 这个字符串,它的前缀有 “A”、“AB”、“ABA” 。
- 后缀:从字符串末尾往前取字符组成的子串,不过不能包含第一个字符。像 “ABAB” 的后缀就有 “B”、“AB”、“BAB” 。
- 最长公共前后缀:在一个字符串的所有前缀和后缀里,找长度最长且内容相同的那一对,它们的长度就是最长公共前后缀的长度。比如 “ABAB” 的最长公共前后缀是 “AB”,长度为2 。
2. next数组的构建(动态规划思想)
next数组里的每个元素 next[i] ,代表的是小片段前 i 个字符组成的子串的最长公共前后缀长度 。
举个例子,对于模式串 “ABABC” ,它的next数组是 [0,0,1,2,0] 。计算过程如下:
- 第一个字符,没有前后缀, next[0]=0 。
- 前两个字符 “AB”,没有公共前后缀, next[1]=0 。
- 前三个字符 “ABA”,最长公共前后缀是 “A”,长度为1, next[2]=1 。
- 前四个字符 “ABAB”,最长公共前后缀是 “AB”,长度为2, next[3]=2 。
- 前五个字符 “ABABC”,没有公共前后缀, next[4]=0 。
下面是用C++ 实现计算next数组的代码:
cpp
// 计算next数组
vector<int> computeNext(string pattern) {
// 模式串的长度
int m = pattern.size();
// 初始化next数组,大小为m,初始值都为0
vector<int> next(m, 0);
// i从1开始,j初始化为0
for (int i = 1, j = 0; i < m; i++) {
// 当j大于0且当前字符和j位置的字符不相等时
while (j > 0 && pattern[i]!= pattern[j])
// j更新为next[j - 1]
j = next[j - 1];
// 如果当前字符和j位置的字符相等
if (pattern[i] == pattern[j])
// j自增1
j++;
// 更新next[i]
next[i] = j;
}
// 返回计算好的next数组
return next;
}
这段代码的逻辑是,在遍历模式串的过程中,通过不断调整j的值,找到当前位置的最长公共前后缀长度。
三、KMP完整代码实现(C++)
下面是完整的KMP匹配算法的C++ 代码,它包含了计算next数组和进行字符串匹配的功能:
cpp
#include <iostream>
#include <vector>
using namespace std;
// 计算next数组
vector<int> computeNext(string p) {
int m = p.size();
vector<int> next(m, 0);
for (int i = 1, j = 0; i < m; i++) {
while (j > 0 && p[i]!= p[j])
j = next[j - 1];
if (p[i] == p[j])
j++;
next[i] = j;
}
return next;
}
// KMP搜索函数
void kmpSearch(string s, string p) {
// 计算模式串p的next数组
vector<int> next = computeNext(p);
// 文本串s的长度
int n = s.size();
// 模式串p的长度
int m = p.size();
// 初始化文本串和模式串的指针
for (int i = 0, j = 0; i < n; i++) {
// 当j大于0且当前字符不匹配时
while (j > 0 && s[i]!= p[j])
// j更新为next[j - 1]
j = next[j - 1];
// 如果当前字符匹配
if (s[i] == p[j])
// j自增1
j++;
// 如果模式串完全匹配
if (j == m) {
// 输出匹配位置
cout << "匹配位置:" << i - m + 1 << endl;
// j更新为next[j - 1],准备下一次匹配
j = next[j - 1];
}
}
}
int main() {
// 定义文本串和模式串
string s = "ABABABCAA", p = "ABABC";
// 调用KMP搜索函数
kmpSearch(s, p);
return 0;
}
在这段代码中, kmpSearch 函数负责在文本串 s 中查找模式串 p 。它先调用 computeNext 函数计算出模式串的next数组,然后在匹配过程中,根据next数组来调整模式串的匹配位置,从而实现高效的字符串匹配。
四、考研模拟题实战(附解析)
问:给定文本串 s="ababaababac" 和模式串 p="ababac" ,求:
1.模式串的next数组;
2.所有匹配起始位置。
解析:
next数组计算:
- 模式串 p = a b a b a c 。
- 计算过程如下:
- 第一个字符 a , next[0]=0 。
- 前两个字符 ab ,无公共前后缀, next[1]=0 。
- 前三个字符 aba ,最长公共前后缀是 a , next[2]=1 。
- 前四个字符 abab ,最长公共前后缀是 ab , next[3]=2 。
- 前五个字符 ababa ,最长公共前后缀是 aba , next[4]=3 。
- 前六个字符 ababac ,无公共前后缀, next[5]=0 。
- 所以 next = [0,0,1,2,3,0] 。
匹配过程:
- 从文本串的第一个字符开始和模式串匹配。
- 当匹配到模式串的第5个字符(字符 c )时,和文本串对应位置不匹配。此时,根据 next[4]=3 ,将模式串右移 5-3 = 2 位继续匹配。
- 最终在文本串的位置5找到完全匹配。
五、KMP的优化与扩展
- Nextval数组:在一些情况下,模式串中存在重复字符,使用next数组可能会导致不必要的比较。Nextval数组对next数组进行了优化,避免了这些冗余匹配,进一步提高了匹配效率。
- 应用场景:KMP算法在很多实际场景中都有广泛应用。比如在生物信息学中,可以用于DNA序列匹配,快速找到特定的基因片段;在编译器开发中,能够实现关键字识别,提升编译效率。
总结
KMP算法的关键就在于巧妙地利用已经匹配过的信息,减少匹配过程中的回溯操作。通过深入理解next数组的原理和计算方法,我们就能轻松解决大部分字符串匹配问题。建议大家在学习过程中,结合文末给出的考研真题,自己动手用代码模拟一遍匹配过程,这样能更好地掌握KMP算法。
参考资料
- 数据结构考研真题解析
-《数据结构 C语言版》