穷举法又叫暴力法。大多数程序员眼里,它是幼稚的,但大师们不这么认为。
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代码如下:
- void BF(char *x, int m, char *y, int n) {
- int i, j;
-
-
- for (j = 0; j <= n - m; ++j) {
- for (i = 0; i < m && x[i] == y[i + j]; ++i);
- if (i >= m)
- OUTPUT(j);
- }
- }
-
如果我们注意到C库函数是汇编优化过的,并通常能提供比C代码更高的性能的话,我们可以用memcmp来完成每一趟比较过程,从而达到更好的性能:
- #define EOS '\0'
-
- void BF(char *x, int m, char *y, int n) {
- char *yb;
-
- for (yb = y; *y != EOS; ++y)
- if (memcmp(x, y, m) == 0)
- OUTPUT(y - yb);
- }
-
自动机的方法其实和穷举法有点相似,都是用最简单直白的方式来做事情。区别在于穷举法是在计算,而自动机则是查表。尽管自动机的构造过程有一点点难解,要涉及到DFA的理论,但是自动机的比较过程那绝对是简单到无语。
简单说来,根据模式串,画好了一张大的表格,表格m+1行σ列,这里σ表示字母表的大小。表格每一行表示一种状态,状态数比模式长度多1。一开始的状态是0,也就是处在表格的第0行,这一行的每个元素指示了当遇到某字符时就跳转到另一个状态。每当跳转到最终状态时,表示找到了一个匹配。
语言表述起来还是比较啰嗦,看代码就知道了:
- #define ASIZE 256
-
- int preAut(const char *x, int m, int* aut) {
- int i, 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;
- }
- return state;
- }
-
-
- void AUT(const char *x, int m, const char *y, int n) {
- int j, state;
-
-
- int *aut = (int*)calloc((m+1)*ASIZE, sizeof(int));
- int Terminal = preAut(x, m, aut);
-
-
- for (state = 0, j = 0; j < n; ++j) {
- state = aut[state*ASIZE+y[j]];
- if (state == Terminal)
- OUTPUT(j - m + 1);
- }
- }
(注:原文的代码使用一个有向图的数据结构,我遵循大师的指引,改用了更简单一点的数组)
从代码上我们很容易看出,自动机的构造需要时间是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。
于是,状态机就在这种代代传承的过程中构造完毕了。
虽然理论上自动机是最完美的匹配方式,但是由于预处理的消耗过大,实践中,主要还是用于正则表达式。
结语:穷举法与自动机各自走了两个极端,因此都没能达到综合性能的最佳,本文之后介绍的算法,可以看成是在穷举和自动机两者之间取舍权衡的结果。