字符串查找——朴素查找和kmp算法



       kmp算法是由三位大神容忍不了普通字符串查找方法的时间效率达到了O(n*m),因而发明的算法。它让字符串的查找达到了O(n+m)。我们可以先看看普通方法解决这个问题,为了让大家更加理解题目,我把题目的意思再表达得更清楚。

       题目:我们设字符串的主串名为str,字符串的子串名为sub,我们现在想要在主串str中查找是否存在子串sub。

 

一、朴素查找算法

思想:我们利用两个下标i和j,i为主串的下标,j为子串的下标,如果相同两个下标对应的元素相同,则继续往下走,如果走到某个位置两个下标对应的元素不同了,那就需要回退,这里需要注意,j回退到子串的0号下标位,i也需要回退,因为刚刚我们搜索失败只是我们以i-j开头的字符串和子串不匹配,我们还可以i-j+1下标的字符开头查找,也许就匹配了呢!回退是防止漏了可能匹配的答案。好吧,文字可能太枯燥难懂,那我们就看图吧。

第一步:两个字符串初始化定位,i和j都在0号下标。

《字符串查找——朴素查找和kmp算法》

第二步:i和j对应的下标相同,则i下标和j下标都可以往后走。

《字符串查找——朴素查找和kmp算法》
第三步:i和j对应的下标不同了就说明匹配失败了,那我们就需要回退,在原来开始字符下标位置向后走一位,这个位置我们是怎么求出来的呢,我们可以看看上面的图,当i和j匹配事变的时候,i其实就走了j步,j每次都从零开始,i和j又是同时走的,所以它两走的步数一致,用i-j我们就回到了这个字符串匹配的开始位置,再+1就是原来位置的下一个字符了,也就是我们检测匹配的新的位置。

《字符串查找——朴素查找和kmp算法》

第四步:如果我们找到了返回什么呢?上面第三步已经解释清楚了,我们就是返回i-j就可以了。

《字符串查找——朴素查找和kmp算法》
代码实现:

//在str的pos位置开始查找子串sub
//缺点:i一定要回退
//优点:简单
int BF(const char *str,const char *sub,int pos)//O(n*m)
{
 if(pos < 0)
 {
  return -1;
 }
 int lenstr = strlen(str);
 int lensub = strlen(sub);
 int i = pos;
 int j = 0;
 
 while(i
 {
  if(str[i] == sub[j])
  {
   i++;
   j++;
  }
  else
  {
   i = i-j+1;//失配,退到i原来的位置的下一个
   j = 0;
  }
 }

 if(j >= lensub)//找到了
 {
  return i-j;
 }
 else
 {
  return -1;
 }
}

二、kmp算法

       大家弄清楚了朴素查找之后,应该也明白了i回退是为了防止有匹配的情况被错过。但是也就是因为回退所以我们的时间效率非常低,也做了很多无用功,比如:

《字符串查找——朴素查找和kmp算法》

      这个例子中,我们是在子串为d的时候无效的,如果现在i回退到i-j+1的位置,其实也没什么用,因为b和a第一个字母就不相同,并且接下来的c、b也都不等于a,所以这个回退显得没有意义。

      有同学可能就会怀疑,不是有些情况确实是需要回退的吗,没错,那我们就来考虑一下需要回退的情况,但是

在最开始我就说过在这个算法中的特点就是i不走,j走,让时间复杂度达到O(n+m),那这样我们要怎么实现呢?大家可以先看图,有图有真相。

    《字符串查找——朴素查找和kmp算法》

《字符串查找——朴素查找和kmp算法》
       在d和c的时候失配(字符串匹配失败)了,那我们自己人工看的话肯定不会把i退到第二个字符开始比较,因为b和a不同,第一个就不同,那肯定就是失败的,我们可以退到第三个字符a进行比较,因为至少第一个字符是相同,可能就匹配了呢。那目前为止,大家应该明白了i不需要回退到第二个字符b上,那我们再想想,如何让i不动,而j动呢?大家看图

《字符串查找——朴素查找和kmp算法》

        最开始的时候是在d不匹配,如果i退到第三个字符a,那么i和j又相同,两个下标一起走,是不是,就会又一起走到i为d,j为a的位置,在这里发生失配。我们会发现再i第一次失配的时候,它在的位置,本来就是d了,它回去又走了一遍还是到了d,那这不是多此一举吗,是不是只要移动j就可以了,j直接移到子串中第三个字符a的位置就可以了。

        理解完这个之后我们可以看一个公式推导,见证奇迹的时候到了!!!!!

《字符串查找——朴素查找和kmp算法》

             我先口头解释一下,方便大家理解待会的公式推导。失配是发生在现在i和j的位置,那么是不是说明失配前主串中i-j到i-1的字符串和子串中0到j-1的字符串是匹配的,不然也到不了现在的位置。那是不是说明失配前主串的后k个字符等于子串失配前的后k个字符,也就是主串中红线划的区域是等于蓝线中划的区域的。我在解释i可以不动,j可以移到k的位置的时候解释了原因,因为子串中前k个字符和我们主串中现在失配前的后k个字符是相同的,所以就算回退,i和j一起走,i还是会走到第一次失配的位置d,j会走到现在k的位置,发生第二次失配,那就说明,主串中失配前的后k-1个字符和子串的前k-1个字符是一样,也就是主串中的蓝线的字符串是等于子串中蓝线的字符串。

         

           为了方便看懂公式,我把str主串换名为S,sub子串换名为 ;

                                                         P0 … Pk-1=Pj-k … Pj-1;(1)

                                                         Si-k … Si-1=P0…Pk-1;     (2)蓝色划线部分相等

                                                         Si-k … Si-1=Pj-k…Pj-1;    (3)红色划线部分相等
   
          由(2)(3)可以得到:

                                                        P0 …Pk-1=Pj-k … Pj-1      (4)P中的红色和蓝色划线部分相等

         请大家务必清楚这个公式的意义,并记住它,因为理解它才知道我们下面为什么要这么做。 我们先来分析一下这个公式的含义。

         我们先光从变量上观察公式(4),可以发现它都没有出现主串S的变量,它的优点就是让我们脱离j回退的时候需要依赖主串,我们就可以直接观察子串就知道要怎样回退,正是因为这样,这个题目仿佛和主串无关了,所以我们不用回退i,只回退j也可以查找子串出现的位置,并且也不用担心漏掉情况。

         我们再通过分析这个公式的含义,看看它给我们了一些什么样信息。k是我们的j需要回退的位置,那这个公式(4)左边的含义是子串必须以零号下标开头,到某个j需要回退k的前一个位置k-1,我们暂且把以零号下标开头的真子串称为前缀;那相应的我们发现公式(4)的右边虽然k按目前的分析来说是个不确定的量,但是,j-1是确定的,它总是失配前的最后一个匹配的字符,也就是它的右边边界是确定的,我们暂且把以失配前的最后一个字符结尾的真子串称为后缀。直观的来说,就是我们回退的位置k和前缀和后缀有关,前缀和后缀必须是相等的。

         但是这个公式中其实还体现了一个隐藏需求,是我们从公式(3)中继承下来的,那就是,我们i本来需要回退,但是它的回退又可能会有很多没有意义的部分,这个问题我也在朴素查找中画图解释过了,如果忘了,可以再看看我上面画的图。本来从上次失配字符串从i-j下标开始它匹配失败了,我们就盲目的前进一步,也不考虑这次比较是否有意义,这个字符和子串的第一个字符就不匹配,这也是效率低的主要原因。那如何才是有意义的呢前进呢?至少我们要往前进到一个下标它的第一个字符和子串的第一个字符是匹配的,那才有意义。我们暂且称它为有意义字符位。其实这其中还有一个隐含需求就是,我们要前进的有意义字符必须是从原来i-j位置往后查找的第一有意义字符,因为我们不能落下任何可能情况,如果找的是第二第三个等等,那就落下了前面的第一种可能的情况。反着想一下,那我们如果从现在i失配的这个位置往后退,那就是要退到最后一个有意义的字符,不然会落下情况没考虑,大家好好理解一下。

         理解完上一段我们就可以理解了那公式(3)Si-k … Si-1中这段长度中,不仅i-k的位置要是有意义的字符,而且还必须是最后一个有意义的字符;所以子串中Pj-k…Pj-1,也要满足这样的条件,最后根据公式(4)我们可以知道,我们要找的就是最大的公共前缀和后缀,通俗一点就是要找到最大长度的公共真子串,再通俗一点就是找到以零号下标开头的真子串和以最后一下标结尾真子串,要让它们达到最大长度的相等。这就是我们公式(4)的含义。

         好,解释了这么多,我们就是总结了一个东西:回退的位置k应该是子串中找到最大公共真子串的前缀的最后一个字符的下一个位置。比如,在上一幅图中,这个k就是最大公共前缀“ab”(最大后缀也是ab)的下一个位置a字符对应的位置,也就是2号下标的位置。

        知道这些,子串字符每在一个位置发生失配的时候我们就知道它应该回退到哪个下标了。我们创建一个数组叫next,next[j]里存放的是在j下标发生失配的时候,j应该回到的位置的下标值。

        我们举个例子练习一下:

     
       让我们来找一下next数组存放的规律。首先我们通过公式

                             P0 … Pk-1=Pj-k … Pj-1;     (1)   ——》p[j]=k;

       注:相当于i回退到从i-j前进的第一个有意义的字符位,j回到零,一起往前走,i回到了原来失配的位置,然后j走到了k的位置,也就是这次的新失配的位置。

       我们现在要求p[j+1];我们分为两种情况.

       第一种情况:p[k]=p[j]

       我们可以以上图的9号下标位置为例,它的p[8]=p[6], 这个就相当于在原来的再原来的前缀和后缀后都再加了一个相同的字符,就相当于在原来的最长公共长度加了1;

       大家可以看看公式:

       p[j]=k                               《——    P0 … Pk-1=Pj-k … Pj-1;

       p[j+1]=k+1                      《——    P0 … Pk-1+Pk=Pj-k … Pj-1+Pj;

       所以p[9]=p[8]+1=7;

       那我们再来讨论一下第二种情况,p[k]!=p[j];例如,我们要求下标为a的位置它对应的next[a];p[7]!=p[9];怎么办呢?我们可以把失配的后缀往下挪动看得更清楚一些

《字符串查找——朴素查找和kmp算法》

              我们观察图片可以发现,其实p[9]!=P[7]是不是其实可以转化为在p[7]的位置的时候字符串发生了失配,那这样的话,我们的要回退的位置k就可以转到next[7]=5去,然后比较相不相等,不相等再以此类推往下走。

        推导过程:

                       p[9]=a  j=9  k=next[9]=7  p[7]=b!=p[9];    (next[j]=k)
                       p[9]=a  j=9  k=next[7]=5  p[5]=b!=p[9];
                       p[9]=a  j=9  k=next[5]=3  p[3]=b!=p[9];
                       p[9]=a  j=9  k=next[3]=1  p[1]=b!=p[9];
                       p[9]=a  j=9  k=next[1]=0  p[0]=a=p[9];

       终于相等了,其实我手已经要快残了!!!!深刻的感受到了出书的人是有多不容易啊!!!!!要敲那么多字!!!回归正题:

       0号位置相等了,我们k的位置呢相当于i和j发生失配的时候i回退到第一个有意义的字符,j回退到0,然后i和j一起走,然后再次发生失配的第一个字符,现在我们在0号下标对应的元素相等了,所以next[10]=0+1=1;当然了,如果是已经退到了0号下标还是不相等,那么next[0]=-1这个位置显然不合法,那就可以直接停止了。也就是只能退到0号位置,无处可退了。好了,那我们现在最难求的next数组也求完了,我们差不多分析完了!!!!来看代码实现:

  //KMP 算法特点:i不回退,时间复杂度O(n+m)
int KMP(const char *str,const char *sub,int pos)//O(n+m)
{
 if(pos < 0)
 {
  return -1;
 }
 int lenstr = strlen(str);
 int lensub = strlen(sub);
 int i = pos;
 int j = 0;
 int *next = (int *)malloc(lensub*sizeof(int));
 GetNext(sub,next);

 while(i
 {
  if(j==-1 || (str[i]==sub[j]))
  {
   i++;
   j++;
  }
  else//i不回退,j退到next[j](即k位置)
  {
   j = next[j];
  }
 }

 free(next);

 if(j >= lensub)//找到了
 {
  return i-j;
 }
 else
 {
  return -1;
 }
}  

          扩展:这样是不是比普通的算法时间复杂度要降低了很多,其实它还可以优化,到底是哪里还可以优化呢?

请听下回分解。

    原文作者:查找算法
    原文地址: https://blog.csdn.net/Betty2017/article/details/78560603
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞