一文吃透KMP算法

       前言:今天,我们要来学习的字符串的高效匹配算法,KMP算法,用于在一个文本串中查找一个模式串的出现位置。相比于朴素的字符串匹配算法,KMP算法具有更低的时间复杂度,KMP算法的核心思想是利用已匹配的部分信息,避免重复比较已经比较过的字符。它通过构建一个模式串的前缀表(或称为部分匹配表),在匹配过程中根据前缀表的内容来决定下一次匹配的位置,下面,就让我们开始今天的学习之旅吧!

目录

1.暴力算法的启发

2.next数组引入

3.kmp算法

next数组如何求出?

kmp算法实现

完整代码实现


 

1.暴力算法的启发

     我们所熟知的暴力的匹配算法,其实也叫作BF算法,就是两个循环嵌套着来一点一点的挪动模式串,当某个位置模式串与被匹配的字符串完全相同时,就找到了匹配的位置,这里的就不再赘述,直接给出代码:

int bf(const string &s, const string &t) {
    int i = 0, j = 0;
    while (i < s.size() && j < t.size()) {
        if (s[i] == t[j]) i++, j++;
        else i = i - j + 1, j = 0;
    }
    if (j == t.size()) return i - j;
    else return -1;
}

动画演示: 

       看到上面的动画,我们不难从中看出主串和模式串进行比较的时候,主串的指针会一直震荡着向前移动,向前走发现不匹配后又要倒回来接着再和子串匹配,我们的kmp算法相较于传统的暴力算法的优势就是在于kmp算法的主串的指针只前进不后退,那这又是如何做到的呢?

      我们不妨假设在i处主串与模式串最后一次匹配成功,再往下就不能匹配了,那么,接下来按暴力的做法,就是将模式串右移一位再次从头进行比较,但是,当我们主串和模式串遇到第一个不匹配元素前,我们已经匹配成功了一些元素,这些元素中隐含着一些信息可以帮助我们优化后序的匹配过程,我们假设子串可以一次向右移动j个单位才能再次和主串匹配成功,那么,在模式串中,就会出现一些可以脱离主串而存在的关系:

2.next数组引入

首先,我们需要知道一些字符串的前后缀的知识:

      \cdot对于我们下面探讨度前后缀,以及公共前后缀,都是真前缀和真后缀,也就是不包含整个字符串的前后缀,比如\displaystyle对于字符串“ababc”,其真后缀仅包含“c”、“bc”、“abc”、“babc”、其真前缀仅包含“a”、“ab”、“aba”、“abab”,该字符串就没有公共的真前后缀。

        从上面的图中我们可以看出,对于模式串的任意一个位置,都可以求出一个使得该处模式串因为后序位置上的字符不匹配时的最小移动距离,这个移动距离其实是我们从上一次匹配中获得的主串和模式串之间的匹配关系信息,使我们知道从上一轮已经匹配成功的字符串中至少移动多少位才有可能有再次与主串匹配的可能,在暴力做法中,我们并没有利用这些上一轮比较后产生的信息,只是让模式串一位一位的向右移动,这就是我们kmp算法的核心,也是优化的核心。

       在比较的过程中,我们让模式串每个位置上的next值等于以该位置结尾的模式串的最长前后缀的长度,这样,我们一开始,仅仅利用模式串就能够求出对应的next数组,但是这和模式串的移动有什么关系呢?

下面,我们以模式串为“abababaab”为例,来模拟演示next数组的形成和模式串移动的关系

我们上面得出了一个结论,

模式串的移动距离=(当前不匹配的下标-1) - next[当前不匹配的下标-1];

这结论是否具有普遍性呢?下面我们来证明一下:

      我们不妨假设在主串的下标 i 处,在模式串的下标 j+1 处,发生了第一处字符不匹配,那么此时,模式串的最后一个与主串匹配处就在下标 j 处,我们利用前面的公式,可以得出,模式串需要右移 j - next[ j ]位,而我们知道,在第一次发生不匹配前,也就是模式串的[ 1 , j ]和主串的从[ i-j , i-1 ]已经是匹配的,根据我们的next数组可知,next[ j ]为从[ 1 , j ]的模式串子串的最大公共前后缀长度,因为前后缀一个在尾部,一个在头部,如果想要再次匹配主串某个位置和模式串的字符,我们至少要将我们已知的公共的前缀部分移动到后缀部分,才能够接续主串与模式串的对应此时我们需要的移动距离就是从[1 ,  j]d的长度减去公共前后缀的长度,也就是 j  -next[ j ],到此,结论得证。

3.kmp算法

      有了next数组,我们的kmp算法算是有了关键的核心了,接下来,如何将这个核心正确安装呢,不要急,我们来慢慢分析,主要分为next数组如何求出以及如何利用next数组来实现kmp算法两部分。

next数组如何求出?

       我看网上大多都是直接让next[0]=-1的操作,本质上是将前缀表右移然后将next[0]处自行置为了-1,其实关于next数组的实现有三种方式,但是都大同小异,我们就以最好理解的,根据我们的前后缀直接求出来的next直接算就可以了,

       首先,易知next[0]=0,我们假设数组下标从0开始,第一个字符相当于前缀包括尾,后缀包括头,所以最长相等前后缀是0,初始时,我们设置两个指针,指针i表示后缀的尾部,指针j表示前缀的尾部,这之所以设置在尾部,是因为前后缀一定包含头部或尾部元素,这样,对于我们不匹配的前后缀,我们回退指针j就可以知道指针i处的最长的前缀的长度,也就相当于知道了next[i]的值,初始时,我们需要让j=0,且i=1,因为后缀最短需要从1开始,也就是字符串至少有两个元素才能产生前后缀,这里分为两种情况:

      \cdotp[i]!=p[j],就相当于当前的前缀(前缀区间是[0,j) )最后一个元素与后缀(后缀区间是 [ i-j ,i) )最后一个元素不再匹配,因为我们此时求的是ne[i],所以,i指针不能再移动,我们只能以前缀的长度来确定我们的ne[i],因为需要前后缀相等,而后缀的尾部元素就是i指向的字符,所以,我们需要通过不断改变前缀尾部元素,也就是j指向的位置来与后缀相匹配,这也叫作回退过程,那么如何回退呢?这就要用到我们的前面的匹配信息了,我们在求ne[i]时,其实,我们已经知道了ne[i-1]和ne[j-1]的值,也就是上一轮的匹配结果,我们可以将当前的前缀回退或者缩小到上一轮比较的前缀,用这个的前缀的最后一个元素p[ne[j-1]]再次来和p[i]做比较,如果不匹配则j再次回退到上一个匹配前缀的尾部,直到匹配成功或者j回退到了0,退无可退,那么此时的ne[i]就直接等于0;

     \cdotp[i]==p[j],说明此时的区间[0,j]和区间[i-j,i]是完全相同的两个字符串,也就是此时前缀和后缀相等,此时,我们的最长相等前后缀就是j+1的长度,所以我们需要让j++,并且给ne[i]赋值即可,最后再让i++,继续新一轮的匹配。

求next数组的代码:

 //求next数组
    ne[0]=0;
    for(int i=1,j=0;i<p.size();i++)
    {
        while(j>0&&p[i]!=p[j])
         j=ne[j];
        if(p[i]==p[j])
         j++;
        ne[i]=j;
    }

kmp算法实现

      每次比较时,若 s [ i ]  与 t [ j ] 相等,则执行 i++, j++ 并继续比较;若不等,则执行 j = nxt[j] 以更新 j (相当于向右移动模式串)。一个特殊情况是,如果某一次比较出现了 s [ i ] 不等于 t [ 0 ] t[0],即在模式串的第一个字符处失配,则 j  的更新值为 j = ne[0] = -1,接下来应当执行 i++, j++,相当于让模式串右移一位并重新在模式串的第一个字符处进行比较。由此可见,定义 n e x t [ 0 ] = − 1 的作用是将两种情况合并为一种情况并达到简化代码的目的。我们通过让j初始直接赋值为0,对于第一个字符就不匹配的情况,我们可以直接移动主串的指针,从而达到将模式串整体向右移动一位的目的即可,代码实现如下:

//KMP匹配过程
    for(int i=0,j=0;i<s.size();i++)
    {
        while(j>0&&s[i]!=p[j])
          j=ne[j-1];
        if(s[i]==p[j])//如果两者匹配,那么i++,j++,i在for循环中自动加1,所以这里不在加
         j++;
        if(j==n)//如果前缀和模式串相同了,说明找到匹配的了
        {
            cout<<i-n+1<<" ";//输出开始匹配的第一个字符的下标
        }
    }

完整代码实现

#include<iostream>

using namespace std;

const int N=100010,M=1000010;

char p[N],s[M];//分别表示模式串和被比较的字符串
int ne[N];//next数组
int n,m;//n表示模式串长度,m表示主串长度
int main()
{
    cin>>n>>p>>m>>s;
    
    //求next数组
    ne[0]=0;
    for(int i=1,j=0;i<n;i++)
    {
        while(j>0&&p[i]!=p[j])
         j=ne[j-1];
        if(p[i]==p[j])
         j++;
        ne[i]=j;
    }
    // for(int i=0;i<n;i++)
    //   cout<<ne[i]<<" ";
    // cout<<endl;
    //KMP匹配过程
    for(int i=0,j=0;i<s.size();i++)
    {
        while(j>0&&s[i]!=p[j])
          j=ne[j-1];
        if(s[i]==p[j])//如果两者匹配,那么i++,j++,i在for循环中自动加1,所以这里不在加
         j++;
        if(j==n)//如果前缀和模式串相同了,说明找到匹配的了
        {
            cout<<i-n+1<<" ";//输出开始匹配的第一个字符的下标
        }
    }
    
    return 0;
}

    

       我从未见过一个早起、勤奋、谨慎、诚实的人抱怨命运不好的。你可以拒绝学习的动力,但是你的竞争者不会。勤奋、重复和大量的练习是给每一个普通人成才的机会,最完美的状态,不是你从不失误,而是你没有放弃成长。没人能把你变得越来越好,时间和精力只是陪衬,支撑你变得越来越好的是你坚强的意志、修行、品行,以及不断的反思和修正。人生最好的贵人,就是努力向上的自己。


 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值