DFA敏感词匹配算法与优化

什么是DFA敏感词算法

  • DFA : 确定有限自动机

    DFA=(Q,Σ,δ,q0,F) \mathrm{DFA}=(Q,\Sigma,\delta,q_0,F) DFA=(Q,Σ,δ,q0,F)

    • 各符号含义:
      • QQ Q :有限状态集合
      • Σ\Sigma Σ :输入字母表(输入符号集合)
      • δ\delta δ :状态转移函数, δ(q,a)=q′,每个(q,a)只对应唯一q′\delta(q,a) = q',每个(q,a)只对应唯一q' δ(q,a)=q′,每个(q,a)只对应唯一q′
      • q0 q_0 q0:初始状态, q0∈Qq0∈Q q0∈Q
      • FF F:终止状态集合, F⊆QF⊆Q F⊆Q

    核心就是确定,任意当前状态+输入有且只有一条转移路径。

    • 不会出现同一个输入有多个状态可转移
    • 不会出现某个输入没有状态可转移
  • 敏感词

    敏感词就是日常游戏中需要被屏蔽或者替换成*号的词,比如你在王者荣耀聊天框骂人触发敏感词算法,要么发不出去,要么被替换成*号。

  • 敏感词匹配算法中的DFA

    敏感词算法中的DFA表现是字典树(Trie树),利用有限状态机实现字符串快速匹配。

    • 一个树结点代表一个状态
    • 每个树点中间的连线就是状态转移函数
    • 根节点就结是初始状态 q0 q_0 q0
    • 词库中的字符串结尾那个字符会被打上终止状态
  • 核心优势

    • 一次遍历文本即可匹配所有敏感词;
    • 时间复杂度近似 O(N)(N 为待检测文本长度),和敏感词数量无关;
    • 支持最长匹配、最短匹配、跳过干扰字符、连续匹配 ; 本文目的让你快速上手了解,只讲最简单的最短匹配和非连续匹配

如何构建字典树

  • 树结点的数据结构

    csharp 复制代码
    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(char c, int capacity = 4)
        {
            Value = c;
            Children = new Dictionary<char, TrieNode>(capacity);
        }
    }
  • 构建字典树

    • 代码逻辑

      喜欢看代码整理逻辑的,可以先看代码,后面我们会用图文过一遍构建过程

    csharp 复制代码
    public 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自动机还有一个功能,就是多模式匹配,但是由于这个多模式匹配要至少额外存储两遍敏感词库,对内存的压力不小,一般没有特殊需求需要记录命中敏感词的数量,都是使用快速的最短匹配
相关推荐
SmalBox7 小时前
【节点】[Subgraph节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox1 天前
【节点】[Preview节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox2 天前
【节点】[LocalVariableRegister节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_3 天前
Unity MyFramework 用法说明(三十一):使用 ExcelManager 统一管理游戏配置表
unity3d
SmalBox3 天前
【节点】[Keyword节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_4 天前
Unity MyFramework 用法说明(三十):使用 GlobalTouchSystem 统一处理 UI 与场景交互
unity3d
SmalBox4 天前
【节点】[GetLocalVariable节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox5 天前
【节点】[CustomFunction节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_6 天前
Unity MyFramework 用法说明(二十九):使用 CameraManager 统一管理游戏摄像机
unity3d