前言:
听到kmp算法大家是不是寒毛都立起来了,看过王道考研咸鱼老师视频的人就会知道kmp算法可是号称考研第二难算法。在视频中,咸鱼老师也并没有讲kmp算法的代码实现仅仅说明了手解kmp算法(用来应试数据结构哈哈哈哈哈)(当然也是因为kmp的代码实现确实有点难~)。这也导致了我花了一个下午去弄懂那仅仅5行的求next数组代码(kmp最核心的部分)。现在特意把自己的思路整理出来给大家,相信看完之后,你会觉得kmp,just soso!
Kmp的使用场景:
KMP算法的作用是在一个已知字符串中查找一个子串的位置,也叫做串的模式匹配。其中要查找的子串就称为模式串。比如主串s=“university”,子串t=“sit”。现在我们要找到子串t 在主串s 中的位置。大家肯定觉得这还不简单,不就在第七个嘛,一眼就看出来了。
当然,在字符串非常少时,“肉眼观察法”不失为一个好方法。但如果要你在一千行文本里找一个单词,我想一般人都会数得崩溃吧。所以为了解决在大篇幅下的字符串匹配(例如现在我们在百度中搜索一个关键字,百度搜索算法必须要在成千上万甚至几亿条文章中为我们去寻找匹配的文章去推送给我们,这就是必须要用到字符串匹配算法了),因此我们必须把这种枯燥的事以一定的算法交给计算机处理。
解决算法:
一、暴力求解法(朴素模式匹配):
简单来说就是:从主串s 和子串t 的第一个字符开始,将两字符串的字符一一比对,如果出现某个字符不匹配,主串回溯到第二个字符,子串回溯到第一个字符再进行一一比对。如果出现某个字符不匹配,主串回溯到第三个字符,子串回溯到第一个字符再进行一一比对…一直到子串字符全部匹配成功或者主串走到末尾。
这个算法的思想非常简单,他不就是枚举嘛。把我们主串中可能有的所有子串都搞出来然后一一和子串进行比较。但是这导致的后果是这个算法的效率非常的低,我们设想一下最糟糕的情况:假设子串长度为m,主串为n。如果我们每次子串和主串匹配都要到最后一个字符才失败那么每次比较都要进行m次,再假设直到最后一个主串中提取的子串才和我们要查找的匹配上,那么之前我们已经进行了n-m+1次的子串匹配,而每次都是m次字符比较,所以总共的时间复杂度就是O(nm-m^2+m),又由于相对于n,m的值非常的小(一般匹配时,我们的子串相对于主串是非常小的,如果差不多的话肉眼观察法就ok了,何必设计算法呢),所以这个时间复杂度可以写为O(nm)。
但是!我们的kmp算法时间复杂度可以到O(n+m)!!这就像什么呢?就像那年奥运会上的经典名菜——“生吃贝利!”
看到这里,大家是不是跃跃欲试了。接下来可要打起精神了,难的要来了!!
二、Kmp算法
前提知识点:
1、一个字符串最长相等前缀和后缀的长度
2、一个字符串的前缀、后缀
简单来说:前缀就是除了最后一位字符外的所有头部子串。后缀就是第一位字符外的所有尾部子串。最长相等前缀和后缀长度就是在所有头部和尾部子串中相等的子串中最长的那个子串的长度。空讲概念没意思,下面就让我来举个例子:
假如:字符串 abcdab
前缀的集合:{a,ab,abc,abcd,abcda}
后缀的集合:{b,ab,dab,cdab,bcdab}
那么最长相等前后缀不就是ab嘛。所以最长相等前缀后缀长度就是2嘛。
这两个概念非常重要,如果你没有看懂一定要仔细去看看,如果看懂了,就鼓励一下自己,马上就可以生吃kmp算法啦!
图解Kmp算法:
1、朴素匹配的问题:
首先让我们来看思考一下为什么朴素模式匹配非常的慢,来看张图片。
这是朴素匹配第一次比较主串和子串的情况,它把主串中的每一个字符当作未知字符和模式串进行比较 。那么在第一次比较后我们对主串的认知应该是这样子的(假设最后一位字符才出现匹配不成功)(因为前面5个字符我们都一一和模式串比较过了便其值了):
可是实际上我们进行下一次主串和子串的比较时,朴素算法眼中的主串是这样子的(因为下一次比较时,又从主串的第二个字符开始一一和模式串进行比较):
因此朴素匹配的问题就在于并没有很好地利用上一次比较得到的信息,从而造成许多的资源浪费。
2、kmp算法的思想:
针对上面朴素算法的缺点,kmp算法提出一个思想:当出现字符匹配失败时,根据匹配失败的字符位置与子串的特点,提出下一次匹配时,子串中该比较的字符位置(其中主串的比较位置永远不会后退)
下面给个具体的例子:假如主串和模式串如下(红色代表已经匹配的字符)(绿色和蓝色代表不匹配字符)
根据上面的kmp思想,我们可以知道通过这一次匹配abcab这一个主串值我们已经知道了,所以下次匹配时要利用这个信息。根据这个信息,我们肉眼分析可得我们仅仅需要保持主串中的指针不变(仍指向e)而模式串中的指针改变(指向模式串第三位c)。(如下图)(目前匹配的是上面主串的绿色e和下面模式串的红色c)(这里是划重点哈!一定要搞懂的!!)
这样子我们就省去了bca开头、cab开头的好几个主串中子串和模式串的匹配。所以接下来的问题在于如何知道这个模式串中指针改变的位数,我们必须给计算机一个标准的公式。
如果搞懂了这个思想,恭喜你,你已经一定程度知道如何手算模拟kmp算法!
下面引入一个思想1:每次匹配失败时,模式串指针如何改变仅仅和模式串本身有关,具体说和模式串匹配失败的字符位置有关。那么这个关系是怎么样的呢?下面我给出一个公式:
模式串右移位数=已匹配字符数-对应的部分匹配值
对应的部分匹配值:匹配失败字符前的字符串中的最长相等前后缀的长度
例如:对于上面的例子abcabe匹配abcabc,在e和c匹配失败时,已匹配字符为abcab其数为5,对应部分匹配值为abcab的最长相等前后缀ab的长度2。所以右移位数为5-2=3。即下一次拿模式串中的第三位字符和主串中的原指针位置比较。
再通过上面刚刚提到的思想1,我们可以知道无论什么时候,当模式匹配失败的位置在模式串的第6位数时,对于abcabc这个子串而言,下一次匹配时都是保持主串指针不变,而模式串右移3位。
由于上面这个特点,我们得到每个模式串都可以根据这个模式串求解出这个模式串每一位置出现匹配失败时,其下次匹配时模式串要移动的位置。举个例子(如图)
然后把这个数据放到next数组中,例如next[2]=1表示当第二个元素匹配失败时,模式串指针指向1(这里默认字符串存储在数组中从下标为1开始,不从0开始)。最终,我们就可以得到一个next数组。
next数组作用有两个:
一是:
next[i]的值表示下标为i的字符前的字符串最长相等前后缀的长度+1,(是否+1取决你如何设定next的下标,是第一个数放下标0还是1)
二是:
表示该处字符不匹配时应该回溯到的字符的下标
如果到这里,你都完全理解了,那么恭喜你对于Kmp算法真的真的只剩下一点点的东西要学了,你已经理解kmp核心的思想了。我们休息一下吧,请大家喝一杯“奶茶”~(咱可没有内涵瑞瑞的意思,只是替华尔街大牛们想要在华夏大地拓展咖啡业务的宏大理想感到可惜,嘻嘻😁)
通过上面,我相信大家已经可以手动写出每个模式串的next数组,并且可以利用next数组模拟出主串匹配模式串的全过程了
如果在反复阅读后仍然没有办法模拟全过程,也不要自责,Kmp算法真的很难呢!这里给这些同学一个建议——去b站看王道考研的kmp算法讲解,咸鱼老师通过动画展示讲的真的很清楚!(比我通过图和文字要清楚多啦)
这篇文章的重点到这里才刚刚开始。下面的内容咸鱼老师可没有讲哦!
3、Kmp算法的代码实现:
先讲个简单的热热身:假如我们已经知道了模式串的next数组要如何实现Kmp算法。根据上面的讲解过程,我们不难写出如下代码实现kmp算法。
int KMPIndex(SqString s,SqString t,int next[])//s是主串,t是模式串,next是模式串对应的右移数组 { int i=0,j=0;//i是用于主串的指针,j是用于模式串的指针 while (i<s.length && j<t.length) { if (j==-1 || s.data[i]==t.data[j])//第一次匹配或者主串模式串相同时,比较下一字符 { i++;j++; //i,j各增1 } else j=next[j]; //i不变,j后退,实现模式串右移 } if (j>=t.length) return(i-t.length); //返回匹配模式串的首字符下标 else return(-1); //返回不匹配标志 }
4、求next数组代码:
注意注意!这一部分是Kmp算法最难以理解的部分,如果第一次没有看懂不要紧,多看几次,多想几天总会理解的。
先来欣赏一下这个代码吧:
typedef struct { char data[MaxSize]; int length; //串长 } SqString; //SqString 是串的数据结构 //typedef重命名结构体变量,可以用SqString t定义一个结构体。 void GetNext(SqString t,int next[]) //由模式串t求出next数组。默认字符串第一位存储 //在t.data下标为0的这个位置 { int j,k; j=0;k=-1; next[0]=-1;//第一个字符前无字符串,给值-1 while (j<t.length-1) //因为next数组中j最大为t.length-1,而每一步next数组赋值都是在j++之后 //所以最后一次经过while循环时j为t.length-2,故next赋值的下标为t.length-1正是最后一位 { if (k==-1 || t.data[j]==t.data[k]) //第一次或比较的字符相等时 { next[++j]=++k; //对应字符匹配情况下,s与t指向同步后移 } else { k=next[k]; //我们现在知道next[k]的值代表的是下标为k的字符前面的字符串最长相等前后缀的长度 //也表示该处字符不匹配时应该回溯到的字符的下标 //这个值给k后又进行while循环判断,此时t.data[k]即指最长相等前缀后一个字符 //为什么要回退此处进行比较,我们往下接着看。其实原理和上面介绍的KMP原理差不多 } } }
我相信大家难以理解的地方是从if语句开始的😁,先不着急理解,咱先给发明Kmp算法的大佬们鼓个掌(因为根本看不懂呢😂)
5、深入理解求next数组算法:
开——始——划——重——点!
假如:求next[j+1],设值为m。已知next[j]=k,则有P1…Pk1-1 = Pj-k1+1…Pj-1
求解:
那么如果Pk1=Pj,则P1…Pk-1PK = Pj-k+1…Pj-1Pj,则next[j+1]=k+1。但如果Pk不等于Pj呢?
那么k=next[k],这是退而求其次去找更小的可能成功的相等的前后缀。
上面的式子,直接看不容易看懂。所以,我们再加一个图举个例子来帮助理解:
在此图中,假设已知next[16]=7 ,现在要求的是next[17]。因为next[16]=7 ,所以可以知道16前面有7个位置的字符是满足前后缀相同的,所以这个时候我们要去看p16和p8是否相等。如果p16=p8,那么这个字符串的前后缀相等的长度就有8个了(原本的7个加上p16=p8)。即next[17]=8(这里看不懂的可以回顾一下上面提到的next数组的作用一哈)。所以可以写出代码如下:
if (t.data[j]==t.data[k]) //比较的字符相等时 { next[++j]=++k; }
当然考虑到初始 j=0;k=-1(这里同样让next[1]表示第一个字符位的next值,从下标1开始),所以对于k=-1时,同样要进入这个if语句的内部。修改代码如下:
if (k==-1 || t.data[j]==t.data[k]) //k为-1或比较的字符相等时 { next[++j]=++k; }
接下来!重点来了,如果p16和p8不相等,怎么办呢?原本的最大前后缀不能用了。 让我们看下三张图。
根据这张图,假如我们现在找next[8],就会得到其等于4,表示下次比较从模式串的第4个开始和主串的第8个比较。且图中画起来的两个蓝框内的数一一对应相同。
根据上面这两种图,不难推出上面这张图的这两个蓝色框内的数相同。
说了这么多,让我们回到起点,我们遇到的问题是p16和p8不相等。这就导致原本的7个相等前后缀不能用,所以我们需要退而求其次去找更小的相等的前后缀,在这里我们就找到了图中这两个蓝色框,为了保证这个框是满足要求的最大的相等前后缀,所以我们写出这个代码:
k=next[k](在本例子中k=4)(next中记录的就是前面的最大的前后缀相等的长度)
这个代码就是代表我们下一轮需要比较的数的位置(本例子中就是第4个和原本的第16个),如果这两个数相同则代表我们找到next[17]的值了,否则继续让k=next[4]然后继续比较。如此循环直到找到为止。
结束:
现在再来看上面的5行代码应该就比较容易理解了!如果还有没有理解的地方,也不要气馁,多看几次,慢慢就会啦。如果有写错的地方,欢迎大家来指正。