前言
字符串匹配算法的目的是在文本串 S
中找到与模式串 P
相匹配的起始索引 m
。
最直接的算法被称为暴力(brute-force)算法,将模式串与文本串的每个可能的起始位置对齐,逐一比较模式串与文本串中对应的字符。如果发现不匹配,移动模式串到下一个位置,重新开始比较;如果模式串完全匹配,则找到了一个匹配位置。
暴力算法的时间复杂度是 $O(n*m)$,其中 $n$ 是文本串长度,$m$ 是模式串长度。在最坏情况下,对于每个可能的起始位置,都需要比较整个模式串。
因此暴力算法虽然直观,但在文本量大的情况下效率很低。本文将介绍一个效率更高的算法-KMP 算法
KMP 算法
KMP 算法是一种高效的字符串匹配算法,通过预处理模式串构建部分匹配表(next 数组),在匹配失败时利用已匹配信息跳过不必要的比较。
关键概念
最长前缀后缀数组:记录模式串中每个位置之前的最长相同前缀和后缀的长度。
前缀:不包含最后一个字符的连续子串。
后缀:不包含第一个字符的连续子串。
核心思想
部分匹配表(next 数组):记录模式串每个前缀的最长公共前后缀长度。
匹配优化:当发生不匹配时,根据 next 数组调整模式串位置,避免回溯主串指针。
算法步骤
预处理阶段
计算 next 数组:
- 对模式串进行分析,构建最长前缀后缀数组
- 帮助在匹配失败时快速回退,避免重复比较
匹配阶段
使用 next 数组进行高效匹配:
- 逐字符比较文本串和模式串
- 当发生不匹配时,利用LPS数组快速移动模式串
代码实现
next 数组构建算法
以模式串 pattern = "ABABCABAB"
为例:
1 | def build_next(pattern): |
模拟构建过程:
1 | 索引: 0 1 2 3 4 5 6 7 8 |
KMP 匹配算法实现
1 | def kmp_search(text, pattern): |
算法模拟演示
输入:
1 | text: "ABABDABACDABABCABAB" |
匹配过程:
1 | 步骤 | 主串位置 | 模式串位置 | 操作 |
算法分析
时间复杂度:$O(m+n)$,其中 $m$ 为模式串长度,$n$ 为主串长度。
空间复杂度:$O(m)$,用于存储 next
数组。
优势:相比暴力匹配的 $O(mn)$ 时间复杂度,显著提升效率。
深入分析
暴力算法的瓶颈问题
问题场景:在文本串 T[0...n-1]
中查找模式串 P[0...m-1]
暴力算法流程:
1 | for i in 0...n-m: |
暴力算法的核心缺陷是每次失配时主串指针i完全回溯,浪费已匹配信息。
KMP的核心突破
已匹配部分的信息要怎么利用?
当 P[0...j-1]
与 T[i-j...i-1]
完全匹配,在位置 j
发生失配时:
1 | 已匹配区域: P[0] P[1] ... P[j-1] |
关键问题:如何最大化利用已匹配区域的信息,避免主串指针 i
回溯?
next 数组的数学定义
对于模式串 P
,定义 next[j]
为子串 P[0...j]
的最长公共前后缀长度。
递推关系:
1 | next[j] = max{ k | P[0...k-1] = P[j-k...j-1] } |
物理意义:当在 P[j]
失配时,模式串可以向右滑动 j - next[j-1]
位。
next数组正确性证明(数学归纳法)
定义:
对于模式串 $P[0\dots m-1]$,定义函数:
$$
f(j)=max{k|0≤k<j且P[0\dots k-1]=P[j-k\dots j-1]}
$$
即子串 $P[0…j−1]$ 的最长公共前后缀长度。
需要证明:算法构建的 $next[j]=f(j)$
归纳基础:
当 $j=0$ 时,$next[0]=0$,空串的 LPS 长度为 0,成立。
归纳假设:
假设对于任意 $j ≤ n$,均有 $next[j] = f(j)$
归纳步骤:
需要证明 $next[n+1]=f(n+1)$
构建过程关键操作:
1 | while j > 0 and pattern[i] != pattern[j]: |
分为两种情况讨论:
Case1:$P[i]=p[j]$
- 此时最长公共前后缀长度可扩展为 $j+1$
- 根据归纳假设,$j=f(i)$,因此 $f(i+1)=j+1$
- 算法执行 $j+=1$ 后赋值,正确性成立
Case2:$P[i]≠p[j]$
- 需找到次长的公共前后缀长度 $k=next[j-1]$
- 由数学归纳法,$next[j-1]=f(j-1)$
- 根据LPS的传递性,次长后缀必然由最长后缀的最长后缀构成。
- 通过循环回溯,最终找到最大的合法k值。
后缀链递减性:
若$P[0…k-1]=P[i-k…i-1]$,则次大的满足条件的$k^′$值为$next[k-1]$
证明:假设存在更大的$k^′$满足条件,则$P[0…k^′-1]$必须同时是$P[0…k-1]$的前缀和$P[i-k…i-1]$的后缀,这与$next[k-1]$定义矛盾。
匹配过程正确性证明(反证法)
命题:
KMP算法不会错过任何可能的匹配位置。
证明框架:
假设存在遗漏:
设存在某个有效匹配起始位置 $s$,但算法未能检测到。矛盾构造:
设算法在某个时刻满足:
$$
i≥s+t,且j≥t
$$
其中 $t$ 是已经匹配的字符数。根据 LPS 定义,此时应有:
$$
t≤next[j−1]
$$
这意味着算法应该通过调整 $j=next[j-1]$ 来覆盖此情况,与假设矛盾。
详细推导:
假设在文本 $T$ 的 $pos$ 位置开始存在完整匹配,即:
$$
T[pos\dots pos+m-1] = P[0\dots m-1]
$$
但算法未能检测到该匹配。
观察算法行为:
- 当扫描到 $T[pos+j]$ 时($0≤j≤m$),若发生失配,算法将调整模式串位置为 $next[j]$。
- 根据next数组的定义,此时新的起始位置为 $pos^′=pos+j-next[j-1]$
关键矛盾点:
若存在未被检测的匹配位置 $pos$,则必须存在某个 $j$ 使得:
$$
next[j-1]<实际存在的公共前后缀长度
$$
这与next数组记录最大值的定义矛盾。
后记
KMP算法相对于暴力算法来说时间复杂度得到了极大的提升,在各种情况下表现良好。