cv.cm

编码 · 算法

KMP 字符串匹配详解:原理、代码与复杂度

阅读约 6 分钟

简短回答

Knuth-Morris-Pratt(KMP)算法能在 O(n + m) 时间内,找出长度为 m 的模式串在长度为 n 的文本中的每一次出现。它先为模式串构建前缀函数(也叫失配表):对每个位置,记录最长的既是前缀又是后缀的真前缀的长度。发生失配时,KMP 借助这张表移动模式串,而文本指针永远不会回退。

打开正则测试

KMP 解决什么问题

字符串搜索要问的是:一个较短的模式串在较长的文本中出现在哪里。朴素方法会尝试每一个起始位置,并逐个字符比较。在普通文本上它很快,但在重复性很高的输入上就很慢:在 aaaaaaaaaa 中搜索 aaaab,每个位置几乎都要比较整个模式串,时间为 O(n × m)。

KMP 在失配之后永远不会重新读取文本字符。Donald Knuth、James H. Morris 和 Vaughan Pratt 于 1977 年在《Fast Pattern Matching in Strings》中发表了该算法。

用大白话讲思路

假设你要搜索模式串 ABABC,已经匹配了 ABAB,而下一个文本字符不是 C。刚匹配的 ABAB 以 AB 结尾,而 AB 恰好也是模式串的开头。所以你已经知道,下一次尝试一开始就有 2 个字符是匹配的,可以直接从模式串的第 2 个位置继续比较,而不必从头开始。

前缀函数保存的就是这些数字。pi[i] 是 pattern[0..i] 的最长真前缀的长度,这个前缀同时也是它的后缀(真是指不等于整个字符串)。

i0123456
pattern[i]ABABACA
pi[i]0012301

例如,pi[4] = 3,因为 ABABA 的开头和结尾都是 ABA。pi[5] = 0,因为 ABABAC 的任何前缀都不以 C 结尾。

逐步演示一个例子

在文本 ABABABC 中搜索模式串 ABABC(其前缀函数为 0, 0, 1, 2, 0)。变量 k 记录当前已匹配的模式串字符数。

文本位置文本字符之前的 k操作之后的 k
0–3A B A B0连续四次匹配4
4A4与 pattern[4] = C 失配:令 k = pi[3] = 2,然后 A 与 pattern[2] 匹配3
5B3与 pattern[3] 匹配4
6C4与 pattern[4] 匹配:完整匹配在位置 6 结束,所以起始位置为 6 − 5 + 1 = 25 → pi[4] = 0

文本指针向前移动了 7 次,从未回退。朴素方法则会在位置 1 重新开始,并再次比较一些字符。

Python 实现

def prefix_function(p):
    pi = [0] * len(p)
    k = 0
    for i in range(1, len(p)):
        while k > 0 and p[i] != p[k]:
            k = pi[k - 1]          # fall back to a shorter border
        if p[i] == p[k]:
            k += 1
        pi[i] = k
    return pi

def kmp_search(text, pattern):
    """Return the start index of every occurrence (overlaps included)."""
    if not pattern:
        return []
    pi, k, hits = prefix_function(pattern), 0, []
    for i, ch in enumerate(text):
        while k > 0 and ch != pattern[k]:
            k = pi[k - 1]
        if ch == pattern[k]:
            k += 1
        if k == len(pattern):
            hits.append(i - k + 1)
            k = pi[k - 1]          # keep going to find overlapping matches
    return hits

print(prefix_function("ABABACA"))      # [0, 0, 1, 2, 3, 0, 1]
print(kmp_search("ABABABC", "ABABC"))  # [2]
print(kmp_search("AAAAA", "AA"))       # [0, 1, 2, 3]

时间与空间复杂度

阶段时间空间原因
构建前缀函数O(m)O(m)每一步 k 至多增加一次,每次回退都会使它减小,所以回退总次数至多为 m。
搜索O(n)O(1) 额外空间对文本的论证相同:至多增加 n 次,所以至多回退 n 次。
总计O(n + m)O(m)最坏情况与最好情况只差一个常数;不存在坏输入。

朴素算法最坏情况下是 O(n × m),但在自然语言文本上往往接近 O(n),所以 KMP 的优势主要体现在重复性高的数据上。

变体与常见错误

  • 回退时差一:失配之后应令 k = pi[k - 1],而不是 pi[k]。pi 是以最后一个匹配位置为下标的。
  • 回退时用 if 而不是 while:在某个字符匹配之前,可能需要连续回退多次。
  • 完整匹配后忘记重置 k:要找出重叠的匹配,令 k = pi[k - 1];要找不重叠的匹配,令 k = 0。
  • 拼接技巧:计算 pattern + separator + text 的前缀函数,分隔符选用两者都不含的字符;值等于 m 的每个位置,都是一次匹配的结束位置。
  • Z 算法计算一个与之紧密相关的数组,同样能在 O(n + m) 内完成搜索。

何时使用及实际应用

算法最佳用途
KMP对单个模式串保证线性时间;适合流式文本,因为它从不回看。
Boyer-Moore 及其变体大字母表上的长模式串;经常能跳过许多字符,grep 等工具就用到它。
Rabin-Karp多个等长的模式串,或用滚动哈希做抄袭检测。
Aho-Corasick同时匹配多个模式串;它把 KMP 的失配链接推广到了字典树上。
后缀数组或后缀自动机对同一段固定文本做大量不同的查询。
  • 面试题:实现 strStr()、最短回文串(s + "#" + reversed(s) 的前缀函数)、重复的子字符串模式、字符串的最小周期(m - pi[m - 1])。
  • 实际应用:在 DNA 序列和网络数据包流中搜索,这些场景看重线性的最坏时间。

常见问题

KMP 中的前缀函数(失配表)是什么?

对于模式串的每个位置 i,它保存 pattern[0..i] 的最长真前缀的长度,该前缀同时也是它的后缀。发生失配时,KMP 用它来得知还有多少个字符仍然匹配。

KMP 的时间复杂度是多少?

O(n + m):构建前缀函数需要 O(m),扫描文本需要 O(n)。即使在最坏情况下也是线性的。

KMP 比 Python 的 str.find 快吗?

不快。str.find 由 C 实现,采用经过优化的搜索算法,实际中快得多。KMP 的价值在于它有保证的线性时间、适合学习,以及可用于非字符串序列。

KMP 能找到重叠的匹配吗?

可以,只要在完整匹配之后令 k = pi[k - 1] 而不是 0。例如,它能在 AAAAA 的位置 0、1、2、3 都找到 AA。

全部编码教程