1. KMP 算法介绍
KMP 算法:全称叫做「Knuth Morris Pratt 算法」,是由它的三位发明者 Donald Knuth、James H. Morris、Vaughan Pratt 的名字来命名的。KMP 算法是他们三人在 1977 年联合发表的。
- KMP 算法思想:对于给定文本串
T与模式串p,当发现文本串T的某个字符与模式串p不匹配的时候,可以利用匹配失败后的信息,尽量减少模式串与文本串的匹配次数,避免文本串位置的回退,以达到快速匹配的目的。
1.1 朴素匹配算法的缺陷
在朴素匹配算法的匹配过程中,我们分别用指针 i 和指针 j 指示文本串 T 和模式串 p 中当前正在对比的字符。当发现文本串 T 的某个字符与模式串 p 不匹配的时候,j 回退到开始位置,i 回退到之前匹配开始位置的下一个位置上,然后开启新一轮的匹配,如图所示。

图1-1:朴素匹配算法示意图(失配时
i回退、j归 0,从头重新匹配)
这样,在 Brute Force 算法中,如果从文本串 T[i] 开始的这一趟字符串比较失败了,算法会直接开始尝试从 T[i + 1] 开始比较。如果 i 已经比较到了后边位置,则该操作相当于将指针 i 进行了回退操作。
那么有没有哪种算法,可以让 i 不发生回退,一直向右移动呢?
1.2 KMP 算法的改进
如果我们可以通过每一次的失配而得到一些「信息」,并且这些「信息」可以帮助我们跳过那些不可能匹配成功的位置,那么我们就能大大减少模式串与文本串的匹配次数,从而达到快速匹配的目的。
每一次失配所告诉我们的信息是:主串的某一个子串等于模式串的某一个前缀。
这个信息的意思是:如果文本串 T[i: i + m] 与模式串 p 的失配是下标位置 j 上发生的,那么文本串 T 从下标位置 i 开始连续的 j - 1 个字符,一定与模式串 p 的前 j - 1 个字符一模一样。
以刚才图中的例子来说,文本串的子串与模式串 p 的失配是在第 5 个位置发生的,那么:
文本串 T 从下标位置 i 开始连续的 5 个字符,一定与模式串 p 的前 5 个字符一模一样,即 "ABCAB" == "ABCAB"。
- 而模式串的前 5 个字符中,前 2 位前缀和后 2 位后缀又是相同的,即
"AB" == "AB"。
所以根据上面的信息,我们可以推出:文本串子串的后 2 位后缀和模式串子串的前 2 位是相同的,即这部分是之前已经比较过的,不需要再比较了,可以直接跳过。
那么我们就可以将文本串中的失配字符直接对准模式串中对应前缀的下一个字符,继续进行对比。这样 i 就不再需要回退了,可以一直向右移动匹配下去。在这个过程中,我们只需要将模式串 j 进行回退操作即可。
KMP 匹配算法
KMP 算法就是使用了这样的思路,对模式串 p 进行了预处理,计算出一个「部分匹配表」,用一个数组 next 来记录。然后在每次失配发生时,不回退文本串的指针 i,而是根据「部分匹配表」来决定模式串可以向右移动的位数。
2. 前缀与后缀
在正式讲解 next 数组之前,需要先清楚「前缀」与「后缀」的定义。
- 真前缀(前缀):从第 0 位开始、到任意位置结束的子串,不包括最后一个字符。
- 真后缀(后缀):从任意位置开始、到最后一位结束的子串,不包括第一个字符。
- 最长公共前后缀:前缀集合 后缀集合中最长的那一个。
举个例子:对字符串 "aabaa" 来说:
- 前缀集合:
{a, aa, aab, aaba} - 后缀集合:
{a, aa, baa, abaa} - 公共前后缀:
{a, aa},最长公共前后缀是"aa",长度为 2。
小练习(建议先自己手算再看答案):
"ababa"→ 最长公共前后缀"aba",长度 3。"aaaaa"→ 最长公共前后缀"aaaa"(真前缀/真后缀),长度 4。"abcabcab"→ 最长公共前后缀"abcab",长度 5。
3. next 数组
3.1 概念与人为规定
next[j] 代表模式串 0 到 j - 1 这段子串的最长公共前后缀的长度。也就是说,next[j] 记录的是:模式串第 j 个字符之前的子串中,最长相等的前缀与后缀的长度。
人为规定:next[0] = -1(因为 0 之前没有子串),next[1] = 0(因为只有一个字符时,真前缀集合为空)。
注意:有些题目或教材(如严蔚敏版《数据结构》)里的 next 数组下标从 1 开始,此时人为规定 next[1] = 0。两种写法本质相同:从 1 开始的数组,就是从 0 开始的数组整体加 1(下标同步平移),见 3.2 的对照表。做题前必须看清题目用的是哪一种约定。
3.2 两种 next 数组约定(重点)
「从 0 开始」和「从 1 开始」是两种常见的 next 数组写法,它们的本质含义相同,只是下标起点和数值相差 1。下面讲清楚两者的区别。
| 项目 | 约定一:从 0 开始(本讲义采用) | 约定二:从 1 开始 |
|---|---|---|
| 数组下标起点 | 下标从 0 开始 | 下标从 1 开始 |
| 人为规定 | next[0] = -1, next[1] = 0 | next[1] = 0 |
next[j] 含义 | 模式串 [0, j-1] 的最长相等前后缀长度 | 模式串前 j-1 个字符([0, j-2])的最长相等前后缀长度 + 1 |
| 失配时怎么跳 | i2 = next[i2]; 若 i2 == 0,则换主串开头 | j = next[j]; 若 j == 1,则换主串开头 |
| 与约定一的关系 | — | next1[j] = next0[j-1] + 1 (j >= 2) |
对照关系举例:"abaabac" 中,约定一的 next0[5] = 2,约定二的 next1[6] = 3 = 2 + 1。整体平移后,约定二的 next1[j] 从 0 开始的数组各元素 +1 即可得到,例如 [-1, 0, 0, ...] → [0, 1, 1, ...]。
next 数组的长度等于模式串长度 m:匹配过程中失配时 i2 最大只到 m - 1,用到的 next[i2] 下标都在 0..m-1 范围内,因此不需要求 next[m]。
为什么会有这两种写法?约定一(从 0 开始)配合 0 下标数组直接用,写代码最自然;约定二(从 1 开始)常见于教材(如严蔚敏版《数据结构》)中用 1 下标表示的串。两者在匹配时失配跳转的公式不同(一个是 i2 = next[i2],一个是 j = next[j]),但只要和对应的匹配代码配套使用,结果完全一致。
3.3 手算 next 数组
以模式串 "abaabac" 为例,逐位手算 next 数组(采用约定一,下标从 0 开始):
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 字符 | a | b | a | a | b | c | a | c |
子串 [0, j-1] | — | a | ab | aba | abaa | abaab | abaabc | abaabca |
| 最长公共前后缀长度 | -1 | 0 | 0 | 1 | 1 | 2 | 0 | 1 |
next[j] | -1 | 0 | 0 | 1 | 1 | 2 | 0 | 1 |
手算方法:对每个 j,只看模式串的前 j 个字符(下标 0 到 j - 1),找到它们的最长公共前后缀长度即可。例如 j = 5 时子串为 "abaab",前缀与后缀最长相等的是 "ab",所以 next[5] = 2。其中 next[0] = -1、next[1] = 0 是人为规定:0 之前没有子串,而只有一个字符时其前缀集合为空。
3.4 递推计算 next 数组
手算理解含义,但代码要用递推。递推过程中用一个变量 cn 记录「当前前缀的长度信息」(即 next[i-1]),从 i = 2 开始填表。递推规则只有三条:
- ① 相等分支:若
ms[i-1] == ms[cn],则next[i] = cn + 1,i、cn同时前进(等价写法:next[i++] = ++cn)。 - ② 回退分支:若不等且
cn > 0,则cn = next[cn](cn往前跳,跳到更短的前缀继续比)。 - ③ 结束分支:若
cn已经跳不动了(cn == 0且仍不等),则next[i] = 0,i前进。
// 递推计算 next 数组(约定一:next[0] = -1,next[1] = 0)int i = 2; // 当前要填 next[i] 的位置int cn = 0; // 前缀长度信息,等价于 next[i-1]while (i < ms.length) { if (ms[i-1] == ms[cn]) { // ① 相等:i 处信息 = cn + 1 next[i] = cn + 1; i++; cn = next[i-1]; // 等价于 ++cn } else if (cn > 0) { // ② 不等且还能跳:cn 回退到 next[cn] cn = next[cn]; } else { // ③ 跳不动:next[i] = 0 next[i] = 0; }}回退的本质:当 i-1 处字符与 cn 处字符不等时,说明以当前前缀长度无法延伸,就退而求其次,去尝试更短的前缀——而“更短的候选”恰好就是 next[cn](最长公共前后缀的前缀部分)。
为什么递推的复杂度是 ?因为 i 最多前进 M 次,cn 最多回退 M 次,总体是常数倍的操作次数。
4. 完整代码 (C++)
下面的代码采用「从 0 开始」的 next 约定(next[0] = -1),和上面第 3 章的叙述完全对应。
#include <iostream>#include <string>#include <vector>using namespace std;
// 求模式串 ms 的 next 数组// next[j] = 模式串 ms[0..j-1] 的最长公共前后缀长度// 人为规定:next[0] = -1,next[1] = 0vector<int> getNextArray(const string& ms) { if (ms.size() <= 1) return ms.empty() ? vector<int>() : vector<int>{-1}; vector<int> next(ms.size()); next[0] = -1; next[1] = 0; int i = 2; // 当前要填 next[i] 的位置 int cn = 0; // 前缀长度信息,等价于 next[i-1] while (i < (int)ms.size()) { if (ms[i-1] == ms[cn]) { // ① i-1 处字符与 cn 处字符相等 next[i++] = ++cn; // 经典写法:next[i] = cn + 1,然后 i、cn 同时前进 } else if (cn > 0) { // ② 不等,cn 回退到 next[cn] cn = next[cn]; } else { // ③ cn 跳不动,next[i] = 0 next[i++] = 0; } } return next;}
// KMP 匹配:返回 m 在 s 中第一次出现的位置,找不到返回 -1// 约定:s.length() >= m.length()int getIndexOf(const string& s, const string& m) { if (s.length() <= m.length()) return -1; if (m.length() == 0) return 0; // 空串按常理返回 0 vector<int> next = getNextArray(m); int i1 = 0, i2 = 0; // i1 指向主串,i2 指向模式串 while (i1 < (int)s.length() && i2 < (int)m.length()) { if (s[i1] == m[i2]) { // ① 相等,同时前进 i1++; i2++; } else if (i2 == 0) { // ② 连开头都匹配不上,换主串开头 i1++; } else { // ③ 失配,模式串前推(主串指针不回退) i2 = next[i2]; } } return (i2 == (int)m.length()) ? i1 - i2 : -1;}测试用例及运行结果(已实际编译运行验证):
| 调用 | 结果 |
|---|---|
getIndexOf("abbcfddddbdcdaddebc", "ABCABCD") | -1 |
getIndexOf("abbcfddddbdcdaddebc", "bcf") | 2 |
getIndexOf("aaaa", "bba") | -1 |
5. 小结:时间复杂度
KMP 的时间复杂度要分两个阶段看:
- 预处理阶段:构造
next数组。i最多前进M次,cn最多回退M次(M是模式串长度),所以是 。 - 匹配阶段:主串指针
i全程不后退、最多前进N次(N是主串长度);失配时模式串指针每次最多回退M次,所以匹配阶段是 。
总复杂度:,远优于暴力的 。
6. nextval 数组优化
标准 KMP 已经很快了,但还存在一种「多余的跳转」:失配时跳到 next[j] 的位置后,如果该位置的字符和失配时的字符一样,那就还会再失配一次,白白浪费一次跳转。nextval 数组就是为了消除这种多余跳转而生的。
6.1 优化思路:找出「跳过去还是失配」的坑
先看一个具体例子:模式串 "aaaabab",主串为 "aaaacaaaab"。匹配到主串第 4 个字符 c 时失配,此时 (模式串指向第 5 个字符「a」)。按照 next 数组跳转:
next[4] = 3,跳到 :p[3] =「a」,和失配字符(主串的c)不一样?——不对,仔细看:
注意:失配发生在 p[j] 与主串字符比较时。若 p[j] == p[next[j]],那么跳到 next[j] 后,主串那个字符仍然不匹配,又会失配一次,然后又要跳到 next[next[j]]...
- 主串字符是
c,p全是a,所以无论j跳到哪,只要p[j] ==「a」,就必然再次失配。
优化思路:与其跳到一个「已知字符相同、必然再次失配」的位置,不如一次跳到「字符真正不同」的位置(或者直接跳无可跳)。把这种压缩后的跳转表叫做 nextval。
6.2 怎么计算:手算 nextval
手算规则一句话:nextval[j] = next[j],但若 p[j] == p[next[j]],则 nextval[j] = nextval[next[j]]。也就是说:看 next 跳过去的那个字符和当前失配字符是否相同,相同就继续向前压缩(取 nextval),不同就直接用 next。
仍以 "abaabacc" 为例,next = [-1, 0, 0, 1, 1, 2, 0, 1],逐位计算 nextval:
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
p[j] | a | b | a | a | b | c | a | c |
next[j] | -1 | 0 | 0 | 1 | 1 | 2 | 0 | 1 |
p[next[j]] | — | a | a | b | b | a | a | b |
| 比较 | — | b ≠ a 不同 | a = a 相同 | a ≠ b 不同 | b = b 相同 | c ≠ a 不同 | a = a 相同 | c ≠ b 不同 |
nextval[j] | -1 | 0 | -1 | 1 | 0 | 2 | -1 | 1 |
对照结论:nextval = [-1, 0, -1, 1, 0, 2, -1, 1]。可以看到 、4、6 三处的值被压缩了:因为跳过去的字符和失配字符相同(都是 a/b/a),干脆再往前取。
6.3 递推计算 nextval(O(M))
递推时并不需要先求完 next 再求 nextval,可以一遍递推同时得到 next 与 nextval:
// 求模式串 ms 的 next 与 nextval(约定一:next[0] = -1)vector<int> getNextValArray(const string& ms) { int m = ms.size(); if (m == 0) return {}; vector<int> next(m); int i = 0, cn = -1; // cn 初始为 -1(对应 next[0]) next[0] = -1; // 原文此处代码不完整}这个写法直接产出 nextval(因为 ms[i] != ms[cn] 时存 cn,相同则存 nxt[cn])。如果把关键行改成 nxt[i] = cn,得到的就是普通 next 数组。
复杂度同样是 :i 最多前进 M 次,cn 最多回退 M 次。
6.4 匹配阶段怎么用 nextval
匹配代码几乎不用改,只需把失配分支的跳转数组换成 nextval:
// 匹配阶段:仅将 i2 = next[i2] 换成 i2 = nextval[i2]} else { i2 = nextval[i2]; // 一跳到位,避免重复失配}好处:最坏情况下(如 "aaaaab" 这种大量相同字符的模式串),nextval 让跳转一步到位,匹配过程从「可能多次回退」变成「几乎不回退」,常数因子更小。复杂度量级不变(仍为 ),但实际速度更快。
6.5 next 与 nextval 对比
next | nextval | |
|---|---|---|
| 含义 | 最长相等前后缀长度(跳转候选) | 压缩多余跳转后的最终跳转位置 |
| 失配跳转 | i2 = next[i2] | i2 = nextval[i2] |
| 可能问题 | 跳过去字符若相同会再次失配 | 一跳到位,字符必不同或已跳无可跳 |
例 "abaabcac" | [-1, 0, 0, 1, 1, 2, 0, 1] | [-1, 0, -1, 1, 0, 2, -1, 1] |
| 适用场景 | 概念讲解、基础 KMP | 追求常数速度优化 |
nextval 是 next 的「压缩版」:跳过去的字符如果和失配字符相同,就继续往前压缩,直到字符不同或跳无可跳为止。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
最后更新于 ,距今已过 0 天
部分内容可能已过时