算法导论 第32章 详解字符串的匹配,自动机,KMP算法

来源:互联网 发布:abp zero 源码 编辑:程序博客网 时间:2024/05/19 19:12

中间跳过了几章,先看自己认为比较容易看懂了几章,结果发现,证明真是难呀。虽然没有怎么看过其他的算法书,但是觉得算法导论虽然在证明,把问题形式化方面稍微有点罗嗦了,但是感觉还是不错了,它不会直接抛给你一个最有效的算法,然后直接跟你讲,它会从最朴素的算法逐渐讲更有效率的算法,这样让读者对问题有更清晰的把握,而且有些高效率的算法往往是建立在朴素的算法上的。字符串匹配就是这样,朴素算法-自动机识别法-KMP算法。Rabin-Karp算法看得我有点头昏脑胀,暂时先放一下。

字符串匹配问题问题很明白。就是给定模式串P,去匹配串T中找P是否出现,返回匹配时T位置的偏移。

我们设P长度为m, T长度为n  ,  Σ 字母表

1.朴素匹配算法就是从T的第一个字符逐个跟P比较,当出现不匹配时,向右移动一位,在重新和P开始比较,T中的每一位都有机会和P从头开始比较,但它的效率并不高,T中的n-m+1位都要比,而且在此位进行匹配时最坏情况下都要比较m个字符,所以最坏情况下运行时间是(n-m+1)*m

如何改进呢,就是过滤掉那些无效的偏移。

2.自动机法就是利用构造的转移函数,一步过滤掉了所有的无效偏移,但是构造自动机转移函数的代价可能会比较大。下面简要的说明一下自动机,自动机算法的证明还是看得比较明白,后面KMP算法的证明hold不住了。

关于自动机的定义大家看书,或者随便找本编译原理的书都有。有限自动机M(Q,q0,A,Σ,δ)

在这里构造的自动机是基于模式P构造的,

状态集合Q一共有m+1个状态,状态为0,1,2,3,4....m , q0=0为起始状态,A=m为接受状态。  δ为状态p遇到字符a转移到状态 q的一个映射。 δ(p,a)=q;而在自动机字符匹配里我们把这个转移函数定义为 δ(p,a)=q=σ((Pp)a),其中Pp表示P的长度为p的前缀。   

       σ() 函数为后缀函数,它的定义是  σ(x) 是字符串x的后缀模式P的最长前缀的长度。 有点拗口,举例如下:

P:a b  a b a

σ(abdcaba)=3 ,因为x   a  b  c a  b  a                

x的后缀 aba 为P的最长前缀。长度为3.

然后定义了这个有什么用呢。我们先不管这个转移函数如何计算。来看自动机如何工作的,自动机是从状态q0出发,读入字符,根据状态转移函数进行相应的转移。现在我们的自动机输入文本为T,依次输入的是T1,T2,T3......T[i]。 现在我们来看根据我们定义的转移函数Ti的状态会是多少,定义Φ(Ti)为字符串Ti读入后所处的状态。

现在我们证明Φ(Ti)=σ(Ti)。证明了这个我们就可以看到自动机是如何工作的了。

PS: Ti 表示T的前i个字符组成的字符串,T[i]表示T字符串的第i个字符

进行归纳  i=0;    T0=空  σ(空)=0  , Φ(T0)=0 成立,,就是初始状态。一个字符都没有读入。

假设Φ(Ti)=σ(Ti)  来证明Φ(Ti+1)=σ(Ti+1) , 状态p为Φ(Ti), 字符a为T[i+1]  。

Φ(Ti+1)=Φ(Ti a) (根据Ti+1的定义,读入T[i+1]个字符后所处的状态)

=δΦ(Ti),a ) (根据Φ的定义)

=δ(p,a) (根据p的定义,它为Φ(Ti))

=σ(Pp a)(根据转移函数σ的定义)

=σ(Ti a)()

根据假设    p=Φ(Ti)=σ(Ti)  

Ti: T1T2T2  ....               T[i-p+1]. . . T[i-2]  T[i-1]  T[i]  a

Pp: P[1]P[2]...........    P[p] a         

上下都加一个字符a 很明显还是相等的。

(接上面)=σ(Ti+1)  (Ti+1=Ti+a)

有了Φ(Ti)=σ(Ti)  我们就可以看到只有读入Ti字符串后 状态为m的时候得到一个匹配。这样自动机就可以正常工作了。起始状态为0,读入一个字符根据转移函数进入下一个状态,每次进入下一个状态查看是不是为m,如果是m则得到一个匹配。

而计算转移函数我们暂时就用根据定义暴力搜索的方法吧,具体看代码。可以利用后面KMP相关方法进行改进。

算法导论上的证明看得我晕头转向,一会儿给出转移函数,一会儿又貌似通过证明来推出转移函数。 我觉得自动机方法的得出应该是先有自动机理论,然后想出转移函数,然后证明可行性,这样比较不乱!睡觉


具体实现代码:

#include<iostream>#include<fstream>#include<ctime>#include<cstdlib>#include"MyTimer.h"#define MAXSIZE 1000using namespace std;unsigned int status[MAXSIZE][26];/*  σδ根据给定的模式P 来造自动机,最重要的部分是转移函数的定义 ,P的长度 m一共m+1个状态,起始状态0,接受状态mq为任意状态(0-m), a为字符   转移函数 δ(q,a)=σ((Pq)a)设 σ((Pq)a)=k 含义: P的一个最长前缀P[1....k],且是 P[1...q]a 这个字符串的后缀*/void computeTransitionFunction(string p)      //O(  m^3|Σ| )  可改进到O(  m|Σ| ){    //通过定义直接计算转换函数  一共有4层循环,所有看上去代价很大,,    int m=p.size();    for(int q=0;q!=m+1;++q){  //从状态0开始计算 一共0-m个状态               m+1 次        for(int j=0;j!=26;++j){ //26个字符 每个都要试                       Σ次        int k=min(m,q+1);       // 求 δ(q,a)的值, 它最大值为 min(m,q+1)                while(k!=0){   //最多减小到0  返回状态0                                  最多m+1次            int i;                for(i=k;i!=0;--i){   //逐个查看 P[1..k]是否满足要求 是 P[1...q]a 这个字符串的后缀   m次                    if(i==k){         //p[k]要与a比较                        if(p[i-1]==char(j+97)){ //相等继续                            continue;    }else    {//否则 这个k不符合要求            break;}            }            else{ //继续比较P[1..k-1]和P[1..q]                if(p[i-1]==p[q-(k-i)])continue;                else break;            }        }//for        //如果i减小到了0 则说明这个k符合要求,        if(i==0){ status[q][j]=k;break;} //赋值        else       { //否则减小k      --k;         }    }//while        }    }}//构造好了自动机 接下来的的匹配就很简单了void finiteAutomationMachine(string t,int m){       //O(n)  int n=t.size();  int q=0;  //初始状态  for(int i=0;i!=n;++i){  //依次读入字符    q=status[q][int(t[i]-97)];  //转移状态    if(q==m)cout<<i-m+1<<endl;  //如果为状态m得到一个匹配。。  }}int main(){/*ofstream outfile("test.txt");srand(time(0));for(int i=1;i!=1001;++i){    int n=rand()%26;    outfile<<char(97+n);    if(i%50==0)outfile<<endl;}*/ifstream infile("test.txt");string T;<span style="white-space:pre"></span>//这个要匹配的字符串外面文件读取的,,大家可以自己指定int i=0;char x;while(!infile.eof()&&infile>>x){T.push_back(x);}string P="fsdfsadsadfadsf";<span style="white-space:pre"></span>//模式PMyTimer times;times.Start();computeTransitionFunction(P);finiteAutomationMachine(T,P.size());times.End();cout<<times.costTime<<"us"<<endl;return 0;}

  自己回顾知识虽然可以加深理解,但好费时间,有时间再写KMP。

KMP算法利用前缀函数来避免无用偏移,他做的工作没有自动机的转移函数做的彻底,但是也可以达到目的。而且使得预处理的时间降到了O(  m)。

计算前缀函数计算方法的证明,和KMP正确性的证明看了几遍也没看明白,只能直接理解代码了。

1.模式P通过 跟自己的比较计算出前缀函数,pai。

pai[q]表示 P[1......q]的真后缀 且是P的前缀的最长长度
如   P
=a b a b a c a

pai[5]=        P[1...5]  a b a b a
                   P        
      a b a b a c a
最大长度是3
所以pai[5]=3
知道了这个有什么用呢 在识别字符串T的时候 根据这个信息可以 略去无用偏移
               
 1 23 4 5 6 7 8
        T       a b a
 b  a a b c
        P       a b a b a c a

 假设此时我们搜索到第6 个字符,结果发现不匹配,
  在朴素算法里 仅仅推进偏移到2 ,在自动机匹配里状态5 读入字符a 回到状态1 也就是推进偏移到7,直接比较第
  7个字符,而在KMP里我们不管读入的字符a ,先推进偏移
5- pai[5]=2 ,无效偏移2被避免 , 继续比较T的第六个字符和P[3+1]字符
                 1 2 3 4 5 6 7 8
        T        a b a b a a b c
        P            a b a b a c a
       
 又不相同的,再看pai[3]=1 ,继续推进,但还是比较第六个字符
                 1 2 3 4 5 6 7 8
        T        a b a
 b a a b c
        P                
   a b a b a c a
       
不相同,此时pai[1]=0
        相当于从T的第六个字符截断,重新开始与P的匹配
        所以还是可以看到自动机是一步到位,
因为他把比较不相同的那位也考虑了进去,但是提前做的工作很多,而KMP利用pai函数,即时地计算需要的信息也可以达到自动机的效果,且效率提高很多。

前缀函数的计算我只能通过代码来理解,证明就让它随风而去吧。

#include<iostream>#include<fstream>#include<ctime>#include<cstdlib>#include"MyTimer.h"using namespace std;void computePrefixFuction(string p,int *pai)  //运用摊还分析 O(m){    //求解前缀函数 ,证明实在看不懂,看代码吧     int m=p.size();pai[1]=0;               //pai[q]<q 所以显然得到int k=0;                //k在循环之前求解pai[q]之前 保持k=pai[q-1]                        //表示 P            1  2   .  .  k                        //     P    1 2 3 4 .  .   .  .  q-1                        // 1.如果k等于0,在求pai[q]的时候就需要从头P[0]和P[q]比较            //2.如果k>0 而P[k+1]!=p[q] 那么就需要在前面匹配的Pk里继续寻找,也就是说进一步缩小k值            //3.如果k>0 而P[k+1]!=p[q]  那么很简单 匹配的长度又加1 k++for(int q=2;q!=m+1;++q){    while(k>0&&p[k]!=p[q-1]){  //2        k=pai[k];    }    if(p[k]==p[q-1]){   //1 ,3        ++k;    }    pai[q]=k;}}void kmpMatch(string T,string P){                  //有了前缀函数 匹配过程就相对容易一点    int m=P.size();    int n=T.size();    int pai[m+1];    computePrefixFuction(P,pai);    int q=0;    for(int i=1;i!=n+1;++i)  //从第一个字符扫到最后一个字符 O(n)    {                       //从上面的图解通过前缀函数的匹配方法,每次比较的都是第i个字符,                        //变的是在这个字符之前和P匹配的字符数q, 初始为0        while(q>0 && P[q]!=T[i-1]){  //q大于0  ,而P的第q+1个字符和T的第i个字符不匹配,那么就找            q=pai[q];                               //P[1...q] 的后缀,P的前缀的最大匹配,也就是pai[q]        }        if(P[q]==T[i-1]){ //如果q为0   拿P的第一个字符和T的第i个字符比较 相等则q加1  不相等 进入比较下一个字符i+1            ++q;        //q大于0  且P的第q+1个字符和T的第i个字符匹配,q加1 很简单        }   if(q==m){  //在比较完第i个字符的时候 发现q==m 就是匹配到了一个P        cout<<i-m<<"    "<<endl;        q=pai[q];   //找到一个匹配了,,要重新规制一下,    }    }}int main(){ifstream infile("test.txt");string T;int i=0;char x;while(!infile.eof()&&infile>>x){T.push_back(x);}string P="ewqw";//string P="ababaca";MyTimer times;times.Start();kmpMatch(T,P);times.End();cout<<times.costTime<<"us"<<endl;return 0;}

0 0
原创粉丝点击