应使用 Aho-Corasick 算法替代 String.Contains 实现多关键词查找,因其时间复杂度从 O(n×m×k) 降至 O(n+k+z),支持高效、线程安全、零分配的批量字面量匹配。

为什么不用 String.Contains 做多关键词查找
当你要在一段文本里同时找 “error”、“warning”、“critical” 这类几十甚至上百个关键词时,逐个调用 String.Contains 会退化成 O(n × m × k),其中 n 是文本长度、m 是平均模式长度、k 是模式数量。实际中很容易卡住,尤其处理日志或网络包载荷时。
真正的问题不在“能不能找到”,而在“会不会拖慢整个服务”。Aho-Corasick(AC)把时间复杂度压到 O(n + k + z),z 是匹配总数,对固定词典+长文本场景几乎是线性的。
- 别在循环里反复编译正则——
Regex构造开销大,且默认不支持多模式共享状态 - 避免用
IndexOfAny混淆:它只匹配单字符,不是子串 - 注意 .NET 的
Span<char></char>友好性:AC 实现若没适配ReadOnlySpan<char></char>,就浪费了零分配优势
用 Microsoft.Extensions.PatternMatching 快速上手 AC
这个 NuGet 包(v7.0+)内置了轻量 AC 自动机,不依赖反射或动态代码生成,适合嵌入式或高吞吐场景。
安装后直接构建自动机:
var trie = new StringMatcher<string>(StringComparison.Ordinal);
trie.Add("error", "error");
trie.Add("warning", "warn");
trie.Add("critical", "crit");
// 构建完成,可复用
var matches = trie.FindMatches("An error occurred, warning: disk full");
// 返回 IEnumerable<(string Pattern, int Index, string Value)>
- 构造后
FindMatches是只读操作,线程安全 - 不支持通配符或正则语法,纯字面量匹配;若需模糊匹配,得另接 Levenshtein 或用
BM单模式回退 - 内存占用约 20–30 字节/字符(取决于模式重叠度),比手写 Dictionary<string, T> 查前缀更省
手写 AC 自动机要注意的三个坑
自己实现 AC 能控制细节(比如支持 UTF-8 字节流、自定义失败跳转逻辑),但容易在三处翻车:
- 失败指针(fail pointer)构建顺序错误:必须按 BFS 层序遍历节点,否则 fail 指向未初始化的父节点,导致死循环或漏匹配
- 输出链(output link)未合并重复匹配:比如模式集含 "he" 和 "she",查 "she" 时应同时报告两个命中,但若只存末端节点 output,会丢掉 "he"
-
忽略 Unicode 组合字符:C# 的
char是 UTF-16 code unit,遇到代理对(surrogate pair)如 ?,直接按char切分就会断开,建议先用StringInfo.GetTextElementEnumerator或转Rune处理
Span<char> + AC 如何避免内存分配
高频调用时,每次传 string 都触发 GC 压力。把 AC 匹配逻辑改成接收 ReadOnlySpan<char>,并让节点跳转基于 Span 索引而非字符串切片:
public ReadOnlySpan<char> Text { get; }
public int StartIndex { get; }
// 匹配时不 new string(text.Slice(i, len))
// 而是 yield return (pattern, i, Text.Slice(i, len))
- 匹配结果中的
Value字段若返回ReadOnlySpan<char>,调用方必须确保源Span生命周期覆盖使用期 - AC 节点结构体最好用
ref struct(如AcStateRef),禁止逃逸到堆上 - .NET 6+ 的
Utf8Parser可配合 AC 做字节级匹配,但需自行处理 UTF-8 多字节边界,慎用
AC 不是银弹——词典动态增删频繁时,重构自动机成本高,这时倒不如用 SortedSet<string> + 二分前缀查找,或者换用 Rabin-Karp 批量哈希。真正关键的是:先确认瓶颈在匹配本身,而不是 IO 或锁竞争。










