在字符串匹配问题中,我们经常会遇到这样一个问题:
给定一个文本串
S,以及一个模式串P,如何快速找到P在S中出现的位置?
例如:
ini
S = "hello world, welcome to golang"
P = "golang"
最直接的办法是 BF(Brute Force,暴力匹配),但随着字符串越来越长,效率会越来越低。
经典的字符串匹配算法主要有:
- BF:暴力匹配
- RK:Rabin-Karp,滚动哈希
- KMP:利用前缀信息避免回退
- BM:Boyer-Moore,从后往前匹配 + 坏字符/好后缀跳跃
这几个算法最大的区别,其实可以概括成一句话:
BF 是"一个一个试",RK 是"先比较指纹",KMP 是"利用已经匹配的信息",BM 是"尽可能一次跳过很多字符"。
一、先统一问题模型
假设:
css
文本串 S:长度 n
模式串 P:长度 m
目标是找到:
css
P 是否出现在 S 中
例如:
css
S = a b c a b c d
└─────┘
P = a b c
我们最多需要考虑:
n - m + 1
个匹配起点。
如果每一个位置都把 P 完整比较一次,那么复杂度自然就是:
scss
O((n - m + 1) * m)
≈ O(nm)
这就是 BF 的基本思路。
二、BF:最简单,但也是最"笨"的
BF(Brute Force)基本没有什么技巧。
假设:
ini
S = "ABCDABCE"
P = "ABCE"
从第一个位置开始:
ABCDABCE
ABCE
比较:
ini
A == A
B == B
C == C
D != E
失败。
那么模式串向右移动一格:
ABCDABCE
ABCE
继续比较。
核心思想
匹配失败之后,只向右移动一个字符,然后重新开始。
伪代码非常简单:
go
func bf(s, p string) int {
n, m := len(s), len(p)
for i := 0; i <= n-m; i++ {
j := 0
for j < m && s[i+j] == p[j] {
j++
}
if j == m {
return i
}
}
return -1
}
时间复杂度
最坏情况下:
ini
S = aaaaaaaaaaaaaaaaaab
P = aaaaab
前面大量字符都能匹配:
erlang
aaaaa...
aaaaab
每次都比较很多次,然后才发现最后一个字符不匹配。
因此:
scss
时间复杂度:O(nm)
空间复杂度:O(1)
BF 的优点是:
- 简单
- 容易实现
- 不需要额外空间
- 数据量比较小时完全够用
缺点也很明显:
已经比较过的信息完全没有利用。
这正是后面 KMP、BM 等算法要解决的问题。
三、RK:真正有意思的是"滚动哈希"
RK(Rabin-Karp)和前面三个算法最大的不同,是:
它不直接比较字符串,而是先把字符串转换成一个数字。
也就是:
字符串
↓
Hash
↓
数字
例如:
arduino
"abc" → 123
那么我们就可以先比较:
bash
hash("abc") == hash("abc")
而不是:
ini
'a' == 'a'
'b' == 'b'
'c' == 'c'
问题来了:
普通 Hash 不难,难的是如何快速计算下一个窗口的 Hash。
这就是 RK 最核心、也是最值得理解的地方。
四、RK 最特别的地方:Rolling Hash
假设:
ini
S = abcdef
P = bcd
模式串长度:
ini
m = 3
那么文本串需要不断计算:
python
abc
bcd
cde
def
如果每次都重新计算 Hash:
scss
hash(abc)
hash(bcd)
hash(cde)
hash(def)
那么 Hash 本身又需要 O(m)。
这样 RK 就没有那么大的优势了。
所以 RK 使用:
Rolling Hash(滚动哈希)
五、一个非常经典的多项式 Hash
可以把字符串看成一个 base 进制的数字。
例如:
abc
假设:
ini
a = 1
b = 2
c = 3
base = 10
那么:
bash
hash("abc")
= 1 × 10²
+ 2 × 10¹
+ 3
= 123
对于:
bcd
就是:
diff
2 × 10²
+ 3 × 10¹
+ 4
= 234
真正实现时通常会配合:
lua
mod
避免数字无限增长。
例如:
bash
hash = (hash * base + value) % mod
六、滚动哈希到底怎么"滚"?
这是 RK 最核心的地方。
假设:
ini
S = abcdef
窗口大小:
ini
m = 3
第一次:
abc
假设:
scss
hash(abc) = 123
现在窗口向右移动:
abc
↓
bcd
我们其实不需要重新计算 bcd。
因为:
abc
和:
bcd
只发生了两件事情:
- 删除最左边的
a - 在右边加入
d
数学上:
csharp
hash(abc)
= a × base² + b × base + c
删除 a:
bash
hash - a × base²
然后整体左移一位:
csharp
(hash - a × base²) × base
最后加上 d:
csharp
newHash =
(hash - a × base²) × base + d
所以:
abc
↓
bcd
可以在:
scss
O(1)
时间内完成 Hash 更新。
这就是 RK 最特别的地方。
七、为什么说 RK 的核心不是 Hash,而是"可更新的 Hash"
很多人第一次学 RK,会把重点放在:
"RK 使用 Hash。"
其实这并不是最关键的。
真正重要的是:
这个 Hash 必须支持窗口滑动时快速更新。
普通 Hash:
bash
abc → hash
bcd → 重新计算 hash
滚动 Hash:
css
abc → hash1
↓
删除 a
加入 d
↓
bcd → hash2
因此:
hash1 → hash2
只需要:
scss
O(1)
八、RK 为什么还要重新比较字符串?
这里有一个非常重要的问题:
bash
hash("abc") == hash("xyz")
并不代表:
ini
"abc" == "xyz"
因为:
Hash 存在碰撞(Collision)。
例如:
bash
hash("abc") == hash("xyz")
理论上是可能发生的。
所以 RK 通常是:
markdown
Hash 不相等
↓
一定不相等
Hash 相等
↓
可能相等
↓
再进行字符串比较
也就是:
ini
if textHash == patternHash {
if text[i:i+m] == pattern {
return i
}
}
因此 RK 本质上是:
Hash 负责快速过滤,字符串比较负责最终确认。
这也是 RK 和 KMP/BM 一个非常重要的区别。
九、RK 的复杂度为什么有"平均 O(n),最坏 O(nm)"?
假设模式串 Hash 计算:
scss
O(m)
文本第一个窗口 Hash:
scss
O(m)
后面的窗口:
scss
O(1)
因为使用 Rolling Hash。
因此正常情况下:
scss
O(m) + O(n)
≈ O(n + m)
如果 Hash 碰撞非常少:
scss
平均:O(n + m)
但是如果发生大量 Hash 碰撞:
bash
hash相等
↓
反复进行字符串比较
就可能退化到:
scss
O(nm)
所以 RK 的典型复杂度可以写成:
| 情况 | 时间复杂度 |
|---|---|
| 平均 | O(n + m) |
| 最坏 | O(nm) |
| 空间 | O(1) 或 O(m),取决于实现 |
十、一个 Go 版本的 Rolling Hash
例如可以这样写:
go
func rabinKarp(s, p string) int {
n, m := len(s), len(p)
if m > n {
return -1
}
const base uint64 = 256
var ph uint64
var sh uint64
var high uint64 = 1
// high = base^(m-1)
for i := 0; i < m-1; i++ {
high *= base
}
// 计算 pattern 和第一个窗口的 hash
for i := 0; i < m; i++ {
ph = ph*base + uint64(p[i])
sh = sh*base + uint64(s[i])
}
for i := 0; i <= n-m; i++ {
// hash 相同,再真正比较字符串
if ph == sh && s[i:i+m] == p {
return i
}
if i < n-m {
// 移除最高位
sh -= uint64(s[i]) * high
// 左移并加入新字符
sh = sh*base + uint64(s[i+m])
}
}
return -1
}
这里最值得理解的就是:
ini
sh -= uint64(s[i]) * high
sh = sh*base + uint64(s[i+m])
这两行实际上就是:
diff
删除左边
+
加入右边
这就是 Rolling Hash。
十一、KMP:不使用 Hash,而是利用"前缀信息"
如果说 RK 的思想是:
不要直接比较字符串,先比较 Hash。
那么 KMP 的思想就是:
匹配失败了,也不要从头开始,因为之前匹配成功的信息是有价值的。
例如:
ini
P = ABABAC
当我们匹配到:
ABABA
最后一个字符失败。
普通 BF:
重新开始
KMP:
我已经知道前面匹配过什么
所以没必要全部重新比较
十二、KMP 最核心的数据结构:next / pi 数组
KMP 会提前分析模式串:
ABABAC
找到每个位置:
最长的相等前缀和后缀。
例如:
ABAB
它的:
前缀:AB
后缀:AB
相等。
所以:
最长相等前后缀长度 = 2
这个信息会被保存下来。
十三、KMP 为什么能够做到 O(n + m)?
匹配的时候:
css
i → 文本串
j → 模式串
如果:
css
s[i] != p[j]
普通 BF 会:
css
i 回退
j 回到 0
KMP 不这么做。
它通过:
css
next[j]
直接告诉我们:
j 应该跳到哪里
所以文本串指针 i 不需要反复回退。
最终:
scss
构建 next:O(m)
匹配:O(n)
总计:
O(n + m)
空间:
scss
O(m)
十四、KMP 的一句话理解
可以把 KMP 理解成:
"我已经匹配成功的部分不是白匹配的,失败之后可以继续利用。"
而 BF:
"失败了?全部重来。"
所以:
lua
BF
失败 → 从头再来
KMP
失败 → 根据 next 跳到合适的位置
十五、BM:反过来,从后往前比较
KMP 是:
从左往右
BM 非常有意思:
从模式串的右边开始比较。
例如:
css
文本:
HERE IS A SIMPLE EXAMPLE
模式:
EXAMPLE
BM 不会从:
css
E
X
A
...
开始。
而是:
css
E X A M P L E
↑
从这里开始
如果发现:
yaml
文本字符 != 模式串字符
那么它会根据已经获得的信息:
一次向右跳很多字符。
十六、BM 最经典的两个规则
BM 主要依赖两个非常重要的思想:
1. 坏字符规则
Bad Character Rule。
假设:
css
模式:A B C D E
↑
从右往左比较:
E
D
C
结果发现:
yaml
C != X
也就是说:
坏字符 = X
那么我们可以看看:
X
在模式串里面最后一次出现在哪里。
如果不存在:
X
那么模式串可以直接跳过很多字符。
2. 好后缀规则
Good Suffix Rule。
假设:
css
模式:A B C D E
C D E
后面的:
CDE
已经匹配成功。
如果前面某个字符失败:
css
A B X D E
↑
那么我们可以寻找:
模式串前面是否还有一个位置可以匹配已经成功的
CDE。
如果存在,就直接把模式串移动过去。
十七、BM 为什么实际运行非常快?
BM 最厉害的地方是:
它不要求每个字符都检查。
例如:
markdown
文本:
abcdefghijklmnopqrstuvwxyz......
模式:
hello
如果从右往左比较:
yaml
o != z
发现:
z
甚至不在模式串:
hello
里面。
那就意味着:
hello
可以直接跳过去。
一次跳:
m
个字符。
因此在很多自然语言、英文文本等场景中,BM 实际比较次数非常少。
这也是为什么很多工程中的字符串查找实现会采用 BM 或 BM 的变种。
十八、BM 的复杂度需要特别说明
这里有一个容易产生误解的地方。
很多资料会写:
scss
BM 平均复杂度 ≈ O(n/m)
这个说法更多是在描述:
BM 平均情况下能够跳过大量字符,因此实际比较次数可能远低于 n。
但严格来说,经典 Boyer-Moore 算法的复杂度分析需要区分具体变体和启发式规则。
一般可以这样记:
scss
最好情况:O(n/m)
平均情况:通常非常快,实际比较次数很低
经典 BM 最坏情况:O(nm)
经过某些优化/特定变体:
可以做到 O(n + m)
所以不要简单理解成:
BM 永远是 O(n/m)。
这是不严谨的。
十九、四个算法放在一起看
现在我们可以把四个算法放到一起:
| 算法 | 核心思想 | 比较方向 | 平均/典型 | 最坏 |
|---|---|---|---|---|
| BF | 一个一个尝试 | 左 → 右 | O(nm) | O(nm) |
| RK | Rolling Hash | 左 → 右 | O(n+m) | O(nm) |
| KMP | 利用前缀信息 | 左 → 右 | O(n+m) | O(n+m) |
| BM | 坏字符 + 好后缀跳跃 | 右 → 左 | 通常非常快 | O(nm)(经典版) |
可以用一个非常直观的方式理解:
BF
位置 1:████████
位置 2: ████████
位置 3: ████████
位置 4: ████████
每次只移动 1 格
RK
窗口 1 → Hash
窗口 2 → Rolling Hash
窗口 3 → Rolling Hash
窗口 4 → Rolling Hash
不需要每次重新计算整个字符串
lua
KMP
匹配成功
███████
↓
失败
↓
根据 next 跳转
已经匹配的信息不浪费
markdown
BM
███████
↑
从右往左
失败
↓
███████
↓↓↓↓↓
一次跳很多
二十、最值得记住的其实是四种"优化思维"
如果你正在学习算法,我反而建议不要死记:
ini
BF = O(nm)
RK = O(n+m)
KMP = O(n+m)
BM = O(n/m)
更重要的是理解它们分别在优化什么。
BF:什么都不优化
失败
↓
向右移动 1
↓
重新匹配
RK:优化"比较"
markdown
字符串比较
↓
先比较 Hash
↓
Hash 不同 → 直接跳过
Hash 相同 → 再比较字符串
而 RK 最漂亮的地方是:
scss
Hash
↓
Rolling
↓
窗口移动 O(1)
KMP:优化"重复匹配"
lua
已经匹配过的字符
↓
不浪费
↓
next / prefix
↓
失败后直接跳
BM:优化"移动距离"
匹配失败
↓
分析坏字符 / 好后缀
↓
尽可能多跳几个字符
所以可以把四个算法浓缩成一句话:
BF 减少不了比较;RK 用 Hash 减少真正的字符串比较;KMP 利用前缀信息减少重复比较;BM 则通过跳跃减少需要检查的位置。
二十一、RK 为什么在工程中也很有价值?
RK 有一个 KMP/BM 没那么明显的优势:
它非常适合"多模式匹配"和"子串指纹"问题。
例如你需要判断:
arduino
"abcdef"
里面有没有:
erlang
"abc"
"xyz"
"def"
"hello"
...
这时候 Hash 就非常自然。
甚至可以提前建立:
patternHashSet
然后扫描文本:
markdown
windowHash
↓
HashSet 查询
↓
是否存在?
于是就变成:
scss
O(n)
级别的扫描 + Hash 查询。
当然,实际工程中还需要认真处理:
- Hash 碰撞
- 大整数溢出
- mod 选择
- 双 Hash
- 字符编码问题
尤其是中文字符串,如果直接按 Go 的 byte 处理,还需要注意 UTF-8:
arduino
"你好"
一个汉字并不是一个 byte。
二十二、最后总结
如果把这四个算法比喻成四个人找一个单词:
BF
"我从头开始一个字符一个字符找。"
简单粗暴:
scss
O(nm)
RK
"我不给你一个个看,我先算指纹。"
核心:
Hash + Rolling Hash
平均:
scss
O(n+m)
最坏:
scss
O(nm)
最值得掌握的是滚动哈希:
旧 Hash
↓
减掉左边字符
↓
整体移动
↓
加上右边字符
↓
新 Hash
KMP
"刚才匹配过的内容,我不会浪费。"
核心:
lua
最长相等前缀/后缀
↓
next
↓
失败后跳转
复杂度:
scss
O(n+m)
BM
"为什么一个字符一个字符移动?一次跳几十个不行吗?"
核心:
diff
坏字符
+
好后缀
+
从右向左匹配
最好/典型情况下可以跳过大量字符,实际搜索非常快;但经典 BM 不能简单地概括为"永远 O(n/m)",最坏情况仍可能达到 O(nm)。
最后用一张图记住它们
scss
字符串匹配
│
┌───────────────┼────────────────┐
│ │ │
BF RK KMP/BM
│ │ │
暴力一个个试 Rolling Hash 利用匹配信息
│ │ │
移动 1 格 Hash 快速过滤 ┌───────┐
│ │
KMP BM
│ │
前缀信息 跳跃
│ │
O(n+m) 实际很快
如果只从学习价值来看,我建议顺序是:
BF
↓
KMP
↓
RK(重点理解 Rolling Hash)
↓
BM
其中 RK 最值得单独搞懂的是"为什么 Hash 可以 O(1) 滚动更新" ;KMP 最值得搞懂的是 next 数组为什么能让指针不回退 ;BM 最值得搞懂的是 坏字符/好后缀为什么能够一次跳过多个位置。
这三个地方真正理解了,字符串匹配这一块基本就串起来了。