本文所有内容整理自《算法导论》第32章。
绪论
字符串匹配问题的形式化定义为:假设文本(text)是一个长度为 n n n 的数组 T [ 1.. n ] T[1..n] T[1..n] ,而模式(pattern)是一个长度为 m m m 的数组 P [ 1.. m ] P[1..m] P[1..m] ,其中 m ⩽ n m \leqslant n m⩽n ,进一步假设 P P P 和 T T T 的元素都是来自一个有限字母集 ∑ \sum ∑ 的字符。例如, ∑ = { 0 , 1 } \sum = \{0,1\} ∑={0,1} 或者 ∑ = { a , b , ⋯   , z } \sum = \{ a,b,\cdots,z \} ∑={a,b,⋯,z} 。字符数组 P P P 和 T T T 通常称为字符串。
如果 0 ⩽ s ⩽ n − m 0\leqslant s \leqslant n-m 0⩽s⩽n−m ,并且 T [ s + 1.. s + m ] = P [ 1.. m ] T[s+1..s+m]=P[1..m] T[s+1..s+m]=P[1..m] ,那么称模式 P P P 在文本 T T T 中出现,且偏移为 s s s 。如果 P P P 在 T T T 中以偏移 s s s 出现,那么称 s s s 是有效偏移;否则,称它为无效偏移。字符串匹配问题就是找到所有的有效偏移,使得在该有偏移下,所给的模式 P P P 出现在给定的文本 T T T 中。
本文将阐述四种算法,其对比如下:
算法 | 预处理时间 | 匹配时间 |
---|---|---|
朴素算法 | 0 0 0 | O ( ( n − m + 1 ) m ) O((n-m+1)m) O((n−m+1)m) |
Rabin-Karp | Θ ( m ) \Theta(m) Θ(m) | O ( ( n − m + 1 ) m ) O((n-m+1)m) O((n−m+1)m) |
有限自动机算法 | O ( m ∣ ∑ ∣ ) O(m\vert \sum \vert) O(m∣∑∣) | Θ ( n ) \Theta(n) Θ(n) |
Knuth-Morris-Pratt | Θ ( m ) \Theta(m) Θ(m) | Θ ( n ) \Theta(n) Θ(n) |
除了表格中直接指出以外,本文不会提及时间复杂度的问题,也不做时间复杂度分析,因为我不会。
朴素字符串匹配算法
朴素字符串匹配算法是通过一个循环找到所有有效偏移,该循环对 n − m + 1 n-m+1 n−m+1 个可能的 s s s 值进行检测,看是否满足条件 P [ 1.. m ] = T [ s + 1.. s + m ] P[1..m]=T[s+1..s+m] P[1..m]=T[s+1..s+m] 。
NAIVE-STRING-MATCHER(T,P)
n = T.length
m = P.length
for s = 0 to n-m
if P[1..m] == T[s+1..s+m]
print "Pattern occurs with shift"s
这种算法效率很低,因为当发现 s s s 无效时,它完全忽略了检测无效 s s s 值时获得的文本的信息。
Rabin-Karp 算法
不失一般性,我们假设
∑
=
{
0
,
1
,
2
,
⋯
 
,
9
}
\sum = \{ 0,1,2,\cdots,9 \}
∑={0,1,2,⋯,9} ,即每个字符都是十进制数字(在一般情况下,可以假定每个字符都是以
d
d
d 为基数表示的数字,其中
d
=
∣
Σ
∣
d=\vert\Sigma\vert
d=∣Σ∣)。那么,我们便可以用长度为
k
k
k 的十进制数来表示由
k
k
k 个连续的字符组成的字符串。比如,字符串'31415'
对应着十进制数31415
。
给定一个模式 P [ 1.. m ] P[1..m] P[1..m] ,假设 p p p 表示其相应的十进制值。类似地,给定文本 T [ 1.. n ] T[1..n] T[1..n] ,假设 t s t_s ts 表示长度为 m m m 的子字符串 T [ s + 1.. s + m ] T[s+1..s+m] T[s+1..s+m] 所对应的十进制值,其中, s = 0 , 1 , ⋯   , n − m s=0,1,\cdots,n-m s=0,1,⋯,n−m 。当且仅当 T [ s + 1.. s + m ] = P [ 1.. m ] T[s+1..s+m]=P[1..m] T[s+1..s+m]=P[1..m] 时, t s = p t_s=p ts=p 。那么,通过比较 p p p 和每一个 t s t_s ts 值,就能找到所有的有效偏移 s s s 。
计算
p
p
p 时可以运用霍纳法则:
p
=
P
[
m
]
+
10
(
P
[
m
−
1
]
+
10
(
P
[
m
−
2
]
+
⋯
+
10
(
P
[
2
]
+
10
P
[
1
]
)
⋯
 
)
)
p=P[m]+10(P[m-1]+10(P[m-2]+\cdots+10(P[2]+10P[1])\cdots))
p=P[m]+10(P[m−1]+10(P[m−2]+⋯+10(P[2]+10P[1])⋯))
计算
t
s
t_s
ts 时可以使用递归:
t
s
+
1
=
10
(
t
s
−
1
0
m
−
1
T
[
s
+
1
]
)
+
T
[
s
+
m
+
1
]
t_{s+1}=10(t_s-10^{m-1}T[s+1])+T[s+m+1]
ts+1=10(ts−10m−1T[s+1])+T[s+m+1]
在实际操作过程中,
p
p
p 和
t
s
t_s
ts 的值可能太大,影响对其进行的操作,因而,我们需要在原有式子上添加模数
q
q
q ,则公式变为这样:
p
=
(
P
[
m
]
+
d
(
P
[
m
−
1
]
+
d
(
P
[
m
−
2
]
+
⋯
+
d
(
P
[
2
]
+
d
P
[
1
]
)
⋯
 
)
)
)
 
m
o
d
 
q
p=(P[m]+d(P[m-1]+d(P[m-2]+\cdots+d(P[2]+dP[1])\cdots))) \bmod q
p=(P[m]+d(P[m−1]+d(P[m−2]+⋯+d(P[2]+dP[1])⋯)))modq
t
s
+
1
=
(
d
(
t
s
−
h
T
[
s
+
1
]
)
+
T
[
s
+
m
+
1
]
)
 
m
o
d
 
q
t_{s+1}=(d(t_s-hT[s+1])+T[s+m+1]) \bmod q
ts+1=(d(ts−hT[s+1])+T[s+m+1])modq
其中
h
≡
d
m
−
1
(
m
o
d
q
)
h \equiv d^{m-1} \pmod q
h≡dm−1(modq) 。
当然基于模 q q q 得到的结果并不完美,模 q q q 同余不代表就相等,但可以作为一种快速的启发式测试方法用于检测无效偏移,排除掉这些无效偏移后,剩下的再用朴素算法去一一验证即可。
RABIN-KARP-MATCHER(T,P,d,q)
n = T.length
m = P.length
h = d^{m-1} mod q
p = 0
t_0 = 0
// preprocessing
for i = 1 to m
p = (dp + P[i]) mod q
t_0 = (dt_0 + T[i]) mod q
// matching
for s = 0 to n-m
if p == t_s
if P[1..m] == T[s+1..s+m]
print "Pattern occurs with shift"s
if s < n-m
t_{s+1} = (d(t-T[s+1]h) + T[s+m+1]) mod q
在最坏情况下,Rabin-Karp和朴素算法是一样的,因为它们都会对每个有效偏移进行显示验证。
利用有限自动机进行字符串匹配
一个有限自动机 M M M 是一个五元组 ( Q , q 0 , A , Σ , δ ) (Q,q_0,A,\Sigma,\delta) (Q,q0,A,Σ,δ) ,其中:
- Q Q Q 是状态的有限集合。
- q 0 ∈ Q q_0\in Q q0∈Q 是初始状态。
- A ⊆ Q A\subseteq Q A⊆Q是一个特殊的接受状态集合。
- Σ \Sigma Σ 是有限输入字母表。
- δ \delta δ 是一个从 Q × Σ Q\times \Sigma Q×Σ 到 Q Q Q 的函数,称为 M M M的转移函数。
有限自动机开始于状态 q 0 q_0 q0 ,每次读入输入字符串的一个字符。如果有限自动机在状态 q q q 时读入了字符 a a a ,则它从状态 q q q 变为状态 δ ( q , a ) \delta(q,a) δ(q,a) (进行了一次转移)。每当其当前状态 q q q 属于 A A A 时,就说自动机 M M M 接受了迄今为止所读入的字符串。没有被接受的输入称为被拒绝的输入。
举例来说,假如有一个有限自动机 M = ( Q = { 0 , 1 } , q 0 = 0 , A = { 1 } , Σ = { a , b } , δ ) M=(Q=\{0,1\},q_0=0,A=\{1\},\Sigma=\{a,b\},\delta) M=(Q={0,1},q0=0,A={1},Σ={a,b},δ) ,其中, δ ( 1 , a ) = δ ( 1 , b ) = δ ( 0 , b ) = 0 , δ ( 0 , a ) = 1 \delta(1,a)=\delta(1,b)=\delta(0,b)=0,\delta(0,a)=1 δ(1,a)=δ(1,b)=δ(0,b)=0,δ(0,a)=1 。则不难看出,这个自动机只接受以奇数个 a a a 结尾的字符串。比如,对于输入 a b a a a abaaa abaaa ,则状态序列为 0 , 1 , 0 , 1 , 0 , 1 0,1,0,1,0,1 0,1,0,1,0,1 ,因而它接受这个输入。如果输入是 a b b a a abbaa abbaa ,则状态序列为 0 , 1 , 0 , 0 , 1 , 0 0,1,0,0,1,0 0,1,0,0,1,0 ,因而它拒绝这个输入。
对于一个给定的模式 P P P ,我们可以在预处理阶段构造出一个字符串匹配自动机,然后再利用它来逐个读取文本 T T T 中的字符,并利用自动机的状态进行匹配,接受的状态表示匹配成功,否则表示匹配失败,比如下图所示的字符串匹配自动机,它接受所有以 a b a b a c a ababaca ababaca 结尾的字符串。
那么问题来了,我们如何通过模式
P
P
P 来确定这个状态转移函数
δ
\delta
δ 呢?
通过观察上图,我们不难发现,状态 q q q 其实质上就代表着当前已读入的字符串含有后缀 P [ 1.. q ] P[1..q] P[1..q] ,且 q q q 是满足这一条件的最大值。为了方便书写,我们记 P [ 1.. k ] P[1..k] P[1..k] 为 P k P_k Pk ,记 a a a 是 b b b 的后缀为 a ⊐ b a \sqsupset b a⊐b 。则状态转移函数 δ \delta δ 满足 P δ ( q , a ) ⊐ P q a P_{\delta(q,a)} \sqsupset P_qa Pδ(q,a)⊐Pqa 其中 a a a 是任意字符, P q a P_qa Pqa 指 P q P_q Pq 和 a a a 拼接而成的字符串。
进一步地,为了将 δ \delta δ 显式地表示出来,我们定义一个辅助函数 σ \sigma σ ,称为对应 P P P 的后缀函数,满足 σ ( x ) = max { k ∣ P k ⊐ x } \sigma(x)=\max\{k \vert P_k\sqsupset x\} σ(x)=max{k∣Pk⊐x}那么,我们就有 σ ( T s ) = q ⇒ σ ( T s + 1 ) = δ ( q , T [ s + 1 ] ) \sigma(T_s)=q \Rightarrow \sigma(T_{s+1})=\delta(q,T[s+1]) σ(Ts)=q⇒σ(Ts+1)=δ(q,T[s+1])其中 T s T_s Ts 表示当前已读入的字符串。
因此,对于给定模式 P [ 1.. m ] P[1..m] P[1..m] ,其相应的字符串匹配自动机定义如下:
-
状态集合 Q Q Q 为 { 0 , 1 , ⋯   , m } \{0,1,\cdots,m\} {0,1,⋯,m} 。开始状态 q 0 = 0 q_0=0 q0=0 ,接受状态集合为 A = { m } A=\{m\} A={m} 。
-
对任意的状态 q q q 和字符 a a a ,转移函数 δ \delta δ 满足: δ ( q , a ) = σ ( P q a ) \delta(q,a)=\sigma(P_qa) δ(q,a)=σ(Pqa)
下面给出算法:
FINITE-AUTOMATON-MATCHER(T,δ,m)
n = T.length
q = 0
for i = 1 to n
q = δ(q,T[i])
if q == m
print "Pattern occurs with shift"i-m
其中的 δ \delta δ 可以放在预处理中做如下定义:
COMPUTE-TRANSITION-FUNCTION(P,Σ)
m = P.length
for q = 0 to m
for each charater a∈Σ
k = min(m+1,q+2)
repeat
k = k-1
until P_k ⊐ P_q a
δ(q,a) = k
return δ
这个算法的缺点也很明显:对于 δ \delta δ 函数的预处理效率比较低。
Knuth-Morris-Pratt 算法
由Knuth、Morris、Pratt三人设计并冠名的KMP算法是一种线性时间字符串匹配算法,这个算法无需计算转移函数 δ \delta δ ,只用到了辅助函数 π \pi π ,它会在线性时间内根据模式预先计算出来,并且存储在数组 π [ 1.. m ] \pi[1..m] π[1..m] 中。
考察朴素字符串匹配算法的操作过程。如下图所示,匹配文本 T = b a c b a b a b a a b c b a b T=bacbababaabcbab T=bacbababaabcbab ,模式 P = a b a b a c a P=ababaca P=ababaca ,当前正在进行匹配的有效偏移 s = 4 s=4 s=4 。此时, q = 5 q=5 q=5 个字符已经匹配成功,但第 6 6 6 个字符不能匹配。
现在,我们要根据这
q
q
q 个已匹配成功的字符来确定哪一些偏移一定是无效的。
比如,
s
+
1
s+1
s+1 的偏移就一定是无效的,因为
P
[
1
]
=
a
P[1]=a
P[1]=a ,而
T
[
(
s
+
1
)
+
1
]
=
b
T[(s+1)+1]=b
T[(s+1)+1]=b 已经和
P
[
2
]
=
b
P[2]=b
P[2]=b 匹配成功了;而
s
+
2
s+2
s+2 的偏移,根据我们目前的信息,是可能有效的,因为
P
P
P 的前三个字符恰好是
P
5
P_5
P5 的后缀,而且,
P
3
P_3
P3 是构成
P
5
P_5
P5 后缀的最长前缀。
这里就有点像刚才 δ \delta δ 函数的味道了。但是类似于这样的信息,我们现在不把它们放入函数中,而是事先计算好存进 π \pi π 数组。比如刚才的那句话,就是 π [ 5 ] = 3 \pi[5]=3 π[5]=3 。
下面是预计算过程的形式化说明:
已知一个模式
P
[
1..
m
]
P[1..m]
P[1..m] ,则模式
P
P
P 的前缀函数
π
\pi
π 满足
π
[
q
]
=
max
{
k
∣
k
<
q
且
P
k
⊐
P
q
}
\pi[q]=\max\{k\vert k<q且P_k \sqsupset P_q\}
π[q]=max{k∣k<q且Pk⊐Pq}即
π
[
q
]
\pi[q]
π[q] 是
P
q
P_q
Pq 的后缀
P
P
P 的最长前缀长度。
下面给出伪代码,你会发现它和刚才的自动机算法很像:
KMP-MATCHER(T,P)
n = T.length
m = P.length
π = COMPUTER-PREFIX-FUNCTION(P)
q = 0
for i = 1 to n
while q > 0 and P[q+1] ≠ T[i]
q = π[q]
if P[q+1] == T[i]
q = q+1
if q == m
print "Pattern occurs with shift"i-m
q = π[q]
COMPUTER-PREFIX-FUNCTION(P)
m = P.length
let π[1..m] be a new array
π[1] = 0
k = 0
for q = 2 to m
while k > 0 and P[k+1] ≠ P[q]
k = π[k]
if P[k+1] == P[q]
k = k+1
π[q] = k
return π
而且,这两个程序之间也十分相像,因为它们都是一个字符串针对模式 P P P 的匹配,前者是 T T T 针对 P P P 的匹配,后者是模式 P P P 针对自己的匹配。
最后,附上用C语言实现的KMP算法模板,其中int kmp(char text[],char pattern[])
函数返回pattern[]
在text[]
中首次出现的下标,int kmp_count(char text[],char pattern[])
函数返回pattern[]
在text[]
中出现的次数。
#include <stdio.h>
#include <string.h>
const int maxn=1e3+10;
struct KMP{
int lent,lenp;
int next[maxn];
void preprocess(char text[],char pattern[]){
lent=strlen(text);
lenp=strlen(pattern);
int k=-1;
next[0]=-1;
for (int q=1;q<lenp;){
if (k==-1||pattern[q]==pattern[k])
next[++q]=++k;
else k=next[k];
}
}
int kmp(char text[],char pattern[]){
preprocess(text,pattern);
int i,q;
for (i=0,q=0;i<lent&&q<lenp;){
if (q==-1||text[i]==pattern[q])
++i,++q;
else q=next[q];
}
return q==lenp?i-lenp:-1;
}
int kmp_count(char text[],char pattern[]){
preprocess(text,pattern);
if (lent==1&&lenp==1) return text[0]==pattern[0];
int res=0;
for (int i=0,q=0;i<lent;++i){
while (q>0&&text[i]!=pattern[q])
q=next[q];
if (text[i]==pattern[q])
++q;
if (q==lenp){
++res;
q=next[q];
}
}
return res;
}
}K;
int main(){
printf("%d\n",K.kmp("abcbcaeebcae","bcae"));//3
printf("%d\n",K.kmp_count("abcbcaeebcae","bcae"));//2
}
写在最后
以前也曾经听别人讲过KMP算法,但都没有提及自动机的概念,这导致我一直对这个算法理解得不是很深刻,学了很容易忘。今天终于通过《算法导论》厘清了几个字符串匹配算法的逻辑结构,这才大致了解到了KMP的由来。以前一直是认为,KMP仅仅是减少匹配的次数,脑子里是小滑片在大滑片上移来移去,现在用状态的思想来看这个算法,虽然略抽象了一些,但却十分自然。