Trie 树,也叫“字典树”。顾名思义,它是一个树形结构。是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题、
当然,这样一个问题可以有多种解决方法,比如散列表、红黑树,Trie树等。
那Trie树到底长什么样子呢?
其具体构建过程如下图。
Trie树主要有两个操作:
那应该如何存储一个Trie树呢?
class TrieNode{
char data;
TrieNode children[26];
};
java代码实现
public class TrieNode {
public char data;
public TrieNode []children = new TrieNode[26];
public boolean isEndingChar = false;
public TrieNode(char data){
this.data = data;
}
}
public class Trie {
private TrieNode root = new TrieNode('/'); // 存储无意义的字符
// 往Trie树中插入一个字符串
public void insert(char []text){
TrieNode p = root;
for (int i = 0; i < text.length; i++) {
int index = text[i] - 'a';
if(p.children[index] == null){
TrieNode newNode = new TrieNode(text[i]);
p.children[index] = newNode;
}
p = p.children[index];
}
p.isEndingChar = true;
}
// Trie树中查找一个字符串
public boolean find(char []pattern){
TrieNode p = root;
for (int i = 0; i < pattern.length; i++) {
int index = pattern[i] - 'a';
if(p.children[index] == null){
return false; //不存在
}
p = p.children[index];
}
if(p.isEndingChar == false){
return false; // 不能完全匹配,只是前缀
}else{
return true; //找到pattern
}
}
}
那么在 Trie 树中,查找某个字符串的时间复杂度是多少?
实现前缀树,最常见的有数组保存(静态开辟数组),当然也可以开动态的指针类型(动态开辟内存)。至于结点对儿子的指向,一般有三种方法:
三种方法,各有特点。
前缀树,也是一种树,为了理解前缀树,我们先从二叉树说起。
常见的二叉树结构是下面这样的:
class TreeNode {
int val;
TreeNode* left;
TreeNode* right;
}
可以看到一个数的节点中包含了三个元素:
二叉树可视化是下面这样的:
二叉树的每个节点只有两个孩子,那如果每个节点可以有多个孩子呢?这就形成了多叉树。多叉树的子节点一般不是固定的,所以会用变长数组来保存所有的子节点的指针。多叉树的结构如下:
class TreeNode {
int val;
vector<TreeNode*> children;
}
多叉树可视化是下面这样:
对于普通的多叉树,每个节点的所有子节点可能是没有任何规律的。而前缀树是每个节点的children 有规律的多叉树。
(只保存小写字符的)「前缀树」是一种特殊的多叉树,字母的字典树每个节点要定义一个大小为 26 的子节点指针数组,分别对应了26个英文字符 ‘a’ ~ ‘z’,也就是说形成了一棵 26叉树。
前缀树的结构可以定义为下面这样。里面存储了两个信息:
class TrieNode {
public:
vector<TrieNode*> children;
bool isWord;
TrieNode() : isWord(false), children(26, nullptr) {
}
~TrieNode() {
for (auto& c : children)
delete c;
}
};
在构建前缀树的时候,按照下面的方法:
下面是一棵「前缀树」,其中保存了 {“am”, “an”, “as”, “b”, “c”, “cv”} 这些关键词。图中红色表示 isWord 为 true。可以看到:
这里并没有把字符画在了节点中。是因为前缀树是根据 字符在 children 中的位置确定子树,而不真正在树中存储了 ‘a’ ~ ‘z’ 这些字符。
在判断一个关键词是否在「前缀树」中时,需要依次遍历该关键词所有字符,在前缀树中找出这条路径。可能出现三种情况:
class TrieNode {
public:
TrieNode () : children(26, nullptr), is_string(false){
}
~TrieNode (){
for(auto it : children){
delete it;
}
}
std::vector<TrieNode *> children;
bool is_string;
};
class Trie {
public:
Trie (){
root = new TrieNode();
}
~Trie (){
delete root;
}
void insert(std::string text){
TrieNode *p = root;
for(auto ch : text){
int i = ch - 'a';
if(p->children[i] == NULL){
p->children[i] = new TrieNode();
}
p = p->children[i];
}
p->is_string = true;
}
bool search(string text){
TrieNode *p = root;
for(auto ch : text){
int i = ch - 'a';
if(p->children[i] == NULL){
return false;
}
p = p->children[i];
}
return p->is_string;
}
bool startsWith(string prefix) {
TrieNode *p = root;
for(auto ch : prefix){
int i = ch - 'a';
if(p->children[i] == NULL){
return false;
}
p = p->children[i];
}
return true;
}
private:
TrieNode *root;
};
Trie 树是一种非常独特的、高效的字符串匹配方法。但是,关于 Trie 树,有一种说法:“Trie 树是非常耗内存的,用的是一种空间换时间的思路”。这是什么原因呢?
Trie树尽管有可能很浪费内存,但是确实非常高效。那为了解决这个内存问题,我们是否有其他办法呢?
实际上,Trie树的变体有很多,都可以在一定程度上解决内存消耗的问题。比如,缩点优化,就是对只有一个子节点的节点,而且此节点不是一个串的结束节点,可以将此节点与子节点合并。这样可以节省空间,但却增加了编码难度。如下:
实际上,字符串的匹配问题,其实就是数据的查找问题。支持动态数据高效查找的数据结构有散列表、红黑树、跳表等。那它们各种有什么优缺点和应用场景呢?
Trie树对要处理的字符串有着及其严苛的要求:
因此,针对在一组字符串中查找字符串的问题,在工程中我们倾向用散列表或者红黑树。因为这两种数据结构,我们都不需要自己去实现,直接利用编程语言中提供的现成类库就行了。
实际上,Trie树只是不适合精确匹配查找,这种问题更适合用散列表或者红黑树来解决。Trie树比较适合查找前缀匹配的字符串
Trie 树是一种解决字符串快速匹配问题的数据结构。如果用来构建 Trie 树的这一组字符串中,前缀重复的情况不是很多,那 Trie 树这种数据结构总体上来讲是比较费内存的,是一种空间换时间的解决问题思路。
尽管比较耗费内存,但是对内存不敏感或者内存消耗在接受范围内的情况下,在 Trie 树中做字符串匹配还是非常高效的,时间复杂度是 O(k),k 表示要匹配的字符串的长度。
但是,Trie 树的优势并不在于,用它来做动态集合数据的查找,因为,这个工作完全可以用更加合适的散列表或者红黑树来替代。Trie 树最有优势的是查找前缀匹配的字符串,比如搜索引擎中的关键词提示功能这个场景,就比较适合用它来解决,也是 Trie 树比较经典的应用场景。
参考
题目 | |
---|---|
leetcode:208.Trie字典(前缀)树 Implement Trie (Prefix Tree) | |
leetcode:211. 添加与搜索单词 - 数据结构设计 Add and Search Word - Data structure design | |
leetcode:642.设计搜索自动补全系统 Design Search Autocomplete System | |
leetcode:648. 单词替换 replace-words | |
leetcode:676. 实现一个魔法字典 implement-magic-dictionary | |
leetcode:677. 键值映射map-sum-pairs |