KMP 算法全称 Knuth-Morris-Pratt,是一个经典的字符串匹配算法。
本文将结合我学习《算法4》一书的理解,从暴力匹配开始,逐步引出 KMP 的核心思想和实现细节。
KMP算法的全称是Knuth-Morris-Pratt,是一个经典的字符串匹配算法。它解决了什么问题呢:在一个非常长的字符串txt 中查找子串pattern,如果存在,返回这个子串的起始索引,否则返回-1。
KMP 就是为了解决这个问题,它比最基础的暴力方法高效得多:
- 暴力匹配时间复杂度:O(m * n)
- KMP 匹配时间复杂度:O(m + n)
其中 m是子串长度,n是主串长度。
先看一下最基础的办法😮 :暴力,本质就是从txt的每一个字符开始,逐个匹配,如果不同,就再测试下一个字符,比如从 aaacaaab
(i)中匹配 aaab
(j),最坏的时间复杂度是O(m*n)
ini
function bruteForce(haystack, needle) {
const n = haystack.length;
const m = needle.length;
for (let i = 0; i <= n - m; i++) {
let j = 0;
while (j < m && haystack[i + j] === needle[j]) {
j++;
}
if (j === m) {
return i;
}
}
return -1;
}
但是很明显 pat
中根本没有字符 c
,在前面根本没必要回退指针 i
去重复匹配,如果字符串中重复的字符比较多,这个方法就显得很蠢。
KMP 算法的核心在于:
它永不回退主串 txt 的指针,而是借助模式串 pat 的结构,跳过无效比较,直接定位下一步的匹配位置。
KMP 会花费空间来记录一些信息,在上述情况中就会显得很聪明。
继续上面那个例子,如果我们知道 pat中重复的字符可以"复用",就可以在失败时跳转到一个合理的位置继续匹配。
例如:从 aaacaaab
(i)中匹配 aaab
(j),当txt中的c第一次与pat中的b不匹配时,在下一步时会变成这样:

又比如,在字符串 aaaaaaab
中匹配 aaab
,暴力解法还会和上面那个例子一样蠢蠢地在每次无法匹配a和b时回退指针 i
,但是kmp就会聪明的在第一次 a 和 b 不匹配时保留 pat 的状态指向第三个a,因为 KMP 算法知道字符 b 之前的字符 a 都是匹配的,所以每次只需要比较字符 b 是否被匹配就行了。
所以大家发现没有,KMP 算法永不回退 txt
的指针 i
,而是借助某种已经算好的信息把 pat
移到正确的位置继续匹配 ,这样时间复杂度只需 O(n) 即可。那么问题来了,如何计算pat下一步要从哪里开始匹配?这时就需要确定有限状态自动机来辅助了,别急,虽然是个新名词,其实本质和动态规划的状态数组是一个思路。
什么是状态机呢🏗️,在这个算法里,就是对于pat字符串,每多匹配一位就是一种新状态,都没匹配是状态0,匹配了第一位就是状态1,这样,然后通过 txt 的每一位确定 pat 串从当前状态转移到什么状态。
可以把模式串 pat想象成一个"状态机":
- 没有匹配任何字符时是状态 0;
- 匹配了第一个字符后是状态 1;
- 依此类推,直到匹配全部字符进入"终止状态"。
画个图理解一下,假设我们的 pat 字符串是ABABC

圆圈内的数字就是状态,开始匹配时 pat
处于起始状态 0,一旦转移到终止状态 5,就说明在 txt
中找到了 pat
比如说如果当前处于状态 2,就说明字符 "AB" 被匹配,注意 j
不是索引,而是状态
当然处于某个状态时,遇到不同的字符,pat
状态转移的行为也不同,
比如在这种情况下怎么办呢,已经匹配了四个字符了:

好,如果状态转移的那些信息已经算出来了,那么我们就会知道:
如果txt的下一个是A,那么回到状态3,都有ABA,所以转移到状态3是最聪明的
如果遇到了字符 "B",只能转移到状态 0
如果遇到了字符 "C",那么当然意味着匹配完成,转移到终止状态5
当然了,还可能遇到其他字符,比如 "D",但是显然应该转移到起始状态 0,因为 pat
中根本都没有字符 "D",匹配不了一点
如果状态转移的信息都已知,我们能获得这样一张图:注意,状态的转移只与 pat 字符串有关,无论要从多复杂的文本中找子串,只要 pat 是一样的,状态机就是一样的

上面详细的展示了什么状态如何转移,也是kmp的核心逻辑,描述出这张图,就是kmp的算法实现。
为了实现状态机🧩,我们首先需要构建 DFA 表。我们先定义一个二维数组
scss
let dfa: number[][] = Array.from({ length: pat.length}, () => Array(256).fill(0));
显然,要确定状态转移的行为,必须明确两个变量,一个是当前的匹配状态 ,另一个是遇到的字符 ,而这个的 dfa[cur][c]
代表在当前状态 cur 下,遇到 txt 中的字符 c 的话,下一步状态是什么。这个256表示字符可能的ascii码值。
比如 dfa[4]['A'] = 3
表示当前是状态 4时,如果遇到字符 A,pat 应该转移到状态 3
基于此可以很快的写出搜索部分的代码,当然这里的前提是 dfa
数组要提前算出来
ini
for(let i=0;i<txt.length;i++){
cur=dfa[cur][txt.charCodeAt(i)]
if(cur===pat.length){
return i-pat.length //如果txt长度为5,pat长度也为5,那么起始位置就是0
}
}
return -1;
到这里,应该还是很好理解的吧,那么下一步,如何算出状态转移的信息呢?
刚刚在状态定义时我们就已经知道,dfa两个维度的含义,当前的状态和遇到的字符,那么我们可以搭一个像动归的架子,来转移这些状态
ini
dfa[0][pat.charCodeAt(0)] = 1; //初始状态,如果在状态0匹配了了pat的第一的字符,则跳到状态1
for (let cur = 1, restartPos = 0; cur < pat.length; cur++) {
for (let c = 0; c < 256; c++) {
dfa[cur][c] = next;
}
}
这个 next 状态应该怎么求呢?
显然,如果遇到的字符 c
和 pat[cur]
匹配的话,状态就应该向前推进一个,也就是说:
ini
// 在当前状态,又匹配了 pat 的字符,就可以前进状态
dfa[cur][pat.charCodeAt(cur)] = cur + 1;
如果遇到的字符 c
和 pat[cur]
不匹配的话,状态就要回退(或者原地不动)。
那么,如何得知要回退到那个状态呢,我们不妨定义一个新变量 re,指向要退到的那个状态。这个 re 状态有一个非常特殊的性质,就是和当前状态具有相同的前缀。什么意思呢,就比如ABABC,状态4的回退状态是2,因为都有 "AB" 前缀。
当状态4遇到了 "A",会将这个 "A" 交给状态2处理,即:
ini
dfa[cur][c] = dfa[re][c];
为什么可以这样呢,因为 KMP 算法就是要尽可能少的回退,如果有相同前缀,又能匹配,就可以少匹配那些前缀了嘛!
你要问这个回退状态的再前一个回退状态是什么,那当然是已经算出来了,因为这种动态规划的思路就是利用过去的结果解决现在的问题,只要指定初始状态,就可以一直推算下去,即:
ini
re = dfa[re][pat.charCodeAt(cur)];
因为在256种字符中只有一个是匹配的,所以我们在for循环中指定回退状态,然后在外面覆盖匹配的状态即可。

有了 DFA 表之后,搜索过程非常直接:
ini
function kmpSearch(txt, pat) {
const dfa = buildDFA(pat);
let j = 0;
for (let i = 0; i < txt.length; i++) {
j = dfa[j][txt.charCodeAt(i)];
if (j === pat.length) return i - j + 1; // 匹配成功
}
return -1;
}
🧠一旦理解状态机,KMP 就成为了一种顺理成章的设计。
如果你看懂了这篇文章,不妨点个赞或者转发一下;如果还有疑问,也欢迎评论交流。
📌 作者简介|前端方向本科生,已拿到小红书 offer,内容聚焦:高频算法、面试手撕题、前端系统设计。博客长期更新中。