字符串匹配问题与KMP思路


考虑一个非常基础的问题,给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回 -1 。这其实是一类字符串匹配问题,又称模式匹配(pattern matching)。该问题可以概括为「给定字符串 𝑆 和 𝑇 ,在主串 𝑆 中寻找子串 𝑇 」。字符 𝑇 称为模式串 (pattern)。
类型
单串匹配:给定一个模式串和一个待匹配串,找出前者在后者中的所有位置。
多串匹配:给定多个模式串和一个待匹配串,找出这些模式串在后者中的所有位置。
出现多个待匹配串时,将它们直接连起来便可作为一个待匹配串处理。
可以直接当做单串匹配,但是效率不够高。
其他类型:例如匹配一个串的任意后缀,匹配多个串的任意后缀
最直接的暴力做法 (Brute Force) 算法。该算法的基本思想是从主串 𝑆 的第一个字符开始和模式串 T 的第一个字符进行比较,若相等,则继续比较二者的后续字符。 适用于原串和模式串都非常短的情况,或者对空间要求极其严苛且不关心最坏情况效率的嵌入式设备。可能很多人听过的KMP算法,可以在 O(n+m) 的时间复杂度内实现两个字符串的匹配。跳过不可能成功的字符串比较.目前被认为最优秀的算法是two-way algorithm。出KMP裸题的人往往会发现直接strstr()都比他自己写的KMP快,就是因为glibc 采用的不是暴力算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func strStr(a string, b string) int {
s, l := len(a), len(b)
if l == 0 { return 0 }
if l > s { return -1 }

first := b[0]
for i := 0; i <= s-l; i++ {
// 优化点:先找第一个字节是否匹配,利用汇编加速
if a[i] != first {
continue
}
// 再比较完整的切片内容
if a[i:i+l] == b {
return i
}
}
return -1
}

所有编程语言的 == 或 memcmp 都是隐性的遍历。 在算法的世界里,凡是涉及“逐个字符比对”的操作,都必须计入复杂度。
时间复杂度O(M*N),当你想进一步优化的时候往往很难想到对应的解法或者完整写出来。
在实际工程中,C 语言的 strStr 或 Java 的 indexOf 源码里,并不总是躺着复杂的 KMP。事实上,很多标准库在处理短字符串时依然会选择带有启发式优化的暴力算法(Naive Approach),或者是更为复杂的 Boyer-Moore 算法。预处理模式串的“自相似性”来打破僵局
C 语言 (strstr): 经典的工业级实现通常会根据字符串长度选择策略。在很多 libc 实现中,它会使用类似于 Two-Way 算法(一种结合了 Boyer-Moore 和位运算优化的算法),既保证 (O(N+M)),又几乎不需要额外空间。Java (indexOf): 在早期的 JDK 中,indexOf 实际上就是你写的暴力匹配。原因很简单:在实际业务场景中,子串通常很短,暴力法的常量开销(Constant Factor)极低,反而比复杂的 KMP 更快。

Go (strings.Index): Go 非常务实。它先用汇编优化的 IndexByte 找第一个字符,如果主串够长,它会引入 Rabin-Karp(滚动哈希)。哈希法的核心是:既然 == 太慢,我能不能把字符串算成一个数字?数字比较可是 (O(1)) 的!

必要的背景知识

字符串的前缀函数(Prefix Function) 是字符串处理中的核心工具,本质是为字符串的每个位置 i 计算一个值 π[i],表示子串 s[0…i] 的最长相等真前缀和真后缀的长度(真前缀/后缀不能是子串本身)

1
2
3
4
5
6
7
8
9
10
11
12
举例来说,对于字符串 abcabcd,

𝜋[4] =2
\pi[4]=2,因为 abcab 相等的真前缀和真后缀只有 ab,长度为 2
𝜋[5] =3
\pi[5]=3,因为 abcabc 相等的真前缀和真后缀只有 abc,长度为 3

𝜋[6] =0
\pi[6]=0,因为 abcabcd 无相等的真前缀和真后缀

同理可以计算字符串 aabaaab 的前缀函数为 [0,1,0,1,2,2,3]
[0, 1, 0, 1, 2, 2, 3]。

**前缀函数(Prefix Function)**用途 核心优势 时间复杂度
KMP 字符串匹配 避免重复比较,高效匹配 预处理 O(m) + 匹配 O(n)
周期检测 快速判断周期并求最小周期 O(n)
查找所有边界 递推枚举所有前后缀相等子串 O(n)
构建最小循环节 快速生成最短循环字符串 O(n)

2. KMP 算法 (Knuth-Morris-Pratt)

核心思想是:在匹配失败时,利用已经匹配成功的已知信息,避免主串指针的回溯,从而提高搜索效率。

核心工具定义:前缀函数 (The Next Array)
匹配成功时:主串指针 ii𝑖 和模式串指针 jj𝑗 同步后移。
失配 (Mismatch) 时:主串指针 ii𝑖 不动,模式串指针 jj𝑗 根据 next 数组回退到 next[j-1] 的位置。
原理:既然已经匹配的部分中,前缀和后缀是相等的,那么后缀部分已经与主串对齐了,直接用前缀替换后缀的位置,跳过重复比较。 一句话总结(面试直击): “KMP 算法的定义就是:通过预计算模式串的‘最长相等前后缀’,在失配时实现主串指针不回溯、模式串指针有效位移的线性搜索过程。”

复杂度:时间 O(N+M),空间 O(M)
适用场景:通用的字符串查找,特别是处理流式数据(不需要回溯输入流)时表现出色。
• 理解“最长相等前后缀”是证明你掌握该算法的关键。

  1. 匹配失败时,总是能够让模式串回退到某个位置,使文本不用回退。
  2. 在字符串比较时,模式串提供的信息越多,计算复杂度越低。(有兴趣的可以了解一下 Trie 树,这是文本提供的信息越多,计算复杂度越低的一个例子。)

子字符串  a b a b a b z a b a b a b a
最大匹配数 0 0 1 2 3 4 0 1 2 3 4 5 6 ?

最长相等前后缀

该问题属于 KMP 算法中**部分匹配表 (Partial Match Table / Next 数组)**的计算。

第 1 步:确定当前匹配状态

在字符串的倒数第二位(索引为 12 的字符 b)处,最大匹配值(最长相等真前后缀长度)为 6。这意味着前缀 ababab 与此时的后缀 ababab 相等。

第 2 步:尝试扩展匹配

当前处理最后一位字符 a(索引为 13)。我们需要查看前缀 ababab 之后的字符是否能与当前的 a 继续匹配:

  • 长度为 6 的前缀是 S[0...5],即 ababab
  • 该前缀后的下一个字符是 S[6],即 z
  • 由于当前字符 S[13] = a 与 S[6] = z 不相等,匹配长度无法增加到 7。

第 3 步:利用已有的匹配值进行回溯 (Fallback)

当匹配失败时,我们需要查找前缀 ababab(长度为 6)的次长相等前后缀

  • 根据题目给出的序列,长度为 6 的子串 ababab 的最大匹配值(索引 5 处的值)是 4
  • 这意味着前缀 ababab 的最长相等前后缀是 abab(长度为 4)。
  • 现在比较前缀 abab 之后的下一个字符,即 S[4],与当前字符 S[13]
    • S[4] = a
    • S[13] = a
  • 两者相等。因此,新的最大匹配数是在长度 4 的基础上加 1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func strStr(s string, t string) int {
if len(s)<len(t){
return -1
}
next, j := make([]int, len(t)),0
// 当前已经连续匹配了多少个模式串前缀字符
// 构建 next: j = 当前前后缀的最大长度

// 主串匹配: j = 当前已匹配的模式串长度

// 而 next[j-1] 是:

// “在不回退 i 的前提下,我还能保留多少匹配成果”
for i:=1;i<len(t);i++{
for j>0 && t[i]!=t[j]{
j = next[j-1]
}
if t[i]==t[j]{
j++
}
next[i]=j
}
for i,j:=0,0;i<len(s);i++{
for j>0 && s[i]!=t[j]{
j = next[j-1]
}
if s[i]==t[j]{
j++
}
if j==len(t){
return i-j+1
}
}
return -1
}

优化算法

  1. Two-Way 算法 (双向匹配算法) 这是目前**工业级标准库(如 C 语言的 glibc, musl)**中 strstr 函数的实际实现方案。 
    核心逻辑:结合了 KMP 的线性时间特性和暴力算法的空间优势。它将模式串分为左部和右部,先从左往右匹配右半部分,再从右往左匹配左半部分。它利用“临界分解”理论(Critical Factorization)来确定滑动的步长。
    复杂度:时间 O(N+M) space 𝑂(1)
    适用场景:对性能要求极高且需要严格控制内存开销的底层系统开发。

KMP 算法的应用范围要比 Manacher 算法广,Manacher 算法只能应用于「回文串」问题,相对比较局限,而「子串匹配」问题还是十分常见的。背过这样的算法的意义在于:相当于大脑里有了一个时间复杂度为 O(n) 的 api 可以使用,这个 api 传入一个原串和匹配串,返回匹配串在原串的位置。

的暴力匹配跨越到线性效率的算法,本质上是一次从“物理模拟”到“信息论应用”的认知跃迁。我们之所以难以自发地想到或者复现那些复杂的解法,是因为暴力算法符合人类视觉直觉(平移、对齐、比对),而高效算法则要求我们在不匹配的瞬间,从失败中提取出对下一次尝试有价值的信息。

以下是三种打破 僵局的技术思路,它们代表了优化匹配效率的三条不同路径。

1. 滑动哈希 (Rolling Hash)

如果你觉得前后缀回退的逻辑过于绕脑,Rabin-Karp 算法提供了一个极其优雅的替代方案:将字符串比对转化为数字比对。

中,最耗时的操作是每次滑动后都要重新比对 个字符。如果我们能给模式串生成一个“指纹”(哈希值),并且能在 的时间内计算出文本串中下一个窗口的“指纹”,匹配就变成了简单的数字等值判断。

技术启示: 这种思路的核心是**“状态平滑演变”**。当你从一个搜索窗口移动到下一个时,新窗口与旧窗口共享了大部分字符( 个)。通过数学技巧(如多项式哈希),你可以把离开的字符从哈希值中“减去”,把新进来的字符“加上”。这教会我们:当面对冗余计算时,观察相邻状态之间的增量变化,往往比重新设计复杂的匹配逻辑更高效。

2. 状态机的视角:自相似性与不再回退 (KMP)

KMP 算法之所以难以实现,是因为它的核心直觉——“利用前缀和后缀的对称性”——在代码层面被抽象成了复杂的下标操作。要理解它,必须跳出“移动模式串”的物理思维,转而采用**“自动机(Automata)”**的视角。

在暴力匹配中,一旦发生不匹配,主串的指针()需要回退,这正是低效的根源。KMP 的启发在于:我们能不能让主串指针永远不回头?

为了实现这一点,我们需要预先分析模式串。如果你在模式串的第 位失败了,说明前 位已经匹配成功了。这部分已经匹配的内容里,如果存在“相等的前后缀”,就意味着模式串的开头部分其实已经和当前主串末尾重合了。

  • 核心认知: 你不是在算下标,而是在构建一个决策地图。当你在某个节点遇到“死路”时,地图直接告诉你应该跳到哪条“备选路”上继续走,而不是回到起点。这种“空间换时间”的预处理,是解决所有线性匹配问题的通法。

3. 从后往前的 (Boyer-Moore)

有时候,换个方向思考会带来指数级的提升。Boyer-Moore 算法(BM)是许多编辑器搜索功能(如 Ctrl+F)背后的功臣,它的直觉非常反常规:从模式串的末尾开始往前匹配。

为什么这更有效?因为它能提供更强的**“排除证据”**。

  • 假设你正在匹配 APPLE,但文本串对应位置的字符是 Z
  • 因为 Z 压根不在 APPLE 里,你根本不需要一格一格滑,可以直接把整个模式串跳过这整段区域。

技术启示: 这引入了**“坏字符规则”“好后缀规则”**。相比于 KMP 纠结于“我已经匹配了什么”,BM 更关注“我遇到了什么没见过的东西”。它教会我们:匹配不只是寻找相同,更是快速排除不同。 在处理大规模字符集(如 ASCII 或 Unicode)时,这种基于“不匹配字符”的跨越式跳跃,往往能让实际运行效率远超线性。


总结:如何构建直觉?

在实际工程中,C 语言的 strStr 或 Java 的 indexOf 源码里,并不总是躺着复杂的 KMP。事实上,很多标准库在处理短字符串时依然会选择带有启发式优化的暴力算法(Naive Approach),或者是更为复杂的 Boyer-Moore 算法。KMP 解决的是“如何在失配时不回头”,Two-Way 解决的是“如何让 CPU 跑得最舒服”,而 Rabin-Karp 则完全换了一个问题建模方式——它不再关心字符是否相等,而是问:这两个窗口在数学意义上是不是同一个对象。正因为这种建模带来的不确定性,Rabin-Karp 很少出现在标准库中,却在更高层的系统与算法设计里反复出现。主流语言的标准库里,几乎没有把 Rabin–Karp 作为“默认字符串查找算法”的。
原因很现实:哈希有碰撞风险,而标准库的 indexOf / strstr 是语义上必须 100% 正确的原语,不能接受“概率正确”。Rabin-Karp更多存在于算法教材、竞赛、或者你明确愿意做二次校验的场景里。

当我们理解了这三种模式,字符串匹配就不再是枯燥的下标移动,而是三种逻辑的选型:

  1. 物理对齐(朴素):最简单,无额外开销,适合小规模任务。
  2. 数学映射(Rabin-Karp):利用哈希快速排除,适合多模式搜索或大数据过滤。
  3. 结构预判(KMP/BM):深入挖掘字符间的内部联系,通过预处理实现“不走回头路”的线性效率。

结论

在讨论字符串模式匹配(Pattern Matching)时,如果跳出具体算法名称,本质上可以将其理解为三种由浅入深、直觉完全不同的技术路线。第一类方法依赖直接比对,即在文本中逐字符尝试匹配模式串,代表算法包括朴素匹配,以及在此基础上通过“跳跃规则”减少无效比较的 Horspool、Sunday 和 Boyer–Moore 系列。第二类方法引入了数学映射的视角,将字符串映射为数值指纹,通过滚动哈希快速判断候选区间是否可能匹配,Rabin–Karp 正是这一思想的典型代表。第三类方法则尝试从模式本身提取结构信息并提前预判匹配过程,通过记录前后缀关系或共享前缀来避免回退与重复计算,KMP、Trie 树以及 AC 自动机都属于这一范式。不同算法看似分散,实则是在“比、算、预判”这三种基本直觉之间不断权衡时间、空间与工程复杂度。

一、基于直接比对 + 启发式跳跃(character comparison driven)

  • 朴素字符串匹配
  • Horspool
  • Sunday
  • Boyer–Moore
    关键词:坏字符 / 好后缀 / 向前跳
    本质:仍然在比字符,只是更聪明地跳

二、基于数学映射(hash / fingerprint driven)

  • Rabin–Karp
    关键词:Rolling Hash、概率正确
    本质:先比“值”,再必要时比“字符”

三、基于结构预判(automaton / prefix structure driven)

  • KMP(单模式,前后缀函数)
  • Trie(多模式,共享前缀)
  • AC 自动机(Trie + failure link)
    本质:匹配前已经“知道失败该去哪”

Author: Stan ke
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source Stan ke !
  TOC