最近在看数据结构与算法,弄了两天的KMP算法,感觉这个算法是一个锻炼用人脑模拟CPU机械执行的一个非常好的例子,我个人觉得理解它对我们写程序的思维是一个很好的锻炼。当任给你一个字符串(长度在可控范围里),你能心中没有任何不解或疑惑地去在大脑中按程序的规则去求出next[]值,去完成一个字符串的匹配,那么恭喜你,你对它已有了深刻的了解,你没有必要浪费时间看这篇入门级的文章。下面我们一起向着这个目标前进吧!
要彻底地弄清KMP算法我们将分以下几步进行:
(1)弄清BF算法,找出他的优缺点,提出一些改进方法
(2)剖析KMP算法的原理和思想
(3)剖析next[]值,深刻理解next[]值的含义
(4)用c++语言在VC2005上实现KMP算法(包括next[]值函数)
(5)修正next[]值,为以示区别将修正后的值命名为nextval[];
(6)实现在人脑中机械执行的目标,一眼看出next[]值
一、关于BF(brute-force)
BF算法,就是我们大多数人通常做字符串匹配时最先想到的算法(前提是没学KMP算法),它是这样的:设S串和T串,其中S串为主串,T串为模式串。
设T串匹配到 j 位置,则S串匹配到 i+j位置,其中 i 为 S 串中与T[0] 对应的的初始位置。
当前将要匹配的两个字符为S[i+j] 与 T[j],
若 S[i+j]==T[j],则j++; 若S [i+j] != T[j],则i++; j=0;
S: aaaaabchu
T: aaab
下一次比较,(i+j) 指针(姑且这么称呼)要回溯,这是由于 j 置零造成的
S:aaaaabchu
T: aaab
这样,主串每次挪动一步,模式串都要重新从头开始匹配,这样就会出现大量的重复匹配或没有必要的匹配。就此题而言,应直接将b后的a挪动到b出再进行匹配,如图所示:
S:aaaaabchu
T: aaab
之所以做出如此选择,是基于模式串的前3个字符已经与主串的字符匹配过了,它们是相同的,因为第4个字符b不匹配,所以可能应该存在b前面的某个字符与主串字符匹配。
本来最最保守的办法是是BF的做法,但是我们可以更大胆地设想一下,我们让S的指针不回溯,当它不能和模式串的某个字符匹配时,我们让它保持不变,而直接让模式串回到开头,再与主串比较,这时移动的距离岂不最大。如图:
S: aaaaabchu
T: aaab
很显然这样做会漏掉解,那么如何才能有一个两全其美的办法,再不漏掉解的情况下,向前移动更多(移动得更多不是目的,而是去减少那些在做最保守的移动的情况下明显是不可能匹配的多余的比较,说地更明确是减少没有必要的比较,所以一定要弄清楚我们是为了减少没有必要的比较,才去增加移动的距离。字符串移动的目的是为了在主串中找到和模式串匹配的子串,而移动距离的多少并不是目的,只是在这种情况下字符串移动地越多,越能快速地达到我们所追求的快速匹配的目的,所以在这种情况下它影响了我们所追求的目的)的距离。这就是即将要登场的让人兴奋的KMP算法。
二、KMP算法的原理和思想
KMP算法是基于我们对BF算法的不满而设计出来的(我个人是这样认为的),算法的改进重点放在如何减少不必要的比较上,从上面的分析我们可以得出这样一个初步的想法:能否利用已有的信息去减少没有意义的比较?那么我们该如何下手呢?不急,我们虽然有了想法,但是我们并不知道从何处下手,这是因为我们并没有透彻BF算法。
1.从上面的分析我们可以知道BF算法有两个主要特征:当失配时主串指针回溯到此趟比较的初始位置的下一个位置,也就是主串指针相对于初始位置后移一位;而模式串指针回溯到初始位置。BF算法之所以容易理解,是因为它在比较过程中唯一的一次判断便是比较两个字符是否相等,因此它的流程控制显得非常简单。所以我觉得太容易理解。但是它的这种容易理解是同过大量的重复机械执行而获得的。一个很明确的方向是,我们需要将前面比较的信息通过一定的方式记录下来,但是我们该如何做呢?
2.上面两个指针都需要回溯导致了组合相乘的算法复杂度,为了减少比较次数,我们不能再采取这种控制结构,因为在这种控制结构下,我们改进的余地很小,每次移动一步。我们可以采取固定一个指针(即该指针不回溯,并不是固定不动)的方式来去进一步尝试,将所有的回溯改为通过另一个指针的移动或回溯来实现。(之所以这样想,是应为很多控制变化复杂性都采取这样的方式,如多元函数求偏导,就是先固定一个变量)那么在这种情况下,当匹配失败是模式串指针该如何移动进行匹配呢?
你一定看出来了,对!主串指针保持不变,模式串指针回溯,拿主串指针所在的位置的字符与与其正下方的字符比较。下图对比了BF和改进型的差别:
匹配失败:
S: aaaaabchu
T: aaab
BF算法:
S:aaaaabchu
T: aaab
改进型算法:
S:aaaaabchu
T: aaab
可以看出,改进型算法省去了BF算法的2次比较(在上面的情况下)。我们的改进有什么依据吗?
当然有,在上面匹配失败的情况下,我们要做的事无非是将模式串后移,因为在当前情况下,已经不可能匹配了(当然更不可能前移了,因为我们是从前面一次次后移才到当前位置)。关键是如何后移,后移多少,才最接近成功匹配(这种预期是建立在已比较的基础上的,对于还没有比较的字符,计算机是无能为力的)。上例匹配失败时,已比较的字符aaab或aaaa,其中aaa是成功匹配的字符。那么我们该如何利用这些信息呢?
3.如果你看出来了(当然是看这之前没有看过任何关于KMP算法的有关介绍,或者说是没有看懂KMP是怎么回事)该如何利用这些信息,那么恭喜你!如果现在KMP算法还没出现,那么KMP算法可能会以你的名字命名。如果没看出来,没关系,不要灰心,毕竟创造一个算法还是很有难度的。当匹配失败时,我们将模式串向右滑动,这时,我们会发现,在前面已完成的匹配中,该部分主串字符串和模式串字符串是完全相同的。这不是废话吗?的确,但是这一点很容易被忽略,易导致后面理解的困难。在我们后移时,整个模式串是作为一个整体的。那么怎样才能做到,不放过任何一个可能匹配的位置,当然同时也要去除依照现有信息可以断定不可能匹配的位置呢?
4.最保守的方法是后移一步,这是BF的做法。从3中我们已经知道当失配时,可能在已比较的模式串中会有与主串当前位置字符相匹配的字符,而且每一个字符都有可能(这是在没有考虑其他字符是否匹配的情况下)。虽然每一个字符都有可能,但是其它字符也有一些确定的限制,那么,我 们就必须先考虑确定的限制。在满足确定性限制的情况下的可能性,才是真正有意义的。(这句话刚好对应了前面所说的,去除没有意义的,没有必要的比较)我们的限制是:模式串当前将要比较字符的前面的所有字符都必须匹配主串对应的字符,同时为了不遗漏,我们必须要确保这个相匹配的字符串是最长的。即是将模式串向后移动的过程中第一个完全匹配的位置。当我们拿一个字符串来和模式串进行匹配时,在失配的情况下,我们只需要拿满足以上限制条件所对应位置的字符来进行比较便可以了。因为上述条件下对应的字符便是在已有信息的基础上去除没有意义的比较后可能与主串匹配的字符。这个过程便是KMP算法的原理和思想。
总结上述分析,我们可以确定KMP算法的控制流程:
1.模式串与主串逐一比较
2.如果失配,模式串指针回溯到满足限制条件的地方,重新比较,而不是如同BF算法一样,主串前移一位从头比较
3.重复上述过程,直至匹配完成或失败
顺理成章,我们必须为模式串的每一个字符预先设置一个在该位匹配失败后指针将要回溯到的位置,这就是所谓的 next[] 值。那么如何设计求 next[] 值的算法呢?
三、剖析next[ ]值,深刻理解next[ ]值的含义
1.如前所述,“我们的限制是:模式串当前将要比较字符的前面的所有字符都必须匹配主串对应的字符,同时为了不遗漏,我们必须要确保这个相匹配的字符串是最长的。即是将模式串向后移动的过程中第一个完全匹配的位置。”在在上面我曾重复过一句废话:“在前面已完成的匹配中,该部分主串字符串和模式串字符串是完全相同的。”这里我们要利用这句话,因为它,我们将对next[ ]的求解方向转移到了模式串上。这样它就变成了模式串固有的性质。因为不匹配发生时,我们至少要将模式串后移一位,正是由于这一特点,我们将抽象出来一个概念next[ ] 值。它表征了一个字符串本身自我覆盖的性质。这里我我们将用图演示:
ababababbba
ababababbba
拖动1格:
ababababbba
ababababbba
拖动2格:
ababababbba
ababababbba
拖动3格:
ababababbba
ababababbba
.......................
我们可以看到红色部分正是模式串的前一部分和后一部分,我们姑且称之为前缀和后缀
那么next[ ]代表什么意义呢?我们抽象出一个next[ ]是由于我们发现“当失配时,可能在已比较的模式串中会有与主串当前位置字符相匹配的字符,而且每一个字符都有可能(这是在没有考虑其他字符是否匹配的情况下)。虽然每一个字符都有可能,但是其它字符也有一些确定的限制,那么,我 们就必须先考虑确定的限制。在满足确定性限制的情况下的可能性,才是真正有意义的。(这句话刚好对应了前面所说的,去除没有意义的,没有必要的比较)我们的限制是:模式串当前将要比较字符的前面的所有字符都必须匹配主串对应的字符,同时为了不遗漏,我们必须要确保这个相匹配的字符串是最长的。即是将模式串向后移动的过程中第一个完全匹配的位置。当我们拿一个字符串来和模式串进行匹配时,在失配的情况下,我们只需要拿满足以上限制条件所对应位置的字符来进行比较便可以了。因为上述条件下对应的字符便是在已有信息的基础上去除没有意义的比较后可能与主串匹配的字符。”,我们需要求出在上述限制下,匹配失败时应该去找的对应的位置。我们又发现他只与模式串本身的性质有关,因此我们只需要对模式串本身特点进行研究。那么我们该如何研究呢?
2.上面我们已经初步了解了前缀和后缀的意思,我们知道它一定是模式串的真子串,这是由不匹配发生时,模式串至少会后移一位引起的。当不匹配发生时,一定会占用模式串的一位字符,这位字符一定是当前已匹配的字符的下一位,我们要用的便是这个不匹配字符对应的 next[ ] 值,所以它表征的应该是它前面的字符串前缀和后缀相等的最大长度(先找出它的物理意义,由于数组下标从0开始再将其转换成匹配失败时要查找的位置),即下一个将要比较的字符的位置,由此我们便很自然地确定了 next[ ] 的求法。对于第一个字符,它前面没有字符,更不可能存在前后缀了,可以对他做特殊处理,如用一个负数做标记;对于第二个字符它前面只有一个字符,没有非空的真子串,所以一定为0,这不是特殊情况,只是在这里提一下。求next[ ] 值采用递推法,即在初始条件next[0]=-8的情况下去推出余下的值。哪怎么个递推法呢?
3.其实这一过程就是一个模式匹配的过程,它实际就是KMP算法的使用,就从初始值开始:
(1)从模式串与主串逐一比较(这里的主串是从模式串的第二个字符截取的)
(2)如果失配,则拿该失配的模式串的字符的next[]值所对应的字符去和主串进行下一次比较;如果匹配,模式串和主串指针都加1,并将模式串指针的值存入下一个字符的next[]
(3)重复上述过程,直至所有next[] 值都求出为止。
相信在以上分析后,你一定能按照对算法思想和原理的理解,独立地写出自己的KMP算法了吧?试试看吧。
四、KMP算法VC2005 C++实现:
- #include<iostream>
- #include <string>
- const int MaxSize=4;
- void Getnext(std::string& t,int next[])
- {
- int i=0,j=-8;//为了便于理解算法的本质,这里将j标记为-8
- next[0]=-8; //导致代码有些冗长,但有利于我们理解KMP算法
- int len=t.length();
- while(i<len-1)
- {
- if(j==-8)//与模式首字符不匹配,强制主串后移一位
- {
- j=0;
- i++;
- next[i]=j;
- }
- else if(t[i]==t[j])
- {
- i++;
- j++;
- next[i]=j;
- }
- else
- j=next[j];
- }
- }
- int KMPindex(std::string& t,std::string& s)
- {
- int i=0,j=0;
- int lens=s.length();
- int lent=t.length();
- int next[MaxSize];
- Getnext(t,next);
- while (j<lent&&i<lens)
- {
- if(j==-8)
- {
- j=0;
- i++;
- }
- else if (t[j]==s[i])
- {
- i++;
- j++;
- }
- else
- j=next[j];
- }
- if(j==lent)
- return i-lent;
- else
- return -1;
- }
- int main()
- {
- std::string s("accababcabcabbabcabcabbcbccabcabbcbcbacbabcabcababcabb"),t("abca");
- std::cout<<KMPindex(t,s)<<std::endl;
- return 0;
- }
上述代码便是KMP算法(包括求next[ ]值)的初步实现,上面是故意将next[0]的值标记为 - 8 的,其实完全可以将其改为 - 1以简化代码,之所以这样做是为了更好地理解KMP算法具体的执行细节,下面我们就用上述代码具体从程序执行的细节出发,从代码的角度来看一下KMP算法的执行流程。
1.首先便是求next[ ]值的函数 void Getnext(std::string& t,int next[]),在这里我将不会在用图形来列举出每一步执行的状态,我现在假设,我们心中已经对KMP算法的原理有了清楚的理解(如果还没有理解KMP算法,建议先将其理解清楚再剖析代码。)模式串第一个字符的next[ ]值我已经将其规定为 - 8 你也可以将其规定为任意一个唯一的标志数,注意是唯一的,即不会与后续字符的next [ ] 值相同,next[0]的值只是为我们提供一个参考,让我们知道什么时候,模式串已经到达了第一个字符。
2.接下来我们来进行一个实质是——模式串与模式串子串的KMP匹配算法 。这是的主串为 t ,模式串为 t 的子串,初始值 j= - 8 ,i= 0;是为了将t串后移一位使之成为模式串(即前缀)应为第一次执行循环体时会强制 j=0,并使模式串主串后移一位即i++;在这种情况下next[1]的值一定为0;这与上面的分析是一致的(因为第二个字符前面只有一个字符,一个字符不可能有非空真子串,故一定为0)。
3.再接下来便是一个标准的递推过程,因为循环没有达到终止条件,循环体继续执行,循环体是由一个多分枝选择语句构成的(真是干净利索),在第一次执行循环体以后,两指针都分别移动到了下一次将要比较的位置,(1)程序首先检查上一次匹配时主串是否与模式串的第一个字符不匹配,如果是,则强制主串后移一位,进入下一次循环;如果不是,则进行下一个分支判断。(2)检查当前位置主串和模式串是否匹配,如果匹配,则可确定主串当前字符的后一个字符的next[ ] 值,在确定主串当前字符的后一个字符的next[ ] 值时,我们同时将i,j指针后移。从而在得到主串当前字符的后一个位置的同时,将两指针移到下一个将要匹配的位置;如果不匹配,则直接进入最后一个分支语句。(3)拿模式串当前位置对应的next[ ] 值所在位置的字符来进行下一次匹配,即令 j= next[ j] 进入下一次循环。这样一直进行下去只到循环终止。从代码结构中我们可以清晰地看到,在两种情况下,控制循环条件的变量才会发生改变,即主串字符与模式串首字母不匹配和模式串字符与主串字符匹配两种情况。
相信经过以上对函数 void Getnext(std::string& t,int next[])源代码的分析,你一定已经透彻了KMP算法。对于函数 int KMPindex(std::string& t,std::string& s)你也一定非常容易的看懂,应为他相对于求next[ ]函数还要简单,他与Getnext()的主要区别便是初始条件不同和循环体中少了一句求next[ ]值的赋值语句,相信这里的为什么有这样的区别不用赘言。
为了代码的精炼,我们将next[0]= -8;改为next[0]= -1代码如下:
- #include<iostream>
- #include <string>
- const int MaxSize=4;
- void Getnext(std::string& t,int next[])
- {
- int i=0,j=-1;
- next[0]=-1;
- int len=t.length();
- while(i<len-1)
- {
- if(j==-1||t[i]==t[j])
- {
- i++;
- j++;
- next[i]=j;
- }
- else
- j=next[j];
- }
- }
- int KMPindex(std::string& t,std::string& s)
- {
- int i=0,j=0;
- int lens=s.length();
- int lent=t.length();
- int next[MaxSize];
- Getnext(t,next);
- while (j<lent&&i<lens)
- {
- if(j==-1||t[j]==s[i])
- {
- i++;
- j++;
- }
- else
- j=next[j];
- }
- if(j==lent)
- return i-lent;
- else
- return -1;
- }
- int main()
- {
- std::string s("accababcabcabbabcabcabbcbccabcabbcbcbacbabcabcababcabb"),t("abca");
- std::cout<<KMPindex(t,s)<<std::endl;
- return 0;
- }
四、修正next[ ]值,为以示区别将修正后的值命名为nextval[ ]
上面的代码已经尽善尽美了吗?似乎是的。但是,当我们仔细体会上述代码,会发现一个问题,如果当前字符匹配,那么,后一个字符的next[ ]值一定会便可以求得,但是如果后一个字符等于他next[] 所指位置的字符时(这种情况是会发生的,如aaaaaaaaaba),这会发生什么情况? 这时,由于next[ ] 值所指向的字符与模式串当前字符相同,拿他来也是不会匹配成功的,为了充分利用已有的信息,我们需要将这种情况考虑在内,增加一次判断语句,直接将它的next[ ]值赋给当前字符串的next[ ]值 ,从而避免在模式串与主串比较时,额外的的开销。这种情况在上一个字符与模式串的首字符(对应上述j==-8这种情况)不匹配时也是会发生的,即如果下一个字符与模式串首字符相同则将其next[ ] 值直接用首字符的标志值代替即可,因为既然已经能够提前判断其值不等,就没有必要放任不管去增加循环执行的次数。我们要利用一切条件去改善性能。我们将修正后的next[]值命名为 nextval[ ] 。以下是修正后的代码
- #include<iostream>
- #include <string>
- const int MaxSize=4;
- void Getnext(std::string& t,int nextval[])
- {
- int i=0,j=-1;
- next[0]=-1;
- int len=t.length();
- while(i<len-1)
- {
- if(j==-1||t[i]==t[j])
- {
- i++;
- j++;
- if(t[i]!=t[j])
- nextval[i]=j;
- else
- nextval[i]=nextval[j];
- }
- else
- j=nextval[j];
- }
- }
- int KMPindex(std::string& t,std::string& s)
- {
- int i=0,j=0;
- int lens=s.length();
- int lent=t.length();
- int nextval[MaxSize];
- Getnext(t,nextval);
- while (j<lent&&i<lens)
- {
- if(j==-1||t[j]==s[i])
- {
- i++;
- j++;
- }
- else
- j=nextval[j];
- }
- if(j==lent)
- return i-lent;
- else
- return -1;
- }
- int main()
- {
- std::string s("accababcabcabbabcabcabbcbccabcabbcbcbacbabcabcababcabb"),t("abca");
- std::cout<<KMPindex(t,s)<<std::endl;
- return 0;
- }
有了以上对KMP算法的剖析,相信理解KMP算法已不再困难了,总的来说,KMP算法还是蛮精巧的,他通过找字符串本身的性质达到充分利用已有信息的目的,最终达到快速匹配的目的
KMP算法算法的时间复杂度为O(m+n);但是它是一种正向思维,即从匹配成功入手,我们知道模式串会在有大量字符的主串中找到匹配的模式,匹配失败的次数在完成一次匹配所需要的总次数中占很大比例,因此KMP算法的这种正向思维在实际效果中并不是非常好,因此便有了通过逆向思考而的到的BM算法,他通过快速去除不匹配而达到快速接近匹配模式的位置。接下来我们会再去讨论BM算法。