Skip to content

String Algorithms

字符串算法常常出现在算法竞赛中,我们单独来看一下。

包括:

  • pattern matching(模式匹配)
  • string searching(字符串搜索)
  • substring algorithms(子串算法)
  • string hashing(字符串哈希)
  • suffix array / suffix tree algorithms(后缀数组 / 后缀树算法)

基础字符串算法:反转字符串

高级字符串算法:

  • KMP、Z-algorithm、
  • AC 自动机(多模式匹配)
  • 后缀数组 / 后缀自动机相关算法
  • Boyer–Moore、Sunday 算法等高级字符串匹配

Manacher算法

Manacher算法是针对最长回文子串这一特定任务而实现的算法,其本质上就是以空间换时间,记录已经探索的信息并加以利用。

问题: 给定字符串 \(s\),找出其中最长的回文子串。暴力做法是 \(O(n^2)\) 甚至 \(O(n^3)\),Manacher可以做到 \(O(n)\)

核心思路: 维护三个关键信息:

  1. 每个已探索位置的回文半径 \(p[i]\)(探索就是向两边扩张判断是否对称)
  2. 当前最右回文的中心点 \(c\)
  3. 当前已知回文能达到的最右边界 \(r\)

1770590055792

预处理: 为了统一处理奇偶长度的回文,在每个字符之间以及首尾插入特殊字符(如 #),将字符串变为奇数长度。例如 aba 变为 #a#b#a#abba 变为 #a#b#b#a#

算法步骤:

  1. 对原串做预处理,得到新串 \(t\)
  2. 维护数组 \(p[i]\) 表示以 \(t[i]\) 为中心的回文半径(不含中心本身)
  3. 遍历每个位置 \(i\): - 如果 \(i < r\)(在已知最右回文范围内),则利用对称性:令 \(i' = 2c - i\)\(i\) 关于 \(c\) 的镜像点),初始化 \(p[i] = \min(p[i'], r - i)\) - 否则 \(p[i] = 0\) - 然后尝试向两边扩展,若两侧字符相等则 \(p[i]\) 加 1 - 若 \(i + p[i] > r\),则更新 \(r = i + p[i]\)\(c = i\)
  4. 找到 \(p\) 数组中的最大值,即为最长回文的半径,反推出原串中的位置

关键优化点:\(i\) 在已知回文范围 \([c - r, c + r]\) 内时,镜像点 \(i'\) 的回文信息可以直接复用,避免了大量重复比较。

def manacher(s: str) -> str:
    # 预处理:插入 '#'
    t = '#' + '#'.join(s) + '#'
    n = len(t)
    p = [0] * n  # p[i] = 以 t[i] 为中心的回文半径
    c = r = 0    # 当前最右回文的中心和右边界

    for i in range(n):
        # 利用对称性初始化
        if i < r:
            mirror = 2 * c - i
            p[i] = min(p[mirror], r - i)

        # 尝试扩展
        while (i + p[i] + 1 < n and i - p[i] - 1 >= 0
               and t[i + p[i] + 1] == t[i - p[i] - 1]):
            p[i] += 1

        # 更新最右边界
        if i + p[i] > r:
            c, r = i, i + p[i]

    # 找最大回文半径
    max_len = max(p)
    center = p.index(max_len)
    # 从变换串的位置还原到原串
    start = (center - max_len) // 2
    return s[start : start + max_len]

时间复杂度: \(O(n)\),每个字符最多被扩展访问一次。空间复杂度: \(O(n)\)

单模式精确匹配

KMP

问题: 在文本 \(T\)(长度 \(n\))中查找模式串 \(P\)(长度 \(m\))的所有出现位置。朴素做法需要 \(O(nm)\),KMP将其优化到 \(O(n + m)\),是理论最经典的单模式精确匹配算法。

核心思想:前缀函数(Prefix Function / Failure Function / next数组)

当匹配过程中发生失配时,朴素算法会把模式串右移一位,从头开始重新比较。KMP的关键洞察是:失配时,模式串已匹配的部分包含了有用的信息,可以利用这些信息跳过不可能成功的对齐位置。

前缀函数 \(\pi[i]\) 的定义:对于模式串 \(P\) 的前 \(i+1\) 个字符构成的子串 \(P[0..i]\)\(\pi[i]\) 是其最长的、既是前缀又是后缀的真子串的长度。

例如对于 $P[0..i] = $ ABCAB,前缀有 A, AB, ABC, ABCA,后缀有 B, AB, CAB, BCAB,最长公共前后缀是 AB,所以 \(\pi[4] = 2\)

前缀函数为什么能避免冗余比较?

\(P[j]\)\(T[i]\) 失配时,我们已经知道 \(P[0..j-1]\)\(T[i-j..i-1]\) 是匹配的。\(\pi[j-1]\) 告诉我们 \(P[0..j-1]\) 的最长公共前后缀长度为 \(\pi[j-1]\),这意味着 \(P[0..\pi[j-1]-1]\) 已经和 \(T\) 中当前位置前面的字符对齐了,可以直接从 \(P[\pi[j-1]]\) 继续比较,而不是从 \(P[0]\) 重新开始。

构建前缀函数的算法:

def build_prefix_function(pattern: str) -> list[int]:
    """构建 KMP 前缀函数(next数组)"""
    m = len(pattern)
    pi = [0] * m  # pi[0] 始终为 0
    length = 0    # 当前最长公共前后缀的长度

    i = 1
    while i < m:
        if pattern[i] == pattern[length]:
            length += 1
            pi[i] = length
            i += 1
        else:
            if length != 0:
                # 回退:利用已有的前缀函数信息
                length = pi[length - 1]
                # 注意这里不递增 i
            else:
                pi[i] = 0
                i += 1
    return pi

构建过程详解(Worked Example):

以模式串 $P = $ ABABAC 为例,逐步构建前缀表 \(\pi\)

\(i\) \(P[0..i]\) 最长公共前后缀 \(\pi[i]\) 说明
0 A (无真子串) 0 单个字符,\(\pi[0]\) 恒为 0
1 AB 无匹配 0 前缀 A 与后缀 B 不同
2 ABA A 1 前缀 A = 后缀 A
3 ABAB AB 2 前缀 AB = 后缀 AB
4 ABABA ABA 3 前缀 ABA = 后缀 ABA
5 ABABAC 无匹配 0 P[5]='C',回退到 pi[2]=1,再比 P[1]='B' != 'C',再回退到 pi[0]=0P[0]='A' != 'C',最终为 0

最终前缀表:\(\pi = [0, 0, 1, 2, 3, 0]\)

KMP搜索算法:

def kmp_search(text: str, pattern: str) -> list[int]:
    """KMP搜索:返回 pattern 在 text 中所有出现的起始位置"""
    n, m = len(text), len(pattern)
    if m == 0:
        return []

    pi = build_prefix_function(pattern)
    result = []

    j = 0  # pattern 中的指针
    for i in range(n):  # text 中的指针
        while j > 0 and text[i] != pattern[j]:
            j = pi[j - 1]  # 利用前缀函数回退

        if text[i] == pattern[j]:
            j += 1

        if j == m:
            result.append(i - m + 1)  # 找到一个匹配
            j = pi[j - 1]             # 继续搜索下一个匹配

    return result

复杂度分析:

  • 构建前缀函数:\(O(m)\)
  • 搜索阶段:\(O(n)\)
  • 总时间复杂度:\(O(n + m)\)
  • 空间复杂度:\(O(m)\)(存储前缀函数数组)

Boyer-Moore系

Boyer-Moore(BM)及其变体 Horspool、Sunday 是工程实践中最常用的单模式匹配算法。虽然最坏情况不一定优于 KMP,但在实际文本中通常远快于 KMP,因为它们能跳过更多的字符

Boyer-Moore 核心思想

BM算法的匹配方向与KMP相反:从模式串的末尾(右端)向前比较。当发生失配时,利用两条规则计算模式串可以向右滑动的距离:

1. 坏字符规则(Bad Character Rule):

当文本中的字符 \(T[i]\) 与模式串 \(P[j]\) 失配时,查看 \(T[i]\) 在模式串中最后一次出现的位置 \(k\)

  • 如果 \(T[i]\) 不在模式串中,整个模式串可以滑过 \(T[i]\),移动距离为 \(j + 1\)
  • 如果 \(T[i]\) 在模式串中且 \(k < j\),将模式串右移使 \(P[k]\) 对齐 \(T[i]\),移动距离为 \(j - k\)
  • 如果 \(k > j\),则该规则给出的移动距离为负数,此时至少移动 1

2. 好后缀规则(Good Suffix Rule):

当部分匹配 \(P[j+1..m-1]\) 已经成功但 \(P[j]\) 失配时,在模式串中寻找另一处与已匹配后缀相同的子串,将其对齐。如果找不到完整的,则寻找已匹配后缀的最长后缀恰好等于模式串的某个前缀的情况。

实际滑动距离取两条规则的较大值

为什么BM在实际中更快?

对于自然语言文本(字母表较大),坏字符规则经常能一次跳过很多位置。BM的最佳情况复杂度为 \(O(n/m)\),即亚线性——不需要检查文本中的每个字符。

Horspool 简化版

Horspool算法是BM的简化版本,只使用坏字符规则,且始终根据当前窗口最右端对应的文本字符来决定移动距离。实现简单,实际效果接近完整BM。

def horspool_search(text: str, pattern: str) -> list[int]:
    """Horspool算法:BM的简化版,只用坏字符规则"""
    n, m = len(text), len(pattern)
    if m == 0 or m > n:
        return []

    # 构建坏字符表:每个字符到模式串末尾的距离
    # 默认移动距离为 m(字符不在模式串中)
    bad_char = {}
    for i in range(m - 1):  # 注意:不包括最后一个字符
        bad_char[pattern[i]] = m - 1 - i

    result = []
    i = 0  # 窗口左端在 text 中的位置
    while i <= n - m:
        j = m - 1  # 从模式串末尾开始比较
        while j >= 0 and text[i + j] == pattern[j]:
            j -= 1

        if j < 0:
            result.append(i)  # 完全匹配
            # 移动窗口
            i += bad_char.get(text[i + m - 1], m)
        else:
            # 根据窗口最右端对应的文本字符决定移动距离
            i += bad_char.get(text[i + m - 1], m)

    return result

Sunday 算法

Sunday算法比Horspool更进一步:失配时不看窗口内的字符,而是看当前窗口之后的第一个字符(即 \(T[i + m]\))来决定移动距离。

  • 如果 \(T[i + m]\) 不在模式串中,窗口直接跳过 \(m + 1\) 个位置
  • 如果 \(T[i + m]\) 在模式串中,将模式串中该字符的最右出现位置\(T[i + m]\) 对齐

Sunday算法实现极其简单,在字母表较大、模式串较长时效果极佳。

复杂度对比

算法 最佳 平均 最坏 空间 特点
KMP \(O(n)\) \(O(n)\) \(O(n)\) \(O(m)\) 理论保证,不回溯文本指针
Boyer-Moore \(O(n/m)\) \(O(n)\) \(O(nm)\) \(O(m + \|\Sigma\|)\) 实践最快,双规则
Horspool \(O(n/m)\) \(O(n)\) \(O(nm)\) \(O(\|\Sigma\|)\) BM简化版,只用坏字符
Sunday \(O(n/m)\) \(O(n)\) \(O(nm)\) \(O(\|\Sigma\|)\) 看窗口后一位,实现最简

其中 \(\|\Sigma\|\) 为字母表大小。在最坏情况下(如 $T = $ aaa...a,$P = $ aaa...ab),BM系列退化为 \(O(nm)\),而KMP始终保持 \(O(n)\)。但在实际应用中,BM系列通常比KMP快 3-5 倍。

Two-Way

Two-Way算法是现代工业级字符串搜索的首选,许多标准库的底层实现都使用了这个算法,包括 glibc 的 strstr、Python 的 str.find / in 运算符、以及 Rust 的字符串搜索。

核心思想:结合KMP与BM的优势

Two-Way算法由 Crochemore 和 Perrin 于 1991 年提出,其关键思想是将模式串分成两部分(临界分解,Critical Factorization),然后:

  • 右半部分从左到右比较(类似KMP的方向)
  • 左半部分从右到左比较(类似BM的方向)

先比较右半部分,匹配后再验证左半部分。如果右半部分失配,利用类似KMP前缀函数的信息来决定移动距离。

为什么标准库选择Two-Way?

特性 KMP BM Two-Way
时间复杂度 \(O(n + m)\) 最坏 \(O(nm)\) \(O(n + m)\)
空间复杂度 \(O(m)\) \(O(m + \|\Sigma\|)\) \(O(1)\)
实际性能 一般 很快

Two-Way的最大优势在于只需要 \(O(1)\) 的额外空间(不需要像KMP那样预先分配 \(O(m)\) 的前缀表),同时保持 \(O(n + m)\) 的最坏情况时间复杂度保证。这使得它在内存敏感的系统库中非常理想——标准库不能假设模式串很短,\(O(m)\) 的空间开销在模式串很长时可能不可接受。

算法概要(概念性描述):

  1. 临界分解: 找到模式串的一个分割点 \(\ell\),将 \(P\) 分为 \(P[0..\ell-1]\)\(P[\ell..m-1]\),使得该分割满足"临界分解定理"的条件
  2. 右半部分匹配:\(P[\ell]\) 开始向右逐字符与文本比较
  3. 左半部分验证: 如果右半部分全部匹配,再向左验证 \(P[0..\ell-1]\)
  4. 移动规则: 失配时根据模式串的周期性质计算移动距离,保证 \(O(1)\) 空间下的高效跳转

由于实现细节涉及临界分解定理和模式串周期分析,代码较为复杂,此处不展开完整实现。有兴趣的读者可参考 glibc 源码中的 str-two-way.h

多模式匹配

比如有10万个敏感词,在一个文本里同时查。

Aho-Corasick

AC自动机(Aho-Corasick Automaton),多模式匹配的绝对统治者。

问题: 给定一组模式串集合 \(\{P_1, P_2, \ldots, P_k\}\) 和一个文本 \(T\),同时在 \(T\) 中找出所有模式串的出现位置。如果用KMP逐个搜索,时间为 \(O(k \cdot (n + m_i))\),当模式串数量很大时效率很低。AC自动机可以一次扫描文本就找到所有模式串的匹配。

核心思想:Trie + 失败指针(Failure Links)

AC自动机本质上是在Trie树上做KMP。KMP的前缀函数作用于单个模式串,而AC自动机将这个思想推广到了Trie上:

  • Trie负责组织所有模式串的前缀结构
  • 失败指针(failure link)类比KMP的前缀函数,指向当前状态的最长真后缀中同时也是某个模式串前缀的状态

构建过程:

  1. 建Trie: 将所有模式串逐个插入Trie树
  2. 建失败指针(BFS): 从Trie的根节点开始BFS,对每个节点 \(u\),其子节点 \(v\)(边字符为 \(c\))的失败指针指向 \(u\) 的失败指针沿 \(c\) 走到的节点。根的所有直接子节点的失败指针指向根
  3. 搜索: 用一个指针在AC自动机上沿文本字符移动,每到一个节点就沿着失败指针链检查是否有模式串在此结束

时间复杂度: \(O(\sum|P_i| + |T| + \text{匹配数})\),即所有模式串长度之和(建Trie) + 文本长度(搜索) + 输出匹配的数量。

典型应用:

  • 敏感词过滤: 互联网内容审核中,将数万个敏感词构建为AC自动机,对每条用户输入做一次扫描即可检出所有敏感词
  • 病毒特征码匹配: 杀毒软件用AC自动机在文件中同时搜索大量病毒签名
  • 基因序列分析: 在DNA序列中同时搜索多个已知基因片段
  • 入侵检测系统(IDS): 网络流量中匹配攻击特征

Python实现:

from collections import deque, defaultdict


class AhoCorasick:
    def __init__(self):
        # goto 函数:goto[state][char] -> next_state
        self.goto = [{}]
        # 失败指针
        self.fail = [0]
        # 输出函数:每个状态对应的模式串列表
        self.output = [[]]
        self.state_count = 0

    def _new_state(self):
        self.state_count += 1
        self.goto.append({})
        self.fail.append(0)
        self.output.append([])
        return self.state_count

    def add_pattern(self, pattern: str, index: int = 0):
        """将一个模式串插入 Trie"""
        state = 0
        for ch in pattern:
            if ch not in self.goto[state]:
                self.goto[state][ch] = self._new_state()
            state = self.goto[state][ch]
        self.output[state].append((pattern, index))

    def build(self):
        """BFS 构建失败指针"""
        queue = deque()

        # 根节点的直接子节点,失败指针指向根
        for ch, s in self.goto[0].items():
            self.fail[s] = 0
            queue.append(s)

        # BFS
        while queue:
            u = queue.popleft()
            for ch, v in self.goto[u].items():
                queue.append(v)
                # 沿着 u 的失败指针找到能接受 ch 的状态
                f = self.fail[u]
                while f != 0 and ch not in self.goto[f]:
                    f = self.fail[f]
                self.fail[v] = self.goto[f].get(ch, 0)
                # 如果失败指针指向自己就修正为根
                if self.fail[v] == v:
                    self.fail[v] = 0
                # 合并输出:继承失败指针对应状态的输出
                self.output[v] = self.output[v] + self.output[self.fail[v]]

    def search(self, text: str) -> list[tuple[int, str]]:
        """在文本中搜索所有模式串,返回 (位置, 模式串) 列表"""
        results = []
        state = 0

        for i, ch in enumerate(text):
            # 沿失败指针寻找能接受 ch 的状态
            while state != 0 and ch not in self.goto[state]:
                state = self.fail[state]
            state = self.goto[state].get(ch, 0)

            # 收集当前状态的所有输出
            for pattern, idx in self.output[state]:
                pos = i - len(pattern) + 1
                results.append((pos, pattern))

        return results


# 使用示例
if __name__ == "__main__":
    ac = AhoCorasick()
    patterns = ["he", "she", "his", "hers"]
    for i, p in enumerate(patterns):
        ac.add_pattern(p, i)
    ac.build()

    text = "ahishers"
    matches = ac.search(text)
    for pos, pat in matches:
        print(f"位置 {pos}: '{pat}'")
    # 输出:
    # 位置 1: 'his'
    # 位置 3: 'she'
    # 位置 4: 'he'
    # 位置 4: 'hers'

AC自动机 vs 朴素多模式搜索:

假设有 \(k\) 个模式串,总长度为 \(M = \sum|P_i|\),文本长度为 \(n\)

方法 时间复杂度 说明
逐个KMP \(O(k \cdot n + M)\) 需要扫描文本 \(k\)
AC自动机 \(O(M + n + z)\) 只需扫描文本 1 次,\(z\) 为匹配数

\(k\) 很大时(如 10 万个敏感词),AC自动机的优势是压倒性的。


评论 #