KMP 算法
KMP 算法
KMP 算法(Knuth-Morris-Pratt 算法)是高效的字符串匹配算法。其核心思想是利用已经匹配过的有效信息,保持主串(文本串)指针不回退,通过预处理模式串的前缀函数,将字符串匹配的时间复杂度稳定优化至线性。
一、核心前置概念
1. 暴力匹配的瓶颈
在主串 \(S\) 中寻找模式串 \(P\) 时,如果使用暴力算法,一旦在某个位置失配(即 \(S[i] \neq P[j]\)),通常会将主串的指针 \(i\) 回退到 \(i-j+2\) 的位置,并将模式串指针 \(j\) 归零,重新开始匹配。这使得最坏时间复杂度达到 \(O(n \times m)\)。
2. 真前缀与真后缀的定义
在 KMP 中,经常用到真前缀和真后缀的概念(注意是真前缀 / 后缀,即不能等于字符串本身):
-
真前缀:除去字符串本身,其开头的一部分构成的子串。
-
真后缀:除去字符串本身,其结尾的一部分构成的子串。
二、前缀函数(nxt 数组 / \(\pi\) 数组)
前缀函数是 KMP 的灵魂,它负责对模式串进行 \\“自我匹配”\\。
1. 定义
对于模式串 \(P\),令 \(nxt[i]\), 表示子串 \(P[1 \dots i]\) 的最长相等真前缀与真后缀的长度。
举例说明(下标从 1 开始):
-
\(P[1 \dots 4] = \text{"abab"}\):最长相等真前后缀为
"ab",长度为 2,所以 \(nxt[4] = 2\)。 -
\(P[1 \dots 4] = \text{"aaaa"}\):最长相等真前后缀为
"aaa",长度为 3,所以 \(nxt[4] = 3\)。 -
\(P[1 \dots 4] = \text{"abcd"}\):没有相等的真前后缀,长度为 0,所以 \(nxt[4] = 0\)。
2. 线性求法(推导)
计算 \(nxt\) 数组采用了 DP 的思想。假设我们已经计算出了 \(nxt[1 \dots i-1]\),现在要计算 \(nxt[i]\):
-
设当前已知的匹配长度 \(j = nxt[i-1]\)。这意味着 \(P[1 \dots j] = P[i-j \dots i-1]\)。
-
尝试扩展:
-
匹配成功:如果 \(P[j + 1] == P[i]\),则当前的前后缀可以继续延长,因此 \(nxt[i] = j + 1\)。
-
匹配失败(失配):如果 \(P[i] \neq P[j+1]\),说明当前长度 \(j\) 行不通。根据 “Border 链”(即所有公共前后缀的长度集合),我们需要寻找次长的公共前后缀。对于 \(P[1 \dots j]\),其次长的公共前后缀长度恰好就是 \(nxt[j]\)。因此,令 \(j = nxt[j]\),然后重复上述判断,直到 \(j = 0\) 或者匹配成功。
-
3. 标准代码实现
const int N = 1e5 + 5;
char p[N]; // 模式串,下标从 1 开始
int nxt[N]; // 前缀函数数组
int m = strlen(p + 1); // 模式串长度
void get_next() {
nxt[1] = 0; // 长度为 1 的子串没有真前后缀
for (int i = 2, j = 0; i <= m; i++) {
// 如果失配,不断回溯寻找次短的公共前后缀
while (j > 0 && p[i] != p[j + 1]) {
j = nxt[j];
}
// 如果当前字符匹配,匹配长度加 1
if (p[i] == p[j + 1]) {
j++;
}
nxt[i] = j;
}
}
三、KMP 匹配主串
当 nxt 数组预处理完成后,就可以在主串 \(S\) 上进行高效的匹配。
1. 匹配规则
我们在主串 \(S\) 上维护一个指针 \(i\),在模式串 \(P\) 上维护一个指针 \(j\)(表示当前 \(P\) 已经成功匹配了长度为 \(j\) 的前缀,即 \(P[1 \dots j] = S[i-j+1 \dots i]\))。
-
主串指针永不后退:对于 \(i = 1 \to n\),依次扫描主串。
-
当前字符比较:比较 \(S[i]\) 与 \(P[j+1]\):
-
成功:如果相等,
j ++。若j == m,说明在主串位置i - m + 1处发现了完整的模式串,记录答案;如果要寻找多个匹配,令j = nxt[j]继续向下寻找。 -
失败:如果不相等,按照与预处理相同的方式,进行
j = nxt[j]的回退。注意,此时 \(i\) 指针保持不变,继续判断新的 \(P[j+1]\) 是否与 \(S[i]\) 相等。
-
2. 标准实现代码
char s[N]; // 主串,下标从 1 开始
int n = strlen(s + 1); // 主串长度
void kmp() {
for (int i = 1, j = 0; i <= n; i++) {
// 如果主串字符 s[i] 与模式串的下一个字符 p[j+1] 不匹配,通过 nxt 数组回退 j
while (j > 0 && s[i] != p[j + 1]) {
j = nxt[j];
}
// 字符匹配成功,模式串指针向前推进
if (s[i] == p[j + 1]) {
j++;
}
// 匹配成功找到一个完整模式串
if (j == m) {
// 输出匹配开始位置 (i - m + 1)
// 如果允许重叠匹配,继续 j = nxt[j]
j = nxt[j];
}
}
}
四、时间复杂度分析
标准 KMP 算法的时间复杂度为 \(O(n + m)\),证明如下:
-
预处理阶段:指针 \(j\) 在
for循环内最多增加 \(m\) 次。每次while循环失配时,\(j\) 都会根据nxt[j]严格减小,因此回退的总次数也不会超过匹配成功的总次数(即 \(m\) 次)。所以预处理是 \(O(m)\)。 -
匹配阶段:主串指针 \(i\) 在循环中每次增加 1,总共增加 \(n\) 次。模式串指针 \(j\) 增加的总次数受限于 \(i\) 扫描的次数(最多 \(n\) 次),而
while回退的总次数也一定小于等于 \(j\) 增加的次数(即 \(O(n)\))。 -
结论:不考虑常数情况下,整体算法复杂度严格为 \(O(n + m)\)。
注:复杂度上界是理论严格线性
注:部分内容由 AI 润色
原创·转载请注明出处
✦
浙公网安备 33010602011771号