穷举法又叫暴力法。大多数程序员眼里,它是幼稚的,但大师们不这么认为。
如果我们注意到C库函数是汇编优化过的,并通常能提供比C代码更高的性能的话,我们可以用memcmp来完成每一趟比较过程,从而达到更好的性能:
自动机的方法其实和穷举法有点相似,都是用最简单直白的方式来做事情。区别在于穷举法是在计算,而自动机则是查表。尽管自动机的构造过程有一点点难解,要涉及到DFA的理论,但是自动机的比较过程那绝对是简单到无语。
从代码上我们很容易看出,自动机的构造需要时间是O(mσ),空间也是O(mσ)(严格来说这份代码使用了O((m+1)σ)),但是一旦构造完毕,接下来匹配的时间则是O(n)。
Rob Pike, 最伟大的C 语言大师之一, 在《Notes on C Programming》中阐述了一个原则:花哨的算法比简单算法更容易出bug、更难实现,尽量使用简单的算法配合简单的数据结构。而Ken Thompson——Unix 最初版本的设计者和实现者,禅宗偈语般地对Pike 的这一原则作了强调:拿不准就穷举(When in doubt , use brute force)。而对于装13爱好者来说,更是自豪的称其使用的是BF算法。
穷举法用在字符串匹配上,简单的描述就是,检查文本从0到n-m的每一个位置,看看从这个位置开始是否与模式匹配。这种方法还是有一些优点的,如:不需要预处理过程,需要的额外空间为常数,每一趟比较时可以以任意顺序进行。
尽管它的时间复杂度为O(mn),例如在文本"aaaaaaaaaaaaaaaaaaaaaaaaaaa"中寻找"aaaaab"时,就完全体现出来了。但是算法的期望值却是2n,这表明该算法在实际应用中效率不低。
C代码如下:
- voidBF(char*x,intm,char*y,intn){
- inti,j;
- /*Searching*/
- for(j=0;j<=n-m;++j){
- for(i=0;i<m&&x[i]==y[i+j];++i);
- if(i>=m)
- OUTPUT(j);
- }
- }
- #defineEOS'\0'
- voidBF(char*x,intm,char*y,intn){
- char*yb;
- /*Searching*/
- for(yb=y;*y!=EOS;++y)
- if(memcmp(x,y,m)==0)
- OUTPUT(y-yb);
- }
简单说来,根据模式串,画好了一张大的表格,表格m+1行σ列,这里σ表示字母表的大小。表格每一行表示一种状态,状态数比模式长度多1。一开始的状态是0,也就是处在表格的第0行,这一行的每个元素指示了当遇到某字符时就跳转到另一个状态。每当跳转到最终状态时,表示找到了一个匹配。
语言表述起来还是比较啰嗦,看代码就知道了:
- #defineASIZE256
- intpreAut(constchar*x,intm,int*aut){
- inti,state,target,old;
- for(state=0,i=0;i<m;++i){
- target=i+1;
- old=aut[state*ASIZE+x[i]];
- aut[state*ASIZE+x[i]]=target;
- memcpy(aut+target*ASIZE,aut+old*ASIZE,ASIZE*sizeof(int));
- state=target;
- }
- returnstate;
- }
- voidAUT(constchar*x,intm,constchar*y,intn){
- intj,state;
- /*Preprocessing*/
- int*aut=(int*)calloc((m+1)*ASIZE,sizeof(int));
- intTerminal=preAut(x,m,aut);
- /*Searching*/
- for(state=0,j=0;j<n;++j){
- state=aut[state*ASIZE+y[j]];
- if(state==Terminal)
- OUTPUT(j-m+1);
- }
- }
(注:原文的代码使用一个有向图的数据结构,我遵循大师的指引,改用了更简单一点的数组)
匹配的过程前面已经说了,太简单了没什么好说的,这里就解释一下构造过程吧!
我们构造的目标是对应模式长度,构造出同样多的状态,用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。
于是,状态机就在这种代代传承的过程中构造完毕了。
虽然理论上自动机是最完美的匹配方式,但是由于预处理的消耗过大,实践中,主要还是用于正则表达式。
结语:穷举法与自动机各自走了两个极端,因此都没能达到综合性能的最佳,本文之后介绍的算法,可以看成是在穷举和自动机两者之间取舍权衡的结果。