字符串匹配四大经典算法:BF、RK、BM、KMP 到底有什么区别?

在字符串匹配问题中,我们经常会遇到这样一个问题:

给定一个文本串 S,以及一个模式串 P,如何快速找到 PS 中出现的位置?

例如:

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

只发生了两件事情:

  1. 删除最左边的 a
  2. 在右边加入 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 最值得搞懂的是 坏字符/好后缀为什么能够一次跳过多个位置

这三个地方真正理解了,字符串匹配这一块基本就串起来了。

相关推荐
卷无止境1 小时前
测试全绿,功能能跑,代码却烂到没法上线:AI编程助手留下的十个坑
后端·python
明月_清风1 小时前
多模式字符串匹配:Trie 与 AC 自动机
后端·算法
木井巳1 小时前
【BFS/DFS 解决 FloodFill 算法】衣橱整理
java·算法·leetcode·深度优先·广度优先·宽度优先
热心网友俣先生2 小时前
2026年高教社杯数模国赛A题:五年命题规律与预测
算法·机器学习·数学建模
嘻哈baby2 小时前
不用 LangChain,从 0 写一个 Ollama 本地 RAG
后端
考虑考虑2 小时前
Redis8.8新特性
运维·redis·后端
geovindu2 小时前
java:Observer Pattern
java·开发语言·后端·观察者模式·设计模式·行为模式
LuminousCPP2 小时前
数据结构 - 排序(二):快速排序从错误初版到优化版|双指针划分 + 三数取中 + 小区间插入优化
c语言·数据结构·笔记·算法·排序算法
山甫aa3 小时前
【从零开始的 Web 后端学习】令牌技术一篇搞定(JWT 登录认证保姆级)
后端·学习·spring·web·jwt