字符串匹配KMP算法及其时间复杂度分析

   字符串匹配算法是非常常见的算法。考虑长度为 n n n的文本(text)字符串 A [ 1 , 2 , ⋯   , n ] A[1,2,\cdots,n] A[1,2,,n],长度为 m m m的匹配(pattern)字符串 B [ 1 , 2 , ⋯   , m ] B[1,2,\cdots,m] B[1,2,,m],并且 m ≤ n m\leq n mn。暴力求解(brute-force)的匹配算法十分直接。将 B B B逐位与 A A A进行对比,直到 B B B完全匹配 A A A的某个子串。例如,先拿 B B B A [ 1 , 2 , ⋯   , m ] A[1,2,\cdots,m] A[1,2,,m]匹配,如果失败,尝试匹配 B B B A [ 2 , 3 , ⋯   , m + 1 ] A[2,3,\cdots,m+1] A[2,3,,m+1],以此类推,直到匹配 B B B A [ n − m + 1 , ⋯   , n ] A[n-m+1,\cdots,n] A[nm+1,,n]。该方法的时间复杂度为 O ( m n ) O(mn) O(mn),详细的分析可以参考参考文献[2]。
   Knuth, Morris和Pratt三人提出了时间复杂度为线性的KMP算法。该算法将时间复杂度从暴力求解的 O ( m n ) O(mn) O(mn)降低为 O ( n + m ) O(n+m) O(n+m)。下面详细讨论该算法,主要参考参考文献[1]。
   考虑下面的图示,其中文本字符串记为 T T T,匹配字符串记为 P P P。匹配字符串为 P = ′ a b a b a c a ′ P='ababaca' P=ababaca。当匹配进行到(a)所示的这一步时, P P P相对于 T T T移动了 s s s位,并且前5位均能正确匹配,匹配失败在第6位。此时, P [ 6 ] = ′ c ′ P[6]='c' P[6]=c,对应的 T [ s + 6 ] = ′ a ′ T[s+6]='a' T[s+6]=a。假如我们正在使用暴力求解算法,当前的匹配失败后,此时我们需要将 P P P向右再移动移位,即总体相对于 T T T移动 s + 1 s+1 s+1位,使得 P [ 1 ] = ′ a ′ P[1]='a' P[1]=a对准 T [ s + 2 ] = ′ b ′ T[s+2]='b' T[s+2]=b,开始新的匹配。显然,这样的匹配也是失败的,并且在第一位就失败了。于是,继续移动 P P P,将它右移一位,使得 P [ 1 ] = ′ a ′ P[1]='a' P[1]=a对准 T [ s + 3 ] = ′ a ′ T[s+3]='a' T[s+3]=a,再次开始匹配。
   在观察上面的匹配过程的时候,我们发现,其实我们在 P P P移动 s s s位的这次匹配失败后,可以直接右移两位,而不是一位。右移两位是因为我们可以看到 T [ s + 2 ] = ′ a ′ = P [ 1 ] T[s+2]='a'=P[1] T[s+2]=a=P[1],而移动移位之后对准的 T [ s + 1 ] T[s+1] T[s+1]显然和 P [ 1 ] P[1] P[1]不相等。这种移位,减少了不必要的匹配。
字符串匹配KMP算法及其时间复杂度分析_第1张图片
   在右移两位之后,开始新的匹配,如(b)所示,此时需要考虑一个问题,那就是我们还需要从第一位 P [ 1 ] P[1] P[1]开始匹配吗?显然不是,从图中可以看出, P [ 1 , 2 , 3 ] P[1,2,3] P[1,2,3]已经和 T [ s + 3 , s + 4 , s + 5 ] T[s+3,s+4,s+5] T[s+3,s+4,s+5]匹配好了,只需要从 P [ 4 ] P[4] P[4]开始匹配。如此一来,相较于暴力求解,又减少了匹配次数。可问题是,我们怎么知道前几是匹配好了,然后从某个点开始新匹配呢?例如在(b)中,我们如何知道前3个点是匹配的,从而从第4个点开始匹配?显然,在(a)的匹配中,我们已经比较过 [ s + 3 , s + 4 , s + 5 ] [s+3,s+4,s+5] [s+3,s+4,s+5]的值了,因此我们可以通过某种手段,将他们的信息储存起来,这种储存方式不一定是显性的,他可以是某种隐含地方式。
   为实现上面分析的想法,我们引入一个辅助(auxiliary)序列 π [ 1 , 2 , ⋯   , m ] \pi[1,2,\cdots,m] π[1,2,,m],他和 P P P等长。辅助序列是实现上述算法思想的关键。从(a)到(b)的关键是需要知道 P [ 1 ] P[1] P[1] T [ s + 1 ] T[s+1] T[s+1]往后的元素中的哪一个是匹配的,我们就把 P P P移动到 P [ 1 ] P[1] P[1]与之对齐。在(a)中,匹配失败于 P [ 6 ] P[6] P[6],假如辅助序列的相邻位可以提供给我们信息,告诉我们现在可以右移2位,使得 P [ 1 ] P[1] P[1] T [ s + 3 ] T[s+3] T[s+3]是匹配的,那我们的想法就实现了。比如 π [ 5 ] \pi[5] π[5]这个元素告诉我们可以右移2位,即 π [ 5 ] = 2 \pi[5]=2 π[5]=2
   实际上, π \pi π中的元素 π [ i ] \pi[i] π[i]表示的是在序列 B [ 1 , 2 , ⋯   , i ] B[1,2,\cdots,i] B[1,2,,i]中,最多有前 π [ i ] \pi[i] π[i]个元素和后 π [ i ] \pi[i] π[i]个元素对应相等,即 B [ 1 , 2 , ⋯   , π [ i ] ] = B [ i − π [ i ] + 1 , i − π [ i ] + 3 ⋯   , i ] B[1,2,\cdots,\pi[i]]=B[i-\pi[i]+1,i-\pi[i]+3\cdots,i] B[1,2,,π[i]]=B[iπ[i]+1,iπ[i]+3,i]。例如,上图中的 P P P对应的 π \pi π π = [ 0 , 0 , 1 , 2 , 3 , 0 , 1 ] \pi=[0,0,1,2,3,0,1] π=[0,0,1,2,3,0,1]。有了 π \pi π,我们再来看如何由(a)变到(b)。在(a)中,匹配于 P [ 6 ] P[6] P[6]失败,于是我们查询其前一位的辅助序列元素 π [ 5 ] = 3 \pi[5]=3 π[5]=3 π [ 5 ] = 3 \pi[5]=3 π[5]=3意味着 P [ 1 , 2 , 3 ] = P [ 3 , 4 , 5 ] P[1,2,3]=P[3,4,5] P[1,2,3]=P[3,4,5]。此外,我们的匹配在 P [ 6 ] P[6] P[6]失败,意味着之前的匹配是成功的,于是有 T [ s + 1 , ⋯   , s + 5 ] = P [ 1 , ⋯   , 5 ] T[s+1,\cdots,s+5]=P[1,\cdots,5] T[s+1,,s+5]=P[1,,5],结合 P [ 1 , 2 , 3 ] = P [ 3 , 4 , 5 ] P[1,2,3]=P[3,4,5] P[1,2,3]=P[3,4,5],于是有 P [ 1 , 2 , 3 ] = P [ 3 , 4 , 5 ] = T [ s + 3 , s + 4 , s + 5 ] P[1,2,3]=P[3,4,5]=T[s+3,s+4,s+5] P[1,2,3]=P[3,4,5]=T[s+3,s+4,s+5],于是我们需要将 P P P右移 π [ 6 − 1 ] − 1 = 2 \pi[6-1]-1=2 π[61]1=2位,使得 P [ 1 , 2 , 3 ] P[1,2,3] P[1,2,3] T [ s + 3 , s + 4 , s + 5 ] T[s+3,s+4,s+5] T[s+3,s+4,s+5]对齐。新的匹配从 P [ 4 ] P[4] P[4] T [ s + 6 ] T[s+6] T[s+6]开始。需要注意的是,从(a)到(b),虽然 P P P移位了,并且新的匹配点变成了 P [ 4 ] P[4] P[4],但是 T T T的匹配点并没有变,仍然是 T [ s + 6 ] T[s+6] T[s+6]
   辅助序列 π \pi π的生成算法如下。他的思想是,对于某个 π [ q ] \pi[q] π[q] k = π [ q − 1 ] k=\pi[q-1] k=π[q1],这意味着 P [ 1 , 2 , ⋯   , k ] = P [ q − k + 1 , q − k + 2 , ⋯   , q ] P[1,2,\cdots,k]=P[q-k+1,q-k+2,\cdots,q] P[1,2,,k]=P[qk+1,qk+2,,q]。比较当前 P [ k + 1 ] P[k+1] P[k+1]是否与 P [ q ] P[q] P[q]匹配,如果匹配,则 π [ q ] = k + 1 \pi[q]=k+1 π[q]=k+1。如果不匹配,则寻找前面某个 k k k,使得 P [ k + 1 ] = P [ q ] P[k+1]=P[q] P[k+1]=P[q]。寻找前面的某个 k k k,我们还得使匹配序列的长度尽量大,因此,令 k = π [ k ] k=\pi[k] k=π[k]
字符串匹配KMP算法及其时间复杂度分析_第2张图片
   KMP的主算法如下,它调用了上面的辅助序列生成算法,并且与辅助序列生成算法在形式上十分相似。
字符串匹配KMP算法及其时间复杂度分析_第3张图片
   下面分析KMP算法的时间复杂度。很多网上的博客都没有讲清楚其复杂度的分析,大多数点出用摊还(amortized)分析法,这里我们直接引用参考文献[2]的分析方法,简单易懂。由于KMP主算法的结构与序列生成算法几乎一样,所以我们分析序列生成算法的时间复杂度,KMP主算法的分析类似可得。
   序列生成算法的时间复杂度主要由第5行的for循环里面的内容决定。第10行的赋值,其时间复杂度是 O ( m ) O(m) O(m),这是显然的。剩下的需要分析的是第7行和第9行的执行次数。这两行均是对 k k k的值进行改变,因此我们研究一下 k k k的取值区间。在刚进入for循环的时候, k k k被赋值为0,而 q q q被赋值为2。在for循环中,只有第9行执行的时候, k k k才增加1。而每一次for循环, q q q都会增加1。每次循环不一定执行第9行。于是,我们知道,整个算法中,都有 k < q ≤ m kk<qm。进一步,第10行赋值 π [ q ] = k \pi[q]=k π[q]=k因此, π [ q ] < q \pi[q]π[q]<q。换个符号,也等价于 π [ k ] < k \pi[k]π[k]<k。所以,第7行的赋值意味着减小 k k k至此,我们知道,第7行减小 k k k,第9行增加 k k k,并且 k < q ≤ m kk<qm。因为第9行执行的次数最多为 m m m,所以第7行执行的次数也不会超过 m m m次。综上,序列生成算法中所有步骤的时间复杂度均是 O ( m ) O(m) O(m),所以算法的总时间复杂度就是 O ( m ) O(m) O(m)。同理,KMP主算法的时间复杂度是 O ( n ) O(n) O(n),整个KMP算法的时间复杂度是 O ( n + m ) O(n+m) O(n+m)

参考文献

[1] Cormen T H, Leiserson C E, Rivest R L, et al,Introduction to algorithms,2009。
[2] 阮行止​,如何更好地理解和掌握 KMP 算法?,2020-02-23。

你可能感兴趣的:(算法,数据结构)