KMP 模式匹配算法与扩展 KMP

2 篇文章 0 订阅

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 0snm,并且 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(TP) 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 因为 ac 的差异而失配。朴素算法此时执行 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} PqTs+q,我们要找一个长度最小的 P k P_k Pk 满足 P k ⊏ P q P_k \sqsubset P_q PkPq 并且 P k ⊐ P q P_k \sqsupset P_q PkPq。存储这个问题答案的函数记作后缀函数,下文写作 nxt

在这里插入图片描述

nxt 辅助数组的构建

可以利用模式串自身与自身的比匹配来得到这样的信息,如下图所示,采用递推的思想,可以在 O ( ∣ P ∣ ) O(|P|) O(P) 的时间内处理所有位置的 nxt 值。

程序中的字符串与数组的下标从 0 0 0 开始,nxt[i] 表示 P P Pi 个字符的最长公共前后缀的长度,也表示当匹配到第 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) nnxt(n),下面这张图帮助你的理解。

在这里插入图片描述

exKMP 扩展 KMP 算法

exKMP 算法是对 KMP 算法的扩展,它不仅可以实现 KMP 的功能,还可以得到更优秀的字符串信息。在开始下面的阅读之前,需要读者已经掌握上面介绍的 KMP 算法。

问题的提出

exKMP 算法要求我们求解出一个关于字符串 T T T P P P 的一个数组 extendextend 表示对于 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 算法的过程中,遇到了种种的问题,写这篇博客一是为了记住其中的要点,二是希望可以帮助后面的学习者掌握这样一个算法。对于更深入的讨论,可以访问下面的链接。

exKMP算法的优秀讲解

关于KMP算法时间复杂度的讨论

对于 KMP 算法,有 MP 与 KMP 两种,在 OI 中大多数以 MP 代 KMP,本文中介绍的实际是 MP 算法,因为 MP 算法的 nxt 数组有着更多的性质,而且时间效率已经非常不错。下面提供一篇关于 MP 与 KMP 的对比文章。

KMP与MP相关

感谢大家的阅读。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值