Trie树简介及应用

作者:京东物流 马瑞

1 什么是Trie树

1.1 Trie树的概念

Trie树,即字典树,又称单词查找树或键树,是一种树形结构,典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。

Trie, also called digital tree and sometimes radix tree or prefix tree (as they can be searched by prefixes), is a kind of search tree—an ordered tree data structure that is used to store a dynamic set or associative array where the keys are usually strings. It is one of those data-structures that can be easily implemented.

1.2 Trie树优点

最大限度地减少无谓的字符串比较,查询效率比较高。核心思想是空间换时间,利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。

  1. 插入、查找的时间复杂度均为O(N),其中N为字符串长度。
  2. 空间复杂度是26^n级别的,非常庞大(可采用双数组实现改善)。

1.3 Trie树的三个基本性质

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

2 Trie树数据结构

以字符串”hi”和”经海路”的数据为例:

Java的数据结构定义:

1@Data 2public class TrieTreeNode { 3 private Character data; 4 private Map<Character, TrieTreeNode> children; 5 private boolean isEnd; 6 // 前缀,冗余信息,可选 7 private String prefix; 8 // 后缀,冗余信息,可选 9 private String suffix; 10} 11

如果只是处理26个英文字符,data可以通过children数组是否为空来判断。如果处理程序,默认children为空来判断是否为最后一个节点,则isEnd字段可以省略。
前缀prefix和suffix可以在数据处理的时候,方便拿到当前节点前缀和后缀信息,如果不需要可以去除。

3 Trie树在脏话过滤中的应用

3.1 脏话关键词Keyword定义

1public class KeyWord implements Serializable { 2 /** 3 * 关键词内容 4 */ 5 private String word; 6//其他省略 7} 8

3.2 关键词查询器

1public class KWSeeker { 2 3 /** 4 * 所有的关键词 5 */ 6 private Set<KeyWord> sensitiveWords; 7 8 /** 9 * 关键词树 10 */ 11 private Map<String, Map> wordsTree = new ConcurrentHashMap<String, Map>(); 12 13 /** 14 * 最短的关键词长度。用于对短于这个长度的文本不处理的判断,以节省一定的效率 15 */ 16 private int wordLeastLen = 0; 17 18//其他处理方法,省略 19} 20

3.3 字符串构造一棵树

1/** 2 * 将指定的词构造到一棵树中。 3 * 4 * @param tree 构造出来的树 5 * @param word 指定的词 6 * @param KeyWord 对应的词 7 * @return 8 */ 9public static Map<String, Map> makeTreeByWord(Map<String, Map> tree, String word, 10 KeyWord KeyWord) { 11 if (StringUtils.isEmpty(word)) { 12 tree.putAll(EndTagUtil.buind(KeyWord)); 13 return tree; 14 } 15 String next = word.substring(0, 1); 16 Map<String, Map> nextTree = tree.get(next); 17 if (nextTree == null) { 18 nextTree = new HashMap<String, Map>(); 19 } 20 // 递归构造树结构 21 tree.put(next, makeTreeByWord(nextTree, word.substring(1), KeyWord)); 22 return tree; 23} 24

对关键词字符串,逐个字符进行构建。

3.4 词库树的生成

1/** 2 * 构造、生成词库树。并返回所有敏感词中最短的词的长度。 3 * 4 * @param sensitiveWords 词库 5 * @param wordsTree 聚合词库的树 6 * @return 返回所有敏感词中最短的词的长度。 7 */ 8public int generalTree(Set<KeyWord> sensitiveWords, Map<String, Map> wordsTree) { 9 if (sensitiveWords == null || sensitiveWords.isEmpty() || wordsTree == null) { 10 return 0; 11 } 12 13 wordsTreeTmp.clear(); 14 int len = 0; 15 for (KeyWord kw : sensitiveWords) { 16 if (len == 0) { 17 len = kw.getWordLength(); 18 } else if (kw.getWordLength() < len) { 19 len = kw.getWordLength(); 20 } 21 AnalysisUtil 22 .makeTreeByWord(wordsTreeTmp, StringUtils.trimToEmpty(kw.getWord()), kw); 23 } 24 wordsTree.clear(); 25 wordsTree.putAll(wordsTreeTmp); 26 return len; 27} 28

3.5 关键词提取

1/** 2 * 将文本中的关键词提取出来。 3 */ 4public List<SensitiveWordResult> process(Map<String, Map> wordsTree, String text, 5 AbstractFragment fragment, int minLen) { 6 // 词的前面一个字 7 String pre = null; 8 // 词匹配的开始位置 9 int startPosition = 0; 10 // 返回结果 11 List<SensitiveWordResult> rs = new ArrayList<SensitiveWordResult>(); 12 13 while (true) { 14 try { 15 if (wordsTree == null || wordsTree.isEmpty() || StringUtils.isEmpty(text)) { 16 return rs; 17 } 18 if (text.length() < minLen) { 19 return rs; 20 } 21 String chr = text.substring(0, 1); 22 text = text.substring(1); 23 Map<String, Map> nextWord = wordsTree.get(chr); 24 // 没有对应的下一个字,表示这不是关键词的开头,进行下一个循环 25 if (nextWord == null) { 26 pre = chr; 27 continue; 28 } 29 30 List<KeyWord> keywords = Lists.newArrayList(); 31 KeyWord kw = AnalysisUtil.getSensitiveWord(chr, pre, nextWord, text, keywords); 32 if (keywords == null || keywords.size() == 0) { 33 // 没有匹配到完整关键字,下一个循环 34 pre = chr; 35 continue; 36 } 37 for (KeyWord tmp : keywords) { 38 // 同一个word多次出现记录在一起 39 SensitiveWordResult result = new SensitiveWordResult(startPosition, tmp.getWord()); 40 int index = rs.indexOf(result); 41 if (index > -1) { 42 rs.get(index).addPosition(startPosition, tmp.getWord()); 43 } else { 44 rs.add(result); 45 } 46 } 47 48 // 从text中去除当前已经匹配的内容,进行下一个循环匹配 49 // 这行注释了,避免"中国人",导致"国人"。搜索不出来,逐个字符遍历 50 // text = text.substring(kw.getWordLength() - 1); 51 pre = kw.getWord().substring(kw.getWordLength() - 1, kw.getWordLength()); 52 continue; 53 } finally { 54 if (pre != null) { 55 startPosition = startPosition + pre.length(); 56 } 57 } 58 59 } 60} 61 62/** 63 * 查询文本开头的词是否在词库树中,如果在,则返回对应的词,如果不在,则返回null。return 返回找到的最长关键词 64 * 65 * @param append 追加的词 66 * @param pre 词的前一个字,如果为空,则表示前面没有内容 67 * @param nextWordsTree 下一层树 68 * @param text 剩余的文本内容 69 * @param keywords 返回的keywords,可能多个 70 * @return 返回找到的最长关键词 71 */ 72public static KeyWord getSensitiveWord(String append, String pre, 73 Map<String, Map> nextWordsTree, String text, List<KeyWord> keywords) { 74 if (nextWordsTree == null || nextWordsTree.isEmpty()) { 75 return null; 76 } 77 78 Map<String, Object> endTag = nextWordsTree.get(EndTagUtil.TREE_END_TAG); 79 // 原始文本已到末尾 80 if (StringUtils.isEmpty(text)) { 81 // 如果有结束符,则表示匹配成功,没有,则返回null 82 if (endTag != null) { 83 keywords.add(checkPattern(getKeyWord(append, endTag), pre, null)); 84 return checkPattern(getKeyWord(append, endTag), pre, null); 85 } else { 86 return null; 87 } 88 } 89 90 String next = text.substring(0, 1); 91 String suffix = text.substring(0, 1); 92 Map<String, Map> nextTree = nextWordsTree.get(next); 93 94 // 没有遇到endTag,继续匹配 95 if (endTag == null) { 96 if (nextTree != null && nextTree.size() > 0) { 97 // 没有结束标志,则表示关键词没有结束,继续往下走。 98 return getSensitiveWord(append + next, pre, nextTree, text.substring(1), keywords); 99 } 100 101 // 如果没有下一个匹配的字,表示匹配结束! 102 return null; 103 } else { // endTag , 添加关键字 104 KeyWord tmp = getKeyWord(append, endTag); 105 keywords.add(checkPattern(tmp, pre, suffix)); 106 } 107 108 // 有下一个匹配的词则继续匹配,一直取到最大的匹配关键字 109 KeyWord tmp = null; 110 if (nextTree != null && nextTree.size() > 0) { 111 // 如果大于0,则表示还有更长的词,继续往下找 112 tmp = getSensitiveWord(append + next, pre, nextTree, text.substring(1), keywords); 113 if (tmp == null) { 114 // 没有更长的词,则就返回这个词。在返回之前,先判断它是模糊的,还是精确的 115 tmp = getKeyWord(append, endTag); 116 } 117 return checkPattern(tmp, pre, suffix); 118 } 119 120 // 没有往下的词了,返回该关键词。 121 return checkPattern(getKeyWord(append, endTag), pre, suffix); 122 123} 124

思路是对某个字符串text,逐个字符ch,获取ch对应的词库树的children,然后获取匹配到的单个或多个结果,将匹配到的关键词在text中的开始和结束下标进行记录,如后续需要html标记,或者字符替换可直接使用。如果未能在词库树中找到对应的ch的children,或者词库树的children未能匹配到去除ch的子字符串,则继续循环。具体可再详细读一下代码。

4 Radix Tree的应用

4.1 RAX - Redis Tree

Redis实现了不定长压缩前缀的radix tree,用在集群模式下存储slot对应的的所有key信息。

1/* Representation of a radix tree as implemented in this file, that contains 2 * the strings "foo", "foobar" and "footer" after the insertion of each 3 * word. When the node represents a key inside the radix tree, we write it 4 * between [], otherwise it is written between (). 5 * 6 * This is the vanilla representation: 7 * 8 * (f) "" 9 * \ 10 * (o) "f" 11 * \ 12 * (o) "fo" 13 * \ 14 * [t b] "foo" 15 * / \ 16 * "foot" (e) (a) "foob" 17 * / \ 18 * "foote" (r) (r) "fooba" 19 * / \ 20 * "footer" [] [] "foobar" 21 * 22 * However, this implementation implements a very common optimization where 23 * successive nodes having a single child are "compressed" into the node 24 * itself as a string of characters, each representing a next-level child, 25 * and only the link to the node representing the last character node is 26 * provided inside the representation. So the above representation is turned 27 * into: 28 * 29 * ["foo"] "" 30 * | 31 * [t b] "foo" 32 * / \ 33 * "foot" ("er") ("ar") "foob" 34 * / \ 35 * "footer" [] [] "foobar" 36 * 37 * However this optimization makes the implementation a bit more complex. 38 * For instance if a key "first" is added in the above radix tree, a 39 * "node splitting" operation is needed, since the "foo" prefix is no longer 40 * composed of nodes having a single child one after the other. This is the 41 * above tree and the resulting node splitting after this event happens: 42 * 43 * 44 * (f) "" 45 * / 46 * (i o) "f" 47 * / \ 48 * "firs" ("rst") (o) "fo" 49 * / \ 50 * "first" [] [t b] "foo" 51 * / \ 52 * "foot" ("er") ("ar") "foob" 53 * / \ 54 * "footer" [] [] "foobar" 55 * 56 * Similarly after deletion, if a new chain of nodes having a single child 57 * is created (the chain must also not include nodes that represent keys), 58 * it must be compressed back into a single node. 59 * 60 */ 61#define RAX_NODE_MAX_SIZE ((1<<29)-1) 62typedef struct raxNode { 63 uint32_t iskey:1; /* Does this node contain a key? */ 64 uint32_t isnull:1; /* Associated value is NULL (don't store it). */ 65 uint32_t iscompr:1; /* Node is compressed. */ 66 uint32_t size:29; /* Number of children, or compressed string len. */ 67 unsigned char data[]; 68} raxNode; 69 70typedef struct rax { 71 raxNode *head; 72 uint64_t numele; 73 uint64_t numnodes; 74} rax; 75 76typedef struct raxStack { 77 void **stack; /* Points to static_items or an heap allocated array. */ 78 size_t items, maxitems; /* Number of items contained and total space. */ 79 void *static_items[RAX_STACK_STATIC_ITEMS]; 80 int oom; /* True if pushing into this stack failed for OOM at some point. */ 81} raxStack; 82

如Redis源码中的注释所写,RAX进行了一些优化,并不会将一个字符串直接按照每个字符进行树的构建,而是在Insert有冲突时节点分割处理,在Delete时如果子节点和父节点都只有一个,则需要进行合并操作。
对于RAX有兴趣的同学,可以看一下rax.h、rax.c的相关代码。

4.2 Linux内核

Linux radix树最广泛的用途是用于内存管理,结构address_space通过radix树跟踪绑定到地址映射上的核心页,该radix树允许内存管理代码快速查找标识为dirty或writeback的页。Linux radix树的API函数在lib/radix-tree.c中实现。
Linux基数树(radix tree)是将指针与long整数键值相关联的机制,它存储有效率,并且可快速查询,用于指针与整数值的映射(如:IDR机制)、内存管理等。

1struct radix_tree_node { 2 unsigned int path; 3 unsigned int count; 4 union { 5 struct { 6 struct radix_tree_node *parent; 7 void *private_data; 8 }; 9 struct rcu_head rcu_head; 10 }; 11 /* For tree user */ 12 struct list_head private_list; 13 void __rcu *slots[RADIX_TREE_MAP_SIZE]; 14 unsigned long tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS]; 15}; 16

关于Linux内核使用Radix Tree的具体代码,有兴趣的同学可以继续深入。

5 总结

Trie树在单词搜索、统计、排序等领域有大量的应用。文章从基础概念到具体的脏话过滤的应用、Redis的RAX和Linux内核的Radix Tree对Trie树做了介绍。数据结构和算法是程序高性能的基础,本文抛砖引玉,希望大家对Trie树有所了解,并在未来开发过程实践和应用Trie树解决中类似情景的问题。

点赞
收藏

评论区

加载中...

相关推荐

Oracle 分组与拼接字符串同时使用

SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(

深入理解跳表及其在Redis中的应用

跳表可以达到和红黑树一样的时间复杂度O(logN),且实现简单,Redis中的有序集合对象的底层数据结构就使用了跳表。其作者威廉·普评价:跳跃链表是在很多应用中有可能替代平衡树的一种数据结构。本篇文章将对跳表的实现及在Redis中的应用进行学习。

二叉树创建后,如何使用递归和栈遍历二叉树?

0.前言前文主要介绍了树的相关概念和原理,本文主要内容为二叉树的创建及遍历的代码实现,其中包括递归遍历和栈遍历。1.二叉树的实现思路1.0.顺序存储——数组实现前面介绍了满二叉树和完全二叉树,我们对其进行了编号——从0到n的不中断顺序编号,而恰好,数组也有一个这样的编号——数组下标,只要我们把二者联合起来,数组就能存储二叉树了。那么非满

高级java面试题,附答案+考点

蚂蚁金服一面1.两分钟的自我介绍2.二叉搜索树和平衡二叉树有什么关系,强平衡二叉树(AVL树)和弱平衡二叉树(红黑树)有什么区别3.B树和B树的区别,为什么MySQL要使用B树4.HashMap如何解决Hash冲突5.epoll和poll的区别,及其应用场景6.简述线程池原理,FixedThreadPoo

DAT (Double Array Trie) 多模式匹配算法

一、简介:1.1、字典树trie:  字典树trie搜索关键码的时间和关键码自身及其长度有关,最快是0(1),,即在第一层即可判断是否搜索到,最坏的情况是0(n),n为Trie树的层数。由于很多时候Trie树的大多数结点分支很少,因此Trie树结构空间浪费比较多。  关键码检索策略可以根据关键码是否可以动态变化

MySQL面试(二)

1、为什么索引遵循最左匹配原则?  当B树的数据项是符合的数据结构,比如(name,age,sex)的时候,B树是按照从左到右的顺序建立搜索树的。比如当(张三,20,F)这样的数据来检索的时候,b树会优先比较name来确定下一步的所搜方向,如果name相同再依次比较age和sex,最后得到检索的数据;但当(20,F)这样的没有name的数据来的时候