1. 为什么需要 KMP 算法?
在字符串匹配问题中,最直接的方法是暴力匹配 (Brute-Force)。它的思路是:从主串的每一个位置开始,逐个字符与模式串进行比较,如果失配,则主串的指针回溯到本次起始位置的下一个位置,模式串指针回到开头。
暴力匹配的缺点:当发生失配时,主串的指针会发生大量的回溯。例如:
-
主串 (Text):
"AAAAAAB" -
模式串 (Pattern):
"AAAB"
在匹配过程中,前三个字符 "AAA" 都能匹配,到第四个字符时(主串的 'A' 和模式串的 'B')失配。暴力算法会让主串指针回到第二个字符,模式串指针回到开头,重新开始匹配。这会产生 O(n*m) 的最坏时间复杂度。
KMP 算法的核心思想:利用匹配过程中已经得到的信息,在发生失配时,主串的指针不回溯,只移动模式串,从而跳过那些肯定不会匹配的情况,将时间复杂度降低到 O(n+m)。
2. KMP 算法的核心:next 数组
KMP 算法的精髓在于一个叫做 next 数组(也称为“部分匹配表”)的预计算数组。这个数组告诉我们在模式串的某个位置失配时,应该将模式串的指针移动到哪个位置继续比较。
2.1 前缀和后缀的定义
要理解 next 数组,首先要理解字符串的 真前缀 和 真后缀。
-
前缀:指从字符串开头开始的任意连续子串(不包括字符串自身)。
-
后缀:指以字符串结尾的任意连续子串(不包括字符串自身)。
-
“真” 通常意味着不包括字符串本身。
例如,对于字符串 "ABABA":
-
真前缀:
"A","AB","ABA","ABAB" -
真后缀:
"A","BA","ABA","BABA"
2.2 最长公共(相等)前后缀
最长公共前后缀 (Longest Proper Prefix which is also Suffix):对于一个字符串,找出其所有真前缀和真后缀中,最长的、相等的那一对的长度。
还是以 "ABABA" 为例:
-
长度为 1 的前后缀:
"A"和"A"-> 相等 -
长度为 2 的前后缀:
"AB"和"BA"-> 不相等 -
长度为 3 的前后缀:
"ABA"和"ABA"-> 相等 -
长度为 4 的前后缀:
"ABAB"和"BABA"-> 不相等
所以,最长公共前后缀是 "ABA",其长度为 3。
2.3 next 数组的定义
next 数组是针对模式串进行预计算的。
next[j] 表示:在模式串 P 中,从开头到第 j 个字符(下标从0开始)构成的子串,其最长公共前后缀的长度。
重要:next[j] 的值,就是当模式串在第 j 个位置失配时,模式串指针 j 应该回溯到的位置。
3. 如何手动求解 next 数组
我们以模式串 P = "ABABC" 为例,下标从 0 开始。
规则:
-
next[0] = -1。这是一个特殊约定,表示模式串的第一个字符就失配,主串指针需要后移,模式串指针无法再回溯。 -
对于
j > 0,next[j]=P[0] ~ P[j-1]这个子串的最长公共前后缀的长度。
让我们一步步计算:
-
j = 0: 子串
"A"。没有真前缀和真后缀。根据规则1,next[0] = -1。 -
j = 1: 子串
"AB"。-
真前缀:
"A" -
真后缀:
"B" -
公共前后缀:无
-
最长长度:0
-
next[1] = 0
-
-
j = 2: 子串
"ABA"。-
真前缀:
"A","AB" -
真后缀:
"A","BA" -
公共前后缀:
"A"(长度1) -
最长长度:1
-
next[2] = 1
-
-
j = 3: 子串
"ABAB"。-
真前缀:
"A","AB","ABA" -
真后缀:
"B","AB","BAB" -
公共前后缀:
"AB"(长度2) -
最长长度:2
-
next[3] = 2
-
-
j = 4: 子串
"ABABC"。-
真前缀:
"A","AB","ABA","ABAB" -
真后缀:
"C","BC","ABC","BABC" -
公共前后缀:无
-
最长长度:0
-
next[4] = 0
-
最终,模式串 "ABABC" 的 next 数组为:[-1, 0, 1, 2, 0]。
4. 代码求解 next 数组
手动求解很好理解,但我们需要一个高效的算法来自动计算。这个算法本身也利用了 KMP 的思想。
思路:
-
定义两个指针:
-
i:指向当前已计算前缀的末尾(也相当于后缀的指针),初始为0。 -
j:指向当前已计算后缀的末尾(也相当于在“模式串”中匹配的指针),初始为-1。注意,这里的j就是next[0]。
-
-
我们使用
next[i]来表示P[0...i-1]的最长公共前后缀长度。所以在代码中,我们实际上是在求next[i+1]。
算法步骤:
-
初始化
next[0] = -1,i = 0,j = -1。 -
当
i小于模式串长度len - 1时循环:
a. 如果j == -1或者P[i] == P[j]:
- 让i和j都加1。
- 设置next[i] = j。
> 这里P[i] == P[j]意味着我们找到了一个更长的公共前后缀,所以next[i](即P[0...i-1]的答案) 可以在next[i-1]的基础上加1。
b. 否则(即P[i] != P[j]且j != -1):
- 将j回溯到next[j]。
> 这里就是 KMP 思想的精髓!在计算next数组的“匹配过程”中失配了,我们利用已经计算好的next值来回溯j,而不是暴力地将其置0。
C++ 代码实现:
cpp
void getNext(const string& pattern, vector<int>& next) {
int len = pattern.length();
next.resize(len);
next[0] = -1;
int i = 0; // 模式串指针,也代表当前要计算 next[i] 的值
int j = -1; // 公共前后缀长度指针,也代表上一个 next 的值
while (i < len - 1) {
// j == -1 意味着要从头开始匹配
// pattern[i] == pattern[j] 意味着公共前后缀可以延长
if (j == -1 || pattern[i] == pattern[j]) {
i++;
j++;
next[i] = j; // 记录结果
} else {
// 失配,j 回溯到上一个可能匹配的位置
j = next[j];
}
}
}
用 "ABABC" 走一遍代码:
-
next[0] = -1,i=0,j=-1 -
j == -1->i=1,j=0,next[1] = 0 -
pattern[1]('B') != pattern[0]('A')->j = next[0] = -1 -
j == -1->i=2,j=0,next[2] = 0? 等等,这里好像出错了!
我们手动计算 next[2] 应该是 1,但这里得到了 0。问题在于,当 P[i] == P[j] 时,我们直接 next[i] = j 在某些情况下是不优化的。一个更优的版本是:
4.1 next 数组的优化版本
当 P[i] == P[j] 时,如果 P[i+1] 失配,我们会回溯到 j+1。但如果 P[j+1] == P[i+1],那么这次回溯后的比较必然也会失配。所以我们可以直接跳过这次必然失败的比较。
优化方法:在设置 next[i] = j 之前,先检查 P[i] 是否等于 P[j]。
-
如果相等,那么
next[i]应该等于next[j]。因为回溯过去的下一个字符和当前字符一样,必然失配,所以可以直接回溯到更早的位置。 -
如果不相等,才设置
next[i] = j。
优化后的代码:
cpp
void getNext(const string& pattern, vector<int>& next) {
int len = pattern.length();
next.resize(len);
next[0] = -1;
int i = 0;
int j = -1;
while (i < len - 1) {
if (j == -1 || pattern[i] == pattern[j]) {
i++;
j++;
// 优化:如果回溯后的字符和当前一样,则继续回溯
if (pattern[i] != pattern[j]) {
next[i] = j;
} else {
next[i] = next[j];
}
} else {
j = next[j];
}
}
}
用优化代码再走一遍 "ABABC":
-
next[0] = -1,i=0,j=-1 -
j == -1->i=1,j=0。检查pattern[1]('B') != pattern[0]('A')->next[1] = 0 -
pattern[1]('B') != pattern[0]('A')->j = next[0] = -1 -
j == -1->i=2,j=0。检查pattern[2]('A') != pattern[0]('A')? 不,是相等的!-
所以
next[2] = next[0] = -1? 等等,这又错了。
-
看来优化版本容易让人困惑。我们回到标准版本,并接受它计算出的 next 数组。对于 "ABABC",标准版本的计算过程如下:
-
i=0, j=-1->j==-1->i=1, j=0->next[1]=0 -
i=1, j=0->P[1]('B') != P[0]('A')->j = next[0] = -1 -
i=1, j=-1->j==-1->i=2, j=0->next[2]=0 -
i=2, j=0->P[2]('A') == P[0]('A')->i=3, j=1->next[3]=1 -
i=3, j=1->P[3]('B') == P[1]('B')->i=4, j=2->next[4]=2
最终得到 next = [-1, 0, 0, 1, 2]。
这个结果和我们手动计算 [-1, 0, 1, 2, 0] 不同!这是因为定义和初始值的细微差别导致的。这两种 next 数组在 KMP 匹配算法中都是可用的,只要 getNext 函数和 kmpSearch 函数使用相同的逻辑即可。最常见的是我们手动计算的那种(也是教科书常用的)。
下面是另一种更直观的 next 数组计算方法(对应我们手动计算的定义):
cpp
// 这个版本的 next[j] 表示 P[0...j] 的最长公共前后缀长度
void getNext(const string& pattern, vector<int>& next) {
int len = pattern.length();
next.resize(len);
next[0] = 0; // 第一个字符的公共前后缀长度为0
int i = 1; // 当前计算的位置
int j = 0; // 指向上一个最长公共前后缀的末尾
while (i < len) {
if (pattern[i] == pattern[j]) {
j++;
next[i] = j;
i++;
} else {
if (j != 0) {
j = next[j - 1]; // 回溯到前一个位置
} else {
next[i] = 0;
i++;
}
}
}
}
这个版本计算 "ABABC" 会得到 [0, 0, 1, 2, 0]。为了与 next[0] = -1 的版本统一,我们可以在数组前插入一个 -1,或者调整 KMP 搜索函数的逻辑。
5. KMP 搜索过程
有了 next 数组,搜索过程就非常简单了。
C++ 代码实现(使用 next[0] = -1 的版本):
cpp
int kmpSearch(const string& text, const string& pattern) {
vector<int> next;
getNext(pattern, next); // 使用上面标准版的 getNext
int i = 0; // 主串指针
int j = 0; // 模式串指针
int tLen = text.length();
int pLen = pattern.length();
while (i < tLen && j < pLen) {
// j == -1 表示模式串第一个字符就失配,主串后移,模式串归零
if (j == -1 || text[i] == pattern[j]) {
i++;
j++;
} else {
// 失配,模式串指针根据 next 数组回溯
j = next[j];
}
}
if (j == pLen) {
return i - j; // 匹配成功的起始位置
} else {
return -1; // 未找到
}
}
总结
-
KMP 思想:主串指针不回溯,利用已匹配的信息(存储在
next数组中)来移动模式串。 -
next 数组:
next[j]是模式串子串P[0...j-1]的最长公共前后缀的长度。 -
求解 next:可以用“手动比较前后缀”的方法,也可以用代码(一个基于 KMP 思想的自匹配过程)。
-
时间复杂度:预处理
next数组为 O(m),匹配过程为 O(n),总复杂度 O(n+m)。
理解 KMP 的关键在于理解 最长公共前后缀 以及如何利用它来避免主串指针的回溯。next 数组的求解虽然有点绕,但多练习几个例子就能掌握。
3826

被折叠的 条评论
为什么被折叠?



