KMP 算法:字符串匹配的艺术
1. 为什么需要 KMP?暴力匹配的痛点
让我们先回顾一下暴力匹配(Brute Force)是如何工作的。假设主串 text = "ABABCABABAB"
,模式串 pattern = "ABAB"
。
1 | text: A B A B C A B A B A B |
当发生不匹配时,暴力匹配会将模式串整体向后移动一位,并重新从模式串的第一个字符开始与主串当前位置的字符进行比较。在最坏情况下(例如 text = "AAAAAAAAB"
, pattern = "AAAB"
),暴力匹配会进行大量的重复比较,导致时间复杂度高达 O(mn)。
观察上述过程,我们发现一个问题:当 pattern[j]
与 text[i]
不匹配时,我们已经知道 text[i-j ... i-1]
和 pattern[0 ... j-1]
是匹配的。暴力匹配却直接抛弃了这些已匹配的信息,从头再来。KMP 算法正是利用了这些已匹配的信息,避免了主串指针 i
的回溯,从而提高了效率。
2. KMP 算法的核心思想:利用“前缀”和“后缀”
KMP 算法的关键在于,当模式串与主串发生不匹配时,它不回溯主串的指针,而是根据模式串自身的特点,将模式串向右滑动尽可能远的距离。这个距离的计算依赖于模式串的**“最长公共前后缀”**信息。
什么是“最长公共前后缀”?
对于模式串的任意一个前缀 pattern[0 ... j-1]
(即 pattern
的一个子串),我们想找到一个最长的真前缀(不包括整个子串本身)和最长的真后缀(不包括整个子串本身)是相同的。
例如,模式串 pattern = "ABABA"
:
- 当子串是
"A"
时:无公共前后缀。 - 当子串是
"AB"
时:无公共前后缀。 - 当子串是
"ABA"
时:前缀A
,后缀A
。最长公共前后缀长度为 1。 - 当子串是
"ABAB"
时:前缀A
,后缀B
。前缀AB
,后缀AB
。最长公共前后缀长度为 2。 - 当子串是
"ABABA"
时:前缀A
,后缀A
。前缀AB
,后缀BA
。前缀ABA
,后缀ABA
。最长公共前后缀长度为 3。
KMP 算法预处理模式串,生成一个 next
数组(也常被称为 lps
数组,即 Longest Proper Prefix which is also Suffix),这个数组记录了模式串每个前缀的最长公共前后缀的长度。
3. next
数组(或 lps
数组)的构建
next[j] 表示 pattern[0 … j-1] 这个子串的最长公共前后缀的长度。
约定 next[0] = -1 (或 next[0] = 0,取决于实现细节),表示模式串第一个字符失配时,模式串应整体后移一位。
以 pattern = "ABABCABAB"
为例,我们来手动构建 next
数组:
索引 j | pattern[j] | pattern[0…j] (子串) | 最长公共前后缀 (长度) | next[j+1] (模式串下一个位置的值) |
---|---|---|---|---|
- | - | 空串 | - | next[0] = -1 (约定) |
0 | A |
A |
空串 (0) | next[1] = 0 |
1 | B |
AB |
空串 (0) | next[2] = 0 |
2 | A |
ABA |
A (1) |
next[3] = 1 |
3 | B |
ABAB |
AB (2) |
next[4] = 2 |
4 | C |
ABABC |
空串 (0) | next[5] = 0 |
5 | A |
ABabca |
A (1) |
next[6] = 1 |
6 | B |
ABabcAB |
AB (2) |
next[7] = 2 |
7 | A |
ABabcABA |
ABA (3) |
next[8] = 3 |
8 | B |
ABabcABAB |
ABAB (4) |
next[9] = 4 |
构建 next
数组的算法:
我们可以使用动态规划的思想来构建 next 数组。
设 next[j] 已经计算好,我们来计算 next[j+1]。
next[j+1] 实际是 pattern[0…j] 这个子串的最长公共前后缀长度。
- 用
k
表示当前已知的最长公共前后缀的长度。初始k=0
。 - 用
j
遍历模式串的索引。
1 | // 构建 next 数组 (next[i] 表示 pattern[0...i-1] 的最长公共前后缀长度) |
注意: 这里的 next 数组定义是 next[j] 表示 pattern[0…j-1] 的最长公共前后缀的长度,即 pattern[j] 失配时,模式串应该移动到 pattern[next[j]] 处继续比较。
或者,更常见的定义是 next[i] 表示 pattern[0…i] 的最长公共前后缀的长度,那么失配时回溯到 next[j-1]。这里我们采用前者,方便后续匹配。
4. KMP 匹配算法
有了 next
数组,KMP 匹配过程就变得高效了。
i
:主串text
的当前比较位置。j
:模式串pattern
的当前比较位置。
匹配过程:
当
text[i]
和pattern[j]
匹配时,i
和j
同时向后移动一位 (i++
,j++
)。当
1
text[i]
和
1
pattern[j]
不匹配时:
- 如果
j > 0
(即不是模式串的第一个字符失配),则将j
更新为next[j]
。这表示我们将模式串向右滑动,使得pattern[0 ... next[j]-1]
与text[i - next[j] ... i-1]
保持对齐,然后从pattern[next[j]]
处开始重新比较。主串指针i
不回溯。 - 如果
j = 0
(即模式串的第一个字符就失配),则只将i
向后移动一位 (i++
),j
保持为 0(模式串整体后移一位,从头开始比较)。
- 如果
当 j
等于模式串的长度 m
时,表示找到了一个匹配。此时,text
中匹配的起始位置是 i - m
。找到后,为了寻找下一个匹配,我们将 j
更新为 next[j]
(即 next[m]
),然后继续匹配。
1 | // KMP 字符串匹配算法 |
5. 完整 C 语言代码示例
1 |
|
6. KMP 算法的优势与复杂度分析
- 时间复杂度:
- 构建
next
数组:O(m),其中 m 是模式串的长度。 - 匹配过程:O(n),其中 n 是主串的长度。主串指针
i
永不回溯,模式串指针j
最多回溯 m 次(每次回溯都会导致j
减小),但每次i
和j
至少有一个会前进,所以总操作次数是线性的。 - 总时间复杂度:*O(m+n)*。
- 构建
- 空间复杂度:
- O(m),用于存储
next
数组。
- O(m),用于存储
与暴力匹配相比,KMP 算法的优势在于: 它在模式串与主串发生不匹配时,能够利用已匹配的模式串前缀信息,避免主串指针的回溯,从而在最坏情况下也能保持线性时间复杂度。这使得它在处理长文本和重复模式的字符串匹配问题时表现出色。