一、字符串匹配字符串匹配指的是从文本中找出给定字符串(称为模式)的一个或所有出现的位置。本文的算法一律输出全部的匹配位置。模式串在代码中用x[m]来表示,文本用y[n]来,而所有字符串都构造自一个有限集的字母表Σ,其大小为σ。根据先给出模式还是先给出文本,字符串匹配分为两类方法:·第一类方法基于自动机或者字符串的组合特点,其实现上,通常是对模式进行预处理;·第二类方法对文本建立索引,这也是现在搜索引擎采用的方法。本文仅讨论第一类方法。文中的匹配算法都是基于这样一种方式来进行的:设想一个长度为m的窗口,首先窗口的左端和文本的左端对齐,把窗口中的字符与模式字符进行比较,这称为一趟比较,当这一趟比较完全匹配或者出现失配时,将窗口向右移动。重复这个过程,直到窗口的右端到达了文本的右端。这种方法我们通常叫slidingwindow。对于穷举法来说,找到所有匹配位置需要的时间为O(mn),基于对穷举法改进的结果,我们按照每一趟比较时的比较顺序,把这些算法分为以下四种:1.从左到右:最自然的方式,也是我们的阅读顺序2.从右到左:通常在实践中能产生最好的算法3.特殊顺序:可以达到理论上的极限4.任意顺序:这些算法跟比较顺序没关系(例如:穷举法)一些主要算法的简单介绍如下:从左到右采用哈希,可以很容易在大部分情况下避免二次比较,通过合理的假设,这种算法是线性时间复杂度的。它最先由Harrison提出,而后由Karp和Rabin全面分析,称为KR算法。在假设模式长度不大于机器字长时,Shift-Or算法是很高效的匹配算法,同时它可以很容易扩展到模糊匹配上。MP是第一个线性时间算法,随后被改进为KMP,它的匹配方式很类似于自动机的识别过程,文本的每个字符与模式的每个字符比较不会超过logΦ(m+1),这里Φ是黄金分隔比1.618,而随后发现的类似算法——Simon算法,使得文本的每个字符比较不超过1+log2m,这三种算法在最坏情况下都只要2n-1次比较。(抱歉限于我的水平这一段既没看懂也没能查证,大家就看个意思吧)基于确定性有限自动机的算法对文本字符刚好只用n次访问,但是它需要额外的O(mσ)的空间。一种叫ForwardDawgMatching的算法同样也只用n次访问,它使用了模式的后缀自动机。Apostolico-Crochemore算法是一种简单算法,最坏情况下也只需要3n/2次比较。还有一种不那么幼稚(NotSoNaive)的算法,最坏情况下是n平方,但是预处理过程的时间和空间均为常数,而且平均情况下的性能非常接近线性。从右到左BM算法被认为是通常应用中最有效率的算法了,它或者它的简化版本常用于文本编辑器中的搜索和替换功能,对于非周期性的模式而言,3n是这种算法的比较次数上界了,不过对于周期性模式,它最坏情况下需要n的二次方。BM算法的一些变种避免了原算法的二次方问题,比较高效的有:ApostolicoandGiancarlo算法、TurboBM算法和ReverseColussi算法。实验的结果表明,QuickSearch算法(BM的一个变种)以及基于后缀自动机的ReverseFactor和TurboReverseFactor算法算是实践中最有效的算法了。ZhuandTakaoka算法和BR算法也是BM的变种,它们则需要O(σ2)的额外空间。特殊顺序最先达到空间线性最优的是Galil-Seiferas和TwoWay算法,它们把模式分为两部分,先从左到右搜索右边的部分,如果没有失配,再搜索左边的部分。Colussi和Galil-Giancarlo算法将模式位置分为两个子集,先从左至右搜索第一个子集,如果没有失配,再搜索剩下的。Colussi算法作为KMP算法的改进,使得最坏情况下只需要3n/2次比较,而Galil-Giancarlo算法则通过改进Colussi算法的一个特殊情况,把最坏比较次数减少到了4n/3。最佳失配和M最大位移算法分别根据模式的字符频率和首字位移,对模式位置进行排序。SkipSearch,KMPSkipSearch和AlphaSkipSearch算法运用“桶”的方法来决定模式的起始位置。任意顺序Horspool算法也是BM的一个变种,它使用一种移位函数,而与字符比较顺序不相干。还有其他的变种如:QuickSearch算法,TunedBoyer-Moore算法,Smith算法,Raita算法。在接下来的章节中,我们会给出上面这些算法的实现。我们把字母表限定为ASCII码或者它的任意子集,编程语言用C,这就意味着数组索引是从0开始,而字符串以NULL结尾。(第一章完。好像这些算法被挨个夸了个遍,反而不知道该选哪一种了,老外介绍别人的东西时就是这样,尽来虚的。)二、穷举与自动机穷举法又叫暴力法。大多数程序员眼里,它是幼稚的,但大师们不这么认为。RobPike,最伟大的C语言大师之一,在《NotesonCProgramming》中阐述了一个原则:花哨的算法比简单算法更容易出bug、更难实现,尽量使用简单的算法配合简单的数据结构。而KenThompson——Unix最初版本的设计者和实现者,禅宗偈语般地对Pike的这一原则作了强调:拿不准就穷举(Whenindoubt,usebruteforce)。而对于装13爱好者来说,更是自豪的称其使用的是BF算法。穷举法用在字符串匹配上,简单的描述就是,检查文本从0到n-m的每一个位置,看看从这个位置开始是否与模式匹配。这种方法还是有一些优点的,如:不需要预处理过程,需要的额外空间为常数,每一趟比较时可以以任意顺序进行。尽管它的时间复杂度为O(mn),例如在文本aaaaaaaaaaaaaaaaaaaaaaaaaaa中寻找aaaaab时,就完全体现出来了。但是算法的期望值却是2n,这表明该算法在实际应用中效率不低。C代码如下:1.voidBF(char*x,intm,char*y,intn){2.inti,j;3.4./*Searching*/5.for(j=0;j=n-m;++j){6.for(i=0;im&&x[i]==y[i+j];++i);7.if(i=m)8.OUTPUT(j);9.}10.}11.如果我们注意到C库函数是汇编优化过的,并通常能提供比C代码更高的性能的话,我们可以用memcmp来完成每一趟比较过程,从而达到更好的性能:1.#defineEOS'\0'2.3.voidBF(char*x,intm,char*y,intn){4.char*yb;5./*Searching*/6.for(yb=y;*y!=EOS;++y)7.if(memcmp(x,y,m)==0)8.OUTPUT(y-yb);9.}10.11.自动机的方法其实和穷举法有点相似,都是用最简单直白的方式来做事情。区别在于穷举法是在计算,而自动机则是查表。尽管自动机的构造过程有一点点难解,要涉及到DFA的理论,但是自动机的比较过程那绝对是简单到无语。简单说来,根据模式串,画好了一张大的表格,表格m+1行σ列,这里σ表示字母表的大小。表格每一行表示一种状态,状态数比模式长度多1。一开始的状态是0,也就是处在表格的第0行,这一行的每个元素指示了当遇到某字符时就跳转到另一个状态。每当跳转到最终状态时,表示找到了一个匹配。语言表述起来还是比较啰嗦,看代码就知道了:1.#defineASIZE2562.3.intpreAut(constchar*x,intm,int*aut){4.inti,state,target,old;5.6.for(state=0,i=0;im;++i){7.target=i+1;8.old=aut[state*ASIZE+x[i]];9.aut[state*ASIZE+x[i]]=target;10.memcpy(aut+target*ASIZE,aut+old*ASIZE,ASIZE*sizeof(int));11.state=target;12.}13.returnstate;14.}15.16.17.voidAUT(constchar*x,intm,constchar*y,intn){18.intj,state;19.20./*Preprocessing*/21.int*aut=(int*)calloc((m+1)*ASIZE,sizeof(int));22.intTerminal=preAut(x,m,aut);23.24./*Searching*/25.for(state=0,j=0;jn;++j){26.state=aut[state*ASIZE+y[j]];27.if(state==Terminal)28.OUTPUT(j-m+1);29.}30.}(注:原文的代码使用一个有向图的数据结构,我遵循大师的指引,改用了更简单一点的数组)从代码上我们很容易看出,自动机的构造需要时间是O(mσ),空间也是O(mσ)(严格来说这份代码使用了O((m+1)σ)),但是一旦构造完毕,接下来匹配的时间则是O(n)。匹配的过程前面已经说了,太简单了没什么好说的,这里就解释一下构造过程吧!我们构造的目标是对应模式长度,构造出同样多的状态,用0表示初始状态,然后第一个字符用状态1表示,第二个用状态2表示,依次类推,直到最后一个字符,用m表示,也是最终状态。一开始,数组全都置0,,这个时候的自动机遇到任何字符都转到初始状态。然后给它看模式的第一个字符,假设这是'a'吧,告诉它,状态0遇到'a'应该到一个新的状态——状态1,所以把第0行的第'a'列修改为1。而这个时候状态1还是空白的,怎么办呢?这时候状态0就想呀,在我被告知遇到'a'要去状态1之前,我原本遇到'a'都要去状态0的,也就是修改之前第'a'列所指的那个状态,称为old状态吧;而现在我遇到'a'却要去一个新的状态,既然以前old状态能处理遇到'a'之后的事情,那么我让新的状态像old状态一样就好了。于是状态0把old状态拷贝到状态1。现在轮到状态1了,给它看第二个字符,它也如法炮制,指向了状态2,又把old状态拷贝给了状态2。于是,状态机就在这种代代传承的过程中构造完毕了。虽然理论上自动机是最完美的匹配方式,但是由于预处理的消耗过大,实践中,主要还是用于正则表达式。结语:穷举法与自动机各自走了两个极端,因此都没能达到综合性能的最佳,本文之后介绍的算法,可以看成是在穷举和自动机两者之间取舍权衡的结果。三、位运算的魔法——KR与SO位运算经常能做出一些不可思议的事情来,例如不用临时变量要交换两个数该怎么做呢?一个没接触过这类问题的人打死他也想不出来。如果拿围棋来做比喻,那么位运算可以喻为编程中的“手筋”。按位的存储方式能提供最大的存储空间利用率,而随着空间被压缩的同时,由于CPU硬件的直接支持,速度竟然神奇般的提升了。举个例子,普通的数组要实现移位操作,那是O(n)的时间复杂度,而如果用位运算中的移位,就是一个指令搞定了。KR算法KR算法之前第一章介绍中说是利用哈希,原文这么介绍的。而我的看法是,哈希只是一个幌子。这个算法的基本步骤同穷举法一样,不同在于每趟比较前先比较一下哈希值,hash值不同就不必比较了。而如果hash值无法高效计算,这样的改进甚至还不如不改进呢。你想想,比较之前还要先计算一遍hash值,有计算的功夫,直接比都比完了。KR算法为了把挨个字符的比较转化为两个整数的比较,它把一个m长度的字符串直接当成一个整数来对待,以2为基数的整数。这样呢,在第一次算出这个整数后,以后每次移动窗口,只需要移去最高位,再加上最低位,就得出一个新的hash值。但是m太大,导致超出计算机所能处理的最大整数怎么办?不用担心,对整数最大值取模,借助模运算的特性,一切可以完美的进行。而且由于是对整数最大值取模,所以取模这一步都可以忽略掉。这是KR算法的代码:1.#defineREHASH(a,b,h)((((h)-(a)*d)1)+(b))2.3.voidKR(char*x,intm,char*y,intn){4.intd,hx,hy