开篇我就不绕弯子了。KMP算法,全称Knuth-Morris-Pratt算法,是数据结构里串(String)这一章最经典的高级模式匹配算法之一。很多人在学《数据结构与算法》时,前面线性表、栈、队列都顺风顺水,一到了串的模式匹配,尤其是KMP,就卡住了。今天这篇就是专门攻克这个卡点的,我会用最直观的方式把next数组怎么求、代码怎么写、面试怎么考讲透,读完之后你至少能手写出正确率很高的KMP实现,并且能解释清楚它为什么比BF算法快。
为什么要单独把KMP拎出来讲?因为字符串匹配在真实开发中太常见了——文本编辑器里的查找、数据库的LIKE模糊查询、日志系统里的关键词过滤、甚至Grep工具底层的搜索逻辑,都离不开模式匹配。如果你只会暴力匹配,遇到长文本、长模式串的场景,性能会非常难受。KMP的价值就在于,它通过预处理模式串,让主串的遍历指针永远不回头,把最坏时间复杂度从O(n×m)压到了O(n+m)。这篇内容适合正在准备期末考试的在校生、准备算法面试的求职者,以及所有想系统过一遍字符串匹配原理的开发者。
1. 先理清楚:串(String)到底是什么
1.1 串的定义和基本概念
串(String)是由零个或多个字符组成的有限序列,一般记作S = "a1 a2 ... an"。这和你平时用的字符串就是一个东西,C语言里的char数组、Java里的String对象、Python里的str,本质上都是串。
这里有个关键点经常被忽视:串是一种"内容受限"的线性表。普通线性表的元素可以是整数、结构体、自定义对象,但串的每一个元素只能是字符。这个限制看似简单,却让串产生了一批自己独有的操作,比如子串定位、模式匹配、串的替换等。教材里经常提到的几个术语你需要先记住:
- 子串:串中任意连续字符组成的子序列,比如"abc"是"abcdef"的子串。
- 主串:包含子串的那个串。
- 模式串:在定位操作中,被用来匹配的那个子串,通常记作P。
- 前缀:从串首开始的任意子串。
- 后缀:从串尾开始的任意子串。
这几个概念中,前缀和后缀特别重要,因为KMP算法的next数组计算,本质上就是在求"最长相等前后缀的长度"。如果你能把"前缀"和"后缀"这两个词刻在脑子里,后面理解next数组会轻松一半。
1.2 串的存储:定长顺序存储和堆分配存储
串在计算机里有两种主流存储方式,分别对应不同的使用场景。
定长顺序存储,说白了就是用一个固定长度的char数组来存串,结构体里带一个length字段表示实际长度。这种方式的优点是实现简单、内存连续、Cache友好,缺点是长度固定,超过了就得截断,灵活性很差。很多C语言教材里的串运算就是基于这种结构写的,适合教学演示,但不适合生产。
堆分配存储则是在运行时动态分配内存,C语言里用malloc/free管理,Java和Python的字符串底层虽然细节不同,但思路类似——根据需要动态调整存储空间。这种方式解决了定长存储的缺陷,代价是内存管理复杂度上升,C语言里一不小心就是内存泄漏或者野指针。
我在实际开发中很少直接手写这两种存储结构,因为现成的高级语言字符串类早就封装好了。但是搞清楚底层结构有个根本好处:你能理解为什么某些串操作是O(n)的,为什么拼接字符串要尽量避免,为什么Java面试爱问String、StringBuffer、StringBuilder的区别。
1.3 String、StringBuffer、StringBuilder到底差在哪
这个点几乎是Java相关的数据结构与算法面试必问题,既然说到串了,就顺便把三家对比讲清楚。
- String是不可变类,一旦创建,内容就不能修改。每次对String做拼接或替换,都会生成一个新对象,旧对象等待GC。优点是线程安全、可缓存哈希值;缺点是大量拼接时性能很差。
- StringBuffer是可变类,内部用char数组存数据,拼接操作直接修改数组内容,不需要频繁创建新对象。它的方法加了synchronized,线程安全,所以有同步开销。
- StringBuilder也是可变类,和StringBuffer几乎一样,唯一区别是方法没有加synchronized,线程不安全,但单线程环境下性能最好。
我平时写代码的原则是:单线程字符串拼接无脑用StringBuilder,多线程且有共享可变字符串的场景才考虑StringBuffer,如果字符串根本不会变就用String。这个三板斧应对开发绰绰有余。
理解串的基本概念之后,我们要切入正题:如何在一个主串S里快速找到一个模式串P。先看最朴素的做法——BF算法,因为不理解BF的痛点,你就无法真正理解KMP的巧妙。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. BF算法:能跑,但是真的慢
2.1 BF算法的实现思路
BF算法,全称Brute Force,也叫朴素模式匹配算法。它的思路非常简单粗暴:让模式串P的每一位依次和主串S的对应位比较,如果某一位不匹配,就把P整体右移一位,重新从P的第一个字符开始比较。
写成Java代码是这个样子:
java复制public static int bfSearch(String s, String p) {
int i = 0, j = 0;
while (i < s.length() && j < p.length()) {
if (s.charAt(i) == p.charAt(j)) {
i++;
j++;
} else {
// 主串指针回退到本轮起始位置的下一位
i = i - j + 1;
j = 0;
}
}
if (j == p.length()) {
return i - j;
}
return -1;
}
核心逻辑就一行:匹配成功就i和j同时后移,匹配失败就让i回退到i - j + 1,j清零。这个"回退"动作就是BF算法慢的根本原因。
2.2 为什么说BF算法最坏情况是O(n×m)
假设主串S长度为n,模式串P长度为m。BF算法在极端情况下,比如主串是"aaaa...aaaab",模式串是"aaaab",你会发现每一轮匹配都要走到模式串的最后一个字符才发现不匹配,然后i回退一格,j归零,重新开始。这样大约要比较(n-m+1)×m次,时间复杂度就是O(n×m)。
这个复杂度在文本处理场景下是不可接受的。举个例子,用KMP之前我做日志关键词过滤,日志文件可能有几十万行,每行平均几千字符,模式串可能有几百字符长。如果用BF算法去扫,最坏情况会卡到让人怀疑人生。我当时一度以为是IO问题,排查了半天才发现瓶颈在字符串匹配上。
所以BF算法的优点是简单、不容易写错,适合模式串很短或者主串很短的情况。但一旦数据规模上来,就必须换思路了。
3. KMP算法的核心思想:让主串指针永不回头
3.1 从一次失败的匹配中能学到什么
KMP算法最核心的洞察是:当某一位匹配失败时,前面已经匹配成功的那些字符其实蕴含了大量信息,我们完全可以根据这些信息决定模式串下一步该跳到什么位置,而不是像BF那样老老实实地回退一格重新来。
我举个具体例子帮助理解。主串S = "abababc",模式串P = "ababc"。假设我们匹配到S[4]='a'和P[4]='c'时发现不匹配,此时P[0..3]="abab"已经和S[0..3]="abab"完全相等了。
BF的做法是:i回退到1,j归零,重新匹配。
KMP的做法是:观察已匹配的前缀"abab",发现它有一个长度为2的相等前后缀"ab"。这意味着,P的前缀"ab"已经出现在主串当前窗口的末尾,所以我们可以直接把模式串右移到这个后缀对齐的位置,也就是让j从4跳到2,i保持不变。
这个"利用已匹配前缀的相等前后缀信息"的思想,就是KMP的全部秘密。
3.2 PM表、next数组、nextval数组的关系
要落地这个思想,我们需要为模式串P预先计算一个数组,记录每个位置匹配失败时j应该跳到哪里。这个数组在不同教材里叫法不太一样,但本质是一回事。
- PM表,全称Partial Match Table,部分匹配表。它记录的是模式串P中每个前缀子串的"最长相等前后缀长度"。以上面的"ababc"为例,PM = [0, 0, 1, 2, 0]。
- next数组,是PM表右移一位,然后在开头补-1得到的。所以next = [-1, 0, 0, 1, 2]。为什么要右移一位?因为当P[j]匹配失败时,需要参考的是P[0..j-1]这个前缀的信息,而不是P[0..j]本身。
- nextval数组,是next数组的优化版。它把模式串中相同字符重复跳转的情况做了压缩,进一步提高效率。
个人建议:先把三级跳的关系搞清楚。如果你只是应付考试,能手算next数组就行;如果要做工程或者面试手写,nextval的优化点也值得掌握。
3.3 匹配失败时的处理逻辑
KMP匹配过程中,每一次失配时的决策逻辑其实只有两条:
- 如果j == -1,说明连模式串的第一个字符都没匹配上,此时主串指针i后移一位,j归零。
- 否则,j直接跳到next[j]的位置,i保持不变,然后继续比较。
这里的关键是,主串指针i在匹配过程中只会增加、不会回退。整个匹配过程就像在幻灯片上看模式串在移动,主串这卷胶片从头到尾只走一遍。这就是KMP能实现线性时间复杂度的原因。
我推荐你自己在纸上画一遍"S = abababc, P = ababc"匹配全过程,感受一下"i不动、j跳变"的节奏。画完这一遍,你对KMP的理解会超过看十遍文章。
4. next数组手算与代码实现:以 p="abacaba" 为例
4.1 手算"abacaba"的PM表和next数组
下面到全文最关键的部分了。我把模式串p = "abacaba"的next数组计算完整演示一遍,这是面试和考试的高频题型。
先明确约定:我使用字符数组下标从0开始的写法,模式串p的长度为7,字符位置分别是p[0]='a',p[1]='b',p[2]='a',p[3]='c',p[4]='a',p[5]='b',p[6]='a'。
第一步,计算每个位置前缀子串的最长相等前后缀长度(PM表)。
| 位置i | 前缀子串 | 最长相等前后缀 | 长度 |
|---|---|---|---|
| 0 | a | 无 | 0 |
| 1 | ab | 无 | 0 |
| 2 | aba | a | 1 |
| 3 | abac | 无 | 0 |
| 4 | abaca | a | 1 |
| 5 | abacab | ab | 2 |
| 6 | abacaba | aba | 3 |
所以PM表 = [0, 0, 1, 0, 1, 2, 3]。
第二步,next数组等于PM表右移一位,在首位补-1。
next = [-1, 0, 0, 1, 0, 1, 2]
我刚才计算这个数组时,你可能会有一个疑问:为什么位置6的next值是2而不是3?原因就是next[j]参考的是p[0..j-1]的信息。当p[6]匹配失败时,它前面已经匹配成功的是p[0..5]="abacab",这个子串的最长相等前后缀长度是2,所以j跳回2。这也是为什么8成教材都强调"next数组最长相等前后缀右移一位"而不是直接取PM表本身。
4.2 计算next数组的代码实现
掌握了手算逻辑,写代码也就是把"找最长相等前后缀"的逻辑程序化。这里我提供一份比较好理解的实现:
java复制public static int[] buildNext(String p) {
int m = p.length();
int[] next = new int[m];
next[0] = -1;
int j = 0;
int k = -1;
while (j < m - 1) {
if (k == -1 || p.charAt(j) == p.charAt(k)) {
j++;
k++;
next[j] = k;
} else {
k = next[k];
}
}
return next;
}
这段代码的写法来自严蔚敏《数据结构》的经典模式。很多初学者第一次看这段都会懵,我解释一下每一步在干什么。
j是当前正在处理的模式串位置,k是已经匹配的最长相等前后缀长度。如果p[j] == p[k],说明前后缀可以继续增长,于是j和k同时后移,next[j]记录当前的k。如果p[j] != p[k],说明前后缀匹配断了,此时不是简单地把k减一,而是让k = next[k],跳回到之前更短的相等前后缀处继续尝试。这个"回退"动作和KMP匹配时的回退本质是一模一样的,理解了这一点,连同这个代码一起都通了。
4.3 KMP主匹配函数的完整代码
有了next数组,KMP主匹配函数就非常简单了:
java复制public static int kmpSearch(String s, String p) {
int[] next = buildNext(p);
int i = 0, j = 0;
while (i < s.length() && j < p.length()) {
if (j == -1 || s.charAt(i) == p.charAt(j)) {
i++;
j++;
} else {
j = next[j];
}
}
if (j == p.length()) {
return i - j;
}
return -1;
}
我把这段代码对比BF版本的差异标出来过很多次,差别就在于失配时从"i回退"变成了"j跳转"。就这一行之差,时间复杂度从O(n×m)降到了O(n+m)。
你可以在IDE里把这段代码跑一下,试试主串S="abacababacaba",模式串P="abacaba",看看返回值是多少。测试过程中你就会发现,主串指针i从头到尾只往前走了一次,这就是KMP的本质。
4.4 nextval数组:给next数组打个补丁
next数组有一个明显的性能瑕疵。拿p="abacaba"来说,当p[5]='b'匹配失败时,next[5]=2,于是比较p[2]='a';但p[2]='a'和p[5]='b'不同,所以就算跳过去也必然失配,这个跳转是无效的。nextval数组就是为了消除这种无效跳转。
nextval数组的计算规则是:在求得next[j]之后,看p[j]是否等于p[next[j]],如果不等,nextval[j] = next[j];如果相等,则继续往前继承,即nextval[j] = nextval[next[j]]。
用p="abacaba"实际算一遍,看看结果:
- next = [-1, 0, 0, 1, 0, 1, 2]
- nextval[0] = -1
- j=1,p[1]='b',next[1]=0,p[0]='a',不相等,所以nextval[1]=0
- j=2,p[2]='a',next[2]=0,p[0]='a',相等,所以nextval[2]=nextval[0]=-1
- j=3,p[3]='c',next[3]=1,p[1]='b',不相等,所以nextval[3]=1
- j=4,p[4]='a',next[4]=1,p[1]='b',不相等,所以nextval[4]=1
- j=5,p[5]='b',next[5]=2,p[2]='a',不相等,所以nextval[5]=2
- j=6,p[6]='a',next[6]=3,p[3]='c',不相等,所以nextval[6]=3
最终nextval = [-1, 0, -1, 1, 1, 2, 3]。
你会发现nextval[2]被优化成了-1,这意味着当p[2]='a'失败时,模式串可以直接从头开始,主串不用比较这个字符,因为模式串首字符也是'a',这段比较注定失败。
5. 时间复杂度与空间复杂度:KMP到底赢在哪
5.1 与BF的完整对比
我把两种算法做了个对比表,方便你直观感受差距。
| 指标 | BF算法 | KMP算法 |
|---|---|---|
| 预处理 | 无 | 需要O(m)时间计算next数组 |
| 匹配阶段时间复杂度 | 最坏O(n×m) | O(n+m) |
| 平均时间复杂度 | O(n+m),多数场景接近线性 | O(n) |
| 空间复杂度 | O(1) | O(m),存储next数组 |
| 主串指针是否回退 | 回退 | 永不回退 |
| 实现难度 | 简单 | 中等,next数组理解有门槛 |
这里补充一个容易被误解的点:KMP的O(n+m)其实是"预处理O(m) + 匹配O(n)"的总和。如果你的模式串很短,比如只有2-3个字符,BF和KMP的实际差距微乎其微,KMP反而因为要预计算而显得更复杂。所以工程上并不总是无脑用KMP,很多成熟的正则引擎在模式串较短时会走暴力匹配。
5.2 更基建的选择:什么时候该用KMP
判断是否需要KMP,我总结了三个条件,都满足时强烈建议用:
- 主串规模大,达到几兆甚至几十MB以上。
- 模式串有一定长度,至少几十个字符。
- 存在大量重复模式,比如日志中的相同前缀,才会触发BF的最坏情况。
如果只是在一段几十个字符的短文本里查一个单词,你用BF或者直接语言内置的indexOf就完事了。没必要为了炫技引入KMP,可读性反而下降。
5.3 工程上的启发:预处理思想
从我自己的经验来看,KMP的最大收获其实不只是这个算法本身,而是一种"预处理换时间"的思想。很多场景下,我们面对的不是单次匹配,而是同一个模式串在多段文本里反复匹配。这种场景下,一次性计算出模式串的next数组,就能在后续每次匹配中复用,边际成本极低。
最典型的例子是日志平台的告警规则过滤:一条告警规则会被拿去扫描成千上万条日志,模式串的预处理只需要做一次,后面全是线性扫描。这种思路也衍生出了更高级的AC自动机(Aho-Corasick),用于多模式串同时匹配,它的核心思想就是在KMP的next基础上扩展到字典树的失配指针。你现在把KMP吃透,日后学AC自动机会快得多。
6. 实操中的常见问题与避坑指南
6.1 next数组到底是-1开头还是0开头
这是初学者问得最多的问题。原因在于不同教材的约定不一致。严蔚敏版本的串位置从1开始编号,next[1]=0,next[2]=1;而大多数编程书籍和竞赛代码使用下标0开始,next[0]=-1。
我的建议很简单:除非你正在准备某本指定教材的考试,否则一律使用下标0、next[0]=-1的写法。原因有两个:一是和程序语言下标体系一致,代码写起来顺手;二是当j等于0时,next[j]等于-1,正好作为"模式串已无路可退,主串前进"的哨兵标志,逻辑非常干净。你要是看到一些代码里next[0]=0的写法,知道它是另一种约定就行,不要花了眼。
6.2 手算next数组老是算错怎么办
手算错误的高频原因有三个:混淆PM表和next、下标从0还是从1没厘清、求最长相等前后缀时没注意"长度小于子串本身"这一隐藏约束。
我自用的手算检查方法:算完PM表后,把next数组整体复制到代码里,用一个测试串(比如S=P+P+P的一部分)去跑kmpSearch,如果匹配结果和BF算法一致,那基本就对了。写程序验证手算结果,是最快最不容易出错的办法。考试时没有编译器,你就用笨办法:对每个位置j,看它前面的子串p[0..j-1]的最长相等前后缀长度,记住next[j]永远比j小。
6.3 面试遇到KMP的现场技巧
面试场景下,我不建议你直接闷头写完整代码。先把思路讲顺最重要,用清晰的三步走,面试官通常会给你过:
- 明确讲出KMP的核心思想:主串指针不回溯,利用模式串自身的相等前后缀信息跳到安全位置。
- 说明next数组的含义,并现场手算一个简单模式串的next数组,比如"ababc"算给面试官看。
- 最后才写代码,代码尽量简洁,buildNext和kmpSearch两个函数分别写,注释写清楚j和k的含义。
如果面试官问next数组的优化,你再把nextval拿出来。这样显得你有深度又不失节奏。相反,如果你一上来就埋头写代码,中间写错了被提问,会比较狼狈。
6.4 一个我自己踩过的坑
最后分享一个我实际踩过的坑。早期我在项目里用KMP匹配URL路径,模式串里包含了中文字符。我当时的实现是Java的String,charAt返回的是UTF-16的code unit,一个中文字符是两个char。这意味着我的next数组处理的是"UTF-16 code unit序列",而不是"字符序列",在某些特殊字符(比如emoji)上会出问题。
后来我改成了先对模式串和主串做统一转换,用code point索引或者直接转成统一的字符数组,问题才彻底解决。这个坑让我意识到:算法本身没问题,但工程实现一定要考虑编码问题。尤其在处理中文、emoji、组合字符时,先弄清楚你用的字符串的底层编码单位,再决定要不要直接套用算法。
写在最后的个人体会
KMP算法教了这么多年,依然是数据结构里"读懂容易、写对难"的典型代表。我自己带过不少学弟学妹,发现一个规律:凡是能把next数组手工推出来的,代码基本也能写对;凡是靠背代码过关的,过几天准忘。所以我的建议是,不要贪快,先在纸上把"abacaba"这类串的next数组推三遍,推熟了再碰代码。相信我,把这一关过了,后面学字符串哈希、Trie树、AC自动机会顺畅非常多。
