刨析字符串匹配的巅峰之作:深入理解 KMP 算法与 next 数组的本质
刨析字符串匹配的巅峰之作:深入理解 KMP 算法与 next 数组的本质
在算法竞赛与真实的工程场景(如文本编辑器的查找替换、病毒特征码匹配)中,字符串模式匹配是一项极其高频的操作。给定一个长度为 N 的主串 S 和一个长度为 M 的模式串 P,我们需要找出 P 在 S 中出现的位置。
最朴素的暴力算法(Brute Force)思想很简单:从 S 的每一个字符开始,和 P 逐个比对。一旦失配,就把 P 往右挪一位,从头再比。这种“每次失配都要回退主串指针”的做法,最坏时间复杂度高达 O(N imes M)。
而 KMP 算法 (Knuth-Morris-Pratt) 的伟大之处在于,它通过预处理模式串 P,提取出其中的内部结构特征,实现了主串指针永不回退,将匹配的时间复杂度死死压在了 O(N + M)。
本文将剥离晦涩的数学定义,用最直白的逻辑,带你彻底攻克 KMP 的核心灵魂——next 数组,并提供最纯粹的 C 语言实战代码。
一、 暴力的痛点与 KMP 的破局点
假设主串 S = "ABACABAD",模式串 P = "ABABAD"。
暴力匹配过程:
当我们在比较到 S[4] ('C') 和 P[4] ('A') 时,发生了失配。
按照暴力逻辑,主串指针会无情地回退到 S[1],模式串指针回退到 P[0],然后重新开始一轮毫无意义的比对。
KMP 的洞察:
KMP 算法认为:失配发生前,我们其实已经成功匹配了一段前缀 "ABAB"。
在这段已匹配的 "ABAB" 中,前缀 "AB" 和后缀 "AB" 是一模一样的!
既然如此,当我们发现在 'A' 处失配时,完全没有必要把 P 挪回最开头,我们可以直接让 P 的前缀 "AB",滑动到刚刚匹配好的后缀 "AB" 的位置上,然后继续比对下一个字符。
这就是 KMP 的破局点:利用已匹配子串中“相同的前后缀”,来决定失配后模式串指针该跳到哪里,从而避免主串指针的回退。
二、 核心灵魂:什么是 next 数组?
\(next\) 数组,又叫“前缀函数”,它只和模式串 P 有关。
对于模式串 \(P\) 的每一个位置 j,next[j] 的严格定义是:子串 P[0...j-1] 中,最长的相等真前缀和真后缀的长度。
(注:真前缀/真后缀 指的是不包含整个子串本身的前后缀。)
简单来说,当在 P[j] 处发生失配时,next[j] 就是模式串指针 j 应该回退到的新位置。
手动推演 next 数组的例子:
假设 P = "ABABCAB"
- j=0: 模式串第一个字符失配,无路可退。通常规定 next[0] = -1。
- j=1: 子串
"A"。最长相等真前后缀长度为0。next[1] = 0。 - j=2: 子串
"AB"。前后缀无交集。next[2] = 0。 - j=3: 子串
"ABA"。前缀"A"== 后缀"A"。next[3] = 1。 - j=4: 子串
"ABAB"。前缀"AB"== 后缀"AB"。next[4] = 2。 - j=5: 子串
"ABABC"。前缀和后缀配不上。next[5] = 0。
三、 C 语言极致实战代码
KMP 算法的实现分为两步:
- 构造 next 数组(模式串的自我匹配)。
- KMP 主体匹配(模式串与主串的匹配)。
有趣的是,你会发现这两步的代码逻辑竟然惊人地相似!因为构造 \(next\) 数组的过程,本质上就是模式串 \(P\) 自己和自己进行 KMP 匹配的过程。
以下是高度精炼、绝无多余变量的 C 语言工业级模板:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
// 1. 构造 next 数组
void get_next(const char *p, int *next, int m) {
next[0] = -1; // 规定第 0 个位置失配时退到 -1
int i = 0; // 当前计算的 next 数组下标
int j = -1; // 最长相等前后缀的长度 (或者说前缀指针)
while (i < m - 1) { // i 推到 m-2 即可算出 next[m-1]
// 如果 j == -1 说明退无可退,或者当前字符匹配成功
if (j == -1 || p[i] == p[j]) {
i++;
j++;
// 【进阶优化版】
// 如果 p[i] 再次失配,且 p[i] == p[j],那么退回到 j 还是会失配!
// 所以直接把 next[j] 的值赋给 next[i],跳过这一次无效的回退。
if (p[i] != p[j]) {
next[i] = j;
} else {
next[i] = next[j];
}
} else {
// 失配,前缀指针 j 按照已有的 next 数组回退
j = next[j];
}
}
}
// 2. KMP 主体匹配函数
// 寻找 p 在 s 中第一次出现的起始下标,若未找到返回 -1
int kmp_search(const char *s, const char *p) {
int n = strlen(s);
int m = strlen(p);
if (m == 0) return 0;
if (n < m) return -1;
int *next = (int *)malloc(m * sizeof(int));
get_next(p, next, m);
int i = 0; // 主串指针
int j = 0; // 模式串指针
while (i < n && j < m) {
// j == -1 说明模式串彻底失配,主串和模式串指针一起右移
if (j == -1 || s[i] == p[j]) {
i++;
j++;
} else {
// 失配!主串指针 i 绝不回退,模式串指针 j 根据 next 数组滑动
j = next[j];
}
}
free(next);
// 如果 j 走到了 m,说明整个模式串都匹配成功了
if (j == m) {
return i - j; // 返回匹配成功的起始下标
}
return -1; // 未找到
}
int main() {
const char *s = "BBC ABCDAB ABCDABCDABDE";
const char *p = "ABCDABD";
int index = kmp_search(s, p);
if (index != -1) {
printf("Match found at index: %d\n", index);
} else {
printf("Match not found.\n");
}
return 0;
}
四、 避坑与进阶总结
-
next数组的越界问题:
在很多教科书中,由于字符串下标习惯从 1 开始,next数组的初值往往设为next[1] = 0。但在现代 C 语言或工程开发中,数组一律从 0 开始。将next[0]设为-1是一个极其优雅的设计,它完美处理了“连模式串第一个字符都匹配不上”的极端情况,从而统一了if (j == -1 || s[i] == p[j])的逻辑。 -
KMP 的真正威力不在于日常搜索:
其实在简单的日常业务开发中,大部分语言底层的indexOf或strstr函数使用的并不是 KMP,而是 BM (Boyer-Moore) 算法或者 SIMD 优化的暴力匹配,因为真实文本中的字符分布大多是随机且极少重复的,暴力的常数更小。
KMP 的真正威力在于:当文本中包含极其浓烈的重复模式、大量相似子串,且被恶意构造的极端数据攻击时,KMP 凭借 \(O(N)\) 的硬底线,绝对不会被拖垮(TLE)。 它代表的是一种通过“预处理特征”来换取“绝对时间稳定性”的终极智慧。

浙公网安备 33010602011771号