字符串学习笔记1——前缀数组与KMP算法

(之前学KMP的时候就一直没学会,其间找了很多资料也没学会,直到现在过去两年多了才真正学会,因而写篇文章记录一下)

在涉及KMP算法之前,先来看一个东西——前缀数组。

什么是前缀数组?就是一个和字符串等长的数组,里边记录了 s [ 0 , 1 , … … , i ] s[0,1,……,i] s[0,1,,i]中最大真前缀等于真后缀的长度,用 π ( i ) \pi(i) π(i)表示。用严格的数学公式定义,就是:

π ( i ) = max ⁡ 0 ≤ k ≤ i { s [ 0 , 1 , … … , k − 1 ] = s [ i − ( k − 1 ) , … … , i ] } \pi(i)=\displaystyle \max_{0 \leq k \leq i} \{ s[0,1,……,k-1]=s[i-(k-1),……,i] \} π(i)=0kimax{s[0,1,,k1]=s[i(k1),,i]}

真前缀、真后缀表示除了自己以外的其他前后缀,那么长度必然小于原长。

那这又是个啥呢?举个例子:

现在有这么个字符串: a b a b a c a \tt ababaca ababaca

通常规定 π ( 0 ) = 0 \pi(0)=0 π(0)=0。显然它都没有真前后缀。

那么对于 π ( 1 ) \pi(1) π(1),表示字串 a b \tt ab ab。显然它没有这样的真前后缀:因为 a ≠ b \tt a \neq b a=b,因而 π ( 1 ) = 0 \pi(1)=0 π(1)=0

对于 π ( 2 ) \pi(2) π(2),它对应着字串 a b a \tt aba aba。我们可以找到这样相等的前后缀: a ‾ b a ‾ \tt \underline ab \underline a aba。显然没有更长的了,因为 a b ≠ b a \tt ab \neq ba ab=ba

π ( 3 ) = 2 \pi(3)=2 π(3)=2,因为 a b ‾ \tt \underline {ab} ab a b ‾ \tt\underline {ab} ab

依次类推, π ( 4 ) = 3 \pi(4)=3 π(4)=3,因为 ( a b a ) a b = a b ( a b a ) \tt (aba)ab=ab(aba) (aba)ab=ab(aba) π ( 5 ) = 0 , π ( 6 ) = 1 \pi(5)=0,\pi(6)=1 π(5)=0,π(6)=1

那么问题来了——怎么求这样的一个东西。

首先一个朴素思想:首先枚举字符串长度,然后再枚举子串长度,最后逐字符判定前后缀是否相等。

但是看看这循环次数:

枚举字符串长度 O ( n ) { O(n)\{ O(n){枚举子串长度 O ( n ) { O(n)\{ O(n){逐字符判定前后缀是否相等 O ( n ) } } } O(n)\}\}\} O(n)}}} O ( n 3 ) O(n^3) O(n3)显然不可承受。毕竟我们还要那这它去做字符串匹配,这倒好,做开头工作都比正式工作的 O ( n m ) O(nm) O(nm)还要慢了。

那这怎么办呢?

其实第一个优化很容易想到:字串长度真的每次都需要从原长-1开始枚举?

给定下面一个字符串:

s 1 s 2 s 3 … … s i − 3 s i − 2 s i − 1 s i s_1s_2s_3……s_{i-3}s_{i-2}s_{i-1}s_i s1s2s3si3si2si1si

现在新来了一个 s i + 1 s_{i+1} si+1。如果我知道 π ( i ) = 0 \pi(i)=0 π(i)=0,我有没有必要还枚举它的长度?显然是没必要的。因为如果 π ( i ) = 0 \pi(i)=0 π(i)=0,那证明这里边根本就没有能匹配的上的前后缀。如果 π ( i + 1 ) ≥ 2 \pi(i+1)\geq 2 π(i+1)2,那么就势必证明前面已经有能匹配的上的,因为对于 s i + 1 s_{i+1} si+1匹配的是前面的 s k s_k sk,对于 s k s_k sk及其前面的字符串显然有 s i s_i si及其前面的字符串进行匹配,那也不至于说 π ( i ) = 0 \pi(i)=0 π(i)=0

举个例子: a b a c a \tt abaca abaca。对于 a b a c \tt abac abac,显然 π ( 4 ) = 0 \pi(4)=0 π(4)=0,此时来了个 a \tt a a π ( 5 ) = 1 \pi(5)=1 π(5)=1,不会大于等于2,因为一旦大于等于2,那么此时 c \tt c c就要找一个前面的字符去匹配了,但是这里并没有,因而不能。所以, π ( i + 1 ) ≤ π ( i ) + 1 \pi(i+1) \leq \pi(i)+1 π(i+1)π(i)+1

此处还有另一种证明:考虑最优情况, s i + 1 = s π ( i ) + 1 s_{i+1}=s_{\pi(i)+1} si+1=sπ(i)+1 π ( i + 1 ) = π ( i ) + 1 \pi(i+1)=\pi(i)+1 π(i+1)=π(i)+1。其他情况只会比这个更糟糕。因而这是上界。

因此,我们在第二重循环枚举子串长度的时候,可以只从长度 π ( i ) + 1 \pi(i)+1 π(i)+1开始。这样可以降低一个 O ( n ) O(n) O(n)的复杂度,也就是 O ( n 2 ) O(n^2) O(n2)

但是这还是不可接受的。再怎么办?

我们枚举长度的时候真的需要每一个长度都去考虑吗? 我们可否利用我们已经做出来的前缀数组,进行这一轮的计算呢?

首先我们考虑一个简单的情况:对于一个字符串

s 1 s 2 s 3 … … s i − 3 s i − 2 s i − 1 s i s_1s_2s_3……s_{i-3}s_{i-2}s_{i-1}s_i s1s2s3si3si2si1si

如果存在一个 j j j,使得 s [ 0 , 1 , … … , j − 1 ] = s [ i − ( j − 1 ) , … … , i ] s[0,1,……,j-1]=s[i-(j-1),……,i] s[0,1,,j1]=s[i(j1),,i],同时 s [ j ] = s [ i + 1 ] s[j]=s[i+1] s[j]=s[i+1],那这时是不是匹配成功了?我们把问题拆解成:前 j − 1 j-1 j1个字符和后面 j − 1 j-1 j1个字符匹配,最后第 j j j个字符和新来的字符匹配,如果都成功就是匹配成功了。

举个例子: a b a b a \tt ababa ababa,原来是 a b a b \tt abab abab,这时来了个 a \tt a a,我们发现取 j = 3 j=3 j=3,这时 a b ‾ \tt \underline {ab} ab a b ‾ \tt\underline {ab} ab,同时第三位的 a \tt a a和最后一位的 a \tt a a相同,这时匹配成功了。

那么,最长的 j j j就是我们想要的。显然这种 j j j不止一个,例如在上面的例子中 j j j还可以等于1。那这又和我们之前做出来的前缀数组有什么关系呢?

由于后面那个单字符匹配仅需 O ( 1 ) O(1) O(1)的时间,因此我们考虑前面这个问题:如何找到一个第二长的前后缀匹配长度?

考虑现在长度为 i i i的字符串 s i s_i si,记 π ( i ) = k \pi(i)=k π(i)=k。我们要找到一个最长的 j j j满足上一段的条件。

s 1 s 2 s 3 … … s k ⏞ π ( i ) = k s k + 1 … … s i − k s i − k + 1 … … s i ⏞ π ( i ) = k \overbrace{s_1s_2s_3……s_k}^{\pi(i)=k}s_{k+1}……s_{i-k}\overbrace{s_{i-k+1}……s_i}^{\pi(i)=k} s1s2s3sk π(i)=ksk+1siksik+1si π(i)=k

显然在 π ( i ) \pi(i) π(i)的区间里,前后缀是相同的,并且最长的。我们要找的 j j j,一定也是在 π ( i ) \pi(i) π(i)这个匹配范围内的。

如果存在一个 j j j满足条件,那么显然, s [ 1 , 2 , … … , j ] = s [ i − j + 1 , … … , i ] s[1,2,……,j]=s[i-j+1,……,i] s[1,2,,j]=s[ij+1,,i]。但是,我们还有一个大条件:在 π ( i ) \pi(i) π(i)的区间里,前后缀是相同的。那么,后面这一段我们就一定可以在前 π ( i ) \pi(i) π(i)的区间中找到一模一样的。即,

s [ 1 , 2 , … … , j ] = s [ k − j + 1 , … … , k ] s[1,2,……,j]=s[k-j+1,……,k] s[1,2,,j]=s[kj+1,,k]

把目光放在前 π ( i ) \pi(i) π(i)的字符串中。那这,不就是这个小字符串的最长前后缀匹配过程?那不就有 j = π ( i ) j=\pi(i) j=π(i)了。

举个例子:

a b a b a d e f a b a b a \tt ababadefababa ababadefababa

我们有 a b a b a ‾ d e f a b a b a ‾ \tt \underline {ababa}def \underline {ababa} ababadefababa。注意到 a b a ‾ b a d e f a b a b a ‾ \tt \underline{aba}badefab\underline{aba} ababadefababa。由上面的分析,我们就一定可以在前五个字符中找到自匹配的。的确存在: a b a b a \tt ababa ababa中确实存在公共前后缀 a b a \tt aba aba

因此,我们只需要每次将 j j j移动到 π ( i ) \pi(i) π(i)的位置即可,不必逐一枚举。

而且,这样处理的复杂度仅为 O ( n ) O(n) O(n),代码也简单的离谱。

int j=0;//初始化为0。
for(int i=1;i<=len;i++)
{
	while(j && b[j+1]!=b[i])//如果当前的j不满足和新来的匹配,那么它就不是一个合格的j,往前跳
		j=next[j];//跳的公式
	if(b[j+1]==b[i])//找到了合法的非零j
		j++;//匹配成功,j后移一位
	next[i]=j;
}

注意:上述代码中j并未清零重新赋值,是因为这样每轮变化后的j本身就等于前一位的next[i](倒数第二行),因而不用重新赋值。

正式引出KMP!

正式的字符串匹配其实就很简单了!

对于待匹配的字符串 S \tt S S(也称模式串)和文本串 T \tt T T,我们只要用一个没有都出现过的字符例如$插在中间:

S $ T \tt S \$ T S$T

然后跑前缀数组就行了!

这为什么对?

因为显然由于这个不会出现的$,这个前缀数组不会超过 S \tt S S的长,那么匹配范围就不会越过 S \tt S S T \tt T T。这时,当匹配过程进入 T \tt T T区间时,这个数组内的值就直接反应了它和 S \tt S S的匹配情况。当这个数等于 S \tt S S的长的时候,就证明前后缀相同,又由于后缀一定在 T \tt T T,而前缀一定在 S \tt S S区间,则 T \tt T T中出现了 S \tt S S的全文。

代码基本上文一样,不再重复。

当然还有一种:直接在 T \tt T T上跑匹配过程,不再更新它这一部分的前缀数组。这一种更流行,代码附上。

j=0;
    for(int i=1;i<=lena;i++)
    {
        while(j && b[j+1]!=a[i])//匹配过程,只不过现在是跨字符串的匹配
            j=next[j];
        if(b[j+1]==a[i])//找到了非零的j
            j++;
        if(j==lenb)//匹配长度为模式串全长,则T中出现S原文
        {
            printf("%d\n",i-lenb+1);//打印位置
            j=next[j];//移动到下一位准备下一次的匹配过程
        }
    }
  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值