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

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

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


具体实现代码:

#include<iostream>
#include<fstream>
#include<ctime>
#include<cstdlib>
#include"MyTimer.h"
#define MAXSIZE 1000
using namespace std;


unsigned int status[MAXSIZE][26];
/*  σδ
根据给定的模式P 来造自动机,最重要的部分是转移函数的定义 ,P的长度 m
一共m+1个状态,起始状态0,接受状态m
q为任意状态(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>//模式P

MyTimer 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;
}

    原文作者:KMP算法
    原文地址: https://blog.csdn.net/y519476132/article/details/41857285
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞