字符串匹配算法是非常常见的算法。考虑长度为 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 m≤n。暴力求解(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[n−m+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]不相等。这种移位,减少了不必要的匹配。
在右移两位之后,开始新的匹配,如(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 π[6−1]−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=π[q−1],这意味着 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[q−k+1,q−k+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的主算法如下,它调用了上面的辅助序列生成算法,并且与辅助序列生成算法在形式上十分相似。
下面分析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<q≤m
。进一步,第10行赋值 π [ q ] = k \pi[q]=k π[q]=k,因此, π [ q ] < q \pi[q]π[q]<q
。换个符号,也等价于 π [ k ] < k \pi[k]k<q≤m
。因为第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。