串之BF、KMP算法完美图解

讲这两算法之前,我们首先了解几个概念:

串:又称字符串,由零个或多个字符组成的有限序列,如S="abcdef"。

子串:串中任意个连续的字符组成的子序列,称为该串的子串,原串称为子串的主串。如T="cde",T是S的子串。子串在主串中的位置,用子串的第一个字符在主串中出现的位置表示,T在S中的位置为3。

模式匹配:模式串的定位运算称为串的模式匹配或串匹配。

假设有两个串S,T,设S主串,也称正文串,T为子串(S包含T),也称模式串(与S进行匹配的串)。在匹配前,T称为模式串更为合适,是因为T有可能不是S子序列

在主串S中查找与模式T相匹配的子串,如果查找成功,返回匹配的子串第一个字符在主串中的位置最笨的办法就是穷举所有S的所有子串,判断是否与T匹配。

时刻注意不管是BF算法还是KMP算法,如果第一次比较就匹配了,程序自然就结束了。故关键是在出现不匹配时,如何确定下一轮进行比较即主串 i 的值Si与子串 j 的值Tj

例如:S="abaabaabeca",T=" abaabe",求子串T在主串S中的位置, 其中用 i,j 分别表示ST正在进行匹配字符的位置。 

1. S 串第1个字符开始: i=1, j=1,比较两个字符是否相等,如果相等,则 i++, j++;如果不等则执行第2步;

2. S 串第2个字符开始:即 i 退回到 i- j+2 (i-(j-1)+1) 的位置,其中 j-1 含义是不等之前比较的次数, 简称增量i-增量意味着 i 回到初值+1便是回溯到初值的下一位

i=2, j=1,比较两个字符是否相等,如果相等,则 i++, j++;如果不等则执行第3步;

3. S 串第3个字符开始:即 i 退回到 i- j+2的位置,即 i=3, j=1,比较两个字符是否相等,如果相等,则 i++, j++;如果不等则执行第4步;

 4. S 串第4个字符开始:即 i 退回到 i- j+2的位置,即 i=4, j=1,比较两个字符是否相等,如果相等,则 i++, j++;由于此时 T 串比较完了,执行第5步;

 5. 需要返回子串T在主串S第一个字符出现的位置j( ) 移动了T.length次,即 i - T.length=10-6=4。因已匹配,不需要+1回溯到一开始位置的下个位置了。

其代码如下:

int Index_BF(SString S, SString T) //返回模式串T在主串S中第一次出现的位置。因返回的是位置,不是状态,虽都是int,但不用Status,另说清谁是模式串,谁是主串。

  { 
    int i=1,j=1,sum=0;   // 指示主串S中进行匹配字符的位置指示主串T中进行匹配字符的位置;sum用于累计比较次数;
    while (i<=S.length&&j<=T.length)//看上面例子分析得出,匹配只有成功和不成功两种情形,换成计算机语言也就是if   else;

      {

          sum++;
        if (S.ch[i]==T.ch[j])  //看上面例子,不管那一轮只要成功,那么有i++ ; j++
          {
            i++;
            j++;          
          }
        else  //看上面例子,不管那一轮只要失败,那么有i回溯到初值的下一位; j 始终是第1个和Si,即j=1;
          {
            i=i-j+2;   //i=i-(j-1)+1,看上面例子中的解释;
            j=1;         
          }
      }//循环完也只有两种情形,要么匹配,要么失败,换成计算机语言仍就是if   else;

     cout<<"BF一共比较了"<<sum<<"次"<<endl;
    if(j>T.length)
      return (i-T.length); //返回位置. 比较成功的次数是T.length, i从初值也后移了T.length,i-T.length回到初值  
    else
        return 0;
  }

上述算法称为 BF(Brute Force) 算法,Brute Force的意思是蛮力,暴力穷举。其时间复杂度最坏达到O(n*m),n,m分别为S、T串的长度。

实际上,完全没必要从S的每一个字符开始,暴力穷举每一种情况,Knuth、Morris和Pratt对该算法进行了改进,称为KMP算法

我们再回头看刚才的例子:

S串第1个字符开始:i=1,j=1,比较两个字符是否相等,如果相等,则i++,j++;按照BF算法,如果不等则i退回到i-j+2的位置,即i=2,j=1。

其实 i 不用回退 j 回退到第3个位置接着比较即可(KMP算法

 是不是像T串向右滑动了一段距离?为什么可以这样?为什么让 回退到第3个位置?而不是第2个,第4个?

因为T串中开头的两个字符  指向的字符前面的两个字符一模一样噢那 j 就可以回退到第3个位置继续比较了因为前面两个字符已经相等,不用比较了,如下图

问题来了, 那我们怎么知道T串中开头的两个字符和 指向的字符前面的两个字符一模一样?难道还要比较?

分析发现: 指向的字符前面的两个字符 和 T串中 指向字符前面的两个字符一模一样因为它们一直相等,才会i++,j++走到后面的位置,不仅主串字串这两个字符一样,ij之前所有字符也一样(S1S2……Si-1=T1T2……Tj-1)。

     也就是说,我们不必判断T串中开头的两个字母S串中 i 指向的字符前面的两个字符是否一样只需要在T串本身比较就可以了。即T′的前缀T1T2和T′的后缀Tj-2Tj-1比较即可(T':T1T2……Tj-1):    

     判断T′="abaab"的前缀后缀是否相等,找相等前缀后缀的最大长度 l 

长度为1的:前缀"a",后缀:"b",不等×

长度为2的:前缀"ab",后缀:"ab",相等√

长度为3的:前缀"aba",后缀:" aab",不等×

长度为4的:前缀"abaa",后缀:"baab",不等×

注意:前缀和后缀不可以取字符串本身,如果取了,不就是原地踏步吗。串的长度为5,前缀和后缀长度最多达到4(l <5)。

相等前缀后缀的最大长度为=2 就可以回退到第+1=3个位置继续比较了( 不变)。

现在我们可以写出通用公式,next[j]表示 可以回退的位置,T′="T1T2…Tj-1",则:

 那么我们很容易求出T="abaabe"的next[j]数组:

     解释1:

j=1:根据公式next[1]=0;

j=2:T′="a",没有前缀和后缀,next[2]=1;

j=3:T′="ab",前缀为"a",后缀为"b",不等,next[3]=1;

j=4:T′="aba",前缀为"a",后缀为"a",相等且l=1;前缀为"ab",后缀为"ba",不等,next[4]=l+1=2;

j=5:T′="abaa",前缀为"a",后缀为"a",相等且l=1;前缀为"ab",后缀为"aa",不等;前缀为"aba",后缀为"baa",不等,因此next[5]=l+1=2;

j=6:T′="abaab",前缀为"a",后缀为"b",不等;前缀为"ab",后缀为"ab",相等且l=2;前缀为"aba",后缀为"aab",不等;前缀为"abaa",后缀为"baab",不等,取最大长度2,因此next[6]=l+1=3。

这样找所有的前缀和后缀比较,是不是也是暴力穷举?那怎么办呢?Look……

用动态规划递推一下(数学归纳法,已知n=1成立, 假设n成立,来证明n+1是否成立):

首先大胆假设,已知next[j]=k(求next[j+1]=?)其当前含义是:下一次匹配时, j=next[j]=k, 后判Si==Tk意味着子串中j之前有k-1个字符前后缀匹配,才会有next[j]=k-1+1=k

那么next[j+1]=? 回想 P97 图4.8,已知 next[j]为表述简洁,不妨令k=next[j], 如何求next[j+1]?  思考 j=5及j=6:                      

考察以下两种情况:

      • tj=tk那么 next[ j+1]=next[j]+1=k+1,即,在j+1前,相等前缀和后缀的长度比next[j]=k多1,eg:j=5

      • tjtk当两者不相等时,我们又开始了这两个串的模式匹配,找 t next[k] 位置的 tk′比较 (j不变,k变,往前回溯到k'=next[k])。注:程序中的处理,只需要把 next[k] 赋值给 k, k=next[k]不用新变量k' 

          如果tj=tk' next[j+1]=next[k]+1=k'+1;

    如果tjtk',则继续向前找next[k](k''=next[k']),如果还不相等,继续向前找next[k′'](k'''=next[k'']),直到找到next[1]=0,停止,此时next[j+1]=next[1]+1=0+1=1,即从第一个字符开始,j=1, eg: j=6

求解next步骤当前j,求j+1
首先位的next值直接赋0,第位的next值直接赋1
其次后面求解每一位的next值时(不妨令求j+1),都要前一位(T.ch[ ])其next值对应位(T.ch[  next[ j ]  ])进行比较。若相等,则该位的next值就是前一位的next值加上1(next[ j ]+1若不等,继续重复这个过程T.ch[ j ]==T.ch[   next[ next[ j ] ]   ],直到找到相等某一位,将其next值加1即可。如果找到第一位也都没有找到,那么该位的next值即为1            

      求解next[]的代码实现如下:

void Get_Next(SString T,int next[])//求模式串T的next[]函数值,其实是知next[1],求next[2],依次……,也即假设已知next[j], ++j后求得next[j]即未我所求;

   {

    next[1] = 0;  //初值

    int k, j;

         j = 1; // j 代表的是后缀末尾的下标, 假设next[j]已知,则 ++j 后,next[j]即是所求

    k = 0; // k代表的是前缀结束时的下标,也就是 前有k个字符的前缀T1T2...Tk等于后缀Tj-m+1...Tj  

       while (j < T.length)  
       { 
       if (k == 0 || T.ch[k] == T.ch[j]) //1. j>=2时, 怎么求next[j+1]?next[3]有前缀后缀,相等->,不等->看上面例子2. 当j=1时,怎么求next[j+1]前缀个数为0->不等<==>k==0后++j;++k;next[j] = k;
        {

           ++j;
           ++k;
          next[j] = k; //++j后才是我们想要的next[j],++k后才是我们想要给next[j]赋的值;一定要先++
        }
          else  //匹配失败的情况,就要进行回溯,下一轮tjtk'比较,j不动,k'=next[k];
         k= next[k]; 
     }
  }

 用上述方法再次求解求出T="abaabe"的next[]数组:

解释2:

1. 初始化时next[1]=0,j=1,k=0,进入循环,判断满足k==0,则执行next[++j]=++k,即next[2]=1,此时j=2,k=1;

2. 进入循环,判断满足T.ch[j]==T.ch[k],T.ch[2]≠T.ch[1],则执行k=next[k],即k=next[1]=0,此时j=2,k=0;

3. 进入循环,判断满足k==0,则执行next[++j]=++k,即next[3]=1,此时j=3,k=1;

4. 进入循环,判断满足T.ch[j]==T.ch[k],T.ch[3]=T.ch[1],则执行next[++j]=++k,即next[4]=2,此时j=4,k=2;

5. 进入循环,判断满足T.ch[j]==T.ch[k],T.ch[4]≠T.ch[2],则执行k=next[k],即k=next[2]=1,此时j=4,k=1;

6. 进入循环,判断满足T.ch[j]==T.ch[k],T.ch[4]=T.ch[1],则执行next[++j]=++k,即next[5]=2,此时j=5,k=2;

7. 进入循环,判断满足T.ch[j]==T.ch[k],T.ch[5]=T.ch[2],则执行next[++j]=++k,即next[6]=3,此时j=6,k=3;

8. j=T.length,循环结束。

  • 结果是不是和穷举前缀后缀一模一样,解释1(穷举)同解释2(归纳)的结果?

有了next[]数组,就很容易进行模式匹配KMP,当S.ch[i]≠T.ch[j]时,j回溯到next[j]的位置 继续和  S.ch[i]比较即可。

这样求解非常方便,但也发现有一个问题:当S.ch[i]≠T.ch[j]时,j退回到next[j],然后S.ch[i]与T.ch[k]比较。这样的确没错,但是如果T.ch[j]=T.ch[k]这次比较就没必要了因为我们刚知道S.ch[i]T.ch[j]啊,

那么肯定S.ch[i]T.ch[k]完全没必要再比了 (S.ch[i]T.ch[j]=T.ch[k]<==>S.ch[i]≠T.ch[k]).
 再向前找下一个next[],即找可k'=next[k]的位置,继续比较就可以了。本来应该和第k个位置比较呢,相当于跳到了k的上一个位置k',减少了一次无效比较。

求解next-val步骤( 当前j,求j ):

  1. next-val数组第一个值直接赋0
  2. next-val第二数:模式串第二个字符为B,对应的下标数组第二个数是1,那就是将模式串的第1个字符和B相比较,A!=B,所以直接将下标数组第二个数1作为next-val数组第二个数的值;
  3. 第三个数:模式串第三个字符为A,对应下标数组第三个数为1,取其作为下标,找到模式串第1个字符为A,A=A,那取next-val的第一个数做为next-val第三个数的值,也就是0.
位置
1
2
3
4
5
6
7
模式串
A
B
A
C
A
B
C
next数组
0
1
1
2
1
2
3
nextval数组
0
1
0
2
0
1
3

修改next[]程序:

求解nextval[]的改进代码实现如下:

 void Get_Nextval(SString T,int nextval[])//求模式串T的nextval[]函数值,在next函数的基础上只需改动tj=tk相等时的情形,让nextval[j]=nextval[k]而不是k 

   {  

    nextval[1] = 0; //初值

    int k, j;

         j = 1; // 代表的是后缀末尾的下标, 若next[j]已知,则 ++j 后,next[j]即是所求;

    k = 0;  // k代表的是前缀结束时的下标,也就是 前有k个字符的前缀T1T2...Tk等于后缀Tj-m+1...Tj  

       while (j < T.length)  
       { 
       if (k == 0 || T.ch[k] == T.ch[j]) //1. 当j=1时,怎么求next[2],也即next[++j]?前缀个数为0,特殊值法知next[2]=1<==>k==0;2. j>=2时,已知next[j], 怎么求next[j+1]eg. aaaab
        {        

++j;
++k;
if(T.ch[k]==T.ch[j])
  nextval[j]=nextval[k]; //相等了意味着Si无需和Tk在比较了,回溯到k的上一个位置k'=next[k],让Si和Sk'比较
else
  nextval[j]=k;  //k其实就是next[j]. 当T.ch[k]!=T.ch[j],相当于nextval[++j]=++k,也即nextval[j]=next[j]=k,此情形同next[]函数;

       }
          else//匹配失败的情况,就要进行回溯,下一轮tj与tk'比较,j不动,k'=next[k];
         k= nextval[k]; 
     }
  }

 /***KMP及改进算法***/

int Index_KMP(SString S,SString T,int next[])//利用非空模式串T的next函数求T在主串S中的位置的KMP算法,形式上近同BF算法

  {

     int i=1,j=1,sum=0;//i-->S,j-->T,sum-->循环次数
    while(i<=S.length&&j<=T.length)

    {
      sum++;
      if(j==0||S.ch[i]==T.ch[j]) // 继续比较后面的字符

        {
          i++;
          j++;
        }
      else
        j=next[j]; // 利用next函数确定下一次模式串中第j个字符与T.ch[i]比较,i不变
    }
    cout<<"KMP一共比较了"<<sum<<"次"<<endl;
    if(j>T.length) // 匹配成功
      return i-T.length;
    else
      return 0;
}

 总结: 

BF算法中:当主串和子串不匹配的时候,主串和子串你的指针都须要回溯,因此致使了该算法时间复杂度比较高为 O(n*m)空间复杂度为 O(1)注:虽然其时间复杂度为 O(n*m) 可是在通常应用下执行,其执行时间近似 O(n+m) 因此仍被使用。 

KMP算法:利用子串的结构类似性,设计next数组,在此之上达到了主串不回溯的效果,大大减小了比较次数,可是相对应的却牺牲了存储空间,KMP算法时间复杂度为 O(n+m) 空间复杂度为 O(n) 

————————————————
在原文的基础上,加上自己的理解给大家解释下,原文链接:https://blog.csdn.net/rainchxy/article/details/78130155

posted @   师大无雨  阅读(2224)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示