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符合条件,所以值是1index = 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理解起来可能还好,但是想把它表达清楚,还是有些难度的,看上去不免啰嗦!
希望我把它讲明白了,各位看官老爷也都理解了,如果没有的话,感谢大家的反馈🙏