什么是DFA敏感词算法
-
DFA : 确定有限自动机
DFA=(Q,Σ,δ,q0,F)
- 各符号含义:
- Q :有限状态集合
- Σ :输入字母表(输入符号集合)
- δ :状态转移函数, δ(q,a)=q′,每个(q,a)只对应唯一q′
- q0:初始状态, q0∈Q
- F:终止状态集合, F⊆Q
核心就是确定,任意当前状态+输入有且只有一条转移路径。
- 不会出现同一个输入有多个状态可转移。
- 不会出现某个输入没有状态可转移。
- 各符号含义:
-
敏感词
敏感词就是日常游戏中需要被屏蔽或者替换成*号的词,比如你在王者荣耀聊天框骂人触发敏感词算法,要么发不出去,要么被替换成*号。
-
敏感词匹配算法中的DFA
敏感词算法中的DFA表现是字典树(Trie树),利用有限状态机实现字符串快速匹配。
- 一个树结点代表一个状态
- 每个树点中间的连线就是状态转移函数
- 根节点就结是初始状态 q0
- 词库中的字符串结尾那个字符会被打上终止状态
-
核心优势
- 一次遍历文本即可匹配所有敏感词;
- 时间复杂度近似 O(N)(N 为待检测文本长度),和敏感词数量无关;
- 支持最长匹配、最短匹配、跳过干扰字符、连续匹配 ; 本文目的让你快速上手了解,只讲最简单的最短匹配和非连续匹配。
如何构建字典树
-
树结点的数据结构
csharpprivate class TrieNode { /// <summary> /// 字符 /// </summary> public char Value { get; set; } /// <summary> /// 子节点 /// </summary> public Dictionary<char, TrieNode> Children { get; set; } /// <summary> /// 终止状态标记 /// </summary> public bool Flag { get; set; } = false; public TrieNode(char c, int capacity = 4) { Value = c; Children = new Dictionary<char, TrieNode>(capacity); } } -
构建字典树
-
代码逻辑
喜欢看代码整理逻辑的,可以先看代码,后面我们会用图文过一遍构建过程
csharppublic void CreateTree(List<string> sensitiveWords) { TrieNode _root = null; strictRoot = new TrieNode('\0', 4096); _root = strictRoot; TrieNode currentNode = _root; foreach (string word in sensitiveWords) { InsertNodeDepth(currentNode, word, 0); } } /// <summary> /// 插入字符 /// </summary> /// <param name="node">树节点</param> /// <param name="word">敏感词</param> /// <param name="index">当前字符索引</param> private void InsertNodeDepth(TrieNode node, string word, int index) { char c = word[index]; TrieNode newNode = null; if (!node.Children.TryGetValue(c, out newNode)) { newNode = new TrieNode(c); node.Children[c] = newNode; } if (index == word.Length - 1) { newNode.Flag = true; // 标记为敏感词结尾 } else { InsertNodeDepth(newNode, word, index + 1); } }-
手动图文构建
设定词库 : "abcdx", "bcd", "bcx","cd", "dx"
-
读取到"abcdx"
-
调用InsertNodeDepth ,传入node = root, word = "abcdx",index = 0
-
此时root的Children是空的,所以会创建一个新节点,存放wordindex = "a"
-
判断当前字符**wordindex = "a"是不是字符串结尾,是则标记,不是则递归调用 InsertNodeDepth,传入node = root→a, word = "abcdx",index = index+1,**遍历word的下一个字符
-
以此类推,直到遍历到**wordindex = "x",**到了字符串结尾,标记这个结点为终止状态
此时得到了一个这样的树(红色结点代表终止状态)

-
-
读取到"bcd"
-
调用InsertNodeDepth ,传入node = root, word = "bcd",index = 0
-
此时root的Children有值,先判断有没有**wordindex = "b",**没有就创建一个新结点
-
判断当前字符**wordindex = "b"是不是字符串结尾,是则标记,不是则递归调用 InsertNodeDepth,传入node = root→a, word = "bcd",index = index+1,**遍历word的下一个字符
-
以此类推,直到遍历到wordindex = "d",到了字符串结尾,标记这个结点为终止状态
于是得到了一个这样的图(红色结点代表终止状态)

-
-
读取到"bcx"
-
调用InsertNodeDepth ,传入node = root, word = "bcx",index = 0
-
此时root的Children有值,先判断有没有**wordindex = "b",**有则不创建新节点
-
判断当前字符**wordindex = "b"是不是字符串结尾,是则标记,不是则递归调用 InsertNodeDepth,传入node = root→b, word = "bcx",index = index+1,**遍历word的下一个字符
-
以此类推,直到遍历到wordindex = "x",到了字符串结尾,标记这个结点为终止状态
于是得到了一个这样的图(红色结点代表终止状态)

-
-
后续读取"dc ", "dx",流程都是一样,我就省略了
最终得到一个这样的图(红色结点代表终止状态)

-
-
总结起来就是,相同的前缀子串会复用同一条路径,大大节省了存储和查找的开销
查找敏感词
代码如下
csharp
/// <summary>
/// 在节点中查找指定字符的子节点
/// </summary>
private TrieNode FindNode(TrieNode node, char c)
{
if (node.Children.TryGetValue(c, out TrieNode childNode))
{
}
return childNode;
}
/// <summary>
/// 创建打断字符表
/// </summary>
private void CreateBreakCharDic(List<char> breakWords)
{
breakCharDic.Clear();
foreach (var c in breakWords)
{
breakCharDic[c] = true;
}
}
/// <summary>
/// 获取匹配的敏感词长度
/// </summary>
public int GetMatchWordLength(string word, int beginIndex, TrieNode root)
{
int index = beginIndex;
TrieNode node = root;
while (index < word.Length)
{
char c = word[index];
// 这里是判断打断字符
if (this.breakCharDic.ContainsKey(c))
{
if (index == beginIndex)
{
return 0;
}
}
else
{
node = FindNode(node, c);
if (node == null)
{
return 0;
}
if (node.Flag)
{
return index - beginIndex + 1;
}
}
index++;
}
return 0;
}
/// <summary>
/// 是否是敏感词
/// </summary>
public bool IsSensitiveWord(string word)
{
int index = 0;
while (index < word.Length)
{
int skipLength = GetMatchWordLength(word, index,this.whiteListRoot);
if (skipLength > 0)
{
index += skipLength;
}
else
{
int matchLength = GetMatchWordLength(word, index, this.strictRoot);
if (matchLength > 0)
{
return true;
}
index++;
}
}
return false;
}
查找敏感词算法较为直观简单了
- 先过滤白名单,白名单字典树和敏感词字典树是同样的构建过程,词库不一样
- 然后匹配敏感词,主要逻辑就是遍历字符串,在字典树中查找并前进(状态转移 ),直至找到来终止状态也就是Flag为true的节点,返回匹配到的长度,长度大于0即判断为匹配成功
算法优化(重点)
优化分两种,第一种懒加载优化,优点是减少启动压力,第二种是AC自动机,优点是查找速度快
懒加载优化
有些敏感词字符量级非常庞大,可能有几mb大小,如果在建立字典树初始化的时候就全部展开,势必会卡顿,也可利用协程来规避,但我们这里利用算法的特性来规避这个情况
jsx
private class TrieNode
{
/// <summary>
/// 字符
/// </summary>
public char Value { get; set; }
/// <summary>
/// 子节点
/// </summary>
public Dictionary<char, TrieNode> Children { get; set; }
/// <summary>
/// 终止状态标记
/// </summary>
public bool Flag { get; set; } = false;
/// 字符表
public List<string> strList = new List<string>();
public TrieNode(char c, int capacity = 4)
{
Value = c;
Children = new Dictionary<char, TrieNode>(capacity);
}
}
这里我们在结点结构里面加入一个字符表,用来暂存当前字符串,用处是留着以后真正匹配敏感词时再展开
jsx
// <summary>
/// 初始化插入字符,这里不做展开,减少初始化压力
/// </summary>
/// <param name="node">树节点</param>
/// <param name="word">敏感词</param>
private void InsertNode(TrieNode node, string word)
{
char c = word[0];
TrieNode newNode = null;
if (!node.Children.TryGetValue(c, out newNode))
{
newNode = new TrieNode(c);
node.Children[c] = newNode;
}
if (word.Length == 1)
{
newNode.Flag = true; // 标记为敏感词结尾
}
else
{
newNode.strList.Add(word);
}
}
/// <summary>
/// 在节点中查找指定字符的子节点
/// </summary>
private TrieNode FindNode(TrieNode node, char c)
{
if (node.Children.TryGetValue(c, out TrieNode childNode))
{
if (childNode.strList.Count > 0)
{
// lazy load
foreach (var word in childNode.strList)
{
InsertNodeDepth(childNode, word, 1);
}
childNode.strList.Clear();
}
}
return childNode;
}
/// <summary>
/// 打断字符字典
/// </summary>
public void CreateTree(List<string> sensitiveWords)
{
TrieNode _root = null;
strictRoot = new TrieNode('\0', 4096);
_root = strictRoot;
TrieNode currentNode = _root;
foreach (string word in sensitiveWords)
{
InsertNode(currentNode, word);
}
}
- 这里我们增加里一个新的插入结点方法InsertNode 用于初始化,在这里我们不全部展开节点,只取字符串的第一个字符,也就只建立树的第一层,再把整个字符串存储在strList里面。
- 然后真正的展开放在FindNode里面,每次也只需展开一个结点,把算法复杂度平均到每个结点,而不是初始化的时候
AC自动机
AC自动机是在原有DFA自动机里的状态结点 加入里一个失败指针 ,指向存在字典树(Trie)中且是当前字符串的最长真后缀
-
最长真后缀是什么
-
例:she的后缀有,she、he、e
真后缀:he、e(剔除自身)
最长真后缀就是he
-
-
为什么要这么做
- DFA在单次文本扫描的时候,如果遇到里匹配失败,需要退回到root层重新往下扫描,效率低下
- AC在单次扫描匹配失败的时候,可以退回到失败指针,由于失败指针指向的是当前扫描文本的最长真后缀 ,也就省去了回到root层重新匹配这个最长真后缀的过程,直接匹配后面的文本
-
构建失败指针
失败指针需要树完全展开的情况下才能构建,因为不展开找不到最长真后缀
优先贴上算法,后面我们以图文一步步构建失败指针
jsx/// <summary> /// 节点 /// </summary> private class TrieNode { /// <summary> /// 字符 /// </summary> public char Value { get; set; } /// <summary> /// 子节点 /// </summary> public Dictionary<char, TrieNode> Children { get; set; } /// <summary> /// 结尾标记 /// </summary> public bool Flag { get; set; } = false; // 失败指针 public TrieNode FailureLink { get; set; } = null; public TrieNode(char c, int capacity = 4) { Value = c; Children = new Dictionary<char, TrieNode>(capacity); } } /// <summary> /// 构建失败指针 /// </summary> private void BuildFailureLinks(TrieNode root) { if (root == null) return; // 常见约定:根的失败指针指向自己,便于匹配阶段统一处理 root.FailureLink = root; Queue<TrieNode> queue = new Queue<TrieNode>(); // 第一层节点:失败指针全部指向 root foreach (TrieNode child in root.Children.Values) { child.FailureLink = root; queue.Enqueue(child); } // BFS 按层构建 while (queue.Count > 0) { TrieNode current = queue.Dequeue(); foreach (KeyValuePair<char, TrieNode> edge in current.Children) { char c = edge.Key; TrieNode child = edge.Value; // 从 current 的失败指针开始,找"可通过 c 转移"的最长后缀状态 TrieNode fallback = current.FailureLink; while (fallback != root && !fallback.Children.ContainsKey(c)) { fallback = fallback.FailureLink; } // 两种情况: // 1) 找到可转移状态 -> 指过去 // 2) 没找到(且 root 也无 c)-> 指向 root if (fallback.Children.TryGetValue(c, out TrieNode next)) { child.FailureLink = next; } else { child.FailureLink = root; } queue.Enqueue(child); } } }我们这里以上面建立好的字典树为例来建立失败指针

-
第一步,把第一层结点的失败指针全部指向root,并且入队
红色连线代表失败指针

-
第二步,遍历到结点Root→a
- 先遍历Root→a的所有子节点,Root→a只有一个子节点Root→a→b,
- 从当前结点Root→a的失败指针开始**,取出Root→a失败指针指向的结点也就是Root,暂存变量fallback,然后判断是否是 "可通过当前字符转移"的最长后缀状态**,代码为
fallback != root && !fallback.Children.ContainsKey(c),不满足条件,fallback原地不动 - 判断当前状态是否是可转移状态,代码为
fallback.Children.TryGetValue(c, out TrieNode next),fallback指向root,也就是判断root.Children中是否存在b,满足条件,于是Root→a→b的失败指针指向Root→b - 将 Root→a→b 入队

-
第三步,遍历到结点Root→b
- 先遍历 Root→b 的所有子节点,Root→b 只有一个子节点 Root→b→c,
- 从当前结点 Root→b 的失败指针开始,取出 Root→b 失败指针指向的结点也就是 Root,暂存变量 fallback,然后判断是否是 "可通过当前字符转移" 的最长后缀状态,代码为
fallback != root && !fallback.Children.ContainsKey(c),不满足条件,fallback 原地不动 - 判断当前状态是否是可转移状态,代码为
fallback.Children.TryGetValue(c, out TrieNode next),fallback 指向 root,也就是判断 root.Children 中是否存在 c,满足条件,于是 Root→b→c 的失败指针指向 Root→c - 将 Root→b→c 入队

-
第四步,遍历到结点 Root→c
- 先遍历 Root→c 的所有子节点,Root→c 只有一个子节点 Root→c→d
- 从当前结点 Root→c 的失败指针开始,取出 Root→c 失败指针指向的结点也就是 Root,暂存变量 fallback,然后判断是否是 "可通过当前字符转移" 的最长后缀状态,代码为
fallback != root && !fallback.Children.ContainsKey(c),不满足条件,fallback 原地不动 - 判断当前状态是否是可转移状态,代码为
fallback.Children.TryGetValue(c, out TrieNode next),fallback 指向 root,也就是判断 root.Children 中是否存在 d,满足条件,于是 Root→c→d 的失败指针指向 Root→d - 将 Root→c→d 入队

-
第五步,遍历到结点 Root→d
- 先遍历 Root→d 的所有子节点,Root→d 只有一个子节点 Root→d→x
- 从当前结点 Root→d 的失败指针开始,取出 Root→d 失败指针指向的结点也就是 Root,暂存变量 fallback,然后判断是否是 "可通过当前字符转移" 的最长后缀状态,代码为
fallback != root && !fallback.Children.ContainsKey(c),不满足条件,fallback 原地不动 - 判断当前状态是否是可转移状态,代码为
fallback.Children.TryGetValue(c, out TrieNode next),fallback 指向 root,也就是判断 root.Children 中是否存在 x,不满足条件,于是 Root→d→x 的失败指针指向 root - 将 Root→d→x 入队

-
第六步,遍历到结点 Root→a→b
- 先遍历 Root→a→b 的所有子节点,Root→a→b 只有一个子节点 Root→a→b→c
- 从当前结点 Root→a→b 的失败指针开始,取出 Root→a→b 失败指针指向的结点也就是 Root→b,暂存变量 fallback,然后判断是否是 "可通过当前字符转移" 的最长后缀状态,代码为
fallback != root && !fallback.Children.ContainsKey(c),fallback 不等于 root 但包含字符 c,条件不成立,fallback 原地不动 - 判断当前状态是否是可转移状态,代码为
fallback.Children.TryGetValue(c, out TrieNode next),fallback 指向 Root→b,也就是判断 Root→b.Children 中是否存在 c,满足条件,于是 Root→a→b→c 的失败指针指向 Root→b→c - 将 Root→a→b→c 入队

-
第七步,遍历到结点 Root→b→c
- 先遍历 Root→b→c 的所有子节点,Root→b→c 有两个子节点:Root→b→c→d、Root→b→c→x
- 子节点 1:Root→b→c→d
- 从当前结点 Root→b→c 的失败指针开始,取出 Root→b→c 失败指针指向的结点也就是 Root→c,暂存变量 fallback,然后判断是否是 "可通过当前字符转移" 的最长后缀状态,代码为
fallback != root && !fallback.Children.ContainsKey(c),fallback 不等于 root 但包含字符 d,条件不成立,fallback 原地不动 - 判断当前状态是否是可转移状态,代码为
fallback.Children.TryGetValue(c, out TrieNode next),fallback 指向 Root→c,也就是判断 Root→c.Children 中是否存在 d,满足条件,于是 Root→b→c→d 的失败指针指向 Root→c→d - 将 Root→b→c→d 入队

- 子节点 2:Root→b→c→x
- 从当前结点 Root→b→c 的失败指针开始,取出 Root→b→c 失败指针指向的结点也就是 Root→c,暂存变量 fallback,然后判断是否是 "可通过当前字符转移" 的最长后缀状态,代码为
fallback != root && !fallback.Children.ContainsKey(c),fallback 不等于 root 且不包含字符 x,满足条件,fallback 更新为 Root→c 的失败指针也就是 Root;再次判断循环条件不成立,停止回溯 - 判断当前状态是否是可转移状态,代码为
fallback.Children.TryGetValue(c, out TrieNode next),fallback 指向 root,也就是判断 root.Children 中是否存在 x,不满足条件,于是 Root→b→c→x 的失败指针指向 root - 将 Root→b→c→x 入队

-
第八步,遍历到结点 Root→c→d
- Root→c→d 没有子节点,无需处理失败指针,直接跳过
-
第九步,遍历到结点 Root→d→x
- Root→d→x 没有子节点,无需处理失败指针,直接跳过
-
第十步,遍历到结点 Root→a→b→c
- 先遍历 Root→a→b→c 的所有子节点,Root→a→b→c 只有一个子节点 Root→a→b→c→d
- 从当前结点 Root→a→b→c 的失败指针开始,取出 Root→a→b→c 失败指针指向的结点也就是 Root→b→c,暂存变量 fallback,然后判断是否是 "可通过当前字符转移" 的最长后缀状态,代码为
fallback != root && !fallback.Children.ContainsKey(c),fallback 不等于 root 但包含字符 d,条件不成立,fallback 原地不动 - 判断当前状态是否是可转移状态,代码为
fallback.Children.TryGetValue(c, out TrieNode next),fallback 指向 Root→b→c,也就是判断 Root→b→c.Children 中是否存在 d,满足条件,于是 Root→a→b→c→d 的失败指针指向 Root→b→c→d - 将 Root→a→b→c→d 入队

-
第十一步,遍历到结点 Root→b→c→d
- Root→b→c→d 没有子节点,无需处理失败指针,直接跳过
-
第十二步,遍历到结点 Root→b→c→x
- Root→b→c→x 没有子节点,无需处理失败指针,直接跳过
-
第十三步,遍历到结点 Root→a→b→c→d
- 先遍历 Root→a→b→c→d 的所有子节点,Root→a→b→c→d 只有一个子节点 Root→a→b→c→d→x
- 从当前结点 Root→a→b→c→d 的失败指针开始,取出 Root→a→b→c→d 失败指针指向的结点也就是 Root→b→c→d,暂存变量 fallback,然后判断是否是 "可通过当前字符转移" 的最长后缀状态,代码为
fallback != root && !fallback.Children.ContainsKey(c),fallback 不等于 root 且不包含字符 x,满足条件,fallback 更新为 Root→b→c→d 的失败指针也就是 Root→c→d;再次判断仍满足条件,继续更新为 Root→d;再次判断循环条件不成立,停止回溯 - 判断当前状态是否是可转移状态,代码为
fallback.Children.TryGetValue(c, out TrieNode next),fallback 指向 Root→d,也就是判断 Root→d.Children 中是否存在 x,满足条件,于是 Root→a→b→c→d→x 的失败指针指向 Root→d→x - 将 Root→a→b→c→d→x 入队

-
第十四步,遍历到结点 Root→a→b→c→d→x
- Root→a→b→c→d→x 没有子节点,无需处理失败指针,直接跳过
所有结点失败指针构建完成!
匹配算法
这一步很简单,直接上代码
csharp
/// <summary>
/// 获取匹配的敏感词长度
/// </summary>
/// <param name="word"></param>
/// <param name="beginIndex">开始索引</param>
/// <param name="root"></param>
/// <returns></returns>
public int GetMatchWordLength(string word, int beginIndex, TrieNode root)
{
int index = beginIndex;
TrieNode node = root;
while (index < word.Length)
{
char c = word[index];
// 如果是打断字符,且当前索引是开始索引,则直接返回0
if (breakCharDic.ContainsKey(c))
{
if (index == beginIndex)
{
return 0;
}
}
else
{
// AC 核心:沿失败链跳转,直到找到有 c 子节点的状态或回到 root
while (node != root && !node.Children.ContainsKey(c))
{
node = node.FailureLink;
}
if (node.Children.TryGetValue(c, out TrieNode next))
node = next;
else
return 0; // root 也没有 c,从此起点无法匹配
if (node.Flag)
{
return index - beginIndex + 1;
}
}
index++;
}
return 0;
}
/// <summary>
/// 判断字符串中是否包含敏感词
/// </summary>
/// <param name="word"></param>
/// <returns></returns>
public bool IsSensitiveWord(string word)
{
int index = 0;
while (index < word.Length)
{
// 跳过白名单
int skipLength = GetMatchWordLength(word, index, this.whiteListRoot);
if (skipLength > 0)
{
index += skipLength;
}
else
{
int matchLength = GetMatchWordLength(word, index, this.strictRoot);
if (matchLength > 0)
{
return true;
}
index++;
}
}
return false;
}
对比DFA匹配算法,只是增加了下面这段失败指针跳转,而不是没找到就立即返回0
csharp
public int GetMatchWordLength(string word, int beginIndex, TrieNode root)
{
...
// AC 核心:沿失败链跳转,直到找到有 c 子节点的状态或回到 root
while (node != root && !node.Children.ContainsKey(c))
{
node = node.FailureLink;
}
...
return 0;
}
多模式匹配
AC自动机还有一个核心能力,基于失败指针的线性时间多模式字符串匹配,下面给出代码
csharp
public enum SensitiveWordType
{
/// <summary>
/// 全屏蔽 命中后通常用于拦截发送、禁止注册等严格场景
/// </summary>
Strict = 1,
/// <summary>
/// 部分屏蔽 命中后允许发送,但会将关键词替换为 *
/// </summary>
Judge = 2,
/// <summary>
/// 白名单 - 优先级最高,命中后直接跳过探测,防止误杀
/// </summary>
WhiteList = 3,
/// <summary>
/// 黑洞字库 区分敏感词,可动态开关
/// </summary>
BlackHole = 4
}
/// <summary>
/// 节点
/// </summary>
private class TrieNode
{
/// <summary>
/// 字符
/// </summary>
public char Value { get; set; }
/// <summary>
/// 子节点
/// </summary>
public Dictionary<char, TrieNode> Children { get; set; }
/// <summary>
/// 结尾标记
/// </summary>
public bool Flag { get; set; } = false;
/// <summary>
/// 敏感词ID
/// </summary>
public int WordID { get; set; } = -1;
// 失败指针
public TrieNode FailureLink { get; set; } = null;
public TrieNode(char c, int capacity = 4)
{
Value = c;
Children = new Dictionary<char, TrieNode>(capacity);
}
}
// 按类型存储原始敏感词列表:Type -> ID对应的原始词列表
private readonly Dictionary<SensitiveWordType, List<string>> wordLists = new();
// 按类型存储词到ID的去重映射:Type -> (词 -> ID)
private readonly Dictionary<SensitiveWordType, Dictionary<string, int>> wordToIDMaps = new();
public void CreateTree(List<string> sensitiveWords, SensitiveWordType type)
{
TrieNode _root = null;
switch (type)
{
case SensitiveWordType.Strict:
roots[type] = new TrieNode('\0', 4096);
_root = roots[type];
break;
case SensitiveWordType.Judge:
roots[type] = new TrieNode('\0', 4096);
_root = roots[type];
break;
case SensitiveWordType.WhiteList:
roots[type] = new TrieNode('\0');
_root = roots[type];
break;
case SensitiveWordType.BlackHole:
roots[type] = new TrieNode('\0');
_root = roots[type];
break;
}
// 获取当前类型的词容器,并清空旧数据
var (_wordList, _wordToID) = GetWordMap(type);
_wordList.Clear();
_wordToID.Clear();
TrieNode _currentNode = _root;
foreach (string _word in sensitiveWords)
{
if (_wordToID.ContainsKey(_word))
{
continue; // 跳过重复的敏感词
}
int _newID = _wordList.Count; // 新词的ID为当前列表长度
_wordList.Add(_word);
_wordToID[_word] = _newID; // 记录词到ID的映射
InsertNodeDepth(_currentNode, _word, 0, _newID);
}
BuildFailureLinks(_root);
}
/// <summary>
/// 插入字符
/// </summary>
/// <param name="node">树节点</param>
/// <param name="word">敏感词</param>
/// <param name="index">当前字符索引</param>
private void InsertNodeDepth(TrieNode node, string word, int index, int wordID)
{
char c = word[index];
if (!node.Children.TryGetValue(c, out TrieNode newNode))
{
newNode = new TrieNode(c);
node.Children[c] = newNode;
}
if (index == word.Length - 1)
{
newNode.Flag = true; // 标记为敏感词结尾
newNode.WordID = wordID; // 记录敏感词ID
}
else
{
InsertNodeDepth(newNode, word, index + 1, wordID);
}
}
/// <summary>
/// 构建失败指针
/// </summary>
/// <param name="root"></param>
private void BuildFailureLinks(TrieNode root)
{
if (root == null) return;
// 常见约定:根的失败指针指向自己,便于匹配阶段统一处理
root.FailureLink = root;
Queue<TrieNode> queue = new Queue<TrieNode>();
// 第一层节点:失败指针全部指向 root
foreach (TrieNode child in root.Children.Values)
{
child.FailureLink = root;
queue.Enqueue(child);
}
// BFS 按层构建
while (queue.Count > 0)
{
TrieNode current = queue.Dequeue();
foreach (KeyValuePair<char, TrieNode> edge in current.Children)
{
char c = edge.Key;
TrieNode child = edge.Value;
// 从 current 的失败指针开始,找"可通过 c 转移"的最长后缀状态
TrieNode fallback = current.FailureLink;
while (fallback != root && !fallback.Children.ContainsKey(c))
{
fallback = fallback.FailureLink;
}
// 两种情况:
// 1) 找到可转移状态 -> 指过去
// 2) 没找到(且 root 也无 c)-> 指向 root
if (fallback.Children.TryGetValue(c, out TrieNode next))
{
child.FailureLink = next;
}
else
{
child.FailureLink = root;
}
queue.Enqueue(child);
}
}
}
/// <summary>
/// 根据类型获取对应的词列表和去重字典
/// </summary>
private (List<string> wordList, Dictionary<string, int> wordToId) GetWordMap(SensitiveWordType type)
{
return (wordLists[type], wordToIDMaps[type]);
}
/// <summary>
/// 从匹配结束位置倒推真实起始下标(兼容打断字符)
/// </summary>
private int GetMatchStartIndex(string word, int endIndex, int wordLength)
{
int count = 0;
int i = endIndex;
while (i >= 0 && count < wordLength)
{
if (!_breakCharDic.ContainsKey(word[i]))
{
count++;
}
if (count == wordLength)
{
return i;
}
i--;
}
return -1;
}
/// <summary>
/// 标准AC自动机:单次遍历文本,返回所有匹配结果
/// </summary>
private List<MatchRange> MatchAll(string word, SensitiveWordType type)
{
List<MatchRange> results = new List<MatchRange>();
if (string.IsNullOrEmpty(word)) return results;
TrieNode root = roots[type];
if (root == null) return results;
var (wordList, _) = GetWordMap(type);
TrieNode node = root;
for (int i = 0; i < word.Length; i++)
{
char c = word[i];
// 打断字符:跳过不转移状态,保持当前匹配进度
if (_breakCharDic.ContainsKey(c))
{
continue;
}
// 标准AC失配回溯:沿失败指针跳转,直到找到可转移节点或回到根
while (node != root && !node.Children.ContainsKey(c))
{
node = node.FailureLink;
}
// 状态转移
if (node.Children.TryGetValue(c, out TrieNode next))
{
node = next;
}
else
{
node = root;
continue;
}
// 沿失败链收集所有后缀匹配(多模式匹配核心)
TrieNode temp = node;
while (temp != root)
{
if (temp.WordID >= 0)
{
int start = GetMatchStartIndex(word, i, wordList[temp.WordID].Length);
if (start >= 0)
{
results.Add(new MatchRange
{
Start = start,
Length = i - start + 1,
RawWord = wordList[temp.WordID]
});
}
}
temp = temp.FailureLink;
}
}
return results;
}
好!你学完了,可以用在项目中或者给你的简历增加一个小亮点
总结
- 原始DFA自动机,对于游戏中的敏感词匹配来说够用,加入懒加载是一些小型游戏项目的主流方案
- AC自动机虽然加快了匹配速度,但也提高了构建的复杂度,且不能够使用懒加载来平均算法复杂度
- AC自动机还有一个功能,就是多模式匹配,但是由于这个多模式匹配要至少额外存储两遍敏感词库,对内存的压力不小,一般没有特殊需求需要记录命中敏感词的数量,都是使用快速的最短匹配