KMP算法介绍

1 篇文章 0 订阅

虽然入ACM大坑也两年了吧emmmm但是感觉一直没怎么努力做题。。就连KMP我也是昨天才学会。。惭愧。


KMP算法的作用

KMP算法是一种高效率的字符串匹配算法,其复杂度近似于O(n),主要用于在一个长度为n的字符串s中,判断是否存在一个长度为m的给定子串t

在讲KMP之前

在讲KMP算法之前,我们先来了解一下普通的暴力算法要如何解决这个问题。

我们想在一个长字符串(称为母串)中寻找一个子串,我们可以枚举母串中所有的长为t的子串(t为子串的长度),然后再比较这两个子串是否相等,若相等即可返回结果。

代码如下:

for(int i = 0; i < strlen(s) - strlen(t); i++) {//i表示子串的起始位置
    int j;
    for(j = 0; j < strlen(t); j++)//逐个比较需要寻找的子串中的第j个字符
        if(s[i+j] != s[j]) break;//匹配失败,跳出循环
    if(j == strlen(t)) return i;//匹配成功,返回子串所在位置
}
return -1;//循环结束,未找到子串

举个例子

s = “ababaababb”

t = “ababb”

我们要在s中寻找t,首先将s中第一个长为4的子串与t比较

(‘x’表示匹配失败,”.’表示未匹配,’√’表示匹配成功)

母串ababaababb
子串ababb
比较结果×

第一次匹配失败了,然后我们将s中第二个子串与t比较,即相当于将t右移一位与s比较

母串ababaababb
子串ababb
比较结果×....

显然第二次匹配又失败了,然后继续与第三个字符串比较

母串ababaababb
子串ababb
比较结果×.

第四次比较:

母串ababaababb
子串ababb
比较结果×....

接着继续比较第五次

母串ababaababb
子串ababb
比较结果×...

第六次

母串ababaababb
子串ababb
比较结果

匹配成功,此时返回i的值为5(从0开始计数)

总的过程如下:

母串ababaababb匹配结果
第一次比较ababb(失配)×
第二次比较a(失配)babb×
第三次比较abab(失配)b×
第四次比较a(失配)babb×
第五次比较ab(失配)abb×
第六次比较ababb

通过4次枚举子串的起始位置,我们找到了这个子串。

我们知道,母串s中长为t的字符串的个数共有|s|-|t|个,每次判断一个子串是否能满足匹配需要至多|t|次,记n=|s|,m=|t|,则这个算法的复杂度为O(mn)

但是,我们发现,有很多次比较是多余的

当我们比较完第一次时,发现第一次在匹配第5个字符的时候匹配失败(简称失配),这时我们就已经知道母串的前四个字符与需要查找的子串相同,即前四个字符为abab

那么,显然我们第二次比较就是多余的。因为母串的前四个字符为abab,其第二个字符b与子串的首字符a不同。

同样,当我们经过第三次比较之后,也就知道母串的第3-5个字符为aba(因为匹配第四个字符时才失配)

所以第四次比较也是多余的。

失配之后怎么办?

我们已经知道,上一次匹配带给我们的信息,足以让我们对接下来的几次匹配进行一些筛选,去掉那些不可能匹配上的选择。

但是怎么确定每次匹配之后下次需要匹配的位置呢?

这就要我们首先思考一下,在上面的例子中,为什么第一次匹配在第五个位置失配后,第二次匹配我们可以跳过。

因为我们的目的是要在母串中找到一个子串,而在第一次匹配过程中,我们已经知道了母串的部分信息——前四个字符与子串相同,即abab。所以在母串中的相应位置若和子串首部的序列不能匹配(母串的第二个字符为b,与子串的首字母a不匹配),那么我们就可以跳过第二次匹配。而第三个字母a与子串的首字母a匹配了,故可以从这个位置继续进行匹配。

子串决定是否可以跳过

因为我们的“跳过”,是因为在某次匹配失败后知道了母串的某些信息,从而根据这些信息可以跳过这些匹配。

在第k次匹配失败后,我们得到的母串的信息一定是:有一段与子串的前k-1个字符相同的子串

从母串中获得的信息是由子串决定的,所以是否可以跳过也是由子串决定的

前缀和后缀决定可以跳过之后匹配的位置

子串决定是否可以跳过,那又是怎么决定可以跳过之后从哪里开始匹配呢??

首先需要介绍两个概念:前缀和后缀

从字面意思上说,前缀就是从前面开始数,后缀就是从后面开始数

例如:有一个整数串,为:1,2,3,4,5,6,7,8,9,10

那么这个串长为3的前缀为1,2,3;长为4的后缀为7,8,9,10

以这个串第6个位置为结束,长为2的前缀为1,2;长为3的后缀为4,5,6

当我们在第k个位置失配后,我们需要找的下一个可能的匹配所在的位置,就恰好是【以第k个位置结束,相同的最长前缀和后缀的长度(在这里认为前缀和后缀不取[0,k-1]这个序列)】

可能有点绕口,为了更直观的理解,我们再来一个例子。

举例之前说明一下,因为我们已经知道,决定能否跳过的只有子串,所以这里不假设母串的信息,只假设在第i次匹配时,子串在第k个位置失配。而且我们研究的主要是,跳过之后开始匹配的位置,所以只列举出第一次匹配,以及下一次不可以跳过的匹配。

母串abcfabcf....能否跳过
第i次比较abcfabce(失配)
第i+1次比较a(失配)bcfabce
第i+2次比较a(失配)bcfabce
第i+3次比较a(失配)bcfabce
第i+4次比较abcf(匹配成功)a(继续匹配之后的母串)bce

在这个例子中,进行完第i次比较后,我们可以跳过子串的前3个字符的比较

我们注意到以第k-1个位置为结束的前缀和后缀(k为失配的位置,在例子中为8),发现长度为3的前缀为abc,长度为3的后缀也为abc,前缀和后缀相等(长度为7的前缀和后缀也相同,但是我们不考虑[0,k-1]这个前/后缀)。故在第k个位置失配后,可以直接从第4个字符开始比较,即跳过子串的前3个字符。

失配函数

通过之前的分析,我们已经知道:

在第k个位置失配后,我们由子串可以得到母串的一些信息

由子串(获得的信息)可以决定,在第k个位置失配后能否跳过接下来的几次匹配

由第k个位置的前缀和后缀可以决定跳过匹配后,下一个需要匹配的位置

我们记f[k]表示在第k个位置失配时,下一次匹配的位置

由上面的信息可以知道:f[k]的值仅由待匹配的子串t来决定,而与母串无直接关联。且f[k]的值恰好等于以k为结尾(不包括第k个字符)时,前缀和后缀完全相同时的最大长度(在这里认为前缀和后缀不取[0,k-1]这个序列)。

举个例子

让我们尝试求一下以下字符串的失配函数:

s = “ababaaababa”

首先,我们令f[0]=f[1]=0,因为无法找到他们满足条件的前缀和后缀。然后从f[2]开始。

s[2]的前缀有”a”,后缀有”b”,没有相同的,故最大长度为0

s[3]的前缀有”a”,”ab”,后缀有”a”,”ba”,最长相同的前缀后缀为”a”,长度为1,故f[3]=1

s[4]的前缀有”a”,”ab”,”aba”,后缀有”b”,”ab”,”bab”,其中”ab”相同,f[4]=2

s[5]的前缀有”a”,”ab”,”aba”,”abab”,后缀有”a”,”ba”,”aba”,”baba”,相同的前后缀有”aba”,长度为3,f[5]=3

s[6]的前缀有”a”,”ab”,”aba”,”abab”,”ababa”,后缀有”a”,”aa”,”baa”,”abaa”,”babaa”,最长相同前后缀为”a”,f[6]=1

s[7]的前缀有”a”,”ab”,”aba”,”abab”,”ababa”,”ababaa,后缀有”a”,”aa”,”aaa”,”baaa”,”abaaa”,”babaaa”,最长相同前后缀为”a”,f[7]=1

同理,k=8时,相同的前后缀为”ab”,f[8]=2。k=9时,相同的前后缀为”aba”,f[9]=3。k=10时,相同的前后缀为”abab”,f[10]=4

所以我们最后得到的f数组为:

sababaaababa
k012345678910
f[k]00012311234
相等的前后缀“”“”“”“a”“ab”“aba”“a”“a”“ab”“aba”“abab”

再重复一下f数组的含义:f[k]表示在第k次匹配失配时,我们无需从头开始匹配子串,而是可以直接从子串的第f[k]个字符开始继续匹配

失配函数的使用——KMP算法主体部分

先来回顾一下我们暴力算法

for(int i = 0; i < strlen(s) - strlen(t); i++) {//i表示子串的起始位置
    int j;
    for(j = 0; j < strlen(t); j++)//逐个比较需要寻找的子串中的第j个字符
        if(s[i+j] != s[j]) break;//匹配失败,跳出循环
    if(j == strlen(t)) return i;//匹配成功,返回子串所在位置
}
return -1;//循环结束,未找到子串

而采用了KMP算法后,我们不再需要枚举母串中所有长为m的子串并一一匹配,而是采取逐个字符匹配的方法。

如果s[i] == t[j],那么这意味着我们已经匹配好了子串中的前j个字符

如果s[i] != t[j],说明我们在匹配子串的第j位时失配了,根据KMP算法的思想,我们可以令j=f[j],继续判断s[i]是否和新的t[j]相等。

转换为代码:

int j = 0;//初始时匹配的是子串的第j个字符
for(int i = 0; i < strlen(s); i++)  {
    //while中条件j是为了防止死循环,然后判断第j位是否失配,若失配,则继续匹配第f[j]个字符
    while(j && s[i] != t[j]) j = f[j];
    //结束while时可能是由于匹配成功而退出,也有可能是由于j=0退出,在此讨论
    if(s[i] == t[j]) j++;//如果是第j位匹配成功,那么匹配下一位
    if(j == m) return i-m+1;//整个子串匹配完成,返回子串开始的位置
}
return -1;//循环结束,未找到子串

失配函数的计算——KMP的预处理部分

为什么先讲KMP的主体部分再讲预处理部分呢?因为预处理部分的计算方法和主体部分非常相似,而且先理解主体部分的思想有助于理解预处理部分的计算过程。

再回顾一下失配函数的定义:f[k]表示以在第k个位置失配时,下一次匹配的位置。下一次匹配的位置正是以k结尾的最长相同前后缀的长度。

具体该怎么求失配函数呢?首先f[0]=f[1]=0,因为我们无法找到对应的前后缀,故也不存在相同的前后缀。

然后,我们要寻找相同的前缀和后缀,那么,我们可以用类似找子串的方式,将子串本身向右平移,使得平移后的前缀和原来的后缀在同一个位置上,再和原来的子串进行比较,看重合的位数有多少,即说明公共的前后缀的长度有多少。既然是比较重合的位数,自然我们也不用考虑溢出的部分。

举个例子

我们将原子串即为t,平移i位的子串记为 ti t i

k012345678910备注
原子串ababaaababa
平移1位ababaaabab t[1]t1[0],f[2]=0 t [ 1 ] ≠ t 1 [ 0 ] , f [ 2 ] = 0
平移2位ababaaaba t[2]=t2[0],f[3]=1 t [ 2 ] = t 2 [ 0 ] , f [ 3 ] = 1
平移2位ababaaaba t[3]=t2[1],f[4]=2 t [ 3 ] = t 2 [ 1 ] , f [ 4 ] = 2
平移2位ababaaaba t[4]=t2[2],f[5]=3 t [ 4 ] = t 2 [ 2 ] , f [ 5 ] = 3
平移2位ababaaaba t[5]t2[3],f[6]=0 t [ 5 ] ≠ t 2 [ 3 ] , f [ 6 ] = 0
平移3位ababaaab t[3]t3[0],f[4]=max(f[3]+1,0)=2 t [ 3 ] ≠ t 3 [ 0 ] , f [ 4 ] = m a x ( f [ 3 ] + 1 , 0 ) = 2
平移4位ababaaa t[4]=t4[0],f[5]=max(f[4]+1,1)=3 t [ 4 ] = t 4 [ 0 ] , f [ 5 ] = m a x ( f [ 4 ] + 1 , 1 ) = 3
平移4位ababaaa t[5]=t4[1],f[6]=0 t [ 5 ] = t 4 [ 1 ] , f [ 6 ] = 0
平移5位ababaa t[5]=t5[0],f[6]=1 t [ 5 ] = t 5 [ 0 ] , f [ 6 ] = 1
平移5位ababaa t[6]t5[1],f[7]=0 t [ 6 ] ≠ t 5 [ 1 ] , f [ 7 ] = 0
平移6位ababa t[6]=t6[0],f[7]=1 t [ 6 ] = t 6 [ 0 ] , f [ 7 ] = 1
平移6位ababa t[7]=t6[1],f[8]=2 t [ 7 ] = t 6 [ 1 ] , f [ 8 ] = 2
平移6位ababa t[8]=t6[2],f[9]=3 t [ 8 ] = t 6 [ 2 ] , f [ 9 ] = 3
平移6位ababa t[9]=t6[3],f[10]=4 t [ 9 ] = t 6 [ 3 ] , f [ 10 ] = 4
平移7位abab t[7]t7[0],f[8]=max(f[7]+1,0)=2 t [ 7 ] ≠ t 7 [ 0 ] , f [ 8 ] = m a x ( f [ 7 ] + 1 , 0 ) = 2
平移8位aba t[8]=t8[0],f[9]=max(f[8]+1,1)=3 t [ 8 ] = t 8 [ 0 ] , f [ 9 ] = m a x ( f [ 8 ] + 1 , 1 ) = 3
平移8位aba t[9]=t8[1],f[10]=f[9]+1=4 t [ 9 ] = t 8 [ 1 ] , f [ 10 ] = f [ 9 ] + 1 = 4
平移9位ab t[9]t9[0],f[10]=max(f[9]+1,0)=4 t [ 9 ] ≠ t 9 [ 0 ] , f [ 10 ] = m a x ( f [ 9 ] + 1 , 0 ) = 4
f[k]00012311234

看懂了吗?我们将待匹配的串t进行平移,再和原来的t进行比较,以平移的串为基准,找到原串中与其重合的部分,这个重合的部分就对应原串中某个位置的相同的前缀和后缀,从而可以更新我们的f数组。转换为代码为:

f[0] = f[1] = 0;
for(int i = 1; i < m-2; i++) {//平移的位数
    int j = 0;//与原串的第j位比较
    while(i+j < m && t[i+j] == t[j]) {
        f[i+j+1] = max(f[i+j+1], f[i+j]+1);//当第i+j位匹配成功时,第i+j+1位所具有的最长前后缀的长度,即为第i+j位的最长前后缀长度+1
        j++;//继续判断下一位是否能匹配成功
    }
}

真正的预处理

有没有发现,这种方法和我们刚开始谈到的暴力找子串的方法有点类似?他们都是将要找的部分逐个平移,然后判断是否找到或是否需要更新。

如果上面的KMP主体部分你已经理解了的话,你很容易想到,这样逐个平移会造成和暴力一样的结果——产生很多次不必要的比较。

比方说,在平移2位时,我们找到了长度为3的前后缀,但在比较t[5]和t2[3]时失配了,然后我们是不是一定要讨论平移3位的情况呢?

其实没必要,因为此时我们已经更新过f[5]的值了,且f[5]=3也就意味着我们已经知道在5的位置上有相同的长度为3的相同前后缀。即t[0-2]和t[2-4]是相同的。虽然我们匹配t[5]失败了,但是我们可以考虑在t[2-4]内有没有已经知道的后缀能与前缀匹配,如果有的话,那我们直接将前缀移过来就可以了。而t[0-2]和t[2-4]相同,f[3]刚好记录的就是t[0-2]中的最长相同前后缀的长度。

换句话说,如果f[3]=l,说明在0-2内有一个长为l的后缀与长为l的前缀相同,又因为f[5]=3,说明t[0-2]和t[2-4]是相同的,因此t[2-4]内也同样有一个长为l的后缀与前缀相同,基于KMP的思想,我们就可以直接将这个前缀移过来。移过来之后我们需要比较的元素就是t[3]和t[5]了。

这个过程用文字表述起来确实很难理解,所以我在这里也花费了不少时间来举例子,如果实在难以理解的话可以反复看我的例子。

即用KMP思想优化后,以上找子串的过程为:

k012345678910备注
原子串ababaaababa
平移1位ababaaabab t[1]t1[0],f[2]=0 t [ 1 ] ≠ t 1 [ 0 ] , f [ 2 ] = 0
平移2位ababaaaba t[2]=t2[0],f[3]=1 t [ 2 ] = t 2 [ 0 ] , f [ 3 ] = 1
平移2位ababaaaba t[3]=t2[1],f[4]=2 t [ 3 ] = t 2 [ 1 ] , f [ 4 ] = 2
平移2位ababaaaba t[4]=t2[2],f[5]=3 t [ 4 ] = t 2 [ 2 ] , f [ 5 ] = 3
平移2位ababaaaba t[5]t2[3],j=f[3]=1 t [ 5 ] ≠ t 2 [ 3 ] , j = f [ 3 ] = 1 ,将t[j]移动到和t[5]对齐的位置
平移4位ababaaa t[5]t4[2],j=f[1]=0 t [ 5 ] ≠ t 4 [ 2 ] , j = f [ 1 ] = 0 ,将t[0]移动到和t[5]对齐的位置
平移5位ababaa t[5]=t5[0],f[6]=1 t [ 5 ] = t 5 [ 0 ] , f [ 6 ] = 1
平移5位ababaa t[6]t5[1],j=f[1]=0 t [ 6 ] ≠ t 5 [ 1 ] , j = f [ 1 ] = 0 ,将t[0]移动到和t[6]对齐的位置
平移6位ababa t[6]=t6[0],f[7]=1 t [ 6 ] = t 6 [ 0 ] , f [ 7 ] = 1
平移6位ababa t[7]=t6[1],f[8]=2 t [ 7 ] = t 6 [ 1 ] , f [ 8 ] = 2
平移6位ababa t[8]=t6[2],f[9]=3 t [ 8 ] = t 6 [ 2 ] , f [ 9 ] = 3
平移6位ababa t[9]=t6[3],f[10]=4 t [ 9 ] = t 6 [ 3 ] , f [ 10 ] = 4
f[k]00012311234f计算完毕

这样找子串,我们就不需要逐个平移,而是在更新f数组的过程中,同时也在调用f数组内的值,借用f数组内的值来舍弃掉许多没有必要的平移。

转换为代码为:

f[0] = f[1] = 0;//初始化
int j = 0;//刚开始比较的是0位
for(int i = 1; i < m-1; i++) {//平移i位的串与原串做比较
    //while中的j防止死循环,不判断j=0时的情况
    while(j && t[i] != t[j]) j = f[j];//跳过比较,直接将第f[j]位移动到第i位继续进行比较
    //j=0的情况在这里考虑
    if(t[i] == t[j]) j++;//第j位匹配成功,准备匹配下一位
    f[i+1] = j;//已经为第i+1个字符找到了一个长为j的相同前后缀
}

怎么样?这个代码和KMP的主代码是不是有异曲同工之妙呢?

这两段代码本来也长得非常相似,所以一定要注意他们的区别哦

小结

KMP算法主要用于在一个母串中寻找子串

暴力寻找子串的方法复杂度为O(mn),其中包含了大量多余的匹配过程。而KMP算法利用每次匹配失配的位置,可以确定母串中一小段内容与子串相同,通过这些信息可以跳过一些多余的比较。而跳过之后所需要进行的下一次比较的位置,由我们的失配函数f来决定,而f[k]的值即为以k为结束的最长相同前后缀的长度。每次在第k个位置失配后,可以直接跳到第f[k]个位置继续进行比较,而中间的部分是可以直接排除的。

失配函数是由待匹配串本身的构造而决定的,求失配函数的过程相当于是【在失配函数中用KMP算法找失配函数】,所以找失配函数的代码与KMP主过程的代码非常相似。

虽然有一些玄学的影响,但是我们普遍认为KMP的复杂度是O(n)级别的,但是当子串无重复或重复片段非常少时,KMP算法也会退化到O(mn)

KMP算法是算法竞赛中常见的算法,其变化不但包括找字符串的子串,也可以找整数的子串。不但可以判断子串是否存在,还能判断子串在母串中出现的最大长度。还有一些其他的变形,所以热衷于算法竞赛的童鞋一定要学会KMP算法哦~

最后开个车,KMP算法有个很厉害的名字————————————(KanMaoPian)

这不是去幼儿园的车,我要下车!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值