理解KMP算法

1,313 阅读11分钟

在一个串中查找是否出现过另一个串,这是KMP的看家本领

参考资料:代码随想录 觉得carl哥说的有些繁琐,整理了一遍我自己的理解。

什么是KMP算法

说到KMP,先说一下KMP这个名字是怎么来的,为什么叫做KMP呢。

因为是由这三位学者发明的:Knuth,Morris和Pratt,所以取了三位学者名字的首字母。所以叫做KMP

KMP有什么用

假设现在有一个文本字符串 aabaabaaf 和一个模板字符串 aabaaf,现在要求在文本串中找出模板串出现的第一个位置 (从0开始)。如果不存在,则返回  -1。

相信很多同学一开始可能都想到双重for循环的暴力解法,遍历文本串从下标 0 到下标 5 时,发现下标5指向的 b模板串中下标5指向的 f 不匹配,故又从文本串的下标 1 重新开始下一轮匹配,此时显然时间复杂度是O(m*n),m、n分别是文本串与模板串的字符串长度。

例子中不难看出,最终答案应该是当我们遍历文本串第四轮,即从下标 3 开始匹配的字符串与模板串相同,但是这样就除了初始从 0 匹配最终从 3 匹配外,多了两次从1匹配和从2匹配的多余步骤,那么该怎么通过我们初始从 0 匹配中得到的数据(可以理解为“失败的经验”)中智能的跳过必定失败的遍历步骤呢?

此时我们发现,文本串0~2 位的aab是和3~5 位一样的,都能与模板串前几位匹配上,也就是说我们第一次匹配失败后,第二次完全可以从文本串下标为 3 的地方开始第二轮遍历,这就可以帮助我们省略掉了文本串中从下标1和下标2开始访问的,这就是KMP算法的主要思想当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。

好了,理解完KMP算法的核心思想,我们接下来就来研究它如何如何记录已经匹配的文本内容通过记录跳过多余匹配步骤,首先,让我们引入两个重要的知识点:前缀表next数组

前缀表

我们先来理解一下什么叫前缀后缀最长公共前后缀

前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串
后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串
例如我们上述模板串aabaaf中,前缀就包括:a,aa,aab,aaba.aabaa;后缀有:f,af,aaf,baaf,abaaf

那么,我们可以引出我们的最长公共前后缀(也叫做最长相等前后缀):即一个字符串的 前缀集后缀集 中最长的相等字串。上文中模板串aabaaf的前后缀集可以看出没有相等字串,所以它的最长相等前后缀为0

如: 字符串a的最长相等前后缀为0(注意字符串的前后缀定义,故单字符没有前后缀);字符串aa的最长相等前后缀为1;字符串aaa的最长相等前后缀为2;等.....。

那么什么是前缀表

前缀表:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。 这就涉及到计算完整的前缀表:

image.png 解释一下:

  • 长度为前1个字符的子串a最长相同前后缀的长度为0;
  • 长度为前2个字符的子串aa最长相同前后缀的长度为1;
  • 长度为前3个字符的子串aab最长相同前后缀的长度为0;
  • 长度为前4个字符的子串aaba最长相同前后缀的长度为1;
  • 长度为前5个字符的子串aabaa最长相同前后缀的长度为2;
  • 长度为前6个字符的子串aabaaf最长相同前后缀的长度为0。

截至到目前,我们把前缀表算了出来。可以看出模板串与前缀表对应位置的数字表示的就是:下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。

前缀表怎么用 / 为什么一定要用前缀表

前缀表为啥就能告诉我们 上次匹配的位置,并跳过去呢?
回顾一下,刚刚匹配的过程在下标5的地方遇到不匹配,模式串是指向f,如图:
image.png 找到的不匹配的位置,那么此时我们要看它的前一个字符的前缀表的数值是多少。为什么要前一个字符的前缀表的数值呢,

下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀和后缀字符串是 子字符串aa ,我们可以通过对称思想,我可以拿模板串当前失败的后缀作为下一轮匹配的前缀(这样就省去了匹配公共前后缀片段的必失败轮次)。因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面从新匹配就可以了。

前一个字符的前缀表的数值是2, 所有把下标移动到下标2的位置继续比配(为什么移到2,因为相同前后缀的原因,后缀段aa肯定在文本串中已经匹配过了,那么我们可以替换为文本串已经匹配过前缀段aa了,也就是说文本串已经匹配成功前缀表数字代表的数量的字符了,即匹配完两个了从下标2第三个匹配起)。然后就找到了下标2,指向b,继续匹配:如图: image.png 所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。

next数组

什么是next数组

next数组可以理解为就是把抽象的前缀表实例化,我们在做题中同样也是构造next数组来运用前缀表这种方法,而不同的使用next数组的方法因人而异,但原理是一样的只是在最终比较的时候有所出入,我们拿模板串 aabaaf举例:

  • 前缀表【0 1 0 1 2 0】作为next数组,比较到f跟文本串有出入,回退到上一位的前缀表对应的 2 作为下一次匹配的起始下标处
  • 前缀表【-1 0 1 0 1 2】作为next数组,就是将第一种的前缀表整体右移,初始值赋予 -1(或者存储字符串长度),比较到f跟文本串有出入,直接拿f的前缀表对应的数值作为下一次匹配的起始下标处、
  • 前缀表【-1 0 -1 0 1 -1】作为next数组,就是将第一种的前缀表整体-1,比较到f跟文本串有出入,回退到上一位的前缀表对应的值+1作为下一次匹配的起始下标处

那么使用next数组的时间复杂度分析:其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。

暴力的解法显而易见是O(n × m),所以KMP在字符串匹配中极大的提高的搜索的效率。

构造next数组(难点)

构造next数组就是对于题中给到的模板串,我们封装一个函数可以算出模板串的前缀表并返回的过程,算出前缀表的过程核心思想就是:接收模板串作为参数;i指针指向后缀末尾,j指针指向前缀末尾(注意:j还代表i位置之前子串的最长相等前后缀长度),i每次向后移动1位,求出以i指向字符为结尾的子串的最大相同前后缀,作为前缀表此位置next[i]的值

这里用 Javascript 实现,有兴趣的uu也可以用自己学习的语言自己感受一下流程有助于掌握

// 计算前缀表采用第一种方法(不右移也不减一)
const getNext = function(needle){
    let j = 0;   //j初始值从0,第一位开始
    let next = [];
    next[0] = j;  //数组next[0]为0,首位(单位)字符肯定没有前后缀
    for(let i=1; i<needle.length; i++){   //注意i从1开始
        // 前后缀不相同
        while (j>0 && needle[i] !== needle[j]){   //j要保证大于0,回退过程中比较前缀尾j与后缀尾i是否相等
            j = next[j-1];  //j回退到上一位,j到字符串首位还不相等说明当前i作为终止位的字串没有最长相等前后缀,next[i]赋值0
        }
        // 前后缀相同
        if (needle[i] === needle[j]){
            j++; //相同,j此时指向的是前缀字符串(上一级),要同时向后移动j和i准备下一轮匹配(移动i在for循环里)
        }
        next[i] = j;// 将j(前缀的长度)赋给next[i]
    }
    return next;
}

对于难点:为什么j同时表示最长相等前后缀?

j是前缀的末尾,也代表着前缀的长度,如果j不断地移进,说明i作为后缀的末尾正在遍历的部分与j作为前缀的末尾正在遍历的部分相同,相同的部分就是“相等前后缀”,那么j代表的数值就是“内容为从0位置开始到i的位置的字符串”的最大相等前后缀长度。

对于难点:为什么"前后缀不匹配时",要用while循环持续回退?

首先要知道next数组对应的是前缀表,前缀表也称部分匹配表。该数组中的值代表着该子串中的最长相等前后缀,也是子串中已经匹配好的长度。如next【4】=2,其对应的子串是aabaa,前缀aa和后缀aa已经匹配好了,长度为2,j指针此时也停留在2。
每次求next[i],可看作前缀与后缀的一次匹配,在该过程中就可以用上之前所求的next,若匹配失败,则像模式串与父串匹配一样,将指针j移到next【j-1】上。
求next过程实际上只与前一个状态有关:
若不匹配,一直往前退到0或匹配为止
若匹配,则将之前的结果传递:
因为之前的结果不为0时,前后缀有相等的部分,所以j所指的实际是与当前值相等的前缀,可视为将前缀从前面拖了过来,就不必将指针从前缀开始匹配了,所以之前的结果是可以传递的。

使用next数组来做匹配

let next = getNext(needle); //获取前缀表next
let j = 0; //j指针指向模式串起始位置
for (let i = 0; i < haystack.length; i++) { //i指向文本串起始位置。
    while (j > 0 && haystack[i] !== needle[j]) //不相同,j就要从next数组里寻找下一个匹配的位置
        j = next[j - 1];
    if (haystack[i] === needle[j]) //如果相同,那么i 和 j 同时向后移动:
        j++; // i的增加在for循环里
    if (j === needle.length) //如果j指向了模式串的末尾,那么就说明模式串完全匹配文本串里的某个子串了。
        return (i - needle.length + 1);//返回当前在文本串匹配模式串的位置i减去模式串的长度,就是文本串字符串中出现模式串的第一个位置
}

return -1;//文本串中没有匹配的字串,依题返回-1

总结

关于KMP算法,主要的是理解它的前缀表思想: 拿模板串当前匹配失败的后缀作为下一轮匹配的前缀:这样就省去了匹配公共前后缀片段的必失败轮次 可以带来的对时间复杂度的优化,难点在于如何构造 next数组,大家可以用leedcode中的28. 找出字符串中第一个匹配项的下标练练手。

想看其余两种前缀表算法的源码👉代码随想录,卡哥还是无敌的!