KMP
算法,是一种高效的字符串匹配算法,使用字符串S
中查找子串M
出现的位置。
说到字符串搜索,我们最先想到的可能就是,IDE(如VSCode)、Word文档等, 他们都提供了内容查找(Ctr + F)的功能

在开始KMP
算法之前,可以先思考下我们自己会怎么实现内容查找
功能呢?
先定义一个模式串P = ABCA
,一个搜索串C = ABCDABCADDADS
,需要在内容串C
中找到模式串P
串出现的的位置
字符串搜索-暴力解法
一般而言,最先想到的就是暴力解法(请先忽略正则
)
我们会设置两个下标变量i
和 j
,分别指向字符串S
和字符串P
,当P[j] != C[i]
时,我们会将i + 1
,j = 0
,开始下一轮新的比较,思路很简单,直接上代码:
js
/**
* @param {string} pattern
* @param {string} content
* @returns {Array<number>}
*/
function findIndexs(pattern, content) {
if (content.length < pattern.length) return [];
let indexs = [];
for (let i = 0; i <= content.length - pattern.length;) {
let j;
for (j = 0; j < pattern.length; j++) {
// 不相等,退出模式串的比较
if (pattern[j] !== content[i + j]) {
break;
}
}
if (j === pattern.length) {// 表示模式串全匹配
indexs.push(i);
}
i++;
}
return indexs;
}
这种实现的时间复杂度是O(N x M),N
是内容串的长度,M
是模式串的长度。
一般
情况下是够用的,但是,我们是有遇到二般
情况的可能性的,比如我们要搜索的内容字符串是10万
个字符或者更多呢?
代码文件可能不常见,但是word文档
、日志文件
呢?肯定会的!
如此一来,我们的暴力解法,很可能会瘫掉,长时间得不到响应结果;
如果你使用VSCode
只是做了一个简单的搜索导致交互卡住或者长时间无响应,你能接受吗?
显然不能接受,因为它太常用了!
也是因为这样的问题越来越显著,Knuth
,Morris
, Pratt
三人在同一年发明高效的字符串搜索算法,后来以三人的名字命名为KMP
算法。
接下来就分析一下KMP
算法的实现思路
字符串搜索-KMP算法
接下来我们先看两组KMP
的比较过程,为理解算法的实现部分,先有一个宏观概念。
定义一个模式串P = ABABC
,一个内容串C = ABAABABABCA
,比较过程如下
图示1
图示2
将
图示1
和图示2
作为第一组比较过程,可以发现跟暴力解法的一些区别:
- 在比较过程中,
i
是会不断前进的 - 当在
i = 3
和j = 3
发生不相等时,将j = 1
而不是0
,继续新一轮比较
主要分析下第二点,根据两轮比较的图示,通过观察隐约可以发现一些规律
比如 i = 3
和 j = 3
时,A != B
;但是肉眼观察后发现,i = 2
和j = 0
时都是A
, 所以他俩就没必要再比了吗,直接跳过 j = 0
,从 j = 1
开始新一轮比较就可以了,这不就节省了一步比较了吗~
那么在观察下面的 图示3
和 图示4
的比较,也是同样的规律,当i = 7
和 j = 4
不相等时,由于前面比较过的且相等的两个字符AB
,也出现在了模式串P
的最前面 ,所以就可以跳过这两个位置的比较,从 j = 2
开始比较,这不就又节省了两步比较了吗~
图示3
图示4

根据图示1
和图示2
,图示3
和图示4
,两组比较过程,其实就是KMP
算法实现搜索的过程。
我们大概知道了KMP
高效的点:保持内容串的下标 i 不断前进,在每一轮比较中,尽量减少模式串跟内容串的比较次数
但是,我们依然困惑的是,我们怎么知道,每一次发生不相等时,应该怎么移动j
的位置,来起到减少比较次数的效果呢?
这就我们下面要谈到的 KMP
算法的核心:失配表(PMT)
失配表(PMT)
KMP
算法跟暴力解法的主要区别,就是多了一个部分匹配表(Partial Match Table)
,也被称为失配表
。
它是为模式串(pattern)
准备的,先预处理模式串
,为模式串的每个字符记录一个值,表示在该字符位置发生不匹配时
,模式串应该回退多远
,这也就是KMP
算法比暴力算法的高效所在了。
理解起来很抽象对不对!
这里需要再引入一个概念:字符串的真前缀
和 真后缀
真前缀
:字符串的连续前缀子串
,且不等于该字符串真后缀
:字符串的连续后缀子串
,且不等于该字符串
比如字符串:ABA,它的真前缀是:A
、AB
(对应下标[0], [0, 1]),它的真后缀是BA
、A
(对应下标([1, 2], [2]))。
关于失配表的计算
失配表
的下标
表示模式串
中每个字符的下标位置;失配表
中的值表示,当前下标值index
,所对应模式串
中从0
到index
的子串中,出现最长的真前缀
等于真后缀
的子串的长度,好绕~~
比如模式串 P = ABABC
,它的失配表是:[0, 0, 1, 2, 0]
举个栗子你就能明白了
index = 0
时,子串是A
,根据真前缀
和真后缀
的定义,可知,单个字符是没有真前缀和真后缀的,所以,得到适配表
对应下标0
的值是0
;index = 1
时,子串是AB
,真前缀A
,真后缀B
,不符合条件,所以值是0
;index = 2
时,子串是ABA
,真前缀[A, AB]
,真后缀是[BA, A]
,只有A
符合条件,所以值是1
index = 3
时,子串是ABAB
,真前缀[A, AB, ABA]
, 真后缀[BAB, AB, B]
,存在AB
符合条件,所以值是2index = 4
时,子串是ABABC
,真前缀[A, AB, ABAB]
, 真后缀[BABC, ABC, BC, C]
,不符合条件,所以值是 0
为什么要使用真前缀
和真后缀
的概念来计算失配表
呢?
使用模式串P = ABCABC
,内容串C = ABAABCABABCA
进行举例:
C = A B A A B C A B
A B C A
P = A B C A B
C
当发生不相等时,i = 8
,j = 5
,应该怎么调整j
的位置呢?
就是利用 真前缀
和 真后缀
相等的且最长子串 这一概念计算的,在发生不相等时,之前已经比较了ABCAB
,他们是相等的,因此将相等的部分
中,真前缀移动到真后缀的位置上去(因为他们是相等的,无需再次比较),也就得到了j
设置为2
的结果,继续下一轮比较:
C = A B A A B C A B
A B C A
P = A B
C A B C
其实理解了关于失配表
表的计算过程,也就相当于理解KMP
算法了~
好了,概念讲的差不多了,我们进入代码部分,呼~(长出一口.....)
部分匹配表的计算
一般将适配表
命名为next
数组,具体实现如下
js
/**
* makeNext
* @param {string} pattern
* @returns {Array<number>}
*/
function makeNext(pattern) {
// 失配表
let next = [0];
// 前缀长度
let prefix_len = 0;
// 第一个字符没有真前缀和真后缀,跳过
let i = 1;
while (i < pattern.length) {
if (pattern[prefix_len] === pattern[i]) {
// 相等
prefix_len += 1;
next.push(prefix_len);
i++;
} else {
// 不相等
prefix_len = next[prefix_len - 1] || 0;
if (prefix_len === 0) {
next.push(0);
i++;
}
}
}
return next;
}
代码很短,但是理解起来还是有些绕的,循环比较分为两种情况:
相等时: pattern[prefix_len] === pattern[i]
举个例子,假如 pattern = ABCAB:
- 当 i = 1 时: prefix_len = 0, pattern[0] === pattern[1],即
A
===B
, prefix_len = 0 - 当 i = 2 时: prefix_len = 0, pattern[0] === pattern[2],即
A
===C
, prefix_len = 0 - 当 i = 3 时: prefix_len = 0, pattern[0] === pattern[3],即
A
===A
, prefix_len = 1 - 当 i = 4 时: prefix_len = 0, pattern[1] === pattern[4],即
B
===B
, prefix_len = 2
应该能发现,pattern[prefix_len]
是为了得到 已匹配 相等前缀子串 的后一个字符 这样就能贪婪地得到最长的前缀子串
不相等时
prefix_len = next[prefix_len - 1] || 0
; 这一步就有点难理解了
举个例子:模式串 P = ABACABAB

当 i = 7
时,pattern[prefix_len] === pattern[i]
的结果是不相等,就需要重新计算i = 7
时的prefix_len
。
使用暴力解法的话,也是可以得到计算结果prefix_len
是2
(AB
ACAB AB
)
但是代码中我们使用更高效的方式,利用之前已经计算的结果,求解新的值,有点动态规划的感觉~,怎么做的呢?

根据我们已知的信息,在i = 7
之前子串的左右两边是完全相同的(ABA
),利用这个信息,我们可以先得到除去最后一个字符 B
的一部分前缀子串,怎么得到呢?
比如最终的符合条件的前缀子串是 xxB
,其中xx
就表示前缀子串的一部分
通过上面图中的观察,能发现xx
作为前缀子串的一部分,一定出现在整个模式串的左边ABA
和右边BA
(对半一下,因为C
不符合条件,那么它之后的A
也不符合条件)中。发现没,BA
是ABA
的子集,那么,这不就是在找ABA
的符合条件的前缀子串吗?
很庆幸,我们已经计算过了啊~
这不就知道表达式是怎么来的了吗~
prefix_len = next[prefix_len - 1] || 0
下面是KMP
算法搜索部分的代码
js
/**
*
* @param {string} content
* @param {string} pattern
*/
function kmpSearch(content, pattern) {
const next = makeNext(pattern);
let i = 0;
let j = 0;
while (i < content.length) {
if (content[i] == pattern[j]) {
// 匹配
j++;
i++;
} else if (j > 0) {
// 失配,跳过部分真前缀
j = next[j - 1];
} else {
// 模式串的第一个字符失配
i++;
}
if (j === pattern.length) {
return i - j;
}
}
return -1;
}
这篇文章码了很多字,因为KMP
理解起来可能还好,但是想把它表达清楚,还是有些难度的,看上去不免啰嗦!
希望我把它讲明白了,各位看官老爷也都理解了,如果没有的话,感谢大家的反馈🙏