字符串匹配算法(二)穷举与自动机

穷举法又叫暴力法。大多数程序员眼里,它是幼稚的,但大师们不这么认为。

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代码如下:
  1. void BF(char *x, int m, char *y, int n) {
  2.    int i, j;
  3.    /* Searching */
  4.    for (j = 0; j <= n - m; ++j) {
  5.       for (i = 0; i < m && x[i] == y[i + j]; ++i);
  6.       if (i >= m)
  7.          OUTPUT(j);
  8.    }
  9. }
  10.   
如果我们注意到C库函数是汇编优化过的,并通常能提供比C代码更高的性能的话,我们可以用memcmp来完成每一趟比较过程,从而达到更好的性能:
  1. #define EOS '/0'
  2.    
  3. void BF(char *x, int m, char *y, int n) { 
  4.   char *yb; 
  5.   /* Searching */ 
  6.   for (yb = y; *y != EOS; ++y) 
  7.     if (memcmp(x, y, m) == 0) 
  8.       OUTPUT(y - yb);
  9. }
自动机的方法其实和穷举法有点相似,都是用最简单直白的方式来做事情。区别在于穷举法是在计算,而自动机则是查表。尽管自动机的构造过程有一点点难解,要涉及到DFA的理论,但是自动机的比较过程那绝对是简单到无语。

简单说来,根据模式串,画好了一张大的表格,表格m+1行σ列,这里σ表示字母表的大小。表格每一行表示一种状态,状态数比模式长度多1。一开始的状态是0,也就是处在表格的第0行,这一行的每个元素指示了当遇到某字符时就跳转到另一个状态。每当跳转到最终状态时,表示找到了一个匹配。

语言表述起来还是比较啰嗦,看代码就知道了:
  1. #define ASIZE 256
  2. int preAut(const char *x, int m, int* aut) {
  3.         int i, state, target, old;
  4.         for (state = 0, i = 0; i < m; ++i) {
  5.                 target = i + 1;
  6.                 old = aut[state * ASIZE + x[i]];
  7.                 aut[state * ASIZE + x[i]] = target;
  8.                 memcpy(aut + target * ASIZE, aut + old * ASIZE, ASIZE*sizeof(int));
  9.                 state = target;
  10.         }
  11.         return state;
  12. }
  13. void AUT(const char *x, int m, const char *y, int n) {
  14.         int j, state;
  15.         /* Preprocessing */
  16.         int *aut = (int*)calloc((m+1)*ASIZE, sizeof(int));
  17.         int Terminal = preAut(x, m, aut);
  18.         /* Searching */
  19.         for (state = 0, j = 0; j < n; ++j) {
  20.                 state = aut[state*ASIZE+y[j]];
  21.                 if (state == Terminal)
  22.                         OUTPUT(j - m + 1);
  23.         }
  24. }
注:原文的代码使用一个有向图的数据结构,我遵循大师的指引,改用了更简单一点的数组

从代码上我们很容易看出,自动机的构造需要时间是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。

于是,状态机就在这种代代传承的过程中构造完毕了。

虽然理论上自动机是最完美的匹配方式,但是由于预处理的消耗过大,实践中,主要还是用于正则表达式。

结语:穷举法与自动机各自走了两个极端,因此都没能达到综合性能的最佳,本文之后介绍的算法,可以看成是在穷举和自动机两者之间取舍权衡的结果。


你可能感兴趣的:(字符串匹配算法(二)穷举与自动机)