很多支持用户发表文本内容的网站或者软件,大都会有敏感词过滤功能,用来过滤掉用户输入的一些淫秽、反动、谩骂等内容,这个功能是怎么实现的呢?
其实,这些功能的最基本的原理就是字符串匹配算法,也就是通过维护一个敏感词的词典,当用户输入的一段文字后,通过字符串匹配算法,来查找用户输入的这段文字,是否包含敏感词,如果有,就用 * 把它替代掉。
我在之前的文章中,讲过很多种字符串匹配算法,它们都可以处理这个问题,但是,对于访问量巨大的网站来说,比如淘宝,用户每天的评论数有几亿甚至几十亿。这个时候,我们对敏感词过滤系统的性能要求就要很高。如果,一个用户输入内容之后要几秒后才能发出去,那这个软件可能就没人用了。
要实现一个高性能的敏感词过滤系统,就要用到我们这篇文章要讲的多模式串匹配算法。
在以前的文章里,我讲了四个单模式串匹配算法,还有一个多模式串匹配算法就是 Trie 树。有兴趣的同学可以跳转过去看。
【数据结构与算法】->算法->字符串匹配基础(上)->BF 算法 & RK 算法
【数据结构与算法】->算法->字符串匹配基础(中)->BM算法->KMP 三倍性能的强大算法
【数据结构与算法】->算法->字符串匹配基础(下)->KMP 算法
【数据结构与算法】->数据结构->Trie树->如何实现搜索引擎的关键词提示功能?
再总结一下这两个概念,单模式串匹配算法,就是一个模式串和一个主串之间进行匹配,也就是在一个主串中查找一个模式串。多模式串匹配算法,就是在多个模式串中和一个主串之间做匹配,也就是说,在一个主串中查找多个模式串。
尽管,单模式串匹配算法也能完成多模式串匹配的工作,比如过滤敏感词,我们可以针对每个敏感词,通过但模式匹配算法(比如 KMP 算法)与用户输入的文字内容进行匹配。但是,这样做的话,每个匹配过程都需要扫描一遍用户输入的内容。整个过程下来就要扫描很多遍用户输入的内容。如果敏感词很多,假如有上千个字符,那我们就要扫描几千遍这样的输入内容。很显然,这样的处理思路比较低效。
与单模式串匹配算法相比,多模式匹配算法在这个问题的处理上就很高效了。它只需要扫描一遍主串,就能在主串中一次性查找多个模式串是否存在,从而大大提高匹配效率。我们知道,Trie 树就是一种多模式串匹配算法,所以我们可以用 Trie 树来实现敏感词过滤功能。
我们可以对敏感词字典进行预处理,构建成 Trie 树结构。这个预处理的操作只需要做一次,如果敏感词字典动态更新了,比如删除、添加了一个敏感词,那我们只需要动态更新一下 Trie 树就可以了。
当用户输入一个文本内容后,我们把用户输入的内容作为主串,从第一个字符开始,在 Trie 树中匹配。当匹配到 Trie 树的叶子节点,或者中途遇到不匹配字符的时候,我们将主串的开始匹配位置后移一位,也就是从第一个字符的下一个字符开始,重新在 Trie 树 中匹配。
基于 Trie 树的这种处理方法,有点类似单模式串匹配中的 BF 算法。我们知道,单模式串匹配算法中,KMP 算法对 BF 算法进行改进,引入了一个 next 数组,让匹配失效时,尽可能将模式串往后多移动几位。借鉴单模式串的优化改进方法,能否对多模式串 Trie 树进行改进,进一步提高 Trie 树的效率呢?这就要用到 AC 自动机算法 了。
AC 自动机算法,全称是 Aho-Corasick 算法。其实, Trie 树跟 AC 自动机之间的关系,就像单模式串匹配中朴素的串匹配算法(BF)和 KMP 算法之间的关系一样,只不过前者针对的是多模式串而已。所以,AC 自动机实际上就是在 Trie 树之上,加了类似 KMP 算法的 next 数组,只不过此处的 next 数组构建在树上。 用代码实现就是下面的样子
class AcNode {
char data;
AcNode[] children = new AcNode[SIZE];
boolean isEndingChar = false;
int length = -1; //当isEndingChar = true时。记录模式串长度
AcNode fail; //失败指针
AcNode(char data) {
this.data = data;
}
}
所以 AC 自动机的构建,包含两个操作:
关于如何构建 Trie 树,在我讲解 Trie 树的文章中已经分析过了,大家可以跳转去看。
【数据结构与算法】->数据结构->Trie树->如何实现搜索引擎的关键词提示功能?
这里我们就重点看一下在构建好 Trie 树之后,如何在它之上构建失败指针。
我还是用一个例子来讲解。这里有 4 个模式串,分别是 c,bc,bcd,abcd;主串是 abcd。
Trie 树中的每一个节点都有一个失败指针,它的作用和构建过程,跟 KMP 算法中的 next 数组极其相似。所以要理解这里的构建,你最好先理解 KMP 算法中的 next 数组的构建过程。
【数据结构与算法】->算法->字符串匹配基础(下)->KMP 算法
假设我们沿 Trie 树走到 p 节点,也就是下图中的紫色节点,那 p 的失败指针就是从 root 走到紫色节点形成的字符串 abc,跟所有模式串前缀匹配的最长可匹配后缀子串,就是箭头指的 bc 模式串。
这里的最长可匹配后缀子串我再多解释一下。字符串 abc 的后缀子串有两个,一个是 bc,一个属 c,我们把它们与其他模式串匹配,如果某个后缀子串可以匹配某个模式串的前缀,那我们 就把这个后缀子串叫作可匹配后缀子串。
我们从可匹配后缀子串中,找出最长的一个,就是最长可匹配后缀子串。我们可以将 p 节点的失败指针指向那个最长匹配后缀子串对应的模式串的前缀的最后一个节点,就是下图中箭头指向的节点。
计算每个节点的失败指针这个过程看起来有些复杂。其实,如果我们把树中相同深度的节点放到同一层,呢么某个节点的失败指针只有可能出现在它所在层的上一层。
我们可以像 KMP 算法那样,当我们要求某个节点的失败指针的时候,我们通过已经求得的、深度更小的那些节点的失败指针来推导。也就是说,我们可以逐层依次来求解每个节点的失败指针。所以,失败指针的构建过程,是一个按层遍历树的过程。
首先 root 的失败指针为 null
。当我们已经求得某个节点 p 的失败指针之后,如何寻找它的子节点的失败指针呢?
我们假设节点 p 的失败指针指向节点 q,我们看节点 p 的子节点 pc 对应的字符,是否也可以在节点 q 的子节点中找到。如果找到了节点 q 的一个子结点 qc,对应的字符跟 pc 对应的字符相同,则将节点 pc 的失败指针指向节点 qc。
如果节点 q 中没有子节点的字符等于节点 pc 包含的字符,则令 q = q->fail
(fail 表示失败指针),然后继续上面的查找,直到 q 是 root 为止,如果还没有找到相同字符的子节点,就让节点 pc 的失败指针指向 root。
package com.tyz.string_matching.core;
import java.util.LinkedList;
import java.util.Queue;
/**
* AC自动机的实现
* @author Tong
*/
public class AhoCorasick {
public static final int SIZE = 26;
private AcNode root;
public AhoCorasick() {
this.root = new AcNode('/');
}
/**
* 实现Trie树的插入功能
* @param text 要插入的字符串
*/
public void insert(char[] text) {
AcNode p = this.root;
for (int i = 0; i < text.length; i++) {
int index = text[i] - 'a';
if (p.children[index] == null) {
AcNode newNode = new AcNode(text[i]);
p.children[index] = newNode;
}
p = p.children[index];
}
p.isEndingChar = true;
p.length = text.length;
}
/**
* 查询字符串在TrieTree中是否存在
* @param pattern 要查询的字符串
* @return
*/
public boolean find(char[] pattern) {
AcNode p = this.root;
for (int i = 0; i < pattern.length; i++) {
int index = pattern[i] - 'a';
if (p.children[index] == null) {
return false; //Trie树中不存在pattern
}
p = p.children[index];
}
return p.isEndingChar;
}
/**
* 构建AC自动机的失败指针
*/
public void buildFailurePointer() {
Queue<AcNode> queue = new LinkedList<>();
this.root.fail = null;
queue.add(root);
while (!queue.isEmpty()) {
AcNode p = queue.remove();
for (int i = 0; i < SIZE; i++) {
AcNode pc = p.children[i];
if (pc == null) {
continue;
}
if (p == root) {
pc.fail = root;
} else {
AcNode q = p.fail;
while (q != null) {
AcNode qc = q.children[pc.data - 'a'];
if (qc != null) {
pc.fail = qc;
break;
}
q = q.fail;
}
if (q == null) {
pc.fail = root;
}
}
queue.add(pc);
}
}
}
class AcNode {
char data;
AcNode[] children = new AcNode[SIZE];
boolean isEndingChar = false;
int length = -1; //当isEndingChar = true时。记录模式串长度
AcNode fail; //失败指针
AcNode(char data) {
this.data = data;
}
}
}
我们前面说了,构建失败指针的方法其实是要进行一个树的广度优先遍历,如果你了解这个,就很好理解这里的构建失败指针的代码了。如果你对树的广度优先遍历还有疑惑,可以跳转去看我这篇文章。
【数据结构与算法】->数据结构->树与二叉树
通过按层来计算每个节点的子节点的失效指针,前面举的例子最后构建完成之后的 AC 自动机就是下面这个样子
AC 自动机到此就构建完成了。我们现在来看一下,如何在 AC 自动机上匹配主串。
我们还是拿之前的例子来讲解,在匹配过程中,主串从 i = 0
开始,AC 自动机从指针 p = root 开始,假设模式串是 b,主串是 a。
p = p->fail
,然后继续这两个过程。关于匹配的这个部分,我还是将代码贴出来,有时候代码是比文字更能说清楚的。
/**
* 在主串中找模式串出现的位置以及长度
* @param text 主串
*/
public void match(char[] text) {
int num = text.length;
AcNode p = root;
for (int i = 0; i < num; i++) {
int index = text[i] - 'a';
while (p.children[index] == null && p != root) {
p = p.fail; //失败指针的作用
}
p = p.children[index];
if (p == null) {
p = root; //如果没有匹配的,就从root开始重新匹配
}
AcNode tmp = p;
while (tmp != root) {
//打印出可以匹配的模式串
if (tmp.isEndingChar == true) {
int pos = i - tmp.length + 1;
System.out.println("匹配起始下标: " + pos + ", 长度: " + tmp.length);
}
tmp = tmp.fail;
}
}
}
这个 match()
方法会将找到主串中出现的所有和模式串匹配的子串,并输出起始下标以及长度。还是建议大家如果不清楚的话,做几遍变量跟踪。
AC 自动机完整代码如下
package com.tyz.string_matching.core;
import java.util.LinkedList;
import java.util.Queue;
/**
* AC自动机的实现
* @author Tong
*/
public class AhoCorasick {
public static final int SIZE = 26;
private AcNode root;
public AhoCorasick() {
this.root = new AcNode('/');
}
/**
* 实现Trie树的插入功能
* @param text 要插入的字符串
*/
public void insert(char[] text) {
AcNode p = this.root;
for (int i = 0; i < text.length; i++) {
int index = text[i] - 'a';
if (p.children[index] == null) {
AcNode newNode = new AcNode(text[i]);
p.children[index] = newNode;
}
p = p.children[index];
}
p.isEndingChar = true;
p.length = text.length;
}
/**
* 查询字符串在TrieTree中是否存在
* @param pattern 要查询的字符串
* @return
*/
public boolean find(char[] pattern) {
AcNode p = this.root;
for (int i = 0; i < pattern.length; i++) {
int index = pattern[i] - 'a';
if (p.children[index] == null) {
return false; //Trie树中不存在pattern
}
p = p.children[index];
}
return p.isEndingChar;
}
/**
* 构建AC自动机的失败指针
*/
public void buildFailurePointer() {
Queue<AcNode> queue = new LinkedList<>();
this.root.fail = null;
queue.add(root);
while (!queue.isEmpty()) {
AcNode p = queue.remove();
for (int i = 0; i < SIZE; i++) {
AcNode pc = p.children[i];
if (pc == null) {
continue;
}
if (p == root) {
pc.fail = root;
} else {
AcNode q = p.fail;
while (q != null) {
AcNode qc = q.children[pc.data - 'a'];
if (qc != null) {
pc.fail = qc;
break;
}
q = q.fail;
}
if (q == null) {
pc.fail = root;
}
}
queue.add(pc);
}
}
}
/**
* 在主串中找模式串出现的位置以及长度
* @param text 主串
*/
public void match(char[] text) {
int num = text.length;
AcNode p = root;
for (int i = 0; i < num; i++) {
int index = text[i] - 'a';
while (p.children[index] == null && p != root) {
p = p.fail; //失败指针的作用
}
p = p.children[index];
if (p == null) {
p = root; //如果没有匹配的,就从root开始重新匹配
}
AcNode tmp = p;
while (tmp != root) {
//打印出可以匹配的模式串
if (tmp.isEndingChar == true) {
int pos = i - tmp.length + 1;
System.out.println("匹配起始下标: " + pos + ", 长度: " + tmp.length);
}
tmp = tmp.fail;
}
}
}
class AcNode {
char data;
AcNode[] children = new AcNode[SIZE];
boolean isEndingChar = false;
int length = -1; //当isEndingChar = true时。记录模式串长度
AcNode fail; //失败指针
AcNode(char data) {
this.data = data;
}
}
}
测试代码如下,大家可以看看这个功能
package com.tyz.string_matching.test;
import com.tyz.string_matching.core.AhoCorasick;
public class Test {
public static void main(String[] args) {
String strOne = "abc";
String strTwo = "tyz";
String strThree = "yzh";
AhoCorasick ac = new AhoCorasick();
ac.insert(strOne.toCharArray());
ac.insert(strTwo.toCharArray());
ac.insert(strThree.toCharArray());
ac.buildFailurePointer();
ac.match("dsabcastyzyzhshss".toCharArray());
}
}
输出的结果如下
通过上面的讲解,相信你已经清楚了这个敏感词过滤系统实现的思路,我们上面的实现起始已经是一个敏感词过滤的原型代码了。它可以找到所有敏感词出现的位置,你只要稍加改造,遍历一遍文本内容,就可以将文本中的所有敏感词替换成 *。
这里我们着重看一下,AC 自动机实现的敏感词过滤系统,是否比单模式串匹配方法更高效。
首先,我们需要将敏感词构建成 AC 自动机,包括构建 Trie 树以及构建失败指针。
我在 Trie 树的文章中讲过,Trie 树构建的时间复杂度是 O(m*len),其中 len 表示敏感词的平均长度,m 表示敏感词的个数。那构建失败指针的时间复杂度是多少呢?我这里给出一个不是很准确的上界。
假设 Trie 树中总的节点数是 k,每个节点构建失败指针的时候,最耗时的环节是 while 循环中的 q = q.fail
,每运行一次这个语句,q 指向节点的深度都会减少 1,而树的高度最高也不会超过 len,所以每个节点构建失败指针的时间复杂度是 O(len)。整个失败指针的构建过程就是 O(k*len)。
不过,AC 自动机的构建过程都是预先处理好的,构建好之后,并不会频繁地更新,所以不会影响到敏感词过滤的执行效率。
我们再来看一下,用 AC 自动机做匹配的时间复杂度是多少。
和刚刚构建失败指针的分析类似,for 循环内部最耗时的部分也是 while 循环,而这一部分的时间复杂度就是 O(n*len),n 表示敏感词的长度。因为敏感词并不会很长,而且这个时间复杂度只是一个非常宽泛的上限,实际情况下,可能近似于 O(n),所以 AC 自动机做敏感词过滤,性能非常高。
看到这里你可能觉得,从时间复杂度上看,AC 自动机匹配的效率和 Trie 树一样啊,但是实际上,因为失效指针可能大部分情况下都指向 root 节点,所以绝大部分情况下,在 AC 自动机上做匹配的效率要远高于刚刚计算出的比较宽泛的时间复杂度。只有在极端情况下,如下图,AC 自动机的性能才会退化的跟 Trie 树一样。