KMP 算法详解:告别暴力匹配,让字符串匹配 "永不回头"
你有没有遇到过这些场景?在编辑器里 Ctrl+F 搜索关键词、用 C 语言的strstr找子串、统计子串出现次数...... 这些我们习以为常的操作,底层都依赖一个核心能力:字符串匹配。
而提到字符串匹配算法,绕不开的就是大名鼎鼎的 KMP 算法。很多同学初学 KMP 的时候,都会卡在「next 数组到底怎么算」「为什么最长相等前后缀是这个值」,甚至觉得它晦涩难懂,不如暴力匹配好理解。
这篇文章,我会从暴力匹配的痛点出发,一步步带你拆解 KMP 的核心逻辑,搞懂 next 数组的本质,最后解答大家最关心的问题:为什么我们常用的库函数,大多不用 KMP?
一、先搞懂:暴力匹配,到底哪里不好?
字符串匹配的需求很简单:给你一个主串S(比如aabaabaaf),和一个模式串T(比如aabaaf),要你找到T在S中第一次出现的位置。
我们最容易想到的就是暴力匹配:用两个指针i(指向主串)和j(指向模式串),逐个字符比对:
- 如果当前字符相等,
i++、j++,继续比对下一个; - 如果不相等,
i回退到本次匹配起始位置的下一位,j重置为 0,从头开始匹配。
我们用S="aabaabaaf"、T="aabaaf"举个例子:
当匹配到S[5] = 'b'和T[5] = 'f'时,字符不相等,暴力匹配会把i回退到S[1],j重置为 0,重新开始匹配。
但这里有个很明显的浪费:我们已经知道前面S[0-4]和T[0-4]是完全匹配的(都是aabaa),这些已经比对过的信息,完全没有被利用起来,反而要回头重新比对,这就是暴力匹配效率低的核心原因。
二、KMP 的核心智慧:不回头,利用已有信息
KMP 算法的核心,就是解决暴力匹配的痛点:主串指针i永不回退,只通过移动模式串的指针j,来完成匹配。
那怎么知道模式串要移动多少呢?这就要靠 KMP 的核心概念:最长相等前后缀。
先搞懂 3 个基础概念
- 前缀 :不包含字符串最后一个字符的、从开头出发的所有子串。比如
aab的前缀是a、aa。 - 后缀 :不包含字符串第一个字符的、以结尾结束的所有子串。比如
aab的后缀是b、ab。 - 最长相等前后缀:在所有前缀和后缀中,最长的、完全相等的子串,它的长度就是我们要的值。
举个大家初学最容易搞混的例子:aaba
- 前缀:
a、aa、aab - 后缀:
a、ba、aba - 相等的子串只有
a,所以最长相等前后缀长度是 1,不是 2(后缀里没有aa,自然不能取 2)。
再看aabaa:
- 前缀:
a、aa、aab、aaba - 后缀:
a、aa、baa、abaa - 最长相等的子串是
aa,所以长度是 2。
next 数组:把匹配规则存起来
KMP 里的next数组,本质就是把模式串每个位置的最长相等前后缀长度存起来。
比如模式串T="aabaaf",我们逐个位置计算,得到的next数组就是:
| 模式串子串 | 最长相等前后缀长度 | next 数组对应值 |
|---|---|---|
a |
0 | next[0] = 0 |
aa |
1 | next[1] = 1 |
aab |
0 | next[2] = 0 |
aaba |
1 | next[3] = 1 |
aabaa |
2 | next[4] = 2 |
aabaaf |
0 | next[5] = 0 |
这个数组有什么用?当匹配失败时,我们不用把j重置为 0,只需要把j回退到next[j-1]的位置,就可以继续匹配,主串的i完全不用动。
还是用之前的例子:当S[5]和T[5]匹配失败时,j=5,我们只需要把j回退到next[4]=2的位置,继续比对S[5]和T[2]即可,完美利用了之前已经匹配的信息,没有任何重复劳动。
三、手把手实现:next 数组的生成代码
搞懂了原理,我们来写代码生成 next 数组,核心用双指针法:
-
定义
j:代表当前前缀的末尾,也代表当前最长相等前后缀的长度; -
定义
i:代表当前后缀的末尾,遍历整个模式串; -
核心逻辑:
- 如果
T[i] == T[j],说明前后缀匹配,j++、i++,把j的值存入next[i]; - 如果
T[i] != T[j],说明匹配失败,把j回退到next[j-1],直到j=0或者匹配成功; - 如果
j=0还不匹配,next[i]设为 0,i++继续遍历。
- 如果
C 语言实现
c
void getNext(int* next, const char* T) {
int len = strlen(T);
int j = 0; // 前缀末尾,最长相等前后缀长度
next[0] = 0; // 第一个字符没有前后缀
for (int i = 1; i < len; i++) {
// 匹配失败,回退j
while (j > 0 && T[i] != T[j]) {
j = next[j-1];
}
// 匹配成功,j加1
if (T[i] == T[j]) {
j++;
}
// 把当前j存入next数组
next[i] = j;
}
}
TypeScript 实现
typescript
/**
* 生成 KMP 算法的 next 数组
* @param pattern 模式串
* @returns next 数组(存储每个位置的最长相等前后缀长度)
*/
function getNext(pattern: string): number[] {
const n: number = pattern.length;
const next: number[] = new Array(n).fill(0);
let j: number = 0; // 前缀末尾指针,同时表示当前最长相等前后缀长度
for (let i = 1; i < n; i++) {
// 匹配失败,回退 j 到前一个位置的 next 值
while (j > 0 && pattern[i] !== pattern[j]) {
j = next[j - 1];
}
// 匹配成功,j 加 1
if (pattern[i] === pattern[j]) {
j++;
}
// 将当前 j 存入 next 数组
next[i] = j;
}
return next;
}
四、完整的 KMP 匹配实现
有了 next 数组,我们就可以实现完整的 KMP 匹配了,逻辑和生成 next 数组很像:
- 主串指针
i遍历主串,永不回退; - 模式串指针
j遍历模式串,匹配失败时用 next 数组回退; - 当
j等于模式串长度时,说明匹配成功,返回起始位置即可。
C 语言实现(模拟 strstr 功能)
c
#include <stdio.h>
#include <string.h>
void getNext(int* next, const char* T) {
int len = strlen(T);
int j = 0;
next[0] = 0;
for (int i = 1; i < len; i++) {
while (j > 0 && T[i] != T[j]) {
j = next[j-1];
}
if (T[i] == T[j]) {
j++;
}
next[i] = j;
}
}
int kmp_strstr(const char* S, const char* T) {
int len_S = strlen(S);
int len_T = strlen(T);
if (len_T == 0) return 0; // 空串默认在0位置匹配
int next[len_T];
getNext(next, T);
int j = 0;
for (int i = 0; i < len_S; i++) {
// 匹配失败,回退j
while (j > 0 && S[i] != T[j]) {
j = next[j-1];
}
// 匹配成功,j加1
if (S[i] == T[j]) {
j++;
}
// 匹配完成,返回起始位置
if (j == len_T) {
return i - len_T + 1;
}
}
return -1; // 没有匹配到
}
int main() {
const char* S = "aabaabaaf";
const char* T = "aabaaf";
printf("匹配起始位置:%d\n", kmp_strstr(S, T)); // 输出3
return 0;
}
TypeScript 实现(模拟搜索与计数功能)
typescript
/**
* KMP 字符串搜索:找到模式串在主串中第一次出现的位置
* @param mainStr 主串
* @param pattern 模式串
* @returns 起始索引(未找到返回 -1)
*/
function kmpSearch(mainStr: string, pattern: string): number {
const lenMain: number = mainStr.length;
const lenPattern: number = pattern.length;
// 边界情况处理
if (lenPattern === 0) return 0;
if (lenPattern > lenMain) return -1;
const next: number[] = getNext(pattern);
let j: number = 0; // 模式串指针
for (let i = 0; i < lenMain; i++) {
// 匹配失败,回退 j
while (j > 0 && mainStr[i] !== pattern[j]) {
j = next[j - 1];
}
// 匹配成功,j 加 1
if (mainStr[i] === pattern[j]) {
j++;
}
// 匹配完成,返回起始位置
if (j === lenPattern) {
return i - lenPattern + 1;
}
}
return -1; // 未找到匹配
}
/**
* KMP 字符串计数:统计模式串在主串中出现的次数
* @param mainStr 主串
* @param pattern 模式串
* @returns 出现次数
*/
function kmpCount(mainStr: string, pattern: string): number {
const lenMain: number = mainStr.length;
const lenPattern: number = pattern.length;
// 边界情况处理(空串规则可根据需求调整)
if (lenPattern === 0) return lenMain + 1;
if (lenPattern > lenMain) return 0;
const next: number[] = getNext(pattern);
let count: number = 0;
let j: number = 0; // 模式串指针
for (let i = 0; i < lenMain; i++) {
// 匹配失败,回退 j
while (j > 0 && mainStr[i] !== pattern[j]) {
j = next[j - 1];
}
// 匹配成功,j 加 1
if (mainStr[i] === pattern[j]) {
j++;
}
// 匹配到一次,计数 +1,继续找下一个
if (j === lenPattern) {
count++;
j = next[j - 1]; // 回退 j 以寻找重叠匹配
}
}
return count;
}
// 测试代码
const mainStr1: string = "aabaabaaf";
const pattern1: string = "aabaaf";
console.log("搜索起始位置:", kmpSearch(mainStr1, pattern1)); // 输出 3
const mainStr2: string = "aabaaabaa";
const pattern2: string = "aa";
console.log("子串出现次数:", kmpCount(mainStr2, pattern2)); // 输出 4
五、灵魂拷问:为什么库函数不用 KMP?
很多同学搞懂 KMP 之后都会有这个疑问:既然 KMP 效率更高,为什么 C 语言的strstr、很多语言的原生字符串方法都不用 KMP?
核心原因有 3 个:
-
KMP 的优势场景很有限
KMP 的优势是主串不回退,适合超长主串(比如几十 MB 的日志、DNA 序列)、模式串有大量重复前缀的场景。但日常开发中,我们处理的大多是短字符串,KMP 预处理 next 数组的开销,反而会抵消匹配的优势。
-
库函数有更优的选择
-
现代标准库的实现,大多不会用纯 KMP:
- Glibc 的
strstr用的是Two-Way 算法,比 KMP 更高效,且不需要额外的数组存储 next 值; - 很多语言的原生方法用的是朴素匹配 + 轻量优化,代码简单、常数时间更小,短字符串场景下实际运行速度比 KMP 更快。
-
实现复杂度的权衡
KMP 的逻辑比朴素匹配复杂很多,对于标准库来说,"简单、稳定、易维护" 也是很重要的考量,在性能差距不大的情况下,自然会优先选择更简单的实现。
六、什么时候该用 KMP?
KMP 不是万能的,但在这些场景下,它的优势会完全体现出来:
- 大文本搜索、海量日志分析,主串长度远大于模式串;
- 网络流、实时数据流的匹配,主串无法回退重读;
- 模式串有大量重复前缀,暴力匹配会出现大量无效回退的场景。
KMP 算法的本质,从来不是什么晦涩的公式,而是 **「不做重复劳动」的编程智慧 **。它通过挖掘已经匹配过的信息,避免了暴力匹配中无意义的回退,把匹配效率稳定在了 O (n+m) 的级别。
当然,我们也不用神化 KMP,日常开发中,绝大多数场景下,标准库自带的字符串匹配函数已经足够好用。学习 KMP 的意义,更多的是理解这种「利用已有信息优化流程」的算法思想,这种思想会在很多算法场景中帮到你。
人生处于顺境时能走多快不重要,重要的是遭遇挫折时,能够快速找回自己
------KMP