《简记字典树Trie》
Trie也称字典树、单词查找树或键树,是一种树形结构。典型的应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。其优点是最大限度的减少无谓的字符串比较,查询效率比哈希表高。
Key Words:字典树、构建字典树、字典树的应用
Beijing, 2020
作者:RaySue
文章目录
树的回顾
- 结点:树中的数据元素
- 结点的度(degree):结点拥有的子树的数目称为度
- 叶子节点:节点的度为0,称为叶子节点leaf、终端节点、末端结点
- 分支结点:结点度不为0,称为非终端节点或分支结点
- 双亲结点(父结点):一个结点是它各子树的根结点的双亲
- 兄弟结点:具有相同双亲结点的结点
- 结点的层次(Level):根节点为第一层,根的孩子为第二层,以此内推
字典树(Trie)
下图给出了字典树的具体实现,字典树的每个结点都是一个数组或者是其他存储结构,每个节点只表示一个字符,然后其子节点也是Trie树,和树的递归结构是一样的。
但是为了清晰的展示字典树,我们通常省略掉其存储的细节,如下图:
Trie的基本性质
-
结点本身不存在完整单词;
-
从根结点到某一结点,路径上经过的字符串连接起来,为该节点对应的字符串;
-
每个结点的所有子节点路径代表的字符串都不相同;
Trie的核心思想
以空间换时间,利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
Trie基本的功能
插入字符串 (insert)
void insert(string word)
{
Trie *node = this; // 这里是类中的方法,this相当于链表的head
for(char c:word)
{
int idx = static_cast<int>(c - 'a'); // 假设只有26个字母的情况
if (node->next[idx] == nullptr)
{
node->next[idx] = new Trie();
}
node = node->next[idx];
}
node->isEnd = true; // 结束字符
}
查找字符串 (search)
bool search(string word)
{
Trie *node = this;
for(char c:word)
{
int idx = static_cast<int>(c - 'a');
if (node->next[idx] == nullptr)
{
return false;
}
node = node->next[idx];
}
return node->isEnd;
}
前缀查询 (startsWith)
bool startsWith(string prefix)
{
Trie *node = this;
for (char c:prefix)
{
int idx = static_cast<int>(c - 'a');
if (node->next[idx] == nullptr)
{
return false;
}
node = node->next[idx];
}
return True;
}
*删除字符串 (delete)
- 删除操作较为复杂,有三种情况:
- 单词是另一个单词的前缀,那么就把待删除单词的isEnd改为false即可,表示这里不再是单词的结束
- 如果单词的所有字母的都没有多个分支,直接删除整个单词
- 如果单词除了后面几个字母,其余的都有分支,那么就删除后面这几个字母
// 待续
Trie的应用场景
词频统计
Trie树常被搜索引擎系统用于文本词频统计。
思路:为了实现词频统计,我们可以修改节点结构,将isKey用一个整型变量count来表示该节点为结尾的关键字的词频。对每一个关键字执行插入操作,若已存在,计数加1,若不存在,插入后count置1。
struct TrieNode
{
int count; // 记录该节点代表的单词的个数
TrieNode *children[26]; // 各个子节点
};
最长公共前缀
前缀匹配,查找N个单词的最长公共前缀
去除重复单词
建立字典树的过程就是给字符串去重的过程,search方法就和unordered_set的count方法一致。
字符串排序
Trie树可以对大量字符串按字典序进行排序,思路也很简单:遍历一次所有关键字,将它们全部插入trie树,树的每个结点的所有儿子很显然地按照字母表排序,然后先序遍历输出Trie树中所有关键字即可。
词频感应
通常我们在搜索引擎中输入前缀后,会出现很多提示,这就是基于字典树的词频感应。字典树根据你给出的 prefix 进行搜索得到出现频率靠前的结果展示出来,这是一个高效的搜索方式,其速度比哈希表还快。
Trie的复杂度:
Trie具有 线性复杂度:
-
时间复杂度:
- 假设所有字符串长度之和为n,构建字典树的时间复杂度为O(n)
- 假设要查找的字符串长度为k,查找的时间复杂度为O(k)
-
空间复杂度:
- 字典树每个节点都需要用一个数组来存储子节点的指针,即便实际只有两三个子节点,但依然需要一个完整大小的数组。所以,字典树比较耗内存,空间复杂度较高。
字典树的简单实现
Python实现
class Trie:
def __init__(self):
self.root = {}
self.end_of_word = '#'
def insert(self, word):
node = self.root
for w in word:
node.setdefault(w, {})
node = node[w]
node[self.end_of_word] = self.end_of_word
def search(self, word):
node = self.root
for char in word:
if char not in node:
return False
node = node[char]
return self.end_of_word in node
def startWith(self, prefix):
node = self.root
for char in prefix:
if char not in node:
return False
node = node[char]
return True
Cpp实现
- 基于 unordered_map 的实现
class Trie
{
private:
int count; // 带词频统计
unordered_map<char, Trie *> root;
public:
/** Initialize your data structure here. */
Trie()
{
count = 0;
}
/** Inserts a word into the trie. */
void insert(string word)
{
Trie *node = this; // 类似链表中的 ListNode *ptr = head;
for (char c:word)
{
if (!node->root.count(c))
{
node->root[c] = new Trie();
}
node = node->root[c];
}
node->count++;
}
/** Returns if the word is in the trie. */
bool search(string word)
{
Trie *node = this;
for (char c:word)
{
if (!node->root.count(c)) return false;
node = node->root[c];
}
// cout << word << " " << node->count << endl;
return node->count > 0;
}
/** Returns if there is any word in the trie that starts with the given prefix. */
bool startsWith(string prefix)
{
Trie *node = this;
for (char c:prefix)
{
if (!node->root.count(c)) return false;
node = node->root[c];
}
return true;
}
};
参考
https://blog.csdn.net/yuzhiqiang666/article/details/80711441
https://blog.csdn.net/lucky_greenegg/article/details/9141759
https://blog.csdn.net/sunny_ss12/article/details/47683715
Q&A
Q1: Trie树的优缺点:
A1:
- 优点:
-
查询快。对于长度为m的键值,最坏情况下只需花费O(m)的时间;而BST需要O(m log n)的时间。 虽然hash 表时间复杂度是O(1),但是,哈希搜索的效率通常取决于 hash 函数的好坏,若一个坏的 hash 函数导致很多的冲突,效率并不一定比Trie树高。
-
当存储大量字符串时,Trie耗费的空间较少。因为键值并非显式存储的,而是与其他键值共享子串。
- 缺点:
- 当查询的范围较大时,字典树比较耗内存,空间复杂度较高。而且实现高质量,快且小的 Trie 非常困难。