一篇文章,讲明白KMP算法的前世今生 SCAU8591、SCAU8592

在这篇文章中,我将试图用尽可能简洁易懂的语言,告诉你什么是KMP算法。


PART 1:简介

我们面对的问题:我们有两个字符串,其中一个较长,称为主串S,另一个较短,称为模式串T。我们想要知道,主串是否存在与模式串相等的子串,如果存在,那么我们想要得到该子串第一个字符在S中的位置。

为了解决这一问题,我们需要一个算法。在这个算法中,当我们提供S和T后:如果存在满足条件的子串,算法会给出相应的位置;否则返回0表示不存在这样的子串。通常情况下我们还会给出一个常量pos,它表示从S中开始查找“子串”的起始位置。我们将这样的算法称为模式匹配算法。

需要说明的是,当我们用数组存储这样的S和T时,它们的第一个字符存在数组下标为1的位置。


PART 2:BF算法

BF算法是解决这一问题的一个简单而直观的算法。它的核心思想是:

①用 i 和 j 分别表示S和T当前正准备比较的字符的位置,初始情况下 i=pos , j=1

②我们认为在S中存在很多潜在的符合要求的子串,事实上,它们的个数是(S.length-T.length+1)。举例说明:S长度为6,T长度为4。则潜在的符合要求的子串分别S[1]~S[4],S[2]~S[5],S[3]~S[6]。在BF算法中,从第一个潜在的符合要求的子串开始匹配(显然这个“子串”的第一个字符位置应该为pos),如果匹配成功,返回位置i-T.length;否则对下一个潜在的符合要求的子串开始匹配。

用代码表示如下:

#include <stdio.h>
#include <string.h>
#define SMAXSIZE 100
#define TMAXSIZE 20

int BF(char S[],char T[],int pos)
{
  int i=pos,j=1;
  while(i<=S[0] && j<=T[0])
  {
    if(S[i]==T[j]) i++,j++; //当前字符匹配成功,继续匹配下一个字符
    else i=i-j+2,j=1; //如果在模式串匹配到j时匹配失败,i需要回退到i-j+2,j需要回退到1(即i定位到下一个潜在的符合要求的子串的开始位置,j回到模式串开头)
  } //对i回退的解释:在模式串匹配到j时,已经匹配了j-1个字符,即i已经从这一个潜在的符合要求的子串的开始位置向后移动了j-1位,则此次匹配的开始位置是i-(j-1)=i-j+1,显然下一个潜在的符合要求的子串的开始位置在这一个潜在的符合要求的子串的开始位置的下一位,因此i需要回退到i-j+2
  if(j>T[0]) return i-T[0]; //有两种情况会使得程序跳出while循环:第一种情况是j>T[0],这时T串匹配完成,说明找到了符合要求的子串;第二种情况是i>S[0],这时主串匹配结束,说明匹配失败了
  else return 0; //匹配成功时i向后移动了T[0]个单位
}

int main()
{
    char ch,S[SMAXSIZE+1],T[TMAXSIZE+1];
    int pos;
    scanf("%d", &pos);
    ch=getchar(); //接收输入pos后的回车
    ch=getchar(); //接收主串S的第一个字符
    for(int i=1;i<=SMAXSIZE &&(ch != '\n');i++)
    {
      S[i]=ch;
      ch=getchar();
      S[0]=i; //S[0]用于存储主串的长度
    }
    ch=getchar(); //接收模式串T的第一个字符
    for(int i=1;i<=TMAXSIZE &&(ch != '\n');i++)
    {
      T[i]=ch;
      ch=getchar();
      T[0]=i; //T[0]用于存储主串的长度
    }
    int number=BF(S,T,pos);
    printf("%d",number);
    return 0;
}

BF算法的时间复杂度:假设S长度为m,T长度为n

在最坏情况下,即每一次匹配到T的最后一位才发现此次匹配失败,那么一共需要进行n*(m-n+1)次匹配操作,考虑在m>>n的情况下,它的时间复杂度是O(m*n)。这样的时间复杂度在极端情况下是不可接受的,于是就有了KMP算法。

但需要说明的是,在实际应用中BF算法在一般情况下的时间复杂度是O(m+n),因此它仍然被大量使用。


PART 3:KMP算法

初识next数组

在KMP算法中,我们需要一个数组:int next[],这个数组的值完全由T决定,它的大小是T.length。

为什么我们需要next数组?这是因为我们可以发现,当S与T在某个字符处匹配失败时,我们可以通过“i不回退,j回退到T的某个位置”的方式继续匹配。相较于BF算法,这种方式显然可以减少匹配操作的次数从而降低时间复杂度。举个例子来说明这个情况:S='abcabcabg',T='abcabg',当匹配到第6个字符时,c和g不等,此时可以保持i指向c不变,j回退到T的c位置继续匹配。

而next数组存储的就是在某个字符处匹配失败时,j回退到T的位置。也就是说,假设在j处匹配失败,那么j应当回退到T的位置是next[j]。在上面的例子中,在j=6时匹配失败,j应该回退到T的位置是3,即next[6]=3。

KMP算法的代码实现

在给出KMP算法的代码实现前,我们还需要考虑这样一种情况:假设S与T的第一个字符就不匹配,我们应该怎么做?在BF算法中,这时应该做的事情是i++,j=1;然而在KMP算法中,我们规定了i不变,因此j应该回退,可是这时的j是第一个元素,它已经退无可退了。

为了解决这个问题,我们做出了两项规定:"next[1]=0" 和 "j==0时,i++,j++;"。于是,当T在第1个位置就匹配失败时,j回到了第0个位置,而当j在第0个位置时,i和j同时加1,于是i指向S的下一个字符,j回到了T的第一个字符。不难看出,这样的操作实际上是等价于BF算法的“i++,j=1;”的。

也就是说:通过规定"next[1]=0"和"j==0时,i++,j++;"解决了第一个字符就不匹配的问题;而通过next数组的其他值,解决了在其他字符处发生不匹配时j的回退位置的问题(显然最坏的情况也就是回到第一个位置从头来过,不至于退无可退)。

由此,当我们得到了next数组后,我们就可以通过next数组实现KMP算法,以下是其代码实现:

int KMP(char S[],char T[],int pos)
{
  int i=pos,j=1;
  while(i<=S[0] && j<=T[0])
  {
    if(j==0||S[i]==T[j]) i++,j++; //当前字符匹配成功,继续匹配下一个字符
    else j=next[j]; //在某一个字符处匹配失败,保持i不变,j回退到T的某个位置,这个位置由next[j]提供
  }
  if(j>T[0]) return i-T[0]; //有两种情况会使得程序跳出while循环:第一种情况是j>T[0],这时T串匹配完成,说明找到了符合要求的子串;第二种情况是i>S[0],这时主串匹配结束,说明匹配失败了
  else return 0; //匹配成功时i向后移动了T[0]个单位
}

KMP算法与BF算法有两处差别:第一,在“j==0”时也要执行“i++,j++;”的操作;第二,当字符不匹配时,让j回退到T的某个位置,这个位置存储在next[j]中,也就是说,执行“ j=next[j];”的操作。


PART 4:next数组

next[j]应该满足什么条件?

①next[j]<j,因为当j回退到next[j]位置时,next[j]应该在j的前面,这样才能称为发生了“回退”。

②如果在j处发生了字符的不匹配,j回退到next[j]处,然后接着进行字符匹配,那么说明T的第1个字符到第next[j]-1个字符与S的第i-next[j]+1个字符到第i-1个字符是匹配的。因为只有当前面的'j-1'个字符都匹配上了,我们才会进行第'j'个字符的匹配。但在不匹配发生之前,S的第i-next[j]+1个字符到第i-1个字符正好对应T的第j-next[j]+1个字符到第j-1个字符。因此next[j]需要满足的条件是:T的第1个字符到第next[j]-1个字符与T的第j-next[j]+1个字符到第j-1个字符匹配。

③next[j]的值尽可能大。在满足上面两个条件的前提下,我们希望可以找到每一个j对应的尽可能大的next[j]值,因为这样可以减少回退的距离,从而减少匹配操作的次数,从而降低时间复杂度。

求next数组

①当我们在求next数组时,我们在求什么?

我们定义T的第1个字符到第j-1个字符所组成的字符串为Q。我们需要知道这样两个概念:Q的前缀是指Q的子串,它满足这样的条件:含Q的第一个字符而不含Q的最后一个字符;Q的后缀是指Q的子串,它满足这样的条件:含Q的最后一个字符而不含Q的第一个字符。

由于next[j]<j,显然T的第1个字符到第next[j]-1个字符为Q的前缀,T的第j-next[j]+1个字符到第j-1个字符为Q的后缀。因此当next[j]的值尽可能大的时候,我们事实上是在寻找Q的最大相等前后缀。假设最大相等前后缀长度为Len,那么Len=next[j]-1。于是可以得到next[j]=Len+1。

②如何求最大相等前后缀长度Len?

首先需要说明的是,我们在求next数组值时是依次求的,这意味着当我们在求next[j]时,我们已经知道了next[1]到next[j-1]的所有next值。为了方便描述,我们将j对应的Q的Len记为Q(j.Len)。

假设next[j]=k,那么Q(j.Len)=k-1,即第1个字符到第k-1个字符和j前面的相同长度的字符串匹配

这个时候,如果T[j]=T[k],那么Q(j+1.Len)=Q(j.Len)+1=k,那么next[j+1]=k+1=next[j]+1。

如果T[j]≠T[k],考虑next[k]=kk,不难发现这时可能的Q(j+1.Len)=kk,条件是T[j]=T[kk]。(k和j前面的长度分别为kk-1的字符串是匹配的,这两个字符串同时也和第1个字符到第kk-1个字符所组成的字符串匹配,因此只要T[j]=T[kk],就有Q(j+1.Len)=kk)

如果T[j]≠T[kk],那么按照上面的方式递归,应该考虑next[kk]=kkk,再判断T[j]和T[kkk]是否相等。

如果相等那么Q(j+1.Len)=kkk,否则再考虑next[kkk]=kkkk。

③递归方式 (在求next[j+1]时)

首先让next[j]=k,如果T[j]=T[k],那么Q(j+1.Len)=k,next[j+1]=k+1;

如果T[j]≠T[k],递归k=next[k],直到T[j]=T[k],然后next[j+1]=k+1;

然而我们考虑这样一种情况:直到k=1都没有找到满足条件的T[k],这说明j+1前面的字符所组成的字符串没有相同前后缀。那么当在j+1处发生字符匹配失败后,j+1应该回到第一个字符处重新开始匹配。即此时next[j+1]应该等于1。而根据上面的递归规则,此时k再经过一次递归next[1]=0到达了k=0的位置,这个位置没有元素显然不可能和T[j]匹配上,这个位置next[j]也没有值,递归也无法再继续下去。为了解决这个问题,我们规定:当k==0时,执行与T[j]=T[k]时相同的操作,即赋值next[j+1]=k+1。这样k就到了1,等价于赋值next[j+1]=1。

④代码实现Get_next

void Get_next(char T[],int next[])
{
  next[1]=0;
  int j=1,k=0; //我们从j=2开始为next数组赋值,但是为了得到next[2]的值,我们需要判断T[1]是否等于T[next[1]],因此我们的j初始值为1,k初始值为next[1]=0。   
  while(j<T[0]) //事实上我们可以知道,如果在j=2处匹配失败,只能回退到第1个字符处,也就是说next[2]值一定为1。但为了使初始条件尽可能简单,我们仍然从next[2]开始为next数组赋值
  { //最后一个赋值的对象是next[T[0]],因此我们的j只需要到T[0]-1就可以了
    if(k==0||T[j]==T[k])
    {
      next[j+1]=k+1; //next[j]==next[k]或者k==0,将next[j+1]赋值k+1
      j++; 
      k=next[j]; //考虑下一个next值,j往后移动一位,k也取到下一个j的next值
    } //事实上,这里的k=next[j]等价于k++,因为在求next[j+2]时,初始的k即next[j+1]一定是确定next[j+1]时的k往后一格,显然这是成立的:next[j+1]=k+1(这个式子中的k即是确定next[j+1]的k)
    else k=next[k]; //递归
  }
}

将j替换为i,k替换为j,再对if语句作简单的等效变换,就得到我们在教材上看到的代码了。

void Get_next(char T[],int next[])
{
  next[1]=0;
  int i=1,j=0; //我们从i=2开始为next数组赋值,但是为了得到next[2]的值,我们需要判断T[1]是否等于T[next[1]],因此我们的i初始值为1,j初始值为next[1]=0。   
  while(i<T[0]) //事实上我们可以知道,如果在i=2处匹配失败,只能回退到第1个字符处,也就是说next[2]值一定为1。但为了使初始条件尽可能简单,我们仍然从next[2]开始为next数组赋值。
  { //最后一个赋值的对象是next[T[0]],因此我们的i只需要到T[0]-1就可以了
    if(j==0||T[i]==T[j]) next[++i]=++j; //T[i]==T[j]或者j==0,将next[i+1]赋值j+1
    else j=next[j]; //递归
  }
}

PART 5:SCAU 8591、SCAU 8592

如果是复制的OJ提供的代码,需要把 #include "iostream.h" 改为 #include "iostream"。

如果是直接复制文章中的代码,可能需要删除某些不必要的空格,根据编译器的提示删除即可。

SCAU 8591代码如下:

#include <stdio.h>
#define TMAXSIZE 20

void Get_next(char T[],int next[])
{
  next[1]=0;
  int i=1,j=0; //我们从i=2开始为next数组赋值,但是为了得到next[2]的值,我们需要判断T[1]是否等于T[next[1]],因此我们的i初始值为1,j初始值为next[1]=0。
  while(i<T[0]) //事实上我们可以知道,如果在i=2处匹配失败,只能回退到第1个字符处,也就是说next[2]值一定为1。但为了使初始条件尽可能简单,我们仍然从next[2]开始为next数组赋值。
  { //最后一个赋值的对象是next[T[0]],因此我们的i只需要到T[0]-1就可以了
    if(j==0||T[i]==T[j]) next[++i]=++j; //T[i]==T[j]或者j==0,将next[i+1]赋值j+1
    else j=next[j]; //递归
  }
}

int main()
{
  int n;
  int next[TMAXSIZE+1];
  char ch,T[TMAXSIZE+1];
  scanf("%d", &n);
  ch=getchar(); //接收输入n后的回车
  for(int j=0;j<n;j++)
  {
    ch=getchar(); //接收模式串T的第一个字符
    for(int i=1;i<=TMAXSIZE &&(ch != '\n');i++)
    {
      T[i]=ch;
      ch=getchar();
      T[0]=i; //存储模式串长度
    }
    Get_next(T,next);
    printf("NEXT J is:");
    for(int i=1;i<=T[0];i++) printf("%d",next[i]);
    printf("\n");
  }
  return 0;
}

SCAU 8592代码如下:

#include <stdio.h>
#define SMAXSIZE 100
#define TMAXSIZE 20

void Get_next(char T[],int next[])
{
  next[1]=0;
  int i=1,j=0; //我们从i=2开始为next数组赋值,但是为了得到next[2]的值,我们需要判断T[1]是否等于T[next[1]],因此我们的i初始值为1,j初始值为next[1]=0。
  while(i<T[0]) //事实上我们可以知道,如果在i=2处匹配失败,只能回退到第1个字符处,也就是说next[2]值一定为1。但为了使初始条件尽可能简单,我们仍然从next[2]开始为next数组赋值。
  { //最后一个赋值的对象是next[T[0]],因此我们的i只需要到T[0]-1就可以了
    if(j==0||T[i]==T[j]) next[++i]=++j; //T[i]==T[j]或者j==0,将next[i+1]赋值j+1
    else j=next[j]; //递归
  }
}

int KMP(char S[],char T[],int pos)
{
  int next[TMAXSIZE+1];
  Get_next(T,next);
  int i=pos,j=1;
  while(i<=S[0] && j<=T[0])
  {
    if(j==0||S[i]==T[j]) i++,j++; //当前字符匹配成功,继续匹配下一个字符
    else j=next[j]; //在某一个字符处匹配失败,保持i不变,j回退到T的某个位置,这个位置由next[j]提供
  }
  if(j>T[0]) return i-T[0]; //有两种情况会使得程序跳出while循环:第一种情况是j>T[0],这时T串匹配完成,说明找到了符合要求的子串;第二种情况是i>S[0],这时主串匹配结束,说明匹配失败了
  else return 0; //匹配成功时i向后移动了T[0]个单位
}

int main()
{
  int i,j,n,pos;
  char ch,S[SMAXSIZE+1],T[TMAXSIZE+1];
  scanf("%d",&n); //指定n对需进行模式匹配的字符串
  ch=getchar(); //接收输入n后的回车
  for(j=0;j<n;j++)
  {
    ch=getchar(); //接收主串S的第一个字符
    for(i=1;i<=SMAXSIZE &&(ch != '\n');i++)
    {
      S[i]=ch;
      ch=getchar();
      S[0]=i; //S[0]用于存储主串的长度
    }
    ch=getchar(); //接收模式串T的第一个字符
    for(i=1;i<=TMAXSIZE &&(ch != '\n');i++)
    {
      T[i]=ch;
      ch=getchar();
      T[0]=i;
    }
    pos=KMP(S,T,1);
    printf("%d\n", pos);
 }
 return 0;
}

欢迎讨论交流!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值