通俗易懂的KMP算法(C++)

最近按照代码随想录中整理的顺序刷力扣题,刷到后第一次了解到KMP算法,看了B站视频,觉得卡哥这集讲的有些精炼,于是自己通过代码理解了一下后,用比较通俗形象的方式,向大家介绍一下KMP算法。

一 什么是KMP算法

KMP算法是由Knuth,Morris和Pratt三位学者发明的,所以取了三位学者名字的首字母,称作KMP算法。
KMP算法主要用在字符串匹配上。
比如我们从字符串"acfacfgded"(需要在哪里找的字符串称为“文本串”)找其中是否包含字符串"acfg"(需要从文本串里找的字符串我们叫做“模式串”),我们一般会想到的解法是暴力求解,两层for循环,依次对模式串的每一个元素进行匹配,如果匹配失败,下次还从模式串的第一个进行匹配,这就导致了较高的时间复杂度(O(n×m))。
而KMP算法不同之处就在于,当模式串的某个元素匹配失败后,不需要再从模式串的第一个元素从头开始匹配了,而是根据前缀表(next)找到模式串中一个最优的位置继续进行匹配

二 什么是前缀表

前后缀

什么是前缀:字符串的前缀是指从第一个元素开始的、不包括最后一个元素的连续字串。
什么是后缀:字符串的后缀是指不包括第一个元素的、以最后一个元素结尾的连续子串。
最长相等前后缀:字符串的最长相等前后缀就是字符串的前缀、后缀中相等的、最长的连续子串。
例如,对于字符串"acdac"。

前缀后缀最长相等前后缀
a、ac、acd、acdacdac、dac、ac、cac

还有一种形象一点的理解,就是你,将一个字符串固定不动,另外一个完全一样的字符串不断向右平移,直到两个字符串的交叉部分的元素相等,相等前后缀就是两个字符串的交叉部分。如"acdac"的最长相等前后缀就是下面这个图
图1

前缀表

前缀表 next ,是一个跟模式串具有同样长度的数组, next 每一个元素 next[i] 记录的是模式串下标 i(包括i)之前的字符串的最长相等前后缀的长度
因此呢,字符串"aabaab"的前缀表就是

010123

上面这个前缀表是原始前缀表,常见的还有另外两种:统一减一、统一右移形式。
字符串"aabaab"的统一减一形式的前缀表是

-10-1012

字符串"aabaab"的统一右移形式的前缀表是

-101012

这三种形式的前缀表没有本质的差别,只是因为代码上的实现不一样导致的区别,我个人更喜欢统一减一形式的,这样子 next[i] 记录的是模式串下标 i(包括 i )之前的字符串的最长相等前后缀的最后元素的下标了

三 前缀表的作用

前缀表在匹配过程中到底有什么用呢?用一个例子来讲解,我们要从文本串 “aabaabaafa” 中找其中是否包含模式串 “aabaaf” 。
首先我们可以得出模式串的前缀表(统一减一形式)是

010120
  1. 首先按正常逻辑我们对文本串和模式串进行匹配,发现到模式串第六个元素,b != f,匹配失败。
    在这里插入图片描述

  2. 当匹配失败时,常规思路是让模式串向右平移一格,接着再从模式串的第一个元素开始匹配。但是在KMP算法中,我们可以利用前缀表,向右平移若干格,且从文本串中匹配失败的位置与模式串再进行匹配。虽然第六个元素匹配失败,但前五个元素是匹配成功的,因此,我们根据前缀表,得到前五个元素的最长相等前后缀的长度是2,这就意味着,我们将模式串向右移若干格之后,能保证使得模式串的长度为2的前缀能跟文本串中匹配失败的前一个位置对齐,最终效果如下图。这样就不用每次都从头开始匹配了。
    在这里插入图片描述

  3. 然后接着将文本串中原先匹配失败的位置与模式串中的刚刚用到的最长相等前后缀的后一个位置进行后续的匹配(这里这个位置的下标恰好就是2!!!这就是一个结论:前缀表的值,就是不匹配之后移动模式串、新的与文本串进行匹配的位置下标!!!

总结

前缀表的作用就是在模式串与文本串匹配失败的时候,不需要一处失败就全盘否定从头开始,而是通过找匹配失败位置前一位的前缀表的值,实现模式串向右移后(通过图来理解),模式串的第 [匹配失败位置前一位的前缀表的值] 位与文本串匹配失败的位置进行新一轮的 匹配。

四 前缀表的构建

可见,前缀表是KMP算法的核心,下面介绍一下前缀表(每个元素是最长前后缀长度)的构建。以下是C++代码举例。

    void getNext(int* next, const string& s) {
        int j = 0;
        next[0] = 0;
        for(int i = 1; i < s.size(); i++) {
            while (j > 0 && s[i] != s[j]) { // j要保证大于0,因为下面有取j-1作为数组下标的操作
                j = next[j - 1]; // 注意这里,是要找前一位的对应的回退位置了
            }
            if (s[i] == s[j]) {
                j++;
            }
            next[i] = j;
        }
    }

这首先定义了两个指针i和j,j指向前缀末尾位置,i指向后缀末尾位置,其实也就是找每个i对应的j是多少。这整个过程像是递归的过程,并且也用到了KMP算法匹配过程的回退思想。

前缀表的构建可分为3步:

  1. 初始化。初始化next[0] = 0,j=0。(for循环的每一轮结束时的j位置对应的前缀,都和i位置的后缀是匹配的,明白这一点非常重要!)

  2. 前后缀不匹配时。for循环的每一轮结束时的j位置对应的前缀,都和i位置的后缀是匹配的,for循环的下一轮开始时,i向右移动了一格,这时候如果 s[i] != s[j] ,这就是前文中的匹配失败的情况,就要找前一位的前缀表的值了,并再跳转到这一位,也就是代码中的

    while (j > 0 && s[i] != s[j]) { // j要保证大于0,因为下面有取j-1作为数组下标的操作
    	j = next[j - 1]; // 注意这里,是要找前一位的对应的回退位置了
    }
    

    之所以是while而不是if,这就是要一直向前回退直到匹配的情况。

  3. 前后缀匹配时。for循环的每一轮结束时的j位置对应的前缀,都和i位置的后缀是匹配的,for循环的下一轮开始时,i向右移动了一格,如果这时候s[i]==s[j],那就是匹配成功了,因为要找的是最长相等前后缀,你就需要j右移。

整个过程大家可以自己举个例子画个图来模拟下一下整个过程,方便自己理解。

五 运用到解题中

力扣 28.找出字符串中第一个匹配项的下标

参考代码

class Solution {
public:
    void getNext(int* next, const string& s) {
        int j = 0;
        next[0] = 0;
        for(int i = 1; i < s.size(); i++) {
            while (j > 0 && s[i] != s[j]) {
                j = next[j - 1];
            }
            if (s[i] == s[j]) {
                j++;
            }
            next[i] = j;
        }
    }
    int strStr(string haystack, string needle) {
        if (needle.size() == 0) {
            return 0;
        }
        int next[needle.size()];
        getNext(next, needle);
        int j = 0;
        for (int i = 0; i < haystack.size(); i++) {
            while(j > 0 && haystack[i] != needle[j]) {
                j = next[j - 1];
            }
            if (haystack[i] == needle[j]) {
                j++;
            }
            if (j == needle.size() ) {
                return (i - needle.size() + 1);
            }
        }
        return -1;
    }
};

不清楚的地方欢迎留言交流

  • 11
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值