字符串匹配算法是非常常见的算法。考虑长度为
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
k<q\leq m
k<q≤m。进一步,第10行赋值
π
[
q
]
=
k
\pi[q]=k
π[q]=k,因此,
π
[
q
]
<
q
\pi[q]<q
π[q]<q。换个符号,也等价于
π
[
k
]
<
k
\pi[k]<k
π[k]<k。所以,第7行的赋值意味着减小
k
k
k。至此,我们知道,第7行减小
k
k
k,第9行增加
k
k
k,并且
k
<
q
≤
m
k<q\leq m
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。