Part 6.1:KMP 算法

引言:智能的 Ctrl+F

在日常使用电脑时,Ctrl+F 是我们用得最多的功能之一。当你在一个数万字的文档中查找一个特定单词时,计算机会瞬间定位到它的位置。这个看似简单的操作背后,隐藏着强大的字符串模式匹配 (String Pattern Matching) 算法。

最朴素的想法是什么?——**暴力匹配 (Brute Force)**。将要查找的单词(模式串)与文档(文本串)从头开始,一个字符一个字符地对齐比较。如果遇到不匹配的字符,就将模式串向后移动一位,再从头开始比较。这种方法在大多数情况下工作得不错,但在某些“恶意”的情况下,它的效率会变得极其低下。

例如,在文本串 AAAAAAAAAAAAAAAAAB 中查找模式串 AAAAAB

Text:   A A A A A A A A A A A A A A A B
Pattern:A A A A A B  (Mismatch at B)
        A A A A A B (Shift 1, mismatch at B)
        A A A A A B (Shift 2, mismatch at B)
        ...

每次失败,我们都只是将模式串后移一位,然后又进行大量重复的 AA 的比较。这种“愚蠢”的回溯,正是暴力匹配的性能瓶颈。

KMP 算法(由 Knuth, Morris, Pratt 三位学者在 1977 年共同发表)正是为了解决这个问题而生。它通过预处理模式串,找到其中的“对称性”,使得在匹配失败时,能够智能地、大幅度地向后滑动,而不需要回溯文本串的指针,从而实现了 O(n) 的线性时间复杂度。

为什么 KMP 算法如此重要?

  • 面试价值:KMP 是字符串算法的必考点,也是中高级工程师面试的常客。它深刻地体现了“空间换时间”和“利用已有信息”的算法思想,是考察候选人算法思维深度和代码实现能力的经典题目。
  • 工程价值:KMP 及其变种是许多文本处理软件、搜索引擎、入侵检测系统、基因序列分析工具的核心组成部分。

本文将带你从零开始,彻底搞懂 KMP 算法的精髓——next 数组。

背景知识:暴力匹配及其缺陷

def brute_force_search(text, pattern):
    n, m = len(text), len(pattern)
    for i in range(n - m + 1): # i: text 中的起始位置
        j = 0
        while j < m:
            if text[i + j] != pattern[j]:
                break
            j += 1
        if j == m:
            return i # 找到匹配
    return -1
  • 时间复杂度:最坏情况下为 O((n-m+1) * m),约等于 O(n*m)
  • 缺陷:文本串的指针 i 在每次匹配失败后,都会回溯到 i+1,丢失了之前已经匹配上的部分信息。

详细算法原理:KMP 的核心——Next 数组

KMP 算法的核心思想是:当失配发生时,主串 text 的指针 i 永不回溯,而是通过移动模式串 pattern 的指针 j 来实现高效匹配。

j 应该移动到哪里,就完全取决于一个预先计算好的 next 数组。

1. 算法的“直觉”:利用已匹配的前缀

假设我们在匹配过程中,text[i]pattern[j] 发生了失配。

text:    ... a b c a b d ...
              ^ 
              i
pattern:     a b c a b c
                   ^
                   j

此时,我们已经知道 texti 之前的一段 abcabpattern 的前缀 abcab 是匹配的。暴力匹配会把 pattern 后移一位,然后从头开始比较,这显然是低效的。

KMP 的想法是:我们已经匹配上的 pattern 前缀 abcab,它的最长公共前后缀是什么?

  • 前缀a, ab, abc, abca
  • 后缀b, ab, cab, bcab
  • 最长公共前后缀ab

这个信息告诉我们,pattern 的开头两个字符 ab 和结尾两个字符 ab 是一样的。由于 texti 之前的部分也等于 abcab,那么 texti 之前的最后两个字符也必然是 ab

图示:利用公共前后缀进行滑动

text:    ... a b c a b d ...
pattern:     a b c a b c  (失配)

已匹配部分: a b c a b
最长公共前后缀:   a b

滑动后:
text:    ... a b c a b d ...
pattern:         a b c a b c (对齐公共前后缀)

我们直接把 pattern 滑动到其前缀 abtext 中对应的后缀 ab 对齐的位置,然后从 pattern[2](即 c)继续和 text[i] 比较。整个过程中,text 的指针 i 没有动!

这个“最长公共前后缀的长度”,就是 next 数组要告诉我们的信息。

2. 严谨原理:前缀函数 next 数组

  • 状态定义next[j] 表示在模式串 pattern 的子串 pattern[0...j] 中,其最长相等前后缀的长度。

    ⚠️ 注意:这里的前后缀是真前缀真后缀,即不包含整个字符串本身。

  • 例子pattern = "ababaca"

j子串 p[0...j]前缀后缀最长相等前后缀next[j]
0a0
1abab0
2abaa, aba, baa1
3ababa, ab, abab, ab, babab2
4ababaaba3
5ababaca1
6ababacaa1

最终 next 数组为 [0, 0, 1, 2, 3, 1, 1]

3. 如何计算 next 数组?(DP 思想)

计算 next 数组的过程,本身就是一个“自己匹配自己”的动态规划过程。

假设我们已经求出了 next[0], ..., next[i-1],现在要求 next[i]

我们比较 pattern[i]pattern[next[i-1]]

  1. **如果 pattern[i] == pattern[next[i-1]]**:
    这意味着 pattern[0...i-1] 的最长相等前后缀,后面再跟上一个相同的字符,构成了 pattern[0...i] 的最长相等前后缀。所以 next[i] = next[i-1] + 1

  2. **如果 pattern[i] != pattern[next[i-1]]**:
    我们需要缩短前缀的长度,寻找一个更短的、能与 pattern[0...i-1] 的后缀匹配的前缀。这个“次长”的前缀长度是多少呢?恰好就是 next[next[i-1] - 1]!这是一个递归的定义。我们不断地向前回溯,直到找到一个匹配的字符,或者回溯到头。

步骤分解 (Step-by-step)

计算 next 数组

  1. 初始化next 数组,next[0] = 0。一个指针 j=0(代表前缀的末尾),一个指针 i=1(代表后缀的末尾)。
  2. **遍历 pattern**:i1m-1
  3. 处理不匹配:当 j > 0pattern[i] != pattern[j] 时,令 j = next[j-1],不断向前回溯。
  4. 处理匹配:如果 pattern[i] == pattern[j],则 j++
  5. 赋值next[i] = j

KMP 匹配

  1. 初始化i=0 (文本串指针),j=0 (模式串指针)。
  2. **遍历 text**:i0n-1
  3. 处理不匹配:当 j > 0text[i] != pattern[j] 时,令 j = next[j-1],模式串指针向前回溯,文本串指针 i 不动。
  4. 处理匹配:如果 text[i] == pattern[j],则 j++
  5. 判断成功:如果 j == m,说明匹配成功,返回 i - m + 1。然后可以令 j = next[j-1] 继续寻找下一个匹配。

Python 实现

def compute_next(pattern):
    """计算 KMP 算法的 next 数组"""
    m = len(pattern)
    next_arr = [0] * m
    j = 0  # j: 当前最长公共前后缀的长度
    
    for i in range(1, m):
        # 如果不匹配,j 回溯
        while j > 0 and pattern[i] != pattern[j]:
            j = next_arr[j - 1]
        
        # 如果匹配,j 增加
        if pattern[i] == pattern[j]:
            j += 1
        
        next_arr[i] = j
    return next_arr

def kmp_search(text, pattern):
    """使用 KMP 算法在 text 中查找 pattern"""
    n, m = len(text), len(pattern)
    if m == 0:
        return 0
    
    next_arr = compute_next(pattern)
    j = 0 # 模式串的指针
    
    for i in range(n): # 文本串的指针
        # 处理不匹配,利用 next 数组回溯 j
        while j > 0 and text[i] != pattern[j]:
            j = next_arr[j - 1]
        
        # 处理匹配
        if text[i] == pattern[j]:
            j += 1
        
        # 匹配成功
        if j == m:
            return i - m + 1
            # 如果需要查找所有匹配,则:
            # print(f"Found pattern at index {i - m + 1}")
            # j = next_arr[j - 1]
            
    return -1

# --- 案例测试 ---
text = "ABABDABACDABABCABAB"
pattern = "ABABCABAB"
next_val = compute_next(pattern)
print(f"Pattern '{pattern}' 的 next 数组是: {next_val}")
# 预期: [0, 0, 1, 2, 0, 1, 2, 3, 4]

start_index = kmp_search(text, pattern)
print(f"在 text 中找到 pattern, 起始索引为: {start_index}") # 预期: 10

复杂度推导过程

时间复杂度: O(n + m)

  • compute_next 函数: O(m)
    • 外层循环 i1m-1,是 O(m)
    • 内层 while 循环看起来可能多次执行,但 j 的总增加次数最多为 m 次,所以 j 的总减少(回溯)次数也最多为 m 次。通过均摊分析while 循环的总执行次数是 O(m) 的。
  • kmp_search 函数: O(n)
    • 外层循环 i0n-1,是 O(n)
    • 内层 while 循环,j 的回溯同样可以用均摊分析。ij 都是单调递增的(j 的回溯总量受 i 的前进总量限制)。i 前进了 n 次,所以 j 的增加总量不超过 n 次,回溯总量也不超过 n 次。因此 while 循环的总执行次数是 O(n) 的。

总时间复杂度 = O(m) + O(n) = O(n + m)

空间复杂度: O(m)

  • 我们需要一个 next 数组来存储模式串的预处理信息,其长度为 m

优势 / 劣势 / 易错点

优势

  1. 线性时间O(n+m) 的时间复杂度,在处理大规模文本和“恶意”数据时效率极高。
  2. 主串指针不回溯:这使得算法非常适合处理流式数据(stream)。

劣势

  1. 理解成本高next 数组的计算过程比较抽象,是 KMP 算法的学习难点。
  2. 实现稍复杂:相比暴力匹配,代码实现更容易出错。

易错点

  1. next 数组的定义:混淆 next[j] 是长度还是索引。
  2. next 数组的计算j = next[j-1] 的回溯逻辑是核心,容易写错。
  3. 索引偏移:在实现 next 数组时,ji 的关系,以及 next[j-1] 的使用,很容易出现 off-by-one 错误。

适用场景

  • 文本编辑器中的查找功能。
  • 搜索引擎中的关键词匹配。
  • 入侵检测系统中,匹配已知的攻击模式。
  • 生物信息学中,在 DNA 序列中查找特定的基因片段。
  • 任何需要高效字符串查找的场景。

总结

KMP 算法通过预处理模式串,挖掘出其自身的结构信息(最长相等前后缀),构建出 next 数组。在匹配过程中,当遇到失配时,它不再是“愚蠢”地将模式串后移一位,而是利用 next 数组进行一次“大跳”,跳过所有不可能匹配成功的位置,从而实现了主串指针不回溯的线性时间匹配。

本文核心要点

核心思想:利用已匹配部分的“最长相等前后缀”信息,来决定失配后模式串应该滑动多远。
核心工具next 数组,next[j] 存储子串 pattern[0...j] 的最长相等前后缀的长度。
关键操作:失配时,j = next[j-1],实现模式串指针的快速回溯。
复杂度:时间 O(n+m),空间 O(m)

掌握了 KMP,你就掌握了字符串匹配的精髓。在下一篇文章中,我们将学习另一种强大的字符串数据结构——Trie 树(字典树),看看它是如何高效处理大量字符串前缀查询问题的。