字符串匹配算法(一)



注:本文大致翻译自EXACT STRING MATCHING ALGORITHMS,去掉一些废话,增加一些解释。

一、简介

文本信息可以说是迄今为止最主要的一种信息交换手段,而作为文本处理中的一个重要领域——字符串匹配,就是我们今天要说的话题。(原文还特意提及文本数据数量每18个月翻一番,以此论证算法必须要是高效的。不过我注意到摩尔定律也是18个月翻番,这正说明数据的增长是紧紧跟随处理速度的,因此越是使用高效的算法,将来待处理的数据就会越多。这也提示屏幕前的各位,代码不要写得太快了……

 

字符串匹配指的是从文本中找出给定字符串(称为模式)的一个或所有出现的位置。本文的算法一律输出全部的匹配位置。模式串在代码中用x[m]来表示,文本用y[n]来,而所有字符串都构造自一个有限集的字母表Σ,其大小为σ。

 

根据先给出模式还是先给出文本,字符串匹配分为两类方法:

·         第一类方法基于自动机或者字符串的组合特点,其实现上,通常是对模式进行预处理;

·         第二类方法对文本建立索引,这也是现在搜索引擎采用的方法。

本文仅讨论第一类方法。

 

文中的匹配算法都是基于这样一种方式来进行的:设想一个长度为m的窗口,首先窗口的左端和文本的左端对齐,把窗口中的字符与模式字符进行比较,这称为一趟比较,当这一趟比较完全匹配或者出现失配时,将窗口向右移动。重复这个过程,直到窗口的右端到达了文本的右端。这种方法我们通常叫sliding window

 

对于穷举法来说,找到所有匹配位置需要的时间为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σ)的空间。

一种叫Forward Dawg Matching的算法同样也只用n次访问,它使用了模式的后缀自动机。

Apostolico-Crochemore算法是一种简单算法,最坏情况下也只需要3n/2次比较。

还有一种不那么幼稚(Not So Naive)的算法,最坏情况下是n平方,但是预处理过程的时间和空间均为常数,而且平均情况下的性能非常接近线性。

 

从右到左

BM算法被认为是通常应用中最有效率的算法,它或者它的简化版本常用于文本编辑器中的搜索和替换功能,对于非周期性的模式而言,3n是这种算法的比较次数上界了,不过对于周期性模式,它最坏情况下需要n的二次方。

BM算法的一些变种避免了原算法的二次方问题,比较高效的有:Apostolico and Giancarlo算法、Turbo BM算法和Reverse Colussi算法。

实验的结果表明,Quick Search算法(BM的一个变种)以及基于后缀自动机的Reverse Factor和Turbo Reverse Factor算法算是实践中最有效的算法

Zhu and Takaoka算法和BR算法也是BM的变种,它们则需要O(σ2)的额外空间。

 

特殊顺序

最先达到空间线性最优的是Galil-Seiferas和Two Way算法,它们把模式分为两部分,先从左到右搜索右边的部分,如果没有失配,再搜索左边的部分。

Colussi和Galil-Giancarlo算法将模式位置分为两个子集,先从左至右搜索第一个子集,如果没有失配,再搜索剩下的。Colussi算法作为KMP算法的改进,使得最坏情况下只需要3n/2次比较,而Galil-Giancarlo算法则通过改进Colussi算法的一个特殊情况,把最坏比较次数减少到了4n/3。

最佳失配和M最大位移算法分别根据模式的字符频率和首字位移,对模式位置进行排序。

Skip Search,KMP Skip Search和Alpha Skip Search算法运用“桶”的方法来决定模式的起始位置。

 

任意顺序

Horspool算法也是BM的一个变种,它使用一种移位函数,而与字符比较顺序不相干。还有其他的变种如:Quick Search算法,Tuned Boyer-Moore算法,Smith算法,Raita算法。

 

在接下来的章节中,我们会给出上面这些算法的实现。我们把字母表限定为ASCII码或者它的任意子集,编程语言用C,这就意味着数组索引是从0开始,而字符串以NULL结尾。

 

二、穷举与自动机


穷举法BF(Brute Force)

特点:

  • 不用预处理
  • 只需常数额外空间
  • 每次把窗口向右移动1
  • 可以以任意顺序比较
  • 搜索时间复杂度O(mn)
  • 字符比较期望次数为2n

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


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 int BF(char *x, int m, char *y, int n) 
{
    if ( x == '\0' || y == '\0' )  
        return -1;
    int x_len = strlen( x );  
    int y_len = strlen( y );
    if ( y_len < x_len )  
        return -1;
    
    int i, 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);
    }
}

如果我们注意到C库函数是汇编优化过的,并通常能提供比C代码更高的性能的话,我们可以用memcmp来完成每一趟比较过程,从而达到更好的性能:
#define EOS '\0'  
     
void BF(char *x, int m, char *y, int n) 
{   
    char *yb; 
    /* Searching */   
    for (yb = y; *y != EOS; ++y)   
        if (memcmp(x, y, m) == 0)   
            OUTPUT(y - yb);  
}

特点:
  • builds the minimal deterministic automaton recognizing the language Sigma*x;
  • extra space in O(msigma) if the automaton is stored in a direct access table;
  • preprocessing phase in O(msigma) time complexity;
  • searching phase in O(n) time complexity if the automaton is stored in a direct access table, O(nlog(sigma)) otherwise.

自动机的方法其实和穷举法有点相似,都是用最简单直白的方式来做事情。区别在于穷举法是在计算,而自动机则是查表。尽管自动机的构造过程有一点点难解,要涉及到DFA的理论,但是自动机的比较过程那绝对是简单到无语。

简单说来,根据模式串,画好了一张大的表格, 表格m+1行σ列,这里σ表示字母表的大小。表格每一行表示一种状态,状态数比模式长度多1。一开始的状态是0,也就是处在表格的第0行,这一行的每个元素指示了当遇到某字符时就跳转到另一个状态。每当跳转到最终状态时,表示找到了一个匹配。
这个算法主要用了一个概念:有限自动机,至于什么是有限自动机请大家百度
这里主要说它的用法。
先定义这里 状态 的概念:状态代表当前已经匹配的字符个数。状态转移代表,在当前状态下,输入一个字符让当前状态发生变化。
那状态转移函数,就是将在当前状态输入下一个字符,和输入该字符后的状态建立一个映射的关系。
定义a为即将输入的字符
定义p为已经匹配的个数(即状态)(初始值为0)
则p=σ(p,a)
当状态转移到p==strlen(模式P)的时候,成功完成一个匹配。
如下图:

建立起状态转移函数的概念后,剩下的事情就是想办法得到该函数。
定义P(n)表示模式P的前n个字符组成的串,P(n)a表示模式P的前n个字符和字符a组成的串
那么σ(p,a)就等于:P的前缀 是P(p)a的后缀 的 最大长度``````
这句话挺绕的``````但我没有更好的语言去把它讲清楚,所以大家根据上图的σ(5,B)=4来绕一下``````

语言表述起来还是比较啰嗦,看代码就知道了:
  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表示,也是最终状态。

1) 一开始,数组全都置0,这个时候的自动机遇到任何字符都转到初始状态。
2) 然后给它看模式的第一个字符,假设这是'a'吧,告诉它, 状态0遇到'a'应该到一个新的状态——状态1,所以把第0行的第'a'列修改为1。而这个时候状态1还是空白的,怎么办呢?

这时候状态0就想呀,在我被告知遇到'a'要去状态1之前,我原本遇到'a'都要去状态0的,也就是修改之前第'a'列所指的那个状态,称为old状态吧;而现在我遇到'a'却要去一个新的状态,既然 以前old状态能处理遇到'a'之后的事情,那么我让新的状态像old状态一样就好了。于是状态0把old状态拷贝到状态1。

3) 现在轮到状态1了,给它看第二个字符,它也如法炮制,指向了状态2,又把old状态拷贝给了状态2。

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

preAut函数构造状态表的各个转换状态,preAut函数中,A,B,C分别用0,1,2代替。
target是目标转换状态,即下一步转到哪个状态。
aut[state * ASIZE + x[i]] = target;    这一句按照模式字符串的正确匹配构造状态,即构造图中的1234567。
memcpy(aut + target * ASIZE, aut + old * ASIZE, ASIZE* sizeof ( int ));    这一句把old状态拷贝给下一个状态。
preAut函数一步步解析如下
i = 0 时,target=1,old=aut[0 + A] = aut[0] = 0,aut[0] = 1,memcpy( aut + 1 * 3, aut + 0 * 3, ... ),state = 1;
i = 1 时,target=2,old=aut[3 + B] = aut[4] = 0,aut[4] = 2,memcpy( aut + 2 * 3, aut + 0 * 3, ... ),state = 2;
i = 2 时,target=3,old=aut[6 + A] = aut[6] = 1,aut[6] = 3,memcpy( aut + 3 * 3, aut + 1 * 3, ... ),state = 3;
i = 3 时,target=4,old=aut[9 + B] = aut[10] = 2,aut[10] = 4,memcpy( aut + 4 * 3, aut + 2 * 3, ... ),state = 4;
i = 4 时,target=5,old=aut[12 + A] = aut[12] = 3,aut[12] = 5,memcpy( aut + 5 * 3, aut + 3 * 3, ... ),state = 5;
i = 5 时,target=6,old=aut[15 + C] = aut[17] = 0,aut[17] = 6,memcpy( aut + 6 * 3, aut + 1 * 3, ... ),state = 6;
i = 6 时,target=7,old=aut[18 + A] = aut[18] = 1,aut[18] = 7,memcpy( aut + 7 * 3, aut + 1 * 3, ... ),state = 7;

按照以上步骤,可以自己一步一步画出上面的图。
虽然理论上自动机是最完美的匹配方式,但是由于 预处理的消耗过大,实践中,主要还是用于正则表达式

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

参考:
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值