KMP (Knuth Morris Pratt) 算法:高效的模式串匹配

KMP 算法是字符串模式串匹配算法的一个难点。本文会对该算法进行较为详尽的介绍。

论文指路:https://epubs.siam.org/doi/abs/10.1137/0206024

该算法由 Donald E. Knuth, James H. Morris, Jr., and Vaughan R. Pratt 提出,故将其姓氏合并,称此算法为 KMP。

研究的问题和下文变量名概览

对于一个长度为 n n n 的模式串(pattern string, 下文简称为 ptr),与长度为 m 的字符串(string, 下文简称为 str)。

详细题面请参见:https://www.acwing.com/problem/content/833/

下文的代码用 C++ 实现,字符串下标从 1 1 1 开始

朴素算法 Naive Algorithm

模式串匹配字符串,我们容易想到朴素的实现方式:

for (int i = 1; i <= m-n+1; i++) { // 枚举字符串起点
    bool match = true;
    for (int j = 1; j <= n; j++) { // 枚举模式串起点
        if (ptr[j] != str[i+j-1]) {
            match = false;
            break;
        }
    }
    if (match == true) {
        printf("%d ", i-1); // 因为字符串下标一般从 0 开始算
    }
}

考虑上面的算法,由于既要枚举字符串,又要枚举模式串,算法整体的时间复杂度是 O ( n ∗ m ) O(n*m) O(nm)

这个平方级别的复杂度显然满足不了字符串匹配对于速度的要求。但是我们仍然可以从上面的朴素算法中感受到这一点:模式串匹配字符串,可以使用双指针实现。

既然如此,我们延续双指针的思想,而对匹配的过程进行优化。这便是 KMP 算法的核心。

KMP 算法过程拆解

以下例子引自上面链接中的论文。但我不知道怎么把水印去掉,那就只好这样了

kmp-1

上面的箭头指向了当前正在进行匹配的位置。由于模式串指向了 a,而字符串中指向 b,两者并不匹配,所以我们向后移动一个单位长度的模式串。

kmp-2

aa 匹配上了,于是指针向后移动。

kmp-3

指针又来到了一个并不匹配的点。此时按照朴素算法的思想,我们应该再把模式串往后移动一个单位。这便是 KMP 算法的巧妙所在——因为移动模式串的目的在于匹配字符串,所以我们应该思考:如何把模式串通过移动最少的单位长度,从而可以继续匹配?也就是说,在上面的例子中,为了让字符串的第五个字符 b 与模式串匹配,同时前面的字符也要同步匹配,我至少要将模式串移动多少次?

kmp-4

显然,在上面的例子中,我们别无它择,只能将其向右移动 4 4 4 个单位长度。KMP 算法通过一个预处理,可以通过非朴素的手段得到这一结论,这在后文中会介绍。但现在我们先别急,再看一组例子。

kmp-5

这个时候又发生了一次匹配失败。我们仍然思考,如何通过最少的移动单位长度使得模式串可以继续匹配?我们发现,只需向右移动 3 3 3 个单位,即可继续匹配,如下图:

kmp-6

我们发现,在上面两个模式串匹配失败的例子中,一个匹配到 a b c x(x 不为 a) 后挂掉,另一个到 a b c a b c a x(x 不为 c) 挂掉。为了让模式串中 x 的位置的字符与字符串匹配,要将模式串向右移动特定的单位长度。移动后,既要保证 x 满足要求,还要保证 x 前的模式串子串仍然要跟字符串匹配。仔细思考后,我们发现,其实就是要求解 x 前的那部分子串,最多前多少个字符构成的前缀和后多少个字符构成的后缀相等。当然,这个子串严格包含于原字符串。

我们将一个字符串中前后缀相等的部分叫做该字符串的 border,即边框。因为前后缀相等,所以也可以看作这个字符串被嵌在了一对对称的字符串之间,正如一幅画的边框一样。所以这个称谓还是很形象的。

很显然,对于任何一个字符串,其一定是它自身的 border。但研究这玩意儿没有意义,所以我们一般只研究长度不等于字符串原长的 border,我们把这样的 border 叫做非平凡 border。对于 KMP 来说,我们希望移动单位长度尽量少,因此我们希望使 border 的长度尽量小。

非平凡 border 的求解

为了方便后文的理解,我们先提出两条引理:

  1. 一个字符串的 borderborder,一定也是原字符串的 border
  2. 空串是一切字符串的 border

这两个定理是易于理解的,不予证明。

kmp-7

回到前面这张图。再回顾以下我们的目的:通过最少的移动单位长度使得模式串可以继续匹配。

因此,我们可以通过求解 a b c a b c a 的所有 border 来达成这一目的。经检验,a b c a, a ∅ \varnothing 都是该字符串的 border

我们希望 border 的长度尽量长,因此对于这个子串来说,我们只需要知道它的最大非平凡 border 即可。若最大非平凡 border 失败了,我们还可以通过引理 2 2 2,通过再求解 borderborder 来获取这个子串更短的 border

类似于 KMP 算法的流程,求解 border 其实就是用模式串匹配模式串的过程。

我认为这里还是直接通过代码会更好理解一些。在博客里用文字和静态图片阐述还是太抽象了。

// 此代码中,nxt[i] 即为 border,表示模式串长度为 i 的前缀的 border 长度,也可以理解为指向模式串的指针下一次要跳转到的位置,因此命名为 nxt(next)
for (int i = 2, j = 0; i <= n; i++) {
    // i 指针用来当前表示求解字符串长度为 i 的前缀。由于任何字符串长度为 1 的前缀的最大非平凡 border 一定为空串,所以 nxt[1] 恒等于 0,因此从 2 开始
    // j 指针指向 border 待匹配字符的前一个位置,因此从 0 开始
    while (j and (ptr[i] != ptr[j+1])) j = nxt[j];
    // j != 0: 如果没有满足条件的 border,那就不用再找了,跳出循环。
    // ptr[i] != ptr[j+1]: 是否能够匹配成功
    if (ptr[i] == ptr[j+1]) j++; // 若匹配成功,那么 border 长度加一
    nxt[i] = j; // 存储 border
}

虽然过程比较抽象,但是上面的代码还是很清晰的。只要双指针的功底扎实,上面的代码应该可以 handle。如果实在理解不了,那就搜搜视频吧。看看动态的模拟会好理解很多。

KMP 算法

有了 border 信息在手,KMP 的求解也就简单了。直接模拟上面的过程即可。

for (int i = 1, j = 0; i <= m; i++) {
    while (j and (str[i] != ptr[j+1])) j = nxt[j]; // 尝试匹配
    if (str[i] == ptr[j+1]) j++; // 匹配成功
    if (j == n) { // 匹配结束
        printf("%d ", i-n); // (i - n + 1) - 1
        j = nxt[j]; // 你可以手动模拟一下,如果把这句删掉,那么在进入下一次循环,执行第一句后,j+1 指向空字符,那么 str[i] 永远都没法匹配成功了。
    }
}

KMP 算法的预处理 border 的算法和匹配的算法的时间复杂度都是线性的,因此整体时间复杂度为 O ( n + m ) O(n+m) O(n+m)。这一点并不好解释,有兴趣的读者可以参照论文阅读,很多网上的文章写的都是糊弄人的。

下一步

KMP 并不是字符串匹配的唯一算法。你还可以去了解:

  • 字符串哈希(String Hash)
  • Z 算法:KMP 算法的优化

尾声

以上便是 KMP 算法的完整解析。这个算法精髓就在于跳跃地移动模式串,而不是慢慢挪动。感谢你能够看到这里。

这是我 AFO 一年后第一次重拾 OI,备赛 CSP 而写下的。由于长时间不接触 OI,文章中不可避免会有些疏漏之处,望轻喷并赐教。

修正记录

  • [2024-08-30 15:08] 发布
  • [2024-08-30 15:09] 修正了一个小小的错误
  • [2024-08-30 23:15] 修正了一些笔误,另外修正了关于 border 的形象化理解,增加了时间复杂度的介绍、下一步学习建议

参考文献

  1. Fast Pattern Matching in Strings: https://epubs.siam.org/doi/abs/10.1137/0206024
  2. KMP Algorithm for Pattern Searching: https://www.geeksforgeeks.org/kmp-algorithm-for-pattern-searching/
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值