Skip to content

字符串匹配算法 ​

#算法 · #字符串 · #KMP · #BM · #Rabin-Karp · #AC自动机

字符串匹配是计算机科学中最基础的问题之一——在文本 T 中查找模式串 P 的所有出现位置。从暴力匹配到 KMP、Boyer-Moore、Rabin-Karp 再到 AC 自动机,每种算法都有独特的优化思路。


问题定义 ​

给定文本 T[0..n-1] 和模式串 P[0..m-1](n >> m)
找出 P 在 T 中所有出现的位置。

示例:
T = "ABABDABACDABABCABAB"
P = "ABABCABAB"
结果:位置 10

算法对比 ​

算法预处理匹配时间最坏空间特点
暴力O(1)O(n·m)O(n·m)O(1)最简单
KMPO(m)O(n)O(n)O(m)永不回退文本指针
Boyer-MooreO(m+σ)O(n/m) 最好O(n·m)O(m+σ)实际最快
Rabin-KarpO(m)O(n) 平均O(n·m)O(1)多模式匹配
AC 自动机O(L·σ)O(n)O(n)O(L·σ)多模式匹配首选

1. KMP(Knuth-Morris-Pratt) ​

核心思想 ​

利用已匹配的信息,失配时不回退文本指针,而是利用**前缀函数(next 数组)**回退模式串指针。

T: A B A B D A B A C D ...
P: A B A B C
     √ √ √ √ ✗

暴力做法:P 整体右移 1 位,重新比较
KMP 做法:发现 "ABAB" 的前缀 "AB" 和后缀 "AB" 相同,
         直接把 P 移动 2 位,从 'A' 继续比较

前缀函数(next 数组) ​

go
// next[i] = P[0..i] 的最长相等前后缀长度
func buildNext(pattern string) []int {
    m := len(pattern)
    next := make([]int, m)
    // next[0] = 0(单个字符的前缀函数为 0)
    j := 0  // 前缀末尾
    for i := 1; i < m; i++ {
        // 失配时,回退 j
        for j > 0 && pattern[i] != pattern[j] {
            j = next[j-1]
        }
        if pattern[i] == pattern[j] {
            j++
        }
        next[i] = j
    }
    return next
}

/*
示例:P = "ABABCABAB"
i:  0 1 2 3 4 5 6 7 8
P:  A B A B C A B A B
next:0 0 1 2 0 1 2 3 4
*/

KMP 匹配 ​

go
func KMP(text, pattern string) []int {
    n, m := len(text), len(pattern)
    if m == 0 {
        return nil
    }

    next := buildNext(pattern)
    result := []int{}
    j := 0  // 模式串指针

    for i := 0; i < n; i++ {
        // 失配时,利用 next 回退 j
        for j > 0 && text[i] != pattern[j] {
            j = next[j-1]
        }
        if text[i] == pattern[j] {
            j++
        }
        if j == m {
            result = append(result, i-m+1)
            j = next[j-1]  // 继续匹配
        }
    }
    return result
}

复杂度分析 ​

  • 预处理:O(m),i 只增不减,j 每次最多回退 i 的增量
  • 匹配:O(n),同理 i 只增,j 回退次数有限

2. Boyer-Moore ​

核心思想 ​

从右向左比较,利用坏字符规则和好后缀规则,一次跳过多位。

从右向左匹配:
T: H E R E   I S   A   S I M P L E   E X A M P L E
P: E X A M P L E
              ↑
           从右开始

坏字符规则 ​

失配字符 b → 找 P 中 b 的最右出现位置 → 右移

T: ... A ...
P: ... B ...    ← A ≠ B,在 P 左边找 'A'
        A ...   ← 找到!右移

如果左边没有,整个模式串移过失配位置

好后缀规则 ​

T: ... B C D E ...
P: ... B C D E       ← 后缀 "BCDE" 已匹配,前面失配

好后缀 "BCDE" 在 P 前面也出现过 → 对齐
没出现过 → 找好后缀的最长后缀,在 P 前缀中出现过 → 对齐
go
// BM 算法简化实现(仅坏字符规则)
func BM(text, pattern string) []int {
    n, m := len(text), len(pattern)
    if m == 0 {
        return nil
    }

    // 坏字符表:记录每个字符在 pattern 中的最右位置
    badChar := [256]int{}
    for i := range badChar {
        badChar[i] = -1
    }
    for i := 0; i < m; i++ {
        badChar[pattern[i]] = i
    }

    result := []int{}
    shift := 0

    for shift <= n-m {
        j := m - 1
        // 从右向左比较
        for j >= 0 && pattern[j] == text[shift+j] {
            j--
        }
        if j < 0 {
            result = append(result, shift)
            // 整体右移 1 或好后缀规则
            if shift+m < n {
                shift += m - badChar[text[shift+m]]
            } else {
                shift++
            }
        } else {
            // 坏字符规则
            shift += max(1, j-badChar[text[shift+j]])
        }
    }
    return result
}

3. Rabin-Karp ​

核心思想 ​

将字符串比较转为哈希值比较。用滚动哈希在 O(1) 内计算下一个窗口的哈希值。

T = "31415926"
P = "415"

Hash("314") → 不匹配
Hash("141") = (Hash("314") - 3*base²) * base + 1 → 滚动计算!
Hash("415") → 匹配!再逐字符验证

滚动哈希公式:
hash(i+1) = (hash(i) - T[i] * base^(m-1)) * base + T[i+m]
go
func RabinKarp(text, pattern string) []int {
    n, m := len(text), len(pattern)
    if m == 0 || m > n {
        return nil
    }

    base := 256          // 字符集大小
    mod := 101           // 大质数取模(防溢出)
    baseM := 1           // base^(m-1) % mod

    // 计算 base^(m-1)
    for i := 0; i < m-1; i++ {
        baseM = (baseM * base) % mod
    }

    // 计算 pattern 的哈希值
    patternHash := 0
    for i := 0; i < m; i++ {
        patternHash = (patternHash*base + int(pattern[i])) % mod
    }

    // 滑动窗口
    textHash := 0
    result := []int{}
    for i := 0; i < n; i++ {
        // 更新滚动哈希
        if i >= m {
            textHash = (textHash - int(text[i-m])*baseM) % mod
            if textHash < 0 {
                textHash += mod
            }
        }
        textHash = (textHash*base + int(text[i])) % mod

        // 哈希匹配 → 逐字符验证(防哈希碰撞)
        if i >= m-1 && textHash == patternHash {
            if text[i-m+1:i+1] == pattern {
                result = append(result, i-m+1)
            }
        }
    }
    return result
}

4. AC 自动机(Aho-Corasick) ​

核心思想 ​

Trie + KMP 失败指针 = 多模式匹配利器。一次扫描文本,同时匹配所有模式串。

模式串集合:{"he", "she", "his", "hers"}
构建 Trie + 失败指针:

                root
               /    \
              h      s
             / \      \
            e   i      h
           /     \      \
          r       s      e
         /                \
        s                  r
                            \
                             s

失败指针(→):
- 每个节点的失败指针指向:当前路径的最长后缀(其他模式串的前缀)
- 如 "she" 的 'e' 失败 → 指向 "he" 的 'e'
go
type ACNode struct {
    children [26]*ACNode
    fail     *ACNode
    isEnd    bool
    output   []string // 该节点匹配的所有模式串
}

type AhoCorasick struct {
    root *ACNode
}

func (ac *AhoCorasick) Insert(word string) {
    node := ac.root
    for _, ch := range word {
        idx := ch - 'a'
        if node.children[idx] == nil {
            node.children[idx] = &ACNode{}
        }
        node = node.children[idx]
    }
    node.isEnd = true
    node.output = append(node.output, word)
}

// BFS 构建失败指针
func (ac *AhoCorasick) BuildFailureLinks() {
    queue := []*ACNode{}
    // root 的直接子节点失败指针指向 root
    for i := 0; i < 26; i++ {
        if ac.root.children[i] != nil {
            ac.root.children[i].fail = ac.root
            queue = append(queue, ac.root.children[i])
        }
    }

    for len(queue) > 0 {
        curr := queue[0]
        queue = queue[1:]

        for i := 0; i < 26; i++ {
            if curr.children[i] == nil {
                continue
            }
            child := curr.children[i]

            // 设置 child 的失败指针
            failNode := curr.fail
            for failNode != nil && failNode.children[i] == nil {
                failNode = failNode.fail
            }
            if failNode == nil {
                child.fail = ac.root
            } else {
                child.fail = failNode.children[i]
                // 合并 output
                child.output = append(child.output, child.fail.output...)
            }

            queue = append(queue, child)
        }
    }
}

// 在文本中搜索所有匹配的模式串
func (ac *AhoCorasick) Search(text string) map[string][]int {
    result := make(map[string][]int)
    node := ac.root

    for i, ch := range text {
        idx := ch - 'a'
        // 失配时跟随失败指针
        for node != ac.root && node.children[idx] == nil {
            node = node.fail
        }
        if node.children[idx] != nil {
            node = node.children[idx]
        }
        // 检查该节点及其失败链上的所有匹配
        for _, word := range node.output {
            result[word] = append(result[word], i-len(word)+1)
        }
    }
    return result
}

应用场景 ​

场景说明
敏感词过滤一次扫描匹配所有敏感词
搜索引擎多关键词搜索
入侵检测Snort/ClamAV 使用 AC 自动机匹配攻击特征
DNA 序列分析匹配多个基因序列
输入法联想多前缀匹配

算法选择决策 ​

需要匹配的模式串数量?

单个模式串:
  ├── 文本较短 → 暴力匹配 / KMP
  ├── 文本很长,字符集小 → KMP
  └── 文本很长,字符集大 → Boyer-Moore(实际最快)

多个模式串:
  ├── 模式串数量少(<10)→ Robin-Karp(多哈希)
  └── 模式串数量多(≥10)→ AC 自动机

后缀数组与后缀自动机 ​

后缀数组(Suffix Array) ​

后缀数组是处理单个长文本的多种查询的神器——最长重复子串、最长公共子串、子串出现次数等。

定义:将字符串 S 的所有后缀按字典序排序后的起始索引数组。

S = "banana"
后缀列表:
  0: banana
  1: anana
  2: nana
  3: ana
  4: na
  5: a

排序后:
  a       → 起始索引 5
  ana     → 起始索引 3
  anana   → 起始索引 1
  banana  → 起始索引 0
  na      → 起始索引 4
  nana    → 起始索引 2

后缀数组 SA = [5, 3, 1, 0, 4, 2]

LCP 数组(Longest Common Prefix,相邻后缀的最长公共前缀):
  SA[0]="a", SA[1]="ana" → LCP = 1  ("a")
  SA[1]="ana", SA[2]="anana" → LCP = 3  ("ana")
  ...

应用:
  - 子串是否存在: 对 SA 二分查找 → O(|pattern| × log n)
  - 最长重复子串: max(LCP[i]) → O(n)
  - 两个串的最长公共子串: 拼接 + LCP → O(n)
  - 不同子串个数: n(n+1)/2 - Σ LCP[i] → O(n)
go
// 使用 O(n log n) 倍增法构建后缀数组
func buildSA(s string) []int {
    n := len(s)
    sa := make([]int, n)
    rank := make([]int, n)
    tmp := make([]int, n)

    // 按单个字符排序
    for i := 0; i < n; i++ {
        sa[i] = i
        rank[i] = int(s[i])
    }

    for k := 1; k < n; k <<= 1 {
        sort.Slice(sa, func(i, j int) bool {
            a, b := sa[i], sa[j]
            if rank[a] != rank[b] {
                return rank[a] < rank[b]
            }
            ra, rb := -1, -1
            if a+k < n {
                ra = rank[a+k]
            }
            if b+k < n {
                rb = rank[b+k]
            }
            return ra < rb
        })
        tmp[sa[0]] = 0
        for i := 1; i < n; i++ {
            // ... 重新计算 rank ...
        }
        rank, tmp = tmp, rank
    }
    return sa
}

正则表达式引擎原理:NFA → DFA ​

编程语言中的正则表达式底层都经历了"模式→NFA→DFA→匹配"的转换。

mermaid
flowchart LR
    Pattern["正则表达式<br/>a(b|c)*d"] --> Parse["解析为 AST"]
    Parse --> NFA["Thompson 构造<br/>→ ε-NFA"]
    NFA --> DFA["子集构造<br/>→ DFA"]
    DFA --> Minimize["Hopcroft 最小化<br/>→ 最小 DFA"]
    Minimize --> Match["逐字符状态转移<br/>→ 匹配结果"]
引擎类型代表算法速度回溯反向引用
DFAgrep, awk, RE2子集构造✅ 快❌ 无❌ 不支持
NFAPCRE, Java, Python回溯搜索🟡 慢✅ 有✅ 支持
HybridV8 (Irregexp)JIT NFA→DFA✅ 快视情况有限

关键:为什么 Go 的 regexp 保证线性时间?

Go 选择了 RE2 算法——纯 DFA 引擎,保证 O(n) 时间复杂度。代价是不支持反向引用(backreference)和零宽断言中某些变体。这是"安全性优先于功能"的设计选择。

go
// Go: 线性时间,但功能受限
re := regexp.MustCompile(`a(b|c)*d`)
re.MatchString("abcd") // O(n), 保证不爆炸

// Python/PCRE: 功能全,但可能在恶意输入下指数爆炸
// 这就是 ReDoS(正则表达式拒绝服务攻击)的根源

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记