17. 字符串算法

系统掌握字符串算法:朴素匹配、KMP 前缀函数、Boyer-Moore、Rabin-Karp 哈希匹配、Manacher 回文、Trie 字典树与 AC 自动机多模式匹配,含复杂度对比与经典题解。

1. 字符串匹配问题与朴素匹配

1.1 问题定义

字符串匹配:给定文本串 text(长度 n)与模式串 pattern(长度 m),在 text 中查找所有与 pattern 相等的位置。匹配算法的核心目标是减少字符比较次数,将最坏情况从 O(n·m) 降到 O(n+m)。

输入:text    = "ABABABCABAB"
      pattern = "ABABC"
输出:匹配位置 = [2]   (0 起始下标)

1.2 朴素匹配(Naive Match)

朴素匹配从每个位置 i 开始,逐字符与 pattern 比较。一旦失配就整体后移一位重新比较:

def naive_match(text, pattern):
    n, m = len(text), len(pattern)
    res = []
    for i in range(n - m + 1):
        j = 0
        while j < m and text[i + j] == pattern[j]:
            j += 1
        if j == m:
            res.append(i)
    return res

print(naive_match("ABABABCABAB", "ABABC"))  # [2]

朴素匹配在退化输入(如 text = “AAAA…AB”,pattern = “AAA…AB”)下每轮几乎比完整个模式串,复杂度为 O(n·m)。大量重叠的字符信息被白白丢弃——这正是 KMP 要解决的问题。

1.3 匹配算法总览

算法平均复杂度最坏复杂度空间核心思想
朴素匹配O(n·m)O(n·m)O(1)暴力移位
KMPO(n+m)O(n+m)O(m)前缀函数跳转
Boyer-MooreO(n/m) 亚线性O(n·m)O(σ+m)坏字符 + 好后缀
Rabin-KarpO(n+m)O(n·m)O(1)滚动哈希
AC 自动机O(n+m+k)O(n+m+k)O(m·σ)Trie + fail 指针

σ 表示字符集大小,k 为多模式匹配命中的次数。工程上文本搜索常用 Boyer-Moore 及其变体(如 GNU grep、Go 的 strings 包),而 KMP 更适合模式串短、字符集小的场景。


2. KMP 算法:前缀函数与 next 数组

2.1 前缀函数(Prefix Function)

KMP 的核心是前缀函数 pi[i]:子串 pattern[0..i] 中,既是其前缀又是其真后缀的最长长度。

例:pattern = “ABABCABAB”,计算 pi:

  • pi[6]:前缀 “ABABCAB”,最长公共前后缀为 “AB”(长度 2)
  • pi[7]:前缀 “ABABCABA”,最长公共前后缀为 “ABA”(长度 3)
  • pi[8]:前缀 “ABABCABAB”,最长公共前后缀为 “ABAB”(长度 4)
i子串最长公共前后缀pi[i]
0A-0
1AB-0
2ABAA1
3ABABAB2
4ABABC-0
5ABABCAA1
6ABABCABAB2
7ABABCABAABA3
8ABABCABABABAB4

2.2 前缀函数计算

def compute_pi(pattern):
    m = len(pattern)
    pi = [0] * m
    j = 0
    for i in range(1, m):
        while j > 0 and pattern[i] != pattern[j]:
            j = pi[j - 1]          # 回退到次长候选前缀
        if pattern[i] == pattern[j]:
            j += 1
        pi[i] = j
    return pi

print(compute_pi("ABABCABAB"))  # [0, 0, 1, 2, 0, 1, 2, 3, 4]

2.3 KMP 匹配主流程

def kmp_match(text, pattern):
    n, m = len(text), len(pattern)
    if m == 0:
        return []
    pi = compute_pi(pattern)
    res = []
    j = 0                                  # 已匹配的模式串长度
    for i in range(n):
        while j > 0 and text[i] != pattern[j]:
            j = pi[j - 1]                  # 失配时按 next 跳转,不回退 i
        if text[i] == pattern[j]:
            j += 1
        if j == m:
            res.append(i - m + 1)
            j = pi[j - 1]                  # 找下一个匹配
    return res

print(kmp_match("ABABABCABAB", "ABABC"))   # [2]

关键性质:文本指针 i 永不回退,只有模式串指针 j 通过 pi 数组跳跃,因此总比较次数 ≤ 2n,匹配与预处理的整体复杂度为 O(n+m)。


3. Boyer-Moore 算法

3.1 逆向比较 + 坏字符规则

Boyer-Moore 从模式串末尾开始向前比较,失配时利用两条启发式规则大幅跳过位置:

坏字符规则(Bad Character):失配字符 c 在模式串中最后一次出现的位置为 last[c],则模式串至少右移 j - last[c] 位(j 为失配处下标)。
好后缀规则(Good Suffix):已匹配的后缀在模式串中能否再次出现,若能则对齐到该位置,否则跳过整个已匹配部分。

# 坏字符表:记录每个字符在 pattern 中最后一次出现的下标
def build_bad_char(pattern):
    last = {}
    for i, ch in enumerate(pattern):
        last[ch] = i          # 后出现的覆盖前面的,得到最右位置
    return last

def bm_search(text, pattern):
    last = build_bad_char(pattern)
    n, m = len(text), len(pattern)
    i = 0
    res = []
    while i <= n - m:
        j = m - 1
        while j >= 0 and text[i + j] == pattern[j]:
            j -= 1
        if j < 0:
            res.append(i)
            i += 1 if m == 1 else (m - last.get(text[i + m], -1) if i + m < n else 1)
        else:
            shift = j - last.get(text[i + j], -1)
            i += max(1, shift)
    return res

3.2 复杂度分析

特性说明
平均复杂度O(n/m),亚线性(模式串越长跳得越快)
最坏复杂度O(n·m)(朴素坏字符规则),加好后缀规则可达 O(n+m)
应用GNU grep、Golang bytes/strings 内部匹配、文本编辑器

工程上 BM 的坏字符表只对 ASCII 等小字符集有效;Unicode 下常用 Sunday 简化变体。对于 DNA(字符集 σ=4)与普通英文文本,BM 通常快于 KMP。


4. Rabin-Karp:滚动哈希匹配

4.1 哈希思想

Rabin-Karp 把长度为 m 的子串映射为一个哈希值,先比较哈希(O(1)),相等时才逐字符确认(防哈希冲突)。

取 base = 131、mod = 2^64 时哈希碰撞概率极低。用滚动哈希在 O(1) 内由 hash(s[i..i+m-1]) 推出 hash(s[i+1..i+m]):
h' = ((h - s[i]·base^(m-1)) · base + s[i+m]) mod M

BASE, MOD = 131, 2**64

def rabin_karp(text, pattern):
    n, m = len(text), len(pattern)
    if m > n:
        return []
    # 预计算 base^(m-1)
    power = pow(BASE, m - 1, MOD)
    # 计算 pattern 与 text[0:m] 的哈希
    target = 0
    for ch in pattern:
        target = (target * BASE + ord(ch)) % MOD
    h = 0
    for ch in text[:m]:
        h = (h * BASE + ord(ch)) % MOD
    res = []
    for i in range(n - m + 1):
        if h == target:
            if text[i:i + m] == pattern:      # 哈希相等,逐字符确认
                res.append(i)
        if i + m < n:
            h = ((h - ord(text[i]) * power) * BASE + ord(text[i + m])) % MOD
    return res

print(rabin_karp("ABABABCABAB", "ABABC"))     # [2]

4.2 特性

特性说明
平均复杂度O(n+m)(冲突极少的随机化哈希)
最坏复杂度O(n·m)(构造大量哈希冲突)
应用海量字符串去重、重复子串检测、生物序列比对、单词自动补全候选

在「检测一篇文章中是否出现任一敏感词」这类场景,先用 RK 哈希在文档级快速过滤,命中再用 KMP/AC 精确匹配,是工程中常见的两级策略。


5. Manacher 算法:最长回文子串

5.1 中心扩展的困境

回文既可以长度为奇数(中心一个字符)也可以为偶数(中心两个字符)。朴素中心扩展对每个中心做 O(n) 扩展,总复杂度 O(n²)。

Manacher 利用已计算回文的镜像对称性,把每个中心扩展到 O(1) 均摊,总复杂度降到 O(n)。

预处理:在字符间与两端插入特殊分隔符 #,把偶回文统一为奇回文。如 “abba” → “#a#b#b#a#",中心为中间的 #。

def manacher(s):
    # 插入分隔符,统一奇偶
    t = "#" + "#".join(s) + "#"
    n = len(t)
    p = [0] * n            # p[i] 为以 i 为中心的回文半径(含中心)
    center, right = 0, 0
    for i in range(n):
        mirror = 2 * center - i
        if i < right:
            p[i] = min(right - i, p[mirror])   # 借用镜像回文半径
        # 中心扩展
        while i - p[i] - 1 >= 0 and i + p[i] + 1 < n and \
              t[i - p[i] - 1] == t[i + p[i] + 1]:
            p[i] += 1
        if i + p[i] > right:
            center, right = i, i + p[i]        # 更新最右边界
    # 还原最长回文(分隔符不计入)
    best, best_c = 0, 0
    for i in range(n):
        if p[i] > best:
            best, best_c = p[i], i
    start = (best_c - best) // 2
    return s[start:start + best]

print(manacher("babad"))    # "bab"("aba" 亦可)
print(manacher("cbbd"))     # "bb"

5.2 关键性质

性质说明
时间复杂度O(n),每个字符最多被访问常数次
空间复杂度O(n),p 数组
应用最长回文子串、回文计数、回文半径统计(可配合差分做计数)

核心洞见:当中心 i 位于已知回文 [center-p, center+p] 内部时,其初始半径至少等于其镜像点的半径,且不能超出右边界——这保证总扩展次数 O(n)。


6. Trie 字典树

6.1 结构定义

Trie(前缀树)用树状结构存储一组字符串,每条边代表一个字符,根到某个标记节点的路径即一个完整单词。共享前缀是其空间与查询优势的根本。

操作复杂度说明
插入O(m)m 为单词长度
查询O(m)逐字符沿边走
删除O(m)自底向上清除无子节点的链
前缀匹配/统计O(m)常用于自动补全
class TrieNode:
    __slots__ = ("children", "is_end", "count")
    def __init__(self):
        self.children = {}
        self.is_end = False      # 是否为一个完整单词的结尾
        self.count = 0           # 以该节点为前缀的单词数

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for ch in word:
            node = node.children.setdefault(ch, TrieNode())
            node.count += 1
        node.is_end = True

    def search(self, word):
        node = self.root
        for ch in word:
            node = node.children.get(ch)
            if node is None:
                return False
        return node.is_end

    def starts_with(self, prefix):
        node = self.root
        for ch in prefix:
            node = node.children.get(ch)
            if node is None:
                return False
        return True

6.2 应用场景

Trie 的典型应用:自动补全(前缀匹配 + DFS 收集候选)、拼写检查(编辑距离剪枝)、IP 路由最长前缀匹配、词频统计。相比哈希表,Trie 天然支持有序遍历与前缀查询,且无哈希碰撞;缺点是空间开销大(可用压缩 Trie / Patricia Trie 优化)。


7. AC 自动机:多模式匹配

7.1 原理

AC 自动机(Aho–Corasick)= Trie + fail 指针。对所有模式串建 Trie,BFS 为每个节点求出 fail 指针(指向"当前匹配串的最长真后缀对应节点”),匹配时失配沿 fail 链跳转,一次扫描文本即可命中所有模式串。

fail 指针本质是把 KMP 的 pi 函数推广到多模式:fail[u] 表示从根到 u 对应字符串的最长真后缀所对应的 Trie 节点。

#include <bits/stdc++.h>
using namespace std;

struct Node {
    int next[26];            // 子节点
    int fail;                // 失配指针
    int end;                 // 以该节点结尾的模式串个数
    Node() { memset(next, -1, sizeof next); fail = end = 0; }
};

vector<Node> tr;

void insert(const string& s) {
    int u = 0;
    for (char c : s) {
        int id = c - 'a';
        if (tr[u].next[id] == -1) {
            tr[u].next[id] = tr.size();
            tr.emplace_back();
        }
        u = tr[u].next[id];
    }
    tr[u].end++;
}

void build_automaton() {
    queue<int> q;
    for (int i = 0; i < 26; i++)
        if (tr[0].next[i] != -1) q.push(tr[0].next[i]);
        else tr[0].next[i] = 0;             // 空边指向根
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int i = 0; i < 26; i++) {
            int v = tr[u].next[i];
            if (v != -1) {
                tr[v].fail = tr[tr[u].fail].next[i];
                tr[v].end += tr[tr[v].fail].end;   // 累加后缀的命中数
                q.push(v);
            } else {
                tr[u].next[i] = tr[tr[u].fail].next[i];  // 压缩失配转移
            }
        }
    }
}

int query(const string& text) {
    int u = 0, ans = 0;
    for (char c : text) {
        u = tr[u].next[c - 'a'];
        ans += tr[u].end;                    // 加上所有以该状态结尾的模式串
    }
    return ans;
}

7.2 复杂度与应用

特性说明
构建复杂度O(模式串总长度 × σ),σ 为字符集大小
匹配复杂度O(n + k),n 为文本长度,k 为总命中次数
应用敏感词过滤、入侵检测(Snort 规则集)、病毒特征扫描、生物序列多模式比对

示例:模式串集合 {“he”, “she”, “his”, “hers”} 构建 AC 自动机后,扫描文本 “ushers” 一次即可命中 “she” 与 “hers”。fail 链压缩后每个字符只做一次数组跳转,故匹配严格线性。


8. 字符串哈希与经典题解

8.1 字符串哈希的工程应用

除匹配外,字符串哈希还广泛用于判重与相似检测:

  • 单哈希去重:文件指纹(Git blob 的 SHA-1)、内容寻址存储。
  • 双哈希 / 布隆过滤器:极大集合的存在性判断(拼写候选、URL 去重)。
  • SimHash / MinHash:网页去重与近似文本判重(垃圾内容检测)。
# 使用 Python 内置哈希做单词频次统计
from collections import Counter

def word_frequency(words):
    return Counter(words)

# 双哈希布隆过滤器示意:k 个哈希位都被置位则认为"可能存在"
class BloomFilter:
    def __init__(self, size, k=3):
        self.bits = [0] * size
        self.k = k

    def _positions(self, item):
        # 用两个基础哈希推导 k 个独立位置
        h1, h2 = hash(item), hash(item + "salt")
        return [(h1 + i * h2) % len(self.bits) for i in range(self.k)]

    def add(self, item):
        for p in self._positions(item):
            self.bits[p] = 1

    def contains(self, item):
        return all(self.bits[p] for p in self._positions(item))

8.2 经典题解表

题目类型常用算法复杂度
查找文本中模式串所有出现KMP / BMO(n+m)
多敏感词同时命中AC 自动机O(n+m+k)
最长回文子串ManacherO(n)
最长重复子串后缀数组 + 二分O(n log n)
前缀自动补全TrieO(m)
判断两字符串是否循环同构哈希 / 最小表示法O(n)
最长公共前缀(多查询)后缀数组 + RMQ / 二分+哈希O(log n)

9. 复杂度对比与应用选型

9.1 综合对比

算法构建匹配最坏适用场景
朴素-O(n·m)O(n·m)m 极小、教学
KMPO(m)O(n+m)O(n+m)字符集小、模式稳定
Boyer-MooreO(σ)O(n/m) 均摊O(n·m)文本长、模式长、字符集大
Rabin-KarpO(m)O(n+m) 平均O(n·m)多模式哈希、去重
ManacherO(n)O(n)O(n)回文问题专用
TrieO(Σm)O(n·m) 单次O(n·m)前缀查询、补全
AC 自动机O(Σm·σ)O(n+k)O(n+k)多模式在线匹配

9.2 选型决策树

  1. 单模式 + 模式很短 → KMP 或朴素;
  2. 单模式 + 文本很长、字符集大 → Boyer-Moore / Sunday;
  3. 多模式 + 需要在线过滤 → AC 自动机;
  4. 需要前缀统计 / 补全 → Trie;
  5. 回文类问题 → Manacher;
  6. 海量判重 → 字符串哈希 + 布隆过滤器。

实战要点:多数语言标准库内部已经选好了最优匹配算法(如 Rust memmem 使用双端 Two-Way 算法),工程中优先复用标准库;只有当你需要同时匹配多模式或统计回文等标准库未覆盖的能力时,才自行实现上述算法。


参考文章

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机基础」更多文章

  1. 22. CPU 缓存与一致性
  2. 21. 传输层与 TCP 深入
  3. 20. 编译原理基础