KMP 模式匹配算法与扩展 KMP 相关
前言
本文导览
KMP 算法简称 Knuth-Morris-Pratt Algorithm (中译:克努特——莫里斯——普拉特操作), 主要用于解决字符串中的模式匹配问题,本文将介绍以下几点
- 字符串的模式匹配问题
- nxt 辅助数组的构建
- KMP 算法的实现
exKMP 算法是对 KMP 算法的一个拓展,使得在同样的时间复杂度内,可以获得更多的信息,本文将介绍以下几点
- exKMP 所要解决的问题
- exKMP 算法的实现
最后,我们分析总结两个算法的时间复杂度。
一些符号与术语
【字符集大小】
一个字符串中所有出现的字符的集合叫做这个字符串的字符集,用符号 Σ \Sigma Σ 表示。
【字符串的连接】
对于一个字符串 x x x 和另一个字符串 y y y,书写方式 x y xy xy 表示 x x x 与 y y y 的一个连结(concatenation),即将 y y y 接在 x x x 的后面。
【前缀与后缀】
对于字符串 s s s, x x x, y y y,如果 s = x y s = xy s=xy,那么 y y y 是 s s s 的后缀(符号为 ⊐ \sqsupset ⊐), x x x 是 s s s 的前缀(符号为 ⊏ \sqsubset ⊏)
KMP 模式匹配算法
模式匹配问题
在介绍 KMP 模式匹配算法之前,先来介绍字符串的模式匹配问题。
对于文本串 T T T 和模式串 P P P,如果 0 ≤ s ≤ n − m 0 \leq s \leq 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 偏移出现,则这种偏移为有效偏移,否则为无效偏移。
字符串匹配问题就是找到所有 P P P 的有效偏移,使得 P P P 在 T T T 中出现。
从朴素算法开始
采用朴素的算法来求解有效偏移时,我们对每一个可能的有效偏移进行枚举,并从这一位置开始,向后匹配所有的字符,看模式串 P P P 与 T T T 从 s s s 开始的部分是否完全一致,不难发现枚举有效偏移的复杂度为 O ( ∣ T ∣ ) O(|T|) O(∣T∣),检查有效偏移的复杂度是 O ( ∣ P ∣ ) O(|P|) O(∣P∣) , 因此这个算法的运行时间为 O ( ∣ T ∣ ∣ P ∣ ) O(|T| |P|) O(∣T∣∣P∣) 即 O ( n 2 ) O(n ^ 2) O(n2) 复杂度。
充分挖掘模式串的性质
让我们考察一下朴素字符串匹配的过程,如下图
a
a
a 中所示的局面是正在检查偏移
s
s
s 是不是有效的,当匹配了
q
=
5
q = 5
q=5 个字符时, 模式串
P
P
P 与
T
T
T 因为 a
与 c
的差异而失配。朴素算法此时执行
s
+
1
s + 1
s+1 并重头来过。但是,我们可以确定
s
+
1
s + 1
s+1 不能成功匹配,因为
P
[
1
]
P[1]
P[1] 与
P
[
2
]
P[2]
P[2] 的字符是不同的。这个性质是与文本串无关的,它只由模式串决定。那么我们需要关心这样一个问题:
从第 x x x 位失配的 P P P ,下一次可能的成功匹配位置在多少位之后?
例如在例子中,我们如果知道在第 6 6 6 位失去匹配的字符串应该向后移动 2 2 2 位,我们的匹配就可以加速进行。
形式化的说:已经知道了
P
q
⊐
T
s
+
q
P_q \sqsupset T_{s+q}
Pq⊐Ts+q,我们要找一个长度最小的
P
k
P_k
Pk 满足
P
k
⊏
P
q
P_k \sqsubset P_q
Pk⊏Pq 并且
P
k
⊐
P
q
P_k \sqsupset P_q
Pk⊐Pq。存储这个问题答案的函数记作后缀函数,下文写作 nxt
。
nxt 辅助数组的构建
可以利用模式串自身与自身的比匹配来得到这样的信息,如下图所示,采用递推的思想,可以在
O
(
∣
P
∣
)
O(|P|)
O(∣P∣) 的时间内处理所有位置的 nxt
值。
程序中的字符串与数组的下标从
0
0
0 开始,nxt[i]
表示
P
P
P 前 i
个字符的最长公共前后缀的长度,也表示当匹配到第 i
位时,如果失配应从
P
P
P 串的第 nxt[i]
为开始进行匹配,这样看上去使得两者的功能有些错位,但是却给我们的匹配带来方便。
inline void getFail(char *P, int *nxt)
{
int m = strlen(P);
nxt[0] = 0; nxt[1] = 0;
for(int i = 1; i < m; i++)
{
int j = nxt[i];
while(j != 0 && P[i] != P[j]) j = nxt[j];
nxt[i + 1] = P[i] == P[j] ? j + 1 : 0;
}
}
算法的思想可以说是用自己匹配自己,具体步骤可以参考下面的图示。
通过 nxt 辅助数组来进行模式匹配
匹配
P
P
P 与
T
T
T 的过程是用别人来匹配自己,它看上去和构造 nxt
没有什么不同。
inline void find(char *T, char *P, int *nxt)
{
int n = strlen(T), m = strlen(P);
getFail(P, nxt);
int j = 0;
for(int i = 0; i < n; i++)
{
while(j != 0 && P[j] != T[i]) j = nxt[j];
if(P[j] == T[i]) j++;
if(j == m) \\ready_find
}
}
具体的做法是,当两个串的元素相等时,步进匹配进程,否则就根据 nxt
辅助函数的值,直接跳转到下一个有可能成功匹配的位置。理解起来并不困难,读者可以自己构造两个文本串模拟来加深理解。
nxt 数组的其他性质
有时,KMP 算法并不会常常出现在题目之中,但是经常能看到 nxt
数组的身影,读者也不难发现,我详细介绍了关于 nxt
数组的知识。下面介绍两个 nxt
数组的性质。
- 根据
nxt
数组的性质,长度为 n n n 的字符串的最长公共前后缀(不包括自己)的长度为 n x t ( n ) nxt(n) nxt(n)。 - 根据上一条性质,我们可以发现,一个串可以表示成一个子串的循环,这个子串的最短长度为 n − n x t ( n ) n-nxt(n) n−nxt(n),下面这张图帮助你的理解。
exKMP 扩展 KMP 算法
exKMP 算法是对 KMP 算法的扩展,它不仅可以实现 KMP 的功能,还可以得到更优秀的字符串信息。在开始下面的阅读之前,需要读者已经掌握上面介绍的 KMP 算法。
问题的提出
exKMP 算法要求我们求解出一个关于字符串
T
T
T 与
P
P
P 的一个数组 extend
,extend
表示对于
T
T
T 的每一个后缀,与
P
P
P 串的最长公共前缀的长度,即,从第 i
为开始所能匹配到的最远位置。
不难发现,这个问题是 KMP 解决的问题的超集,当 extend[i] = length(P)
时,
i
i
i 就是一个有效偏移,就是 KMP 算法求解的对象。
尝试解决
我们定义三个工具来作为我们解决问题的辅助。
- 位置标记 a a a
- 位置标记 p p p
- 辅助数组 n x t nxt nxt
其中
p
p
p 是从
a
a
a 位置开始,第一个失去匹配的位置。
n
x
t
nxt
nxt 数组表示每个模式串
P
P
P 的后缀与模式串
P
P
P 的最长公共前缀的长度。通过 nxt
来求解 extend
有如下三个情况:
第一种情况:如果 i + nxt[i - a] < p
,如图,标记的区间长度相同,根据 nxt
数组的定义,此时 extend[i] = next[i - a]
。
第二种情况:如果 i + nxt[i - a] == p
,如图,T[p] != P[p - a]
且 P[p - i] != P[p - a]
,但 T[p]
有可能等于 P[p - i]
,所以我们可以直接从 T[p]
与 P[p - i]
开始往后匹配,加快了速度。
第三种情况:如果 i + nxt[i - a] > p
,那说明 T[i...p]
与 P[i-a...p-a]
相同,注意到 T[p] != P[p - a]
且 P[p - i] == P[p - a]
,也就是说 T[p] != P[p - i]
,所以就没有继续往下判断的必要了,我们可以直接将extend[i]
赋值为 p - i
。
其中类似于 KMP, nxt
数组就是字符串
P
P
P 自身匹配的结果,可以写出下面的代码。
inline void getFail(char *P, int *nxt)
{
int a = 0, p = 0, m = strlen(P);
nxt[0] = m;
for(int i = 1; i < m; i++)
{
if(i + nxt[i - a] >= p)
{
p = max(p, i);
while(p < m && P[p] == P[p - i]) p++;
nxt[i] = p - i; a = i;
}
else nxt[i] = nxt[i - a];
}
}
inline void getExtend(char *T, char *P, int *nxt, int *extend)
{
int a = 0, p = 0;
int m = strlen(P), n = strlen(T);
getFail(T, nxt);
for(int i = 0; i < n; i++)
{
if(i + nxt[i - a] >= p)
{
p = max(i, p);
while(p < n && p - i < m && T[p] == P[p - i]) p++;
extend[i] = p - i; a = i;
}
else extend[i] = nxt[i - a];
}
}
算法的时间复杂度
KMP 算法的时间复杂度
KMP 算法由初始化和进行匹配两部分进行,对于初始化算法部分,我们来证明每一次的 while
总共会进行
O
(
∣
P
∣
)
O(|P|)
O(∣P∣) 次,首先:每一次 nxt[i]
的增长最多是 1,也就是最多增长
∣
P
∣
|P|
∣P∣ 次,其次:每一次 while
循环只会降低 nxt[i]
的值而不会增加。综合这些因素,递减来源于 while
循环,最多下降
∣
P
∣
|P|
∣P∣ 次,所以复杂度为
O
(
∣
P
∣
)
O(|P|)
O(∣P∣)。这只是一个大致的感性理解,有兴趣的读者可以使用部分摊还分析的知识。并且类比于初始化操作,匹配的复杂度是
O
(
∣
T
∣
)
O(|T|)
O(∣T∣)。
KMP 算法预处理 O ( ∣ P ∣ ) O(|P|) O(∣P∣),匹配 O ( ∣ T ∣ ) O(|T|) O(∣T∣),是优秀的线性时间算法。
exKMP 算法的时间复杂度分析
下面来分析一下 exKMP 算法的时间复杂度,通过上面的算法介绍可以知道,对于第一种情况,无需做任何匹配即可计算出 extend[i]
,对于第二、三种情况,都是从未被匹配的位置开始匹配,匹配过的位置不再匹配,也就是说对于母串的每一个位置,都只匹配了一次,所以算法总体时间复杂度是
O
(
∣
T
∣
)
O(|T|)
O(∣T∣)的,同时为了计算辅助数组 next[i]
需要先对字串
P
P
P 进行一次 exKMP 算法处理。
KMP 算法预处理 O ( ∣ P ∣ ) O(|P|) O(∣P∣),匹配 O ( ∣ T ∣ ) O(|T|) O(∣T∣),也是优秀的线性时间算法。
后记
我在学习 KMP 算法的过程中,遇到了种种的问题,写这篇博客一是为了记住其中的要点,二是希望可以帮助后面的学习者掌握这样一个算法。对于更深入的讨论,可以访问下面的链接。
对于 KMP 算法,有 MP 与 KMP 两种,在 OI 中大多数以 MP 代 KMP,本文中介绍的实际是 MP 算法,因为 MP 算法的 nxt
数组有着更多的性质,而且时间效率已经非常不错。下面提供一篇关于 MP 与 KMP 的对比文章。
感谢大家的阅读。