CategoryResourceRepost/极客时间专栏/数据结构与算法之美/基础篇/36 | AC自动机:如何用多模式串匹配实现敏感词过滤功能?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

193 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="36 | AC自动机如何用多模式串匹配实现敏感词过滤功能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/34/af2df4ccd908eda7eb8ca0a372982434.mp3"></audio>
很多支持用户发表文本内容的网站比如BBS大都会有敏感词过滤功能用来过滤掉用户输入的一些淫秽、反动、谩骂等内容。你有没有想过这个功能是怎么实现的呢
实际上,这些功能最基本的原理就是字符串匹配算法,也就是通过维护一个敏感词的字典,当用户输入一段文字内容之后,通过字符串匹配算法,来查找用户输入的这段文字,是否包含敏感词。如果有,就用“***”把它替代掉。
我们前面讲过好几种字符串匹配算法了,它们都可以处理这个问题。但是,对于访问量巨大的网站来说,比如淘宝,用户每天的评论数有几亿、甚至几十亿。这时候,我们对敏感词过滤系统的性能要求就要很高。毕竟,我们也不想,用户输入内容之后,要等几秒才能发送出去吧?我们也不想,为了这个功能耗费过多的机器吧?**那如何才能实现一个高性能的敏感词过滤系统呢**?这就要用到今天的**多模式串匹配算法**。
## 基于单模式串和Trie树实现的敏感词过滤
我们前面几节讲了好几种字符串匹配算法有BF算法、RK算法、BM算法、KMP算法还有Trie树。前面四种算法都是单模式串匹配算法只有Trie树是多模式串匹配算法。
我说过,单模式串匹配算法,是在一个模式串和一个主串之间进行匹配,也就是说,在一个主串中查找一个模式串。多模式串匹配算法,就是在多个模式串和一个主串之间做匹配,也就是说,在一个主串中查找多个模式串。
尽管单模式串匹配算法也能完成多模式串的匹配工作。例如开篇的思考题我们可以针对每个敏感词通过单模式串匹配算法比如KMP算法与用户输入的文字内容进行匹配。但是这样做的话每个匹配过程都需要扫描一遍用户输入的内容。整个过程下来就要扫描很多遍用户输入的内容。如果敏感词很多比如几千个并且用户输入的内容很长假如有上千个字符那我们就需要扫描几千遍这样的输入内容。很显然这种处理思路比较低效。
与单模式匹配算法相比多模式匹配算法在这个问题的处理上就很高效了。它只需要扫描一遍主串就能在主串中一次性查找多个模式串是否存在从而大大提高匹配效率。我们知道Trie树就是一种多模式串匹配算法。那如何用Trie树实现敏感词过滤功能呢
我们可以对敏感词字典进行预处理构建成Trie树结构。这个预处理的操作只需要做一次如果敏感词字典动态更新了比如删除、添加了一个敏感词那我们只需要动态更新一下Trie树就可以了。
当用户输入一个文本内容后我们把用户输入的内容作为主串从第一个字符假设是字符C开始在Trie树中匹配。当匹配到Trie树的叶子节点或者中途遇到不匹配字符的时候我们将主串的开始匹配位置后移一位也就是从字符C的下一个字符开始重新在Trie树中匹配。
基于Trie树的这种处理方法有点类似单模式串匹配的BF算法。我们知道单模式串匹配算法中KMP算法对BF算法进行改进引入了next数组让匹配失败时尽可能将模式串往后多滑动几位。借鉴单模式串的优化改进方法能否对多模式串Trie树进行改进进一步提高Trie树的效率呢这就要用到AC自动机算法了。
## 经典的多模式串匹配算法AC自动机
AC自动机算法全称是Aho-Corasick算法。其实Trie树跟AC自动机之间的关系就像单串匹配中朴素的串匹配算法跟KMP算法之间的关系一样只不过前者针对的是多模式串而已。所以**AC自动机实际上就是在Trie树之上加了类似KMP的next数组只不过此处的next数组是构建在树上罢了**。如果代码表示,就是下面这个样子:
```
public class AcNode {
public char data;
public AcNode[] children = new AcNode[26]; // 字符集只包含a~z这26个字符
public boolean isEndingChar = false; // 结尾字符为true
public int length = -1; // 当isEndingChar=true时记录模式串长度
public AcNode fail; // 失败指针
public AcNode(char data) {
this.data = data;
}
}
```
所以AC自动机的构建包含两个操作
<li>
将多个模式串构建成Trie树
</li>
<li>
在Trie树上构建失败指针相当于KMP中的失效函数next数组
</li>
关于如何构建Trie树我们上一节已经讲过了。所以这里我们就重点看下**构建好Trie树之后如何在它之上构建失败指针**
我用一个例子给你讲解。这里有4个模式串分别是cbcbcdabcd主串是abcd。
<img src="https://static001.geekbang.org/resource/image/f8/f1/f80487051d8f44cabf488195de8db1f1.jpg" alt="">
Trie树中的每一个节点都有一个失败指针它的作用和构建过程跟KMP算法中的next数组极其相似。所以**要想看懂这节内容你要先理解KMP算法中next数组的构建过程**。如果你还有点不清楚建议你先回头去弄懂KMP算法。
假设我们沿Trie树走到p节点也就是下图中的紫色节点那p的失败指针就是从root走到紫色节点形成的字符串abc跟所有模式串前缀匹配的最长可匹配后缀子串就是箭头指的bc模式串。
这里的最长可匹配后缀子串我稍微解释一下。字符串abc的后缀子串有两个bcc我们拿它们与其他模式串匹配如果某个后缀子串可以匹配某个模式串的前缀那我们就把这个后缀子串叫作**可匹配后缀子串**。
我们从可匹配后缀子串中找出最长的一个就是刚刚讲到的最长可匹配后缀子串。我们将p节点的失败指针指向那个最长匹配后缀子串对应的模式串的前缀的最后一个节点就是下图中箭头指向的节点。
<img src="https://static001.geekbang.org/resource/image/58/ca/582ec4651948b4cdc1e1b49235e4f8ca.jpg" alt="">
计算每个节点的失败指针这个过程看起来有些复杂。其实,如果我们把树中相同深度的节点放到同一层,那么某个节点的失败指针只有可能出现在它所在层的上一层。
我们可以像KMP算法那样当我们要求某个节点的失败指针的时候我们通过已经求得的、深度更小的那些节点的失败指针来推导。也就是说我们可以逐层依次来求解每个节点的失败指针。所以失败指针的构建过程是一个按层遍历树的过程。
首先root的失败指针为NULL也就是指向自己。**当我们已经求得某个节点p的失败指针之后如何寻找它的子节点的失败指针呢**
我们假设节点p的失败指针指向节点q我们看节点p的子节点pc对应的字符是否也可以在节点q的子节点中找到。如果找到了节点q的一个子节点qc对应的字符跟节点pc对应的字符相同则将节点pc的失败指针指向节点qc。
<img src="https://static001.geekbang.org/resource/image/da/1f/da685b7ac5f7dc41b2db6cf5d9a35a1f.jpg" alt="">
如果节点q中没有子节点的字符等于节点pc包含的字符则令q=q-&gt;failfail表示失败指针这里有没有很像KMP算法里求next的过程继续上面的查找直到q是root为止如果还没有找到相同字符的子节点就让节点pc的失败指针指向root。
<img src="https://static001.geekbang.org/resource/image/91/61/91123d8c38a050d32ca730a93c7aa061.jpg" alt="">
我将构建失败指针的代码贴在这里你可以对照着讲解一块看下应该更容易理解。这里面构建Trie树的代码我并没有贴出来你可以参看上一节的代码自己实现。
```
public void buildFailurePointer() {
Queue&lt;AcNode&gt; queue = new LinkedList&lt;&gt;();
root.fail = null;
queue.add(root);
while (!queue.isEmpty()) {
AcNode p = queue.remove();
for (int i = 0; i &lt; 26; ++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);
}
}
}
```
通过按层来计算每个节点的子节点的失效指针刚刚举的那个例子最后构建完成之后的AC自动机就是下面这个样子
<img src="https://static001.geekbang.org/resource/image/51/3c/5150d176502dda4adfc63e9b2915b23c.jpg" alt="">
AC自动机到此就构建完成了。我们现在来看下**如何在AC自动机上匹配主串**
我们还是拿之前的例子来讲解。在匹配过程中主串从i=0开始AC自动机从指针p=root开始假设模式串是b主串是a。
<li>
如果p指向的节点有一个等于b[i]的子节点x我们就更新p指向x这个时候我们需要通过失败指针检测一系列失败指针为结尾的路径是否是模式串。这一句不好理解你可以结合代码看。处理完之后我们将i加一继续这两个过程
</li>
<li>
如果p指向的节点没有等于b[i]的子节点那失败指针就派上用场了我们让p=p-&gt;fail然后继续这2个过程。
</li>
关于匹配的这部分,文字描述不如代码看得清楚,所以我把代码贴了出来,非常简短,并且添加了详细的注释,你可以对照着看下。这段代码输出的就是,在主串中每个可以匹配的模式串出现的位置。
```
public void match(char[] text) { // text是主串
int n = text.length;
AcNode p = root;
for (int i = 0; i &lt; n; ++i) {
int idx = text[i] - 'a';
while (p.children[idx] == null &amp;&amp; p != root) {
p = p.fail; // 失败指针发挥作用的地方
}
p = p.children[idx];
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(&quot;匹配起始下标&quot; + pos + &quot;; 长度&quot; + tmp.length);
}
tmp = tmp.fail;
}
}
}
```
## 解答开篇
AC自动机的内容讲完了关于开篇的问题你应该能解答了吧实际上我上面贴出来的代码已经是一个敏感词过滤的原型代码了。它可以找到所有敏感词出现的位置在用户输入的文本中的起始下标。你只需要稍加改造再遍历一遍文本内容主串就可以将文本中的所有敏感词替换成“***”。
所以我这里着重讲一下,**AC自动机实现的敏感词过滤系统是否比单模式串匹配方法更高效呢**
首先我们需要将敏感词构建成AC自动机包括构建Trie树以及构建失败指针。
我们上一节讲过Trie树构建的时间复杂度是O(m*len)其中len表示敏感词的平均长度m表示敏感词的个数。那构建失败指针的时间复杂度是多少呢我这里给出一个不是很紧确的上界。
假设Trie树中总的节点个数是k每个节点构建失败指针的时候你可以看下代码最耗时的环节是while循环中的q=q-&gt;fail每运行一次这个语句q指向节点的深度都会减少1而树的高度最高也不会超过len所以每个节点构建失败指针的时间复杂度是O(len)。整个失败指针的构建过程就是O(k*len)。
不过AC自动机的构建过程都是预先处理好的构建好之后并不会频繁地更新所以不会影响到敏感词过滤的运行效率。
我们再来看下,**用AC自动机做匹配的时间复杂度是多少**
跟刚刚构建失败指针的分析类似for循环依次遍历主串中的每个字符for循环内部最耗时的部分也是while循环而这一部分的时间复杂度也是O(len)所以总的匹配的时间复杂度就是O(n*len)。因为敏感词并不会很长而且这个时间复杂度只是一个非常宽泛的上限实际情况下可能近似于O(n)所以AC自动机做敏感词过滤性能非常高。
你可以会说从时间复杂度上看AC自动机匹配的效率跟Trie树一样啊。实际上因为失效指针可能大部分情况下都指向root节点所以绝大部分情况下在AC自动机上做匹配的效率要远高于刚刚计算出的比较宽泛的时间复杂度。只有在极端情况下如图所示AC自动机的性能才会退化的跟Trie树一样。
<img src="https://static001.geekbang.org/resource/image/8c/37/8cd064ab3103f9f38b02f298fc01c237.jpg" alt="">
## 内容小结
今天我们讲了多模式串匹配算法AC自动机。单模式串匹配算法是为了快速在主串中查找一个模式串而多模式串匹配算法是为了快速在主串中查找多个模式串。
AC自动机是基于Trie树的一种改进算法它跟Trie树的关系就像单模式串中KMP算法与BF算法的关系一样。KMP算法中有一个非常关键的next数组类比到AC自动机中就是失败指针。而且AC自动机失败指针的构建过程跟KMP算法中计算next数组极其相似。所以要理解AC自动机最好先掌握KMP算法因为AC自动机其实就是KMP算法在多模式串上的改造。
整个AC自动机算法包含两个部分第一部分是将多个模式串构建成AC自动机第二部分是在AC自动机中匹配主串。第一部分又分为两个小的步骤一个是将模式串构建成Trie树另一个是在Trie树上构建失败指针。
## 课后思考
到此为止,字符串匹配算法我们全都讲完了,你能试着分析总结一下,各个字符串匹配算法的特点和比较适合的应用场景吗?
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。