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

32 阅读12分钟

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

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

例如:

S = "hello world, welcome to golang"
P = "golang"

最直接的办法是 BF(Brute Force,暴力匹配),但随着字符串越来越长,效率会越来越低。

经典的字符串匹配算法主要有:

  • BF:暴力匹配
  • RK:Rabin-Karp,滚动哈希
  • KMP:利用前缀信息避免回退
  • BM:Boyer-Moore,从后往前匹配 + 坏字符/好后缀跳跃

这几个算法最大的区别,其实可以概括成一句话:

BF 是“一个一个试”,RK 是“先比较指纹”,KMP 是“利用已经匹配的信息”,BM 是“尽可能一次跳过很多字符”。


一、先统一问题模型

假设:

文本串 S:长度 n
模式串 P:长度 m

目标是找到:

P 是否出现在 S 中

例如:

S = a b c a b c d
    └─────┘
P = a b c

我们最多需要考虑:

n - m + 1

个匹配起点。

如果每一个位置都把 P 完整比较一次,那么复杂度自然就是:

O((n - m + 1) * m)
≈ O(nm)

这就是 BF 的基本思路。


二、BF:最简单,但也是最“笨”的

BF(Brute Force)基本没有什么技巧。

假设:

S = "ABCDABCE"
P = "ABCE"

从第一个位置开始:

ABCDABCE
ABCE

比较:

A == A
B == B
C == C
D != E

失败。

那么模式串向右移动一格:

ABCDABCE
 ABCE

继续比较。

核心思想

匹配失败之后,只向右移动一个字符,然后重新开始。

伪代码非常简单:

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
}

时间复杂度

最坏情况下:

S = aaaaaaaaaaaaaaaaaab
P = aaaaab

前面大量字符都能匹配:

aaaaa...
aaaaab

每次都比较很多次,然后才发现最后一个字符不匹配。

因此:

时间复杂度:O(nm)
空间复杂度:O(1)

BF 的优点是:

  • 简单
  • 容易实现
  • 不需要额外空间
  • 数据量比较小时完全够用

缺点也很明显:

已经比较过的信息完全没有利用。

这正是后面 KMP、BM 等算法要解决的问题。


三、RK:真正有意思的是“滚动哈希”

RK(Rabin-Karp)和前面三个算法最大的不同,是:

它不直接比较字符串,而是先把字符串转换成一个数字。

也就是:

字符串
   ↓
Hash
   ↓
数字

例如:

"abc"123

那么我们就可以先比较:

hash("abc") == hash("abc")

而不是:

'a' == 'a'
'b' == 'b'
'c' == 'c'

问题来了:

普通 Hash 不难,难的是如何快速计算下一个窗口的 Hash。

这就是 RK 最核心、也是最值得理解的地方。


四、RK 最特别的地方:Rolling Hash

假设:

S = abcdef
P = bcd

模式串长度:

m = 3

那么文本串需要不断计算:

abc
bcd
cde
def

如果每次都重新计算 Hash:

hash(abc)
hash(bcd)
hash(cde)
hash(def)

那么 Hash 本身又需要 O(m)

这样 RK 就没有那么大的优势了。

所以 RK 使用:

Rolling Hash(滚动哈希)


五、一个非常经典的多项式 Hash

可以把字符串看成一个 base 进制的数字。

例如:

abc

假设:

a = 1
b = 2
c = 3
base = 10

那么:

hash("abc")
= 1 × 10²
+ 2 × 10¹
+ 3

= 123

对于:

bcd

就是:

2 × 10²
+ 3 × 10¹
+ 4

= 234

真正实现时通常会配合:

mod

避免数字无限增长。

例如:

hash = (hash * base + value) % mod

六、滚动哈希到底怎么“滚”?

这是 RK 最核心的地方。

假设:

S = abcdef

窗口大小:

m = 3

第一次:

abc

假设:

hash(abc) = 123

现在窗口向右移动:

abc
 ↓
bcd

我们其实不需要重新计算 bcd

因为:

abc

和:

bcd

只发生了两件事情:

  1. 删除最左边的 a
  2. 在右边加入 d

数学上:

hash(abc)
= a × base² + b × base + c

删除 a

hash - a × base²

然后整体左移一位:

(hash - a × base²) × base

最后加上 d

newHash =
    (hash - a × base²) × base + d

所以:

abc
 ↓
bcd

可以在:

O(1)

时间内完成 Hash 更新。

这就是 RK 最特别的地方。


七、为什么说 RK 的核心不是 Hash,而是“可更新的 Hash”

很多人第一次学 RK,会把重点放在:

“RK 使用 Hash。”

其实这并不是最关键的。

真正重要的是:

这个 Hash 必须支持窗口滑动时快速更新。

普通 Hash:

abc → hash
bcd → 重新计算 hash

滚动 Hash:

abc → hash1
       ↓
       删除 a
       加入 d
       ↓
bcd → hash2

因此:

hash1 → hash2

只需要:

O(1)

八、RK 为什么还要重新比较字符串?

这里有一个非常重要的问题:

hash("abc") == hash("xyz")

并不代表:

"abc" == "xyz"

因为:

Hash 存在碰撞(Collision)。

例如:

hash("abc") == hash("xyz")

理论上是可能发生的。

所以 RK 通常是:

Hash 不相等
    ↓
一定不相等

Hash 相等
    ↓
可能相等
    ↓
再进行字符串比较

也就是:

if textHash == patternHash {
    if text[i:i+m] == pattern {
        return i
    }
}

因此 RK 本质上是:

Hash 负责快速过滤,字符串比较负责最终确认。

这也是 RK 和 KMP/BM 一个非常重要的区别。


九、RK 的复杂度为什么有“平均 O(n),最坏 O(nm)”?

假设模式串 Hash 计算:

O(m)

文本第一个窗口 Hash:

O(m)

后面的窗口:

O(1)

因为使用 Rolling Hash。

因此正常情况下:

O(m) + O(n)
≈ O(n + m)

如果 Hash 碰撞非常少:

平均:O(n + m)

但是如果发生大量 Hash 碰撞:

hash相等
    ↓
反复进行字符串比较

就可能退化到:

O(nm)

所以 RK 的典型复杂度可以写成:

情况时间复杂度
平均O(n + m)
最坏O(nm)
空间O(1) 或 O(m),取决于实现

十、一个 Go 版本的 Rolling Hash

例如可以这样写:

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
}

这里最值得理解的就是:

sh -= uint64(s[i]) * high
sh = sh*base + uint64(s[i+m])

这两行实际上就是:

删除左边
+
加入右边

这就是 Rolling Hash


十一、KMP:不使用 Hash,而是利用“前缀信息”

如果说 RK 的思想是:

不要直接比较字符串,先比较 Hash。

那么 KMP 的思想就是:

匹配失败了,也不要从头开始,因为之前匹配成功的信息是有价值的。

例如:

P = ABABAC

当我们匹配到:

ABABA

最后一个字符失败。

普通 BF:

重新开始

KMP:

我已经知道前面匹配过什么
所以没必要全部重新比较

十二、KMP 最核心的数据结构:next / pi 数组

KMP 会提前分析模式串:

ABABAC

找到每个位置:

最长的相等前缀和后缀。

例如:

ABAB

它的:

前缀:AB
后缀:AB

相等。

所以:

最长相等前后缀长度 = 2

这个信息会被保存下来。


十三、KMP 为什么能够做到 O(n + m)?

匹配的时候:

i → 文本串
j → 模式串

如果:

s[i] != p[j]

普通 BF 会:

i 回退
j 回到 0

KMP 不这么做。

它通过:

next[j]

直接告诉我们:

j 应该跳到哪里

所以文本串指针 i 不需要反复回退。

最终:

构建 next:O(m)

匹配:O(n)

总计:

O(n + m)

空间:

O(m)

十四、KMP 的一句话理解

可以把 KMP 理解成:

“我已经匹配成功的部分不是白匹配的,失败之后可以继续利用。”

而 BF:

“失败了?全部重来。”

所以:

BF
失败 → 从头再来

KMP
失败 → 根据 next 跳到合适的位置

十五、BM:反过来,从后往前比较

KMP 是:

从左往右

BM 非常有意思:

从模式串的右边开始比较。

例如:

文本:

HERE IS A SIMPLE EXAMPLE

模式:

EXAMPLE

BM 不会从:

E
X
A
...

开始。

而是:

E X A M P L E
            ↑
          从这里开始

如果发现:

文本字符 != 模式串字符

那么它会根据已经获得的信息:

一次向右跳很多字符。


十六、BM 最经典的两个规则

BM 主要依赖两个非常重要的思想:

1. 坏字符规则

Bad Character Rule。

假设:

模式:A B C D E
            ↑

从右往左比较:

E
D
C

结果发现:

C != X

也就是说:

坏字符 = X

那么我们可以看看:

X

在模式串里面最后一次出现在哪里。

如果不存在:

X

那么模式串可以直接跳过很多字符。


2. 好后缀规则

Good Suffix Rule。

假设:

模式:A B C D E
          C D E

后面的:

CDE

已经匹配成功。

如果前面某个字符失败:

A B X D E
    ↑

那么我们可以寻找:

模式串前面是否还有一个位置可以匹配已经成功的 CDE

如果存在,就直接把模式串移动过去。


十七、BM 为什么实际运行非常快?

BM 最厉害的地方是:

它不要求每个字符都检查。

例如:

文本:
abcdefghijklmnopqrstuvwxyz......

模式:
        hello

如果从右往左比较:

o != z

发现:

z

甚至不在模式串:

hello

里面。

那就意味着:

hello

可以直接跳过去。

一次跳:

m

个字符。

因此在很多自然语言、英文文本等场景中,BM 实际比较次数非常少。

这也是为什么很多工程中的字符串查找实现会采用 BM 或 BM 的变种。


十八、BM 的复杂度需要特别说明

这里有一个容易产生误解的地方。

很多资料会写:

BM 平均复杂度 ≈ O(n/m)

这个说法更多是在描述:

BM 平均情况下能够跳过大量字符,因此实际比较次数可能远低于 n。

但严格来说,经典 Boyer-Moore 算法的复杂度分析需要区分具体变体和启发式规则。

一般可以这样记:

最好情况:O(n/m)

平均情况:通常非常快,实际比较次数很低

经典 BM 最坏情况:O(nm)

经过某些优化/特定变体:
可以做到 O(n + m)

所以不要简单理解成:

BM 永远是 O(n/m)。

这是不严谨的。


十九、四个算法放在一起看

现在我们可以把四个算法放到一起:

算法核心思想比较方向平均/典型最坏
BF一个一个尝试左 → 右O(nm)O(nm)
RKRolling 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

不需要每次重新计算整个字符串

KMP

匹配成功
███████
    ↓
失败
    ↓
根据 next 跳转

已经匹配的信息不浪费

BM

███████
       ↑
    从右往左

失败
 ↓
███████
        ↓↓↓↓↓
     一次跳很多

二十、最值得记住的其实是四种“优化思维”

如果你正在学习算法,我反而建议不要死记:

BF = O(nm)
RK = O(n+m)
KMP = O(n+m)
BM = O(n/m)

更重要的是理解它们分别在优化什么。

BF:什么都不优化

失败
↓
向右移动 1
↓
重新匹配

RK:优化“比较”

字符串比较
     ↓
先比较 Hash
     ↓
Hash 不同 → 直接跳过
Hash 相同 → 再比较字符串

而 RK 最漂亮的地方是:

Hash
 ↓
Rolling
 ↓
窗口移动 O(1)

KMP:优化“重复匹配”

已经匹配过的字符
        ↓
        不浪费
        ↓
next / prefix
        ↓
失败后直接跳

BM:优化“移动距离”

匹配失败
   ↓
分析坏字符 / 好后缀
   ↓
尽可能多跳几个字符

所以可以把四个算法浓缩成一句话:

BF 减少不了比较;RK 用 Hash 减少真正的字符串比较;KMP 利用前缀信息减少重复比较;BM 则通过跳跃减少需要检查的位置。


二十一、RK 为什么在工程中也很有价值?

RK 有一个 KMP/BM 没那么明显的优势:

它非常适合“多模式匹配”和“子串指纹”问题。

例如你需要判断:

"abcdef"

里面有没有:

"abc"
"xyz"
"def"
"hello"
...

这时候 Hash 就非常自然。

甚至可以提前建立:

patternHashSet

然后扫描文本:

windowHash
    ↓
HashSet 查询
    ↓
是否存在?

于是就变成:

O(n)

级别的扫描 + Hash 查询。

当然,实际工程中还需要认真处理:

  • Hash 碰撞
  • 大整数溢出
  • mod 选择
  • 双 Hash
  • 字符编码问题

尤其是中文字符串,如果直接按 Go 的 byte 处理,还需要注意 UTF-8:

"你好"

一个汉字并不是一个 byte。


二十二、最后总结

如果把这四个算法比喻成四个人找一个单词:

BF

“我从头开始一个字符一个字符找。”

简单粗暴:

O(nm)

RK

“我不给你一个个看,我先算指纹。”

核心:

Hash + Rolling Hash

平均:

O(n+m)

最坏:

O(nm)

最值得掌握的是滚动哈希:

旧 Hash
  ↓
减掉左边字符
  ↓
整体移动
  ↓
加上右边字符
  ↓
新 Hash

KMP

“刚才匹配过的内容,我不会浪费。”

核心:

最长相等前缀/后缀
        ↓
      next
        ↓
失败后跳转

复杂度:

O(n+m)

BM

“为什么一个字符一个字符移动?一次跳几十个不行吗?”

核心:

坏字符
+
好后缀
+
从右向左匹配

最好/典型情况下可以跳过大量字符,实际搜索非常快;但经典 BM 不能简单地概括为“永远 O(n/m)”,最坏情况仍可能达到 O(nm)


最后用一张图记住它们

                  字符串匹配
                       │
       ┌───────────────┼────────────────┐
       │               │                │
      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 最值得搞懂的是 坏字符/好后缀为什么能够一次跳过多个位置

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