使用场景
针对敏感词的过滤,如淘宝用户每天的评论数有几亿、甚至几十亿,如果快速高效的过滤掉一堆敏感词汇呢?本文将讲解一种多模式匹配算法----AC自动机;
简述
AC自动机算法是构造一个Trie树,然后再添加额外的失败指针。这些额外的适配指针准许在查找字符串失败的时候进行回退(例如在Trie树种查找单词bef失败后,但是在Trie树种存中bea这个单词,失配指针会指向前缀be),转向某些前缀分支,免于重复匹配前缀,提高算法效率。
下面主要通过Trie树、失败指针、匹配查找三部分进行简述
Trie树
Trie 树,也叫“字典树”。是一个多叉树。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题
举例说明: 用26个小写字母举例,我们有 6 个字符串,它们分别是:how,hi,her,hello,so,see
根节点无值,其它的就按照每个单词的字母依次构建树,标红的就为一个叶子节点;
由上看出Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起
代码实现
class TrieNode {
private int num; //有多少单词经过当前节点,即由根节点到该节点组成的字符串模式出现的次数
private TrieNode[] son;//子节点数组
private boolean wordEnd;//是否是叶子结点
private char val;//当前节点的值
TrieNode() {
num =1;
son = new TrieNode[engNum];
wordEnd = false;
}
}
//建立字典树
public void insert(String str) {
if(StringUtils.isEmpty(str)) {
return;
}
TrieNode curNode = root;
char[] arr = str.toCharArray();
for(char c:arr) {
int index = c-'a';
TrieNode node = curNode.son[index];
if(node == null) {
//当前节点为空则创建
curNode.son[index] = new TrieNode();
curNode.son[index].val = c;
}else {
//不为空则加一
node.num = node.num +1;
}
curNode = curNode.son[index];
}
curNode.wordEnd = true;
}
//在字典中查找一个完全匹配的单词
public boolean has(String str) {
if(StringUtils.isEmpty(str)) {
return false;
}
TrieNode node = root;
//找到指定前缀在字典树的位置
char[] arr = str.toCharArray();
for(int i=0;i<arr.length;i++) {
int index = arr[i] -'a';
if(node.son[index] == null) {
return false;
}
node = node.son[index];
}
//到这里,如果最后的字符是叶子结点,则完全匹配;否则为部分匹配;
return node.wordEnd;
}
Fail指针
在查找字符串失败的时候进行回退(例如在Trie树种查找单词bef失败后,但是在Trie树种存中bea这个单词,失配指针会指向前缀be),转向某些前缀分支,免于重复匹配前缀,提高算法效率
构建原理:
设置root 子节点fail 指针都指向自己,而root 的失败指针为null,其它节点举例,莫个节点为father,其孩子节点记为child。求child的Fail指针时,首先我们要找到其father的Fail指针所指向的节点,假如是t的话,我们就要看t的孩子中有没有和child节点所表示的字母相同的节点,如果有的话,这个节点就是child的fail指针,如果发现没有,则需要找father->fail->fail这个节点,然后重复上面过程,如果一直找都找不到,则child的Fail指针就要指向root。
描述:
如图所示,首先root最初会进队,然后root,出队,我们把root的孩子的失败指针都指向root。因此图中h,s的失败指针都指向root,如红色线条所示,同时h,s进队。
接下来该h出队,我们就找h的孩子的fail指针,首先我们发现h这个节点其fail指针指向root,而root又没有字符为e的孩子,则e的fail指针是空的,如果为空,则也要指向root,如图中蓝色线所示。并且e进队,此时s要出队,我们再找s的孩子a,h的fail指针,
我们发现s的fail指针指向root,而root没有字符为a的孩子,故a的
fail指针指向root,a入队,然后找h的fail指针,同样的先看s的fail指针是root,发现root又字符为h的孩子,所以h的fail指针就指向了第二层的h节点。e,a , h 的fail指针的指向如图蓝色线所示。
此时队列中有e,a,h,e先出队,找e的孩子r的失败指针,我们先看e的失败指针,发现找到了root,root孩子中没有字符为r
的孩子,则r的失败指针指向了root,并且r进队,然后a出队,我们也是先看a的失败指针,发现是root,则y的fail指针就会指向root.并且y进队。然后h出队,考虑h的孩子e,则我们看h的失败指针,指向第二层的h节点,看这个节点发现有字符值为e的节点,最后一行的节点e的失败指针就指向第三层的e。最后找r的指针,同样看第二层的h节点,其孩子节点不含有字符r,则会继续往前找h的失败指针找到了根,根下面的孩子节点也不存在有字符r,则最后r就指向根节点,最后一行节点的fail指针如绿色虚线所示。
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map.Entry;
public class AhoCorasickAutomation {
/*本示例中的AC自动机只处理英文类型的字符串,所以数组的长度是128*/
private static final int ASCII = 128;
/*AC自动机的根结点,根结点不存储任何字符信息*/
private Node root;
/*待查找的目标字符串集合*/
private List<String> target;
/*表示在文本字符串中查找的结果,key表示目标字符串, value表示目标字符串在文本串出现的位置*/
private HashMap<String, List<Integer>> result;
/*内部静态类,用于表示AC自动机的每个结点,在每个结点中我们并没有存储该结点对应的字符*/
private static class Node{
/*如果该结点是一个终点,即,从根结点到此结点表示了一个目标字符串,则str != null, 且str就表示该字符串*/
String str;
/*ASCII == 128, 所以这里相当于128叉树*/
Node[] table = new Node[ASCII];
/*当前结点的孩子结点不能匹配文本串中的某个字符时,下一个应该查找的结点*/
Node fail;
public boolean isWord(){
return str != null;
}
}
/*target表示待查找的目标字符串集合*/
public AhoCorasickAutomation(List<String> target){
root = new Node();
this.target = target;
buildTrieTree();
build_AC_FromTrie();
}
/*由目标字符串构建Trie树*/
private void buildTrieTree(){
for(String targetStr : target){
Node curr = root;
for(int i = 0; i < targetStr.length(); i++){
char ch = targetStr.charAt(i);
if(curr.table[ch] == null){
curr.table[ch] = new Node();
}
curr = curr.table[ch];
}
/*将每个目标字符串的最后一个字符对应的结点变成终点*/
curr.str = targetStr;
}
}
/*由Trie树构建AC自动机,本质是一个自动机,相当于构建KMP算法的next数组*/
private void build_AC_FromTrie(){
/*广度优先遍历所使用的队列*/
LinkedList<Node> queue = new LinkedList<Node>();
/*单独处理根结点的所有孩子结点*/
for(Node x : root.table){
if(x != null){
/*根结点的所有孩子结点的fail都指向根结点*/
x.fail = root;
queue.addLast(x);/*所有根结点的孩子结点入列*/
}
}
while(!queue.isEmpty()){
/*确定出列结点的所有孩子结点的fail的指向*/
Node p = queue.removeFirst();
for(int i = 0; i < p.table.length; i++){
if(p.table[i] != null){
/*孩子结点入列*/
queue.addLast(p.table[i]);
/*从p.fail开始找起*/
Node failTo = p.fail;
while(true){
/*说明找到了根结点还没有找到*/
if(failTo == null){
p.table[i].fail = root;
break;
}
/*说明有公共前缀*/
if(failTo.table[i] != null){
p.table[i].fail = failTo.table[i];
break;
}else{/*继续向上寻找*/
failTo = failTo.fail;
}
}
}
}
}
}
}
文本串的匹配
当前指针curr指向AC自动机的根节点:curr=root。
1.遍历文本串,
匹配
是结束节点,就把当前的模式串记下;
未结束节点,curr指向孩子节点,继续遍历字符串
不匹配:
若fail == null,则说明没有任何子串为输入字符串的前缀,这时设置curr = root,继续遍历字符串
若fail != null,则将curr指向 fail节点,继续匹配步骤。
假设文本串text = “abchnijabdfk”。
说明如下:
1)按照字符串顺序依次遍历到:a-->b-->c-->h ,这时候发现文本串中下一个节点n和Trie树中下一个节点i不匹配,且h的fail指针非空,跳转到Trie树中ch位置。
注意c-->h的时候判断h不为结束节点,且c的fail指针也不是结束节点。
2)再接着遍历n-->i,发现i节点在Trie树中的下一个节点找不到j,且有fail指针,则继续遍历,
遍历到d的时候要注意,d的下一个匹配节点f是结束字符,所以得到匹配字符串:ijabdf,且d的fail节点也是d,且也是结束字符,所以得到匹配字符串abd,不过不是失败的匹配,所以curr不跳转
/*在文本串中查找所有的目标字符串*/
public HashMap<String, List<Integer>> find(String text){
/*创建一个表示存储结果的对象*/
result = new HashMap<String, List<Integer>>();
for(String s : target){
result.put(s, new LinkedList<Integer>());
}
Node curr = root;
int i = 0;
while(i < text.length()){
/*文本串中的字符*/
char ch = text.charAt(i);
/*文本串中的字符和AC自动机中的字符进行比较*/
if(curr.table[ch] != null){
/*若相等,自动机进入下一状态*/
curr = curr.table[ch];
if(curr.isWord()){
result.get(curr.str).add(i - curr.str.length()+1);
}
/*这里很容易被忽视,因为一个目标串的中间某部分字符串可能正好包含另一个目标字符串,
* 即使当前结点不表示一个目标字符串的终点,但到当前结点为止可能恰好包含了一个字符串*/
if(curr.fail != null && curr.fail.isWord()){
result.get(curr.fail.str).add(i - curr.fail.str.length()+1);
}
/*索引自增,指向下一个文本串中的字符*/
i++;
}else{
/*若不等,找到下一个应该比较的状态*/
curr = curr.fail;
/*到根结点还未找到,说明文本串中以ch作为结束的字符片段不是任何目标字符串的前缀,
* 状态机重置,比较下一个字符*/
if(curr == null){
curr = root;
i++;
}
}
}
return result;
}
public static void main(String[] args){
List<String> target = new ArrayList<String>();
target.add("abd");
target.add("abdk");
target.add("abchijn");
target.add("chnit");
target.add("ijabdf");
target.add("ijaij");
String text = "abchnijabdfk";
AhoCorasickAutomation aca = new AhoCorasickAutomation(target);
HashMap<String, List<Integer>> result = aca.find(text);
System.out.println(text);
for(Map.Entry<String, List<Integer>> entry : result.entrySet()){
System.out.println(entry.getKey()+" : " + entry.getValue());
}
}