AC算法结合双数组trie树优化关键词匹配与替换

AC算法结合双数组trie树优化关键词匹配与替换

参考文章和项目:

trie树

trie又称前缀树、字典树;其基本性质:

  • 根节点不包含字符,除根节点外的每个节点都只包含一个字符;
  • 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串;
  • 每个节点的所有子节点包含的字符串不相同;

特点:

  • 查找性能高;
  • 很容易实现排序(每一层使用treeMap结构,中序遍历树即可排序);
  • 空间换时间(如果每一层都利用hash表提升查询性能,则会出现大量哈希表,占用空间);

示意图(字符串:abb、abc、bc、bca):

trie树示意图

基本操作:

  • 插入字符串:从左到右扫描字符串,如果字符在根节点下没出现过,则插入该字符;然后沿着字典树下走一步,继续下一个字符,重复操作直到字符串结尾,则记录emits;
  • 搜索字符串:从左到右扫描字符串,沿着字典树往下找,若找到了,则继续,直到扫描结束(匹配成功)或者没找到(匹配失败);

Aho-Corasick算法

参考文章:Aho-Corasick算法的Java实现与分析

Aho-Corasick算法简称AC算法,其思想是按照文本的字符顺序,接收字符并发生状态转移,这些状态缓存了转移成功且为模式串结尾、转移成功但不是模式串结尾、转移失败等三种情况下的跳转与输出情况,从而降低了匹配操作的时间复杂度至O(n);

算法基本结构:

  • success:也称goto表(实际上就是trie树),代表匹配成功时转移到下一个状态;
  • failure:无法按照success跳转时,则按照failure跳转至下一个状态;
  • emits:也称output表,代表命中模式串(可以同时命中多个);

用法举例(模式串:he、she、his、hers):

构建自动机(实线代表success,虚线代表failure,红色节点代表emits):

在这里插入图片描述

匹配过程(输入文本:ushers):

  • u:无节点匹配,继续;
  • s:按success表跳转至节点3;
  • h:按success表跳转至节点4;
  • e:按success表跳转至节点5,emits命中she、he;
  • r:按seccess表跳转失败,按failure表跳转至节点8;
  • s:按success表跳转至节点9,emits命中hers,输入结束;

构建:

  • success表和emits实际上就是trie树,按照trie树的插入规则插入数据即可构建;
  • failure表:
    • 规定深度为1的节点的fail值都是根节点;
    • 按照树的深度顺序依次计算各个节点的fail值;
    • 计算规则为:假设当前状态是s1,s1的前一状态时s0,那么有:s1=s0.success(c);则s2=s0.failure.success(c),如果s2为空,则s3=s0.failure.failure.success(c),依次下去,直到sn不为空或者sn为根节点,则s1.failure=sn

failure表构建示例(以上文中的自动机示意图为例):

  • 节点1、3的fail值为根节点;
  • 节点2:failure=1.failure=0
  • 节点6:failure=1.failure=0
  • 节点4:failure=3.failure.success('h')=1
  • 节点8:failure=2.failure=0
  • 节点7:failure=6.failure.success('s')=3
  • 节点5:failure=4.failure.success('e')=2
  • 节点9:failure=8.failure.success('s')=3

双数组trie树

参考文章:双数组Trie树(DoubleArrayTrie)Java实现

双数组trie树(DoubleArrayTrie)是一种空间复杂度较低的trie树,应用于字符区间大的语言(如:中文),是trie树结构的压缩形式,仅用两个线性的数组来表示trie树,该结构有效结合了数字搜索树检索时间高效的特点和链式表示的trie树空间结构紧凑的特点;双数组trie的本质是一个确定有限状态的自动机,每个节点代表一个状态,根据变量不同进行状态转换,当到达结束状态或者无法转移时,完成一次查询操作;在双数组所有键中包含的字符串之间的联系都通过简单的数学加法运算表示,这样不仅提高了检索速度,也省去了链式结构中大量的指针,节省空间;

基本原则(s代表状态,c代表输入字符的编码):

  • root节点:base[0] = 1;check[0] = 0
  • base[s1] + c1 = s2;
  • base[s2] + c2 = s3;
  • 当base[sn] < 0时,代表字符串结束;
  • check[s2] = base[s1];

每个节点的插入过程实际就是维护base和check两个数组,base用于存储当前状态和状态转移,check用于验证是否由同一个状态转移而来,即该状态的上一个状态是否正确,base小于0,则代表字符串结束;而维护这两个数组的核心就是,找到一个begin值使得check[begin]+check[begin+1]+...+check[begin+n]==0;也就是找到一段连续的空闲空间用于存放每个节点字符对应的code;从而实现快速定位字符的同时压缩trie树的空间结构;

使用原理

参考文章:Aho Corasick自动机结合DoubleArrayTrie极速多模式匹配

AC算法结合双数组字典树匹配过程:

  • 遍历输入字符串(c为字符,sn为状态,n从0开始,s0=0),
  • 先按success跳转:s(n+1)=base[sn] + c + 1,若check[s(n+1)]=base[sn],则跳转成功,返回s(n+1),若跳转失败,则判断s0==0?是则返回0,否则继续按failure跳转;
  • 按failure跳转,s(n+1)=base[fail[sn]] + c + 1,若check[s(n+1)]=base[sn],则跳转成功,返回s(n+1),若跳转失败,则判断s0==0?是则返回0,否则继续按failure跳转;
  • 根据上面的返回值,验证output表,若output[s(n+1)] != null,代表匹配成功,返回所有匹配的模式串;
  • 继续下一个字符的处理;

应用

本次需求:关键字替换,要求将文本中出现的关键词替换为指定链接,且重复出现的关键词只替换第一次,同时,若某个关键词a是另一个关键词b的一部分,那么如果文本匹配b时,则不匹配a;

代码实现(基于github项目:AhoCorasickDoubleArrayTrieAhoCorasickDoubleArrayTrie类提供的功能做扩展):

扩展build方法

根据trie树匹配过程可以知道,同一个节点对应的output表输出可能会对应多个模式串,根据本次需求,需要优先匹配较长的模式串,因此需要扩展build方法,对output表的模式串按长度排序;

@Override
public void build(Map<String, V> map) {
    super.build(map);
    for (int[] out : this.output) {
        if (out != null && out.length > 1) {
            ArrayList<Integer> list = new ArrayList<>(out.length);
            for (int value : out) {
                list.add(value);
            }
            list.sort(Comparator.comparingInt(e -> l[e]));
            for (int i = 0; i < list.size(); i++) {
                out[i] = list.get(i);
            }
        }
    }
}

扩展匹配方法

原工具类只提供了匹配全部模式串的方法,这里需要添加一个只匹配一次的的方法,具体实现方法是:

  • 利用set去重
  • 匹配output表时,只取长度最长的一个
  • 对于匹配成功的模式串保存到缓存变量(解决相同前缀情况下,短的词提前输出的情况,例如:买卖、合同、买卖合同三个词,应该优先匹配较长的劳动合同),
  • 当按success表跳转失败且缓存变量不为空时,则先判断待输出变量与缓存变量的位置关系,如果有重叠,则选择长度较长模式串输出,同时调整set集合内容,若没有重叠,则输出待输出变量,将缓存变量保存到待输出变量(解决关键词位置重叠的情况,例如对于文本:买卖合同的效率,模式串:买卖合同、合同的效率,两个模式串都会匹配成功,但是因为关键词重叠,只选择较长的一个替换);
  • 利用offset记录每次替换后的偏移量;
public StringBuilder parseTextOnce(String text, IHitHandler<V> processor) {
    int position = 1, currentState = 0;
    int offset = 0;
    HashSet<Integer> set = new HashSet<>();
    HitPos currHit = null, preHit = null;
    StringBuilder result = new StringBuilder(text);
    for (int i = 0; i < text.length(); ++i) {
        // 跳转success
        int newCurrentState = transitionWithRoot(currentState, text.charAt(i));
        if (newCurrentState <= 0 && currHit != null) {
            if (set.add(currHit.getHit())) {
                // 文本:买卖合同的效率;关键词:买卖合同、合同的效率;这种情况下,会导致重复替换,需要排除下;
                if (preHit != null) {
                    if (currHit.getBegin(offset) >= preHit.getEnd(offset)) {
                        offset += processor.hit(result, preHit.getBegin(offset), preHit.getEnd(offset), v[preHit.getHit()]);
                    } else {
                        if (preHit.getLength() >= currHit.getLength()) {
                            offset += processor.hit(result, preHit.getBegin(offset), preHit.getEnd(offset), v[preHit.getHit()]);
                            set.remove(currHit.getHit());
                            currHit = null;
                        } else {
                            set.remove(preHit.getHit());
                        }
                    }
                }
                preHit = currHit;
            }
        }
        // 跳转failure
        while (newCurrentState == -1) {
            currentState = fail[currentState];
            newCurrentState = transitionWithRoot(currentState, text.charAt(i));
        }
        currentState = newCurrentState;
        // 判断emits
        int[] hitArray = output[currentState];
        if (hitArray != null) {
            // 文本:买卖合同,关键词:买卖、买卖合同,此时扫描到买卖时也会命中emits,但并不是预期的买卖合同,需要处理下
            int hit = hitArray[hitArray.length - 1];
            currHit = new HitPos(hit, position, l[hit]);
        }
        ++position;
    }
    if (preHit != null) {
        offset += processor.hit(result, preHit.getBegin(offset), preHit.getEnd(offset), v[preHit.getHit()]);
    }
    if (currHit != null) {
        if (set.add(currHit.getHit())) {
            processor.hit(result, currHit.getBegin(offset), currHit.getEnd(offset), v[currHit.getHit()]);
        }
    }
    return result;
}

public interface IHitHandler<V> {
    int hit(StringBuilder result, int begin, int end, V value);
}

@Data
@AllArgsConstructor
private static class HitPos {
    private int hit;
    private int pos;
    private int length;

    public int getBegin(int offset) {
        return offset + this.pos - this.length;
    }

    public int getEnd(int offset) {
        return offset + this.pos;
    }

}

使用

  • 构建(由于trie树的构建相对比较占用资源,耗时较长,在并发构建的情况下可能会造成outOfMemory,因此需要加锁处理,又因为关键词数量较多,查询关键词也比较耗时和占用带宽,所以对于trie树的构建结果需要做本地缓存
public static <T> CustomTrie<KeywordBo> buildCustomTrie(Collection<T> collection, Function<T, String> keyword, Function<T, String> link) {
    Map<String, KeywordBo> map = new HashMap<>(collection.size() * 4 / 3);
    for (T t : collection) {
        map.put(keyword.apply(t), new KeywordBo(keyword.apply(t), link.apply(t)));
    }
    CustomTrie<KeywordBo> acdat = new CustomTrie<>();
    acdat.build(map);
    return acdat;
}

public synchronized static <T> CustomTrie<KeywordBo> syncBuildCustomTrie(
    Cache<String, CustomTrie<KeywordBo>> cache, String key,
    Collection<T> collection, Function<T, String> keyword, Function<T, String> link) {
	// 实际使用时建议将关键词查询操作也一并写在同步代码块中
    CustomTrie<KeywordBo> trie = cache.getIfPresent(key);
    if (trie != null) {
        return trie;
    } else {
        trie = KeywordUtil.buildCustomTrie(collection, keyword, link);
        cache.put(key, trie);
    }
    return trie;
}
  • 使用示例:
public static void main(String[] args) throws Exception {
    String text = "法律法规一、劳动合同的效率,==劳动合同劳动=合同=合同的效率";
    List<String> wordsList = Arrays.asList("法律", "法规", "劳动", "合同", "劳动合同", "合同的效率", "效率");
    CustomTrie<KeywordBo> acdat = buildTrie(wordsList, e -> e, e -> String.format("<<%s>>", e));
    System.out.println(replace(text, acdat));
}
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,以下是一个简单的基于数组Trie的敏感词过滤算法Java实现: ```java import java.util.*; public class DoubleArrayTrie { private int[] base; // base数组 private int[] check; // check数组 private int[] fail; // fail指针数组 private boolean[] isWord; // 是否为单词结尾 private int size; // trie中节点数 // 将敏感词列表构建为trie public void buildTrie(List<String> words) { // 计算trie的节点数 size = 1; Map<Character, Integer> charToIndex = new HashMap<>(); charToIndex.put('\0', 0); // 根节点 for (String word : words) { for (char c : word.toCharArray()) { if (!charToIndex.containsKey(c)) { charToIndex.put(c, size++); } } } // 初始化base、check、fail和isWord数组 base = new int[size]; check = new int[size]; fail = new int[size]; isWord = new boolean[size]; // 构建trie Arrays.fill(base, -1); Arrays.fill(check, -1); Arrays.fill(fail, -1); for (String word : words) { int cur = 0; for (char c : word.toCharArray()) { int next = charToIndex.get(c); if (base[cur] == -1) { base[cur] = next; } else { int t = base[cur] + next; int k = -1; while (true) { if (check[t + k] == -1) { check[t + k] = cur; base[next] = t + k; break; } else { k--; } } } cur = base[cur] + next; } isWord[cur] = true; } } // 计算fail指针 public void buildFail() { Queue<Integer> queue = new LinkedList<>(); for (int i = 0; i < 256; i++) { if (base[i] != -1) { fail[base[i]] = 0; queue.offer(base[i]); } } while (!queue.isEmpty()) { int cur = queue.poll(); for (int i = 0; i < 256; i++) { int next = base[cur] + i; if (check[next] == cur) { fail[next] = base[fail[cur]] + i; queue.offer(next); } else if (check[next] == -1) { check[next] = fail[next] = base[fail[cur]] + i; } } isWord[cur] |= isWord[fail[cur]]; // 更新是否为单词结尾 } } // 匹配文本串中的敏感词 public List<String> match(String text) { int cur = 0; List<String> result = new ArrayList<>(); for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); while (base[cur] == -1 && cur != 0) { // 回溯到可跳转的状态 cur = fail[cur]; } cur = base[cur] + charToIndex.getOrDefault(c, 0); if (isWord[cur]) { // 匹配到了一个敏感词 int start = i - word.length() + 1; result.add(text.substring(start, i + 1)); } } return result; } } ``` 使用示例: ```java List<String> words = Arrays.asList("敏感词1", "敏感词2", "敏感词3"); DoubleArrayTrie trie = new DoubleArrayTrie(); trie.buildTrie(words); trie.buildFail(); List<String> result = trie.match("这是一个包含敏感词1和敏感词2的文本"); System.out.println(result); // ["敏感词1", "敏感词2"] ``` 希望对您有帮助!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值