DoubleArrayTrie 的原理理解和实现

平时使用双数组字典树的场景蛮多的,但是一直没有明白它的构建过程,所以通过各位大佬的文章,总结出自己可以理解的双数组字典树的构建过程,结合一些实际的例子,体会一下具体的用法。
整个文章的思路都是以Trie为基础,然后根据下面几种Trie依次简单梳理一下。

Array Trie
List Trie
Hash Trie
Double Array Trie

在看双数组字典数之前我们先看看什么是字典树。

字典树(Trie)

字典树的定义

字典树:又称为Trie树,前缀树,这是一种字符串上的树形数据结构。
也就是将一个字符串构建成一个树的形状,如下图。
对于有限集合 { AC,ACE,ACFF,AD,CD,CF,ZQ }。
R表示根节点。
在这里插入图片描述对于字符串的处理,我们通常有应用就是在字符串集合中判断字符串是否存在,这个也是匹配算法的一个瓶颈,那么对于普通匹配算法,如果遍历查找时间复杂度是O(n^2),用二分查找法时间复杂度是O(logn),如果用TreeMap去匹配,时间复杂度是O(logn),这里的n指的是词典的大小,如果用HashMap的话,时间复杂度是O(1),但是空间复杂度又上去了,所以,想要找到一种速度又快,同时内存又省的数据结构,来完成这个匹配操作。字典树就符合这些特征。
先简单了解一下字典树的基本原理

字典树的原理

字典树的每一个边都对应一个字,从根节点往下的路径构成一个个字符串。字典树并不直接在节点存储字符串,而是将词语视作根节点到某一节点之间的一条路径
并且在终点节点上做个标记(该节点对应词语的结尾),字符串就是一条路径,要查询某一个单词,就需要顺着这条路径从根节点往下走,如果能走到特殊标记的节点(蓝色结点),那么说明当前字符串在集合中,否则当前字符串不在集合中。
下图中是以下词{“abc”、“abcd”、“adb”、“b”、“bcd”、“efg”、“hik”},构成的前缀树。
原图出自
在这里插入图片描述
橙色标记该节点是一个词的结尾(词的结尾不一定是到叶子节点),数字只是一个编号,这些词和对应的路径如下表所示。

词语路径
abc0-1-2-3
abcd0-1-2-3-4
adb0-1-2-5
b0-6
bcd0-6-7-8
efg0-9-10-11
hik0-12-13-14

备注:橙色=色节点不一定是叶子节点,也就是词的结尾不一定是叶子节点。
字典树的时间复杂度最坏的情况是O(logn),但是它的速度优于二分查找,毕竟随着路径的深入,前缀匹配是递进的过程,算法不必在比较字符串的前缀。

字典树的特性

  1. 以空间换时间
  2. 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
  3. 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
  4. 每个节点的所有子节点包含的字符都不相同。

再简单的理解

比如现在有10000个单词列表,我们要判断student这个单词有没有出现过,遍历查找时间复杂度是O(n^2),用二分查找法时间复杂度是O(logn),用字典树也是O(logn),但是上面说了为什么字典树更加优秀,那么用字典树的查找规则就是先找到s,再去s的子树中找t,依次类推,看看能不能找到student这条路径。

字典树的实现

具体需要实现方法有以下几个

  • void insert(String word):添加word;
  • void delete(String word):删除word;
  • boolean search(String word):查询word是否在字典树中;
/**
 * 前缀树
 */
public class TrieTree {
    //字典树节点
    class TrieNode {
        public int path;
        public int end;
        public HashMap<Character, TrieNode> map;

        public TrieNode() {
            path = 0;
            end = 0;
            map = new HashMap<>();
        }
    }

    private TrieNode root;

    public TrieTree() {
        root = new TrieNode();
    }

    /**
     * 插入一个新的单词
     * @param word
     */
    public void insert(String word) {
        if (word == null)
            return;
        TrieNode node = root;
        node.path++;
        char[] words = word.toCharArray();
        for (int i = 0; i < words.length; i++) {
            if (node.map.get(words[i]) == null) {
                node.map.put(words[i], new TrieNode());
            }
            node = node.map.get(words[i]);
            node.path++;
        }
        node.end++;
    }

    public boolean search(String word) {
        if (word == null)
            return false;
        TrieNode node = root;
        char[] words = word.toCharArray();
        for (int i = 0; i < words.length; i++) {
            if (node.map.get(words[i]) == null)
                return false;
            node = node.map.get(words[i]);
        }
        return node.end > 0;
    }

    public void delete(String word) {
        if (search(word)) {
            char[] words = word.toCharArray();
            TrieNode node = root;
            node.path--;
            for (int i = 0; i < words.length; i++) {
                if (--node.map.get(words[i]).path == 0) {
                    node.map.remove(words[i]);
                    return;
                }
                node = node.map.get(words[i]);
            }//for
            node.end--;
        }//if
    }

    public int prefixNumber(String pre) {
        if (pre == null)
            return 0;
        TrieNode node = root;
        char[] pres = pre.toCharArray();
        for (int i = 0; i < pres.length; i++) {
            if (node.map.get(pres[i]) == null)
                return 0;
            node = node.map.get(pres[i]);
        }
        return node.path;
    }

    public static void main(String[] args) {
        TrieTree trie = new TrieTree();
        System.out.println(trie.search("程龙颖"));//f
        trie.insert("自然人");
        trie.insert("自然");
        trie.insert("自然语言");
        trie.insert("自语");
        trie.insert("入门");
        System.out.println(trie.search("自然"));//t
        trie.delete("自然语言");
        System.out.println(trie.search("自然语言"));//f
        trie.insert("自然语言");
        System.out.println(trie.search("自然语言"));//t
        System.out.println(trie.prefixNumber("自然"));//3
    }
}

DFA简单理解

TrieTree本质上是一个确定有限自动机(DFA)。
DFA的特征:有一个有限状态集合和一些从一个状态通向另一个状态的边,每条边上标记有一个符号,其中一个状态是初态,某些状态是终态。但不同于不确定的有限自动机,DFA中不会有从同一状态出发的两条边标志有相同的符号。
对于DFA来说,每个节点代表一个“状态”,每条边代表一个“变量”。

双数组字典树

双数组字典树(DoubleArrayTrie, DAT)是由三个日本人提出的一种字典树的高效实现,兼顾了查询效率与空间存储。DAT极大地节省了内存占用。

优点

在Trie数实现过程中,我们发现了每个节点均需要 一个数组来存储next节点,非常占用存储空间,空间复杂度大,双数组Trie树正是解决这个问题的。双数组字典树(DoubleArrayTrie)是一种空间复杂度低的Trie树,应用于字典树压缩、分词、敏感词等领域。所以,DAT是前缀树的一个变形,同样也是一个DFA。

缺点

每个状态都依赖于其他状态,所以当在词典中插入或删除词语的时候,往往需要对双数组结构进行全局调整,从而灵活性能较差。

定义

将原来需要多个数组才能表示的Trie树,使用两个数组就可以存储下来,可以极大的减小空间复杂度。由于用base和check两个数组构成,又称为双数组字典树。
具体来说就是使用两个数组base[]和check[]来维护Trie树,base[]负责记录状态,check[]用于检验状态转移的正确性,当check[i]为负值时,表示此状态为字符串的结束。
具体来说,当状态b接受字符c然后转移到状态p的时候,满足的状态转移公式如下:

p = base[b] + c
check[p] = base[c]	

构建双数组的过程

对于词典 { AC,ACE,ACFF,AD,CD,CF,ZQ },构建双数组具体过程如下。
在这里插入图片描述在构造之前,先梳理几个概念

  • STATE:状态,也就是数组的下标
  • CODE: 状态转移值,实际为字符的 ASCII码
  • BASE: 表示后继节点的基地址的数组,叶子节点没有后继,标识为字符序列的结尾标志

主要是基于 dart-java,此版本对双数组算法做了一个改进,即darts双数组中有以下的改进。

	base[0] = 1 
	check[0] = 0

第二个改进就是令字符的code = ascii+1

结合两个数组的状态转移公式有以下条件

base[0] = 1 
check[0] = 0 
p = base[b] + c
check[p] = base[c]	

基于base和check两个数据构建双数组的流程整体如下

1 建立根节点root,令base[root] =1
2 找出root的子节点 集{root.childreni }(i = 1...n) , 使得 check[root.childreni ] = base[root] = 1
3 对 each element in  root.children : 
  1)找到{elemenet.childreni }(i = 1...n) ,注意若一个字符位于字符序列的结尾,则其孩子节点包括一个空节点,其code值设置为0找到一个值begin使得每一个check[ begini + element.childreni .code] = 0
  2)设置base[element.childreni] = begini
  3)对element.childreni 递归执行步骤3,若遍历到某个element,其没有children,即叶节点,则设置base[element]为负值(一般为在字典中的index取负)

备注:构建的时候,从广度搜索,从深度构建词典
1、根据上面的那个例子{ AC,ACE,ACFF,AD,CD,CF,ZQ }来说,最开始有

base[0] = 1 
check[0] = 0 

备注:ascii表格

	65 	A
	66 	B
	...

此外,结合darts双数组的改进code= ascii+1, 以及i = base[0] + code可以得到下面每个字符的position(i)和对应字符的code值。base[0] = 1

rootACDEFQZ
i0676992
code066686970718291

2、根据构造过程中的第二步,距离root节点深度为1的所有children其 c h e c k [ r o o t . c h i l d r e n i ] = b a s e [ r o o t ] = 1 check[root.children_i ] = base[root] = 1 check[root.childreni]=base[root]=1,在模式串中root的三个子节点'A', 'C', 'E'的check值都是1, 假设root经过A C Z 的作用分别到达 p 1 , p 2 , p 3 p_1 , p_2, p_3 p1,p2,p3三个状态,可以得到下面矩阵。

rootACZ
i0676992
base1
check0111
statep0p1p2p3

3、根据构建的第三步,状态p1是由条件 'A’触发的,那么’A’的base值的计算方式需要满足以下的规则:
我们知道,对于每一个字符, 需要确定一个base值,使得对于所有以该字开头的词,在双数组中都能放下。
已知A的子节点值为{C D}, 需要找一个begin值,使得check[begin +'C'.code] = check[begin +'D'.code] = 0满足, 即check[begin + 68] = check[begin + 69] = 0,换句话说,需要找到一个begin,从而找到之前没有使用过的空间。

a、当begin=1的时候,有check[1+ 68] 和check[1+ 69] 都必须为0,
但是check[1 + 68] 存在字符‘C’,
所以check[begin +’C’.code] = check[begin +’D’.code] = 0不成立。
b、当begin=2的时候
需要有check[2+ 68]check[2 + 69] 的值都必须为0
check[begin + 68] = check[begin + 69] = 0
所以有base[p1] = begin = 2, 状态p1= 67。

p4 = base[p1] + ‘C’.code = 2 + 68 = 70 ,
p5 = base[p1] + ‘D’.code = 2 + 69 = 71,
check[p5] = check[p4] = base[p1] = 2,
那么有以下矩阵
备注:AC指的就是A左子树C,AD指的就是A的右子树D。

rootACZACAD
i06769927071
base12
check011122
statep0p1p2p3p4p5

4、根据上一步,继续深度遍历,走A的左子树C,继续推导。已知C的子节点是{null、E、F},需要找一个begin值,使得check[begin +null.code] = check[begin +'E'.code] = check[begin +'F'.code] = 0满足, 在子节点有空的情况下,需要设置base[null] = -1(取负整数,从-1开始,下一次出现就是-2)。
所以有base[null] = -1
所以就有 p n u l l p_{null} pnull = check[null] = p4 + 2,因为position为70,71有占位。所以后移。
所以就有 p n u l l p_{null} pnull = 72
同时出现空的时候,有check[null] = p n u l l p_{null} pnull = 72
又因为check[null] = base[p4]
所以base[p4] = 72
null由*表示

rootACZACADAC*ACEACF
i0676992707172
base1272-1
check01112272
statep0p1p2p3p4p5p6=nullp7p8

5、然后继续求ACE和ACF这两个条链路,先求base[p7]base[p8]
有公式:check[begin + 'E'.code] = 0
有公式:check[begin + 'F'.code] = 0
现在当begin从3开始,当为3的时候,
check[3 + 70] = 0成立
check[3 + 71] = 0成立
所以
p7 = base[p4] + E.code = 72 +70 = 142
p8 = base[p4] + F.code = 72 +71 = 143
所以
check[p7] = base[p4] = 72
check[p8] = base[p4] = 72

rootACZACADAC*ACEACF
i0676992707172142143
base1272-1
check011122727272
statep0p1p2p3p4p5p6=nullp7p8

6、然后开始算ACE*这个链路,由于自己诶单包含为null节点,所以有
base[null] = -2
所以就可以有 p n u l l p_{null} pnull = check[null] =73,因为position为70,71有占位。所以后移,给一个空的值就行。
所以就有 p n u l l p_{null} pnull = 73
同时出现空的时候,有check[null] = p n u l l p_{null} pnull = 73
又因为check[null] = base[p7]
所以base[p7] = 73

rootACZACADAC*ACEACFACE*
i067699270717214214373
base1272-173-2
check01112272727273
statep0p1p2p3p4p5p6=nullp7p8p9=null

7、然后走ACFF,ACFF*。依次类推。

最终的不含非空节点矩阵如下

rootACZCDDFQEFF
i0676992707177798614214374
base1284727678808373375
check01111228847272
statep0p1p2p3p4p5p6p7p8p9p10p11

使用DFA的形式来描绘,节点表示state,字符作为转移条件,不同字符触发不同的state,可得到到树如下图,其中红色部分正好是第5步骤的矩阵;绿色部分是按照模式集合得到的ouput表。
在这里插入图片描述

参考

https://blog.csdn.net/u013300579/article/details/78869742
https://blog.csdn.net/zhoubl668/article/details/6957830
https://github.com/komiya-atsushi/darts-java
https://linux.thai.net/~thep/datrie/datrie.html
https://www.cnblogs.com/ooon/p/4883159.html
https://blog.csdn.net/xlxxcc/article/details/67631988

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
好的,以下是一个简单的基于双数组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]; // 构建trieArrays.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"] ``` 希望对您有帮助!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值