聊聊KMP-字符串搜索算法

KMP算法,是一种高效的字符串匹配算法,使用字符串S中查找子串M出现的位置。

说到字符串搜索,我们最先想到的可能就是,IDE(如VSCode)、Word文档等, 他们都提供了内容查找(Ctr + F)的功能

在开始KMP算法之前,可以先思考下我们自己会怎么实现内容查找功能呢?

先定义一个模式串P = ABCA,一个搜索串C = ABCDABCADDADS,需要在内容串C中找到模式串P串出现的的位置

字符串搜索-暴力解法

一般而言,最先想到的就是暴力解法(请先忽略正则

我们会设置两个下标变量ij,分别指向字符串S 和字符串P,当P[j] != C[i] 时,我们会将i + 1j = 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作为第一组比较过程,可以发现跟暴力解法的一些区别:

  1. 在比较过程中,i 是会不断前进的
  2. 当在i = 3j = 3发生不相等时,将j = 1而不是0,继续新一轮比较

主要分析下第二点,根据两轮比较的图示,通过观察隐约可以发现一些规律

比如 i = 3j = 3 时,A != B;但是肉眼观察后发现,i = 2j = 0 时都是A, 所以他俩就没必要再比了吗,直接跳过 j = 0,从 j = 1 开始新一轮比较就可以了,这不就节省了一步比较了吗~

那么在观察下面的 图示3图示4的比较,也是同样的规律,当i = 7j = 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,它的真前缀是:AAB(对应下标[0], [0, 1]),它的真后缀是BAA(对应下标([1, 2], [2]))。

关于失配表的计算

  1. 失配表下标表示模式串中每个字符的下标位置;
  2. 失配表中的值表示,当前下标值index,所对应模式串中从0index的子串中,出现最长的真前缀等于真后缀的子串的长度,好绕~~

比如模式串 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符合条件,所以值是2
  • index = 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 = 8j = 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_len2AB ACAB AB

但是代码中我们使用更高效的方式,利用之前已经计算的结果,求解新的值,有点动态规划的感觉~,怎么做的呢?

根据我们已知的信息,在i = 7 之前子串的左右两边是完全相同的(ABA),利用这个信息,我们可以先得到除去最后一个字符 B 的一部分前缀子串,怎么得到呢?

比如最终的符合条件的前缀子串是 xxB,其中xx就表示前缀子串的一部分

通过上面图中的观察,能发现xx作为前缀子串的一部分,一定出现在整个模式串的左边ABA和右边BA(对半一下,因为C不符合条件,那么它之后的A也不符合条件)中。发现没,BAABA的子集,那么,这不就是在找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理解起来可能还好,但是想把它表达清楚,还是有些难度的,看上去不免啰嗦!

希望我把它讲明白了,各位看官老爷也都理解了,如果没有的话,感谢大家的反馈🙏

相关推荐
LuciferHuang3 小时前
震惊!三万star开源项目竟有致命Bug?
前端·javascript·debug
GISer_Jing3 小时前
前端实习总结——案例与大纲
前端·javascript
天天进步20153 小时前
前端工程化:Webpack从入门到精通
前端·webpack·node.js
姑苏洛言4 小时前
编写产品需求文档:黄历日历小程序
前端·javascript·后端
知识分享小能手5 小时前
Vue3 学习教程,从入门到精通,使用 VSCode 开发 Vue3 的详细指南(3)
前端·javascript·vue.js·学习·前端框架·vue·vue3
姑苏洛言5 小时前
搭建一款结合传统黄历功能的日历小程序
前端·javascript·后端
YuTaoShao6 小时前
【LeetCode 热题 100】141. 环形链表——快慢指针
java·算法·leetcode·链表
你的人类朋友6 小时前
🤔什么时候用BFF架构?
前端·javascript·后端
知识分享小能手6 小时前
Bootstrap 5学习教程,从入门到精通,Bootstrap 5 表单验证语法知识点及案例代码(34)
前端·javascript·学习·typescript·bootstrap·html·css3