神奇的KMP——线性时间匹配算法(初学者请进)

前言

我其实一直非常渴望一种线性时间的字符串匹配算法,我之前曾经做过一些自己的解释器。要知道解释器可是需要大量的字符串匹配工作的,但是当时我所知道的最快的匹配算法也不过就是“朴素”,这就导致了我的解释器效率显得比较低。最近我终于见到了梦寐以求的线性时间匹配算法——KMP。但是,这个算法很抽象、很难理解,看了很多博客也没看懂。所以,我决心写一篇博客,一篇算法初学者也能看得懂的算法博客。

(p.s:解释器(英语:Interpreter),又译为直译器,是一种电脑程序,能够把高级编程语言一行一行直接转译运行。解释器不会一次把整个程序转译出来,只像一位”中间人”,每次运行程序时都要先转成另一种语言再作运行,因此解释器的程序运行速度比较缓慢。它每转译一行程序叙述就立刻运行,然后再转译下一行,再运行,如此不停地进行下去。)

(感兴趣的同学可以自己做个解释器试试,不过强烈建议进修一下“AC自动机”算法,我过一会也去进修一下。)

(另外,我的文章中小括号中的内容是非常重要的,请大家务必注意!)

1.朴素字符串匹配算法

首先,字符串匹配算法是做什么的呢?插入一段百度百科的定义:

字符串匹配是计算机科学中最古老、研究最广泛的问题之一。一个字符串是一个定义在有限字母表∑上的字符序列。例如,ATCTAGAGA是字母表∑ = {A,C,G,T}上的一个字符串。字符串匹配问题就是在一个大的字符串T中搜索某个字符串P的所有出现位置。其中,T称为文本,P称为模式,T和P都定义在同一个字母表∑上。

请忽略掉上文中除了加粗部分外的所有文字。

比如:我要在字符串T=“abcabcdeabcd”中做字符串P=“bcd”的匹配,计算结果就为:4和9。你可能会问:“不应该是5和10吗?”这没有错,在C语言(或是C++语言中)字符串是从“0”数起的,第一个字符的“下标”为“0”,第二个字符的“下标”为1,以此类推。字符串出现的位置用这个子串的“串首索引数”表示。

然后我们再说朴素算法。

void strSearch(char T[],char P[])//在T中做P的匹配
{
    int start=0;//表示当前的匹配是从start位置处开始的
    int i=0,j=0;//i,j分别表示T和P的当前位置
    while(T[i]!=0)
    {
        if(P[j]==0)//如果P[j]走到了字符串尾
        {//说明这次匹配是成功的
            cout<<start<<endl;//输出当前子串的索引数
            start++;
            i=start;
            j=0;//从T[start+1]和P[0]处重新匹配
        }
        if(T[i]==P[j])//当前为满足匹配
        {
            i++;
            j++;//继续判断下一位
        }else{//当前位不满足匹配
            start++;
            i=start;
            j=0;//从T[start+1]和P[0]处重新匹配
        }
    }
    if(P[j]==0)
        cout<<start<<endl;//如果不加这一行代码可能会忽略最后一次匹配的成果
}

朴素算法的原理是很好解释的:让start的值从0开始,对比从T中从start处开始的子串与P,一旦发现了不同的字符就说明start并不是一个我想要的解。然后start++,循环执行下去,复杂度为O(mn),m、n为这两个字符串的长度。


2.KMP算法思想

其实KMP的算法思想对于我们这些“小白”来说很抽象。我试着去理解了很长一段时间,才对KMP有了一点点初步的认识。再加上博客大神不能理解我们“小白”的痛苦,所以我决心要研究出一个所有人都能看得懂的讲解。

首先,想一想为什么朴素算法的复杂度那么高。因为,每一次“失配”(匹配失败)的时候,都要“从零开始”重头再来,从j=0开始进行匹配。假设有这么一种情况:我要在一个字符串中匹配P=“aabaaa”。然后我在母串中找到了一个“aabaaa”(如图),最后一位失配,那么我刚刚做的那么多次(7次)的比较运算其实就相当于是白算了。那能不能找到一种方法让我之前大量的计算不白算呢?

出现了一次失配

如果我能把刚才的那种情况直接转化为下面这张图片的情形,其实就可以很好地解决这个问题:

我想要达到的跳转状态

现在是你开动起脑筋的时刻了,接下来的内容真的不是很好想。P是一个6位长的字符串,到目前为止它的前五位已经被成功配对,但是它的第六位配对失败。我们就要利用我们已经得到的结果——就是毕竟它成功地匹配了五位。这五位的后两位字符为“aa”,而匹配串的前两位也为“aa”,如果我从这里开始是不是可以默认前两个“aa”已经完成进行了配对,而从第三个字符开始继续匹配。也就是说,当前已经匹配成功的部分有一个(不为整个子串的)后缀,为P的一个前缀。为了保证我的计算结果一定是正确的,我每一次都要找到子串中最长的(而且不为整个子串的)、并且为匹配串P的前缀的一个后缀,然后把它们相同的位“对齐”到一块,再继续进行匹配。

这便是KMP的算法思想了:

KMP算法是一种用于字符串匹配的算法,这个算法的高效之处在于当在某个位置匹配不成功的时候可以根据之前的匹配结果从模式字符串的另一个位置开始,而不必从头开始匹配字符串。——360百科

你可能会觉得:“每次你还要计算一个最长的后缀为匹配串的前缀,这不是更慢吗?”是的,所以说我们可以把这个移动的量做一个预处理,先把它们计算出来,再进行T和P的匹配。而我知道我所找到的“子串”的前几位其实就是和P是相同的,所以这就使得这个移动量与T(也就是母串)没有了半毛钱关系。

其实这个预处理的过程其实倒更像是用匹配串P 自己匹配自己 的过程。
(这句话不懂没关系,请同学们慢慢体会。)


3.KMP的实现

我们可以定义一个数组,叫做“失配函数”f,f[k]用来表示当j=k匹配失败之后,我们就让j=f[k]从而继续匹配。分析这样两个问题:一、f[0]一定要等于0(这是我们人为规定的)。二、f[1]也一定等于0,因为1号字符是字符串中的第2个字符如果它失配之后,只能把首位移动过来(我们每一次移动都是选取这个字符左侧的一个位置移动到当前位置继续匹配,而1号结点左侧只有一个字符,就是0号字符),因此f[1]=0。

(接下来的部分很不好理解,我尽量说得明白一些。)

有了这两个边界条件我们就可以进行递推了:对于第i位的失配f[i],已知如果f[i-1]=j(也就是j的位置之前的P串为匹配到i-1号字符而产生的子串的一个后缀),如果P[i]恰好等于P[j],是不是就说明f[i]就应该等于j+1。因为P前i-1的部分已经可以和Pj前的部分形成匹配,而且P[i]==P[j],这就说明P前i的部分可以和j+1前的部分形成匹配。但如果P[i]!=P[j],那么我们就要令j=f[j](根据递推的原理我们目前正在计算f[i+1]而j一定小于等于i,所以f[j]一定已经计算完成,所以可以直接调用),(你可以把这想想成是P[i]与P[j]的失配)然后继续进行匹配。直到进行到j=0为止。(因为如果已经计算到j=0,这已经是最前面的一个字符了,不可能再找到一个更靠前的字符作为“移动量”。又因为f[0]=0,如果继续循环地计算j=f[j]的话就会出现死循环,所以一定要进行判断然后退出循环。)

现在是时候给大家看一看代码了:

int f[PLenMax]={};//失配函数,PLenMax表示P的最大长度
void getFail(char* P,int* f) //建立失配函数 
{
    int m=strlen(P);//文本串的长度 
    f[0]=0;
    f[1]=0;//递推的边界值 
    for(int i=1;i<m;i++)//对于除了起始点外的每一位,递推计算f[i+1]
    {
        int j=f[i];
        while(j!=0 && P[i]!=P[j])
            j=f[j];
        //沿着失配边走直到走到零或一个不为零的点满足P[i]等于P[j]
        f[i+1]=(P[i]==P[j])?j+1:0;//p.s:应该没有“问号冒号表达式”不懂的同学吧...
        //如果P[i]等于P[j]则F[i+1]=j+1
        //否则f[i+1]=0; 
    }
}

void find(char* T,char* P,int* f)//KMP的匹配函数
{
    int n=strlen(T),m=strlen(P);//分别求出两个串的长度 
    getFail(P,f);//计算失配函数 
    int j=0;
    for(int i=0;i<n;i++)
    {
        while(j!=0 && P[j]!=T[i])//如果失配,按照失配函数找到上一次匹配 
            j=f[j];
        if(P[j]==T[i])//如果匹配成功就把j+1匹配下一位
            j++;
        if(j==m)//找到匹配 
            printf("%d\n",i-m+1);//输出结论
    }
}

这样KMP就实现了,是不是特别的激动,反正我是特别的激动!


4.后续的一些分析

关于这个算法的时间复杂度分析:这个算法分成两部分,初始化部分和匹配部分。为了便于分析我们令T的长度为n,P的长度为m。在find函数中有一个i的循环,它的循环次数是n。在这个循环中无论j如何变化,每次循环i都会加一,j的变化的时间复杂度看成O(1),所以说find函数的复杂度为O(n)。同理getFail函数的复杂度为O(m),所以总时间复杂度为O(m+n)。(可能说得比较扯淡啊,望大家谅解。)

(其实我们平时都叫一口一个“KMP”、一口一个“KMP”地叫着这个算法,其实这个算法并不是真正的KMP,它只是“MP”,而KMP需要进一步的优化处理,感兴趣的同学可以自己度娘一下。)

新手上路,请多关照。如有谬误,敬请谅解!

(网上给出的解释实在是看不懂,所以我决定把它粘在最后面,有感兴趣的同学可以研究一下!)

(“KMP算法”是)一种由Knuth(D.E.Knuth)、Morris(J.H.Morris)和Pratt(V.R.Pratt)三人设计的线性时间字符串匹配算法。这个算法不用计算变迁函数δ,匹配时间为Θ(n),只用到辅助函数π[1,m],它是在Θ(m)时间内,根据模式预先计算出来的。数组π使得我们可以按需要,”现场”有效的计算(在平摊意义上来说)变迁函数δ。粗略地说,对任意状态q=0,1,…,m和任意字符a∈Σ,π[q]的值包含了与a无关但在计算δ(q,a)时需要的信息。由于数组π只有m个元素,而δ有Θ(m∣Σ∣)个值,所以通过预先计算π而不是δ,使得时间减少了一个Σ因子。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值