文章目录
前言
最近参与的项目中有这样一个需求,要求在前端对一些涉及到诸如保存评价、公告相关功能的输入框的输入内容做限制,用户输入的内容中如果包含指定的敏感词字典配置的敏感词,需要使用和敏感词等长的 “*” 进行替换。对于这样一个需求,该如何实现?以下是我具体的解决方案。
一、正则表达式替换
首先想到的解决方案是直接使用字符串自带的 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 树如图,根节点设置为空值(‘/’)节点。
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 节点),这里有点绕口,其实就是下图中箭头指向的节点。
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,如下图:
2.3 情形三
如果情形二中,qc 没找到,那就把 q 节点指向 q 的失败指针 q.fail(根据情形一找到的模式串 c 中的 c 节点),然后查看 p 的子节点(下图为 e 字符),在 q.fail 中找到了相同的 e 字符,则把 pc 失败指针指向 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 匹配各模式串过程的变量变化。
再举两个例子:
- 敏感词是abc和bc,主串是abc,那么按照fail指针算法,abc中的c会链接到bc中的c,那么我匹配上了abc自然就相当于匹配上了bc,不用单独在主串中找是否含有bc。
- 主串是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 方法。
- match 方法的时间复杂度:match 方法的时间复杂度主要取决于 text 的长度 n。在最坏的情况下,match 方法需要遍历 text 中的每个字符,对于每个字符,它需要在 AC 树中进行一次匹配。因此,match 方法的时间复杂度是 O(n)。
- 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 的长度。
注意:插件中敏感词字典仅用于测试,实际项目中的敏感词字典是由前端页面添加保存到服务端数据库,然后从服务端查询获取。
总结
- AC 自动机的优势在于在一个主串中匹配多模式串,可应用于敏感词替换功能,且只需构建一次 Trie 树,每当增加、删除敏感词时,只需要动态修改 Trie 树,在处理输入文本较长、敏感词数量较多的情形下,性能较高。
- 构建 AC 自动机 Trie 树关键在于失败指针的构建,fail 指针的作用:
- 在已经匹配上的敏感词中找到是否还有子集包含敏感词
- 看这个子集的后续节点能否进一步匹配。
- 正则表达式替换是基于回溯搜索,它的优势在于可以处理简单的替换逻辑,在敏感词多输入串长时性能可能会比较低。
本文有关 AC 自动机的相关内容主要是从《数据结构与算法之美》第36讲 中吸其精华,加入部分自己的见解加工而成,然后将其运用到实际项目的需求中,仅供参考。
创作不易,若有不足之处,感谢指正。