AC自动机探究(一)
1.初识AC自动机
1.1什么是AC自动机
可以简单将AC自动机理解为字典树的加强版本,使用AC自动机可以存储敏感词数据,去使用AC自动机筛选出大文本中是否有敏感词数据。
所以使用AC自动机可以简单,快速的根据AC自动机寻找到文本中的敏感词数据,统计出敏感词集合并返回。
1.2AC自动机的前身:字典树
1.2.1什么是字典树
AC自动机是由字典树发展而来的,是字典树的加强版本,AC自动机就是由字典树发展而来的。
字典树是可以使用树的方式,用最少的存储空间和较为低廉的时间成本存储/获取/删除字符串的集合结构。
1.2.2为什么要使用字典树
使用集合结构存储字符串:
假设我们使用的是数组存储,存储a,ab,abc,abcd,abcde的空间复杂度是O(N),而且去查询字符串的时候,时间复杂度最好的情况下也是惊人的O(N^2),所以使用数组这种结构存储字符串数据,是比较消耗性能的。
空间复杂度:O(N)
时间复杂度(查询):O(N^2)
使用字典树存储字符串:
使用这种字典树存储结构虽然空间复杂度仍然是O(N),但是查询速度降低到了O(N)。
字典树可以将增删改查字符串的速度降低到O(N),是一种非常适合存储大量字符串,并且需要大量增删改查操作的情况。
1.2.3字典树的封装
/**
* 封装的普通前缀树
*/
public class PrefixTree {
// 记录根节点
private Node root;
public PrefixTree() {
root = new Node();
}
/**
* 封装的前缀树Node节点
*/
private static class Node {
public Node[] nexts = new Node[26];
public int pass;
public int end;
}
/**
* 插入单词
*
* @param word
*/
public void insert(String word) {
if (word == null) return;
char[] chars = word.toCharArray();
Node curNode = root;
curNode.pass++;
int path;
for (char c : chars) {
path = c - 'a';
if (curNode.nexts[path] == null) {
curNode.nexts[path] = new Node();
}
curNode = curNode.nexts[path];
curNode.pass++;
}
curNode.end++;
}
/**
* 搜索单词
*
* @param word
* @return
*/
public int search(String word) {
if (word == null) return -1;
char[] chars = word.toCharArray();
Node curNode = root;
int path;
for (char c : chars) {
path = c - 'a';
if (curNode.nexts[path] == null) {
return -1;
}
curNode = curNode.nexts[path];
}
return curNode.end;
}
/**
* 搜索前缀的数量
*
* @param prefixWord
* @return
*/
public int searchPrefixCount(String prefixWord) {
if (prefixWord == null) return -1;
char[] chars = prefixWord.toCharArray();
Node curNode = root;
int path;
for (char c : chars) {
path = c - 'a';
if (curNode.nexts[path] == null) {
return -1;
}
curNode = curNode.nexts[path];
}
return curNode.pass;
}
/**
* 删除字典树中的数据
*
* @param word
*/
public boolean delete(String word) {
if (word == null) {
throw new NullPointerException("word is null");
}
char[] chars = word.toCharArray();
Node curNode = root;
int path;
// 先查询是否存在
int count = search(word);
if (count > 0) {
return false;
}
// 能走到这里说明字典树中一定存在数据
for (char c : chars) {
path = c - 'a';
if (--curNode.nexts[path].end == 0) {
curNode.nexts[path] = null;
break;
}
curNode = curNode.nexts[path];
}
return true;
}
}
1.2.4字典树结构分析
主要是看字典树中的节点分析,字典树中没什么,主要是保存了根节点,主要去分析树上的每个节点。
// 记录根节点
private Node root;
public PrefixTree() {
root = new Node();
}
/**
* 封装的前缀树Node节点
*/
private static class Node {
public Node[] nexts = new Node[26];
public int pass;
public int end;
}
字典树上的节点中主要有三个字段:nexts字段,pass字段,end字段。
nexts字段中存储的是节点下面的通路,表示字典树下面的子节点数组。
pass字段主要用来表示节点通过的次数,主要可以通过这个字段查找前缀数据。
end字段主要用来表示节点作为结尾的次数,主要可以通过这个字段查找字典树中存储了多少数据。
1.3AC自动机对字典树的加强
AC自动机加强了字典树,主要是体现在fail指针上。
AC自动机的节点上增加了fail指针,节点定义如下:
private static class Node {
public Node[] nexts;
public String endStr;
public boolean endUse;
public Node fail;
}
其他的先不用管,先分析这个fail指针到底是做什么的:
1.头节点的fail指针指向null。
2.头节点的子节点的fail指针都指向头节点。
3.其他节点的fail节点的指向法则是这样的:先找头节点的fail指针,然后去找头节点fail指针有没有和自己对应路线一样的节点,如果有则指向,没有则继续找fail指针,知道找到fail指针指向null节点为止。
fail指针的设计可以将查询文章中对应敏感词的复杂度大大降低,因为如果仅仅使用字典树去查询文章中的敏感词,是个非常缓慢的过程,接下来分析一下使用字典树查询文章中敏感词。
1.4字典树实现敏感词查询
使用字典树实现敏感词检索是非常复杂的,虽然我们可以借助字典树快速检索字符串是否存在,但是文章分割,太难了。
假设我们的文章一共有四个字abcd,敏感词为:abcd,abc,bc,d。现在尝试检索出所有敏感词:
/**
* 搜索敏感词
*
* @param article
* @return
*/
public Set<String> searchTouchy(String article) {
if (article == null) throw new NullPointerException();
if (article.isEmpty()) return Collections.emptySet();
Set<String> res = new HashSet<>();
int end = 0;
while (end < article.length()) {
for (int p1 = 0; p1 <= end; p1++) {
for (int p2 = 0; p2 <= p1; p2++) {
try {
String word = article.substring(p2, p1 + 1);
int count = search(word);
if (count > 0 && !res.contains(word)) {
res.add(word);
}
} catch (Exception e) {
throw e;
}
}
}
end++;
}
return res;
}
这个算法是一个时间复杂度为O(N^3)的算法,不推荐使用,效率太低了。
但是如果我们使用的是AC自动机,就可以将时间复杂度降低到O(N^2),接下来我们从AC自动机的功能入手,解密AC自动机的前生今世。
2.实战篇
2.1AC自动机的插入和构建
2.1.1初探AC自动机
在了解AC自动机实现查找敏感词之前,需要先构造整个AC自动机,AC自动机说白了就是一个加强版本的字典树,在节点上加强出了fail指针。
构建AC自动机采用的手段是:先按字典树的逻辑插入所有敏感词,最终再将所有节点的fail节点构建起来。
2.1.2AC自动机的插入
AC自动机的插入其实和字典树的插入没有什么区别,不再多说。
/**
* 插入数据
*
* @param word
*/
public void insert(String word) {
if (word == null) return;
Node curNode = root;
int path;
char[] chars = word.toCharArray();
for (char c : chars) {
path = c - 'a';
if (curNode.nexts[path] == null) {
curNode.nexts[path] = new Node();
}
curNode = curNode.nexts[path];
}
curNode.endStr = word;
}
2.1.3AC自动机fail指针构建
code的实现如下,基本思路就是就是,宽度优先遍历,每次遍历到一个节点的时候,在加入到队列的过程中,将子节点的fail指针构建指向。fail指针的构建:1.头节点的所有子节点的fail指针指向头节点。2.其他子节点去找父节点的fail节点,如果父节点的fail节点next节点中有子节点的对应节点,就指向对应节点,如果没有对应节点就继续找fail节点的fail节点,直到fail节点为null。
/**
* 构建build
*/
public void build() {
if (root == null) return;
// 宽度有限遍历
Queue<Node> queue = new LinkedList<>();
Node curNode = root;
Node fail = null;
Node next = null;
queue.add(curNode);
while (!queue.isEmpty()) {
curNode = queue.poll();
for (int index = 0; index < curNode.nexts.length; index++) {
next = curNode.nexts[index];
if (next != null) {
// 默认设置为root
next.fail = root;
// 处理非默认情况
fail = curNode.fail;
while (fail != null) {
if (fail.nexts[index] != null) {
next.fail = fail.nexts[index];
}
fail = fail.fail;
}
queue.add(curNode.nexts[index]);
}
}
}
}
fail指针构建的流程图:
后续会去分析这样做的合理性,先看懂流程,再分析出来原理。
2.2AC自动机实现查找敏感词
2.2.1实现步骤
使用AC自动机可以很轻松查找敏感词,具体步骤如下:
从头节点开始。
1.判断头节点的子节点有没有符合的路径,如果没有则跳过。
2.如果有则向下走,走成功一次之后,就借助fail指针转一个圈子,将所有数据比较一下。
3.走到不能走的时候,就向fail指针走,如果有就继续向下走,没有就继续沿着fail指针继续走,直到走到根节点为止。
4.如果在中途回到了根节点,查看根节点下面是否有路,有路就走,无路跳过。
用文字表述可能有点难以理解,接下来用流程图的方式表述:
简化一下实现的步骤:
其实实现步骤可以简单理解的,首先我们必须要认清的本质是:fail指针指到最后,都能指回到根节点。
只要可以把握住这个本质,很多问题就迎刃而解了:从根节点出发,只要能走下去,每走一步都要顺着fail指针转一圈收集答案,收集到根节点为止,即答案收集完毕。继续沿着原有节点向下走,如果走不动了,就跳转到自己的fail节点,直到跳到能走/根节点(其实这就是转子收缩,记住这个概念,原理分析要用到),跳到根节点就跳到下一个字符重复流程。
2.2.2实现代码
/**
* 搜索AC自动机中存储的敏感词
*
* @param article
* @return
*/
public List<String> searchTouchy(String article) {
if (article == null) throw new NullPointerException();
if (article.isEmpty()) return Collections.emptyList();
List<String> res = new ArrayList<>();
char[] words = article.toCharArray();
Node curNode = root;
int path;
for (char word : words) {
path = word - 'a';
// 不符合条件就绕着fail转起来, 走转子判断流程
if (curNode.nexts[path] == null && curNode != root) {
curNode = curNode.fail;
}
// 转到不能转为止
curNode = curNode.nexts[path] == null ? root : curNode.nexts[path];
// 如果不是头节点, 也就是能走/转成功了, 就走转子收集流程
Node follow = curNode;
while (follow != null) {
// 转子已走
if (follow.endUse) {
break;
}
if (follow.endStr != null) {
res.add(follow.endStr);
follow.endUse = true;
}
// 继续转
follow = follow.fail;
}
}
return res;
}
2.2.3测试对比
2.2.3.1封装测试工具
测试工具我们使用SpringAOP和Spring的StopWatch实现。
封装Aspect切面:
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
@Aspect
@Component
public class ExecutionTimeTestAspect {
@Around("@annotation(com.xinghai.annotation.TimeTest)")
public Object observerExecTime(ProceedingJoinPoint joinPoint) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
Object res = joinPoint.proceed();
stopWatch.stop();
System.out.println("执行的时间: " + stopWatch.getLastTaskTimeNanos() + "ns");
return res;
}
}
封装注解:
import java.lang.annotation.*;
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TimeTest {
}
在测试方法上添加TimeTest注解:
/**
* 搜索敏感词
*
* @param article
* @return
*/
@TimeTest
public Set<String> searchTouchy(String article) {
}
/**
* 搜索AC自动机中存储的敏感词
*
* @param article
* @return
*/
@TimeTest
public List<String> searchTouchy(String article) {
}
2.2.3.2测试方法
使用以下测试方案:
@Test
public void test() {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(SpringConfig.class);
ACAutomaton touchy = applicationContext.getBean(ACAutomaton.class);
touchy.insert("abcd");
touchy.insert("abc");
touchy.insert("bc");
touchy.insert("d");
touchy.insert("fuck");
touchy.build();
System.out.println(touchy.searchTouchy("adhasuidhoiadjfuckhioasdhjiaddddshdbujikashduiawhdiukasjhbdjkasgjkhyfdgasuydgasuijhdgasjdgahjskdghjaskda"));
PrefixTree_SearchTouchyForArticle touchy2 = applicationContext.getBean(PrefixTree_SearchTouchyForArticle.class);
touchy2.insert("abcd");
touchy2.insert("abc");
touchy2.insert("bc");
touchy2.insert("d");
touchy2.insert("fuck");
System.out.println(touchy2.searchTouchy("adhasuidhoiadjfuckhioasdhjiaddddshdbujikashduiawhdiukasjhbdjkasgjkhyfdgasuydgasuijhdgasjdgahjskdghjaskda"));
}
2.2.3.3测试结果
执行时间:AC自动机61700ns,字典树38739300ns,字典树的时间消耗是AC自动机的627倍,指数级增长。
接下来我们进行更专业的测试。随机生成一段文本,使用AC自动机和字典树检测敏感词。
2.2.4测试工具封装
由于这里我使用了策略者模式,过度设计了一下,主要是我考虑到以后随机生成字符串可能具有一定通用性,以后可能还要在其基础上进行封装改善,所以就先过度设计了,这里只给出核心代码:
import com.xinghai.strategy.strgenerate.stg.GenerateRandomStr;
public class GenerateSmallLetterStr implements GenerateRandomStr {
@Override
public String generate(int count) {
// 准备初始字符
char init = 'a';
// 准备StringBuilder拼接
StringBuilder builder = new StringBuilder();
for (int index = 0; index < count; index++) {
// 生成0-25的偏移量
int offset = (int) (Math.random() * 26);
builder.append((char) (init + offset));
}
return builder.toString();
}
}
2.2.5精准测试
使用封装的工具生成一千个字符的文章,看看我们算法的速度怎么样:
@Test
public void test() {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(SpringConfig.class);
String words = GenerateRandomStr.generateRandomStr(1000, StrTypeEnum.SmallLetter);
ACAutomaton touchy = applicationContext.getBean(ACAutomaton.class);
touchy.insert("abcd");
touchy.insert("abc");
touchy.insert("bc");
touchy.insert("d");
touchy.insert("fuck");
touchy.build();
System.out.println(touchy.searchTouchy(words));
PrefixTree_SearchTouchyForArticle touchy2 = applicationContext.getBean(PrefixTree_SearchTouchyForArticle.class);
touchy2.insert("abcd");
touchy2.insert("abc");
touchy2.insert("bc");
touchy2.insert("d");
touchy2.insert("fuck");
System.out.println(touchy2.searchTouchy(words));
}
这....,字典树的速度整整比AC自动慢了24万5663倍。
可以看到AC自动机相对于字典树的暴力检测来说,速度提升的非常显著,数据量越大越显著。
接下来探究AC自动机的原理。
3.原理篇
原理篇将在下一篇文章中更新,将会以最细节的方式更新,因为作者最近自由意志沉沦,实在是无力爆肝了,而且原理这方面真的需要作者花费时间讲清楚,作者不希望草草了之,下一篇会以万字干活的形式更新。
3.1原理篇总览
1.尽力放弃原则。
2.转子收缩优化。
3.2尽力放弃原则
为什么字典树的复杂度这么高?而且随着word文本量增大,算法时间消耗激增?
因为想要从字典树中找出所有敏感词,需要把传入的文章中的所有的字符串枚举出,时间复杂度是O(M^2),不管是行不行的,全尝试一遍,这就不符合尽力放弃原则
但是AC自动机整体借助转子收缩优化,尽力去放弃一些不可能的字符串,将时间复杂度大幅度降低。=
4.结语
我们总要花点时间去做自己的事情,人生只有一次,勇敢一点,再差又能如何呢?又怎能甘愿措施机会呢?作者没更新完原理篇纯粹是因为,作者陷入了一个需要花费精力解决的事情,作者不要错失,因为作者渴望一个结果。
原文地址:https://blog.csdn.net/2301_79108772/article/details/143805829
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!