AC自动机应用——敏感词替换插件


前言

最近参与的项目中有这样一个需求,要求在前端对一些涉及到诸如保存评价、公告相关功能的输入框的输入内容做限制,用户输入的内容中如果包含指定的敏感词字典配置的敏感词,需要使用和敏感词等长的 “*” 进行替换。对于这样一个需求,该如何实现?以下是我具体的解决方案。


一、正则表达式替换

首先想到的解决方案是直接使用字符串自带的 replace 方法配合正则表达式实现。

function replaceKeywords(text, keywords) {
  let result = text;
  for (let keyword of keywords) {
    let regex = new RegExp(keyword, 'g');
    result = result.replace(regex, '*'.repeat(keyword.length));
  }
  return result;
}

let text = '测试输入:中国和广东都是敏感词,可以匹配多个中国';
let keywords = ['中国', '广东'];
console.log(replaceKeywords(text, keywords));
// 测试输入:**和**都是敏感词,可以匹配多个**

使用此方法实现的优点就是代码写起来简单,通俗易懂。

分析一下此方法的时间复杂度为 O(n*m),其中 n 代表输入文本的长度,m 为敏感词的数量。这是因为 replaceKeywords 方法需要遍历文本中的每个字符,然后每个字符都需要遍历敏感词字典中的每个敏感词进行比对。当 m 比较大的时候,相对而言,耗时还是比较长的。

二、AC自动机:多模式串匹配算法

1. 概念

针对每个敏感词,通过单模式串匹配算法与用户输入的文字内容进行匹配,每个匹配过程都需要扫描一遍用户输入的内容。整个过程下来就要扫描很多遍用户输入的内容。如果敏感词很多,比如几千个,并且用户输入的内容很长,假如有上千个字符,那我们就需要扫描几千遍这样的输入内容。这种解决方案比较低效,而多模式匹配算法就比较高效,它只需要扫描一遍主串,就能在主串中一次性查找多个模式串是否存在,从而提高匹配效率。

Trie 树就是一种多模式串匹配算法,在 Trie 树的基础上增加对敏感词进行预处理,构建成 Trie 树结构,这样预处理操作只需要一次,每当敏感词字典动态更新,比如删除、添加敏感词,只需动态更新 Trie 树即可。

Trie 树匹配:把用户输入的内容作为主串,从第一个字符(假设是字符 C)开始,在 Trie 树中匹配。当匹配到 Trie 树的叶子节点,或者中途遇到不匹配字符的时候,我们将主串的开始匹配位置后移一位,也就是从字符 C 的下一个字符开始,重新在 Trie 树中匹配。在 Trie 树的基础上引入失败指针,让每次匹配失败时模式串尽可能多的往后移动几位,这就是 AC 自动机

AC 自动机构建时的工作:

  • 将多个模式串构建成 Trie 树
  • 在 Trie 树的每个节点上都构建失败指针

2. 如何构建失败指针

假设有4个模式串,c、bc、bcd、abcd,主串是 abcd。以模式串构建 Trie 树如图,根节点设置为空值(‘/’)节点。
模式串 Trie 树

2.1 情形一

假设我们沿 Trie 树走到 p 节点,也就是下图中的紫色节点,那 p 的失败指针就是从 root 走到紫色节点形成的字符串 abc,跟所有模式串前缀匹配的最长可匹配后缀子串,就是箭头指的 bc 模式串。

这句话怎么理解?p 节点经过 a、b 节点走到 c 节点,字符串为 abc,它有两个后缀子串 bc、c,其它所有模式串(c、bc、bcd、abcd)的前缀包含 b、bc、a、ab、abc,abc 的后缀子串中只有 bc 可以和其他模式串的前缀匹配,如果出现多个可匹配的情况,就取出其中最长的一个子串作为最长可匹配后缀子串。

假设找到最长可匹配后缀子串 bc,将 p 节点(字符串 abc 的 c节点)的失败指针指向那个最长匹配后缀子串(bc)对应的模式串的前缀(bcd 模式串的前缀 bc)的最后一个节点(bcd 模式串中的 c 节点),这里有点绕口,其实就是下图中箭头指向的节点。
情形一p找q

2.2 情形二

当已经求得某个节点 p 的失败指针之后,如何寻找它的子节点的失败指针。

假设节点 p 的失败指针指向节点 q,我们看节点 p 的子节点 pc 对应的字符,是否也可以在节点 q 的子节点中找到。如果找到了节点 q 的一个子节点 qc,对应的字符跟节点 pc 对应的字符相同,则将节点 pc 的失败指针指向节点 qc。

在情形一中字符串 abc 的 c 节点(p)失败指针指向 bcd 的 c 节点(q),abcd 字符串中的 d 字符(pc),刚好在 q 节点的子节点中也存在 d 字符(qc),那么节点 pc 的失败指针就指向 qc,如下图:
情形二pc找qc

2.3 情形三

如果情形二中,qc 没找到,那就把 q 节点指向 q 的失败指针 q.fail(根据情形一找到的模式串 c 中的 c 节点),然后查看 p 的子节点(下图为 e 字符),在 q.fail 中找到了相同的 e 字符,则把 pc 失败指针指向 q.fail 的有相同字符的子节点。下图只是举例这种情形,并非当前案例。
情形三q.fail子节点

2.4 情形四

q.fail 的子节点中如果也没找到,则继续向上查找,直到找到 root 节点位置,如果找到 root 都还没有相同字符的子节点,则让 pc 失败指针指向 root 节点。

下图为本案例所有节点的失败指针指向。
所有失败指针指向

3. 如何匹配主串

遍历主串的每个字符,从 i = 0 开始, AC 自动机从 p = root 开始。假设主串为 str,模式串为 subStr

  • 如果 p 节点有一个等于当前匹配的模式串 subStr[i] 的子节点 x,就把 p 指向 x,如果 p 没有这样的子节点并且 p 不指向根节点,就把 p 指向 p.fail,继续查找它的子节点存不存在 x,不存在继续指向 p.fail 直到 root 节点为止。
  • 如果上面找到了节点 x,p 指向了 x,则让一个 tmp 指针指向 p 所在节点,利用 tmp 进行匹配,判断 tmp 是不是模式串的结算字符,如果是说明匹配到了模式串,就记录下这个模式串在主串 str 中的起始下标 i - tmp.length + 1 和结束下标 i。如果 tmp 不是结算字符,则借助 tmp.fail 把指针上移,直到 tmp 指向 root 节点。

匹配的算法如下:

match(text) {
    let root = this.root;
    let n = text.length;
    let p = root;
    let matches = [];

    for (let i = 0; i < n; i++) {
      let char = text[i];
      while (!p.children.get(char) && p != root) {
        p = p.fail;
      }

      p = p.children.get(char);
      if (!p) {
        p = root;
      }
      let tmp = p;
      while (tmp != root) {
        if (tmp.isEndingChar == true) {
          matches.push({ start: i - tmp.length + 1, end: i });
        }
        tmp = tmp.fail;
      }
    }
    return matches;
  }

方便理解,以下列举一个匹配的简单案例,模式串还是 c,bc,bcd,abcd,构建的 Trie 还是如上方的图,主串修改为 abce,以下表格对应主串 abce 匹配各模式串过程的变量变化。

主串abce匹配过程
再举两个例子:

  1. 敏感词是abc和bc,主串是abc,那么按照fail指针算法,abc中的c会链接到bc中的c,那么我匹配上了abc自然就相当于匹配上了bc,不用单独在主串中找是否含有bc。
  2. 主串是abcd,敏感词是abc,bcd,如果我匹配上abc,但是发现abc后面没有d,然后发现abc的c链接到bcd中的c,转过去一看,果然后面有d,就不用单独在主串中找是否含有bcd了。

4. 敏感词替换插件封装

plugins/keywordsReplace/install.js

/**
 * @name 关键词替换插件
 * @description 用于过滤某些输入字符串中含有的敏感词,使用多模式串匹配算法 —— AC 自动机
 * 1、对敏感词字典进行预处理,构建 Trie 树
 * 2、在 Trie 树上构建失败指针(相当于 KMP 中的失效函数函数 next 数组)
 */

const patterns = ['中国', '广东'] // 敏感词字典

// AC 树节点
class ACNode {
  constructor(data) {
    this.data = data
    this.children = new Map() // 使用 Map 记录当前节点的子节点
    this.isEndingChar = false // 当 isEndingChar=true 时,表示到达模式串的结尾字符
    this.length = 0 // 到达结尾字符节点,记录当前模式串的长度,用于替换
    this.fail = null // 失败指针,用于在匹配失败时,快速地跳转到下一个可能的匹配位置
  }
}

class ACTree {
  // Trie 树的根节点设置为空节点
  constructor() {
    this.root = new ACNode('/')
  }

  insert(text) {
    let node = this.root
    for (let char of text) {
      if (!node.children.get(char)) {
        node.children.set(char, new ACNode(char))
      }
      node = node.children.get(char)
    }

    node.isEndingChar = true
    node.length = text.length
  }

  // 构建失败指针,存放在每个节点上
  buildFailurePointer() {
    let root = this.root
    let queue = []
    queue.push(root)

    while (queue.length > 0) {
      let p = queue.shift()

      for (let pc of p.children.values()) {
        if (!pc) {
          continue
        }

        if (p == root) {
          pc.fail = root
        } else {
          let q = p.fail
          while (q) {
            let qc = q.children.get(pc.data)
            if (qc) {
              pc.fail = qc
              break
            }
            q = q.fail
          }
          if (!q) {
            pc.fail = root
          }
        }
        queue.push(pc)
      }
    }
  }

  // 自动匹配,返回模式串匹配的起始下标、结束下标的数组
  match(text) {
    let root = this.root
    let n = text.length
    let p = root
    let matches = []

    for (let i = 0; i < n; i++) {
      let char = text[i]
      while (!p.children.get(char) && p != root) {
        p = p.fail
      }

      p = p.children.get(char)
      if (!p) {
        p = root
      }
      let tmp = p
      while (tmp != root) {
        if (tmp.isEndingChar == true) {
          matches.push({ start: i - tmp.length + 1, end: i })
        }
        tmp = tmp.fail
      }
    }
    return matches
  }

  // 模式串替换
  replace(text, matches, replaceChar) {
    let result = text.split('') // 将 text 转换为数组,方便替换
    for (let match of matches) {
      for (let j = match.start; j <= match.end; j++) {
        result[j] = replaceChar // 将 text 中的连续 p.length 个字符替换
      }
    }
    return result.join('') // 将数组转换回字符串
  }
}

// 构建 ACTree
let acTree = new ACTree()
function getACTree(automata) {
  for (let pattern of patterns) {
    automata.insert(pattern)
  }
  automata.buildFailurePointer()
}
getACTree(acTree)

// 关键词替换方法
function keywordsReplace(text, replaceChar = '*') {
  let matches = acTree.match(text)
  return acTree.replace(text, matches, replaceChar)
} 

export default {
  install: Vue => {
    Vue.prototype.$keywordsReplace = keywordsReplace
  }
}

插件使用:在输入框的监听事件中对输入内容进行处理即可,例如在表单的文本域输入失去焦点事件时使用

listeners: {
  blur: e => {
    e.target.value = this.$keywordsReplace(e.target.value)
  },
}

插件使用案例

插件使用案例
AC 自动机的时间复杂度取决于两个主要部分,match 和 replace 方法。

  1. match 方法的时间复杂度:match 方法的时间复杂度主要取决于 text 的长度 n。在最坏的情况下,match 方法需要遍历 text 中的每个字符,对于每个字符,它需要在 AC 树中进行一次匹配。因此,match 方法的时间复杂度是 O(n)。
  2. replace 方法的时间复杂度:replace 方法的时间复杂度主要取决于 matches 的长度 m。在最坏的情况下,matches 中的每个匹配都是 text 的整个长度,因此 matches 的长度 m 最大为 n。对于每个匹配,replace 方法需要将 text 中的一部分字符替换为 replaceChar。因此,replace 方法的时间复杂度是 O(m * n)。

所以,keywordsReplace 方法的总时间复杂度是 O(n + m * n),其中 n 是 text 的长度,m 是 matches 的长度。

注意:插件中敏感词字典仅用于测试,实际项目中的敏感词字典是由前端页面添加保存到服务端数据库,然后从服务端查询获取。


总结

  1. AC 自动机的优势在于在一个主串中匹配多模式串,可应用于敏感词替换功能,且只需构建一次 Trie 树,每当增加、删除敏感词时,只需要动态修改 Trie 树,在处理输入文本较长、敏感词数量较多的情形下,性能较高
  2. 构建 AC 自动机 Trie 树关键在于失败指针的构建,fail 指针的作用:
  • 在已经匹配上的敏感词中找到是否还有子集包含敏感词
  • 看这个子集的后续节点能否进一步匹配。
  1. 正则表达式替换是基于回溯搜索,它的优势在于可以处理简单的替换逻辑,在敏感词多输入串长时性能可能会比较低。

本文有关 AC 自动机的相关内容主要是从《数据结构与算法之美》第36讲 中吸其精华,加入部分自己的见解加工而成,然后将其运用到实际项目的需求中,仅供参考。
创作不易,若有不足之处,感谢指正。

  • 21
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
以下是一个基于AC自动机敏感词检测系统后端部分的C++代码: ```c++ #include <iostream> #include <vector> #include <queue> #include <cstring> using namespace std; const int MAXN = 10005; const int MAXM = 100005; int trie[MAXN][26], fail[MAXN], cnt = 0; // trie树,fail指针,节点计数器 int end[MAXN]; // 记录trie树上的单词节点 bool vis[MAXN]; // 记录访问过的节点 int n; // 敏感词的个数 char word[MAXM]; // 待检测的字符串 vector<int> sensitive; // 记录敏感词的出现位置 void insert(char *s, int id) // 插入单词 { int p = 0; for (int i = 0; s[i]; i++) { int c = s[i] - 'a'; if (!trie[p][c]) trie[p][c] = ++cnt; p = trie[p][c]; } end[p] = id; } void buildAC() // 构建AC自动机 { queue<int> que; for (int i = 0; i < 26; i++) if (trie[0][i]) que.push(trie[0][i]); while (!que.empty()) { int u = que.front(); que.pop(); for (int i = 0; i < 26; i++) { int &v = trie[u][i]; if (v) { fail[v] = trie[fail[u]][i]; que.push(v); } else v = trie[fail[u]][i]; } } } void query() // 查询字符串中的敏感词 { int p = 0; for (int i = 0; word[i]; i++) { int c = word[i] - 'a'; p = trie[p][c]; for (int j = p; j && !vis[j]; j = fail[j]) { vis[j] = true; if (end[j]) sensitive.push_back(i - strlen(word) + 1); // 记录敏感词的出现位置 } } } int main() { cin >> n; for (int i = 1; i <= n; i++) { char s[MAXM]; cin >> s; insert(s, i); } buildAC(); cin >> word; query(); if (sensitive.empty()) cout << "No sensitive words found." << endl; else { for (int i = 0; i < sensitive.size(); i++) { for (int j = sensitive[i]; j < sensitive[i] + strlen(word); j++) cout << word[j]; cout << " is a sensitive word at position " << sensitive[i] << "." << endl; } } return 0; } ``` 在该程序中,我们先使用`insert()`函数构建了一颗Trie树,并在每个单词的最后一个字母节点上标记该单词的编号,以便查询时能够输出敏感词的编号。接着,我们使用`buildAC()`函数构建了AC自动机,并在自动机上使用BFS算法将每个节点的fail指针指向其失败路径上的最长可匹配后缀节点。最后,我们使用`query()`函数在自动机上查询待检测的字符串,记录每个匹配到的单词节点,并输出其在字符串中的位置。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值