Trie(字典树)
1. 基本概念介绍
定义: Trie(前缀树/字典树)是一种用于 高效存储和检索字符串集合 的树形数据结构,特别适合处理前缀相关的查询。
-
基本性质:通过共享公共前缀来优化存储空间:
- 根节点不包含数据字符,除根节点外每个节点都只包含一个字符
- 从根节点到叶子节点的路径表示一个完整字符串
- 每个节点的所有子节点包含的字符都不相同
-
适用范围:
- 单词检索
- 统计某一个单词出现的频率
- 按照字典序排序字符串
- 字符串的前缀搜索
应用场景:
- 输入法/IDE自动补全:输入前缀快速推荐候选词
- 拼写检查:检查单词是否存在于词典
- IP路由表匹配:最长前缀匹配(LPM)
- 九宫格键盘预测:T9键盘单词预测
2. Trie的核心特点
算法核心:利用字符串的公共前缀 来减少查询时间,最大限度的减少不必要的字符串比较。
- 前缀共享:例如存储
["apple", "app", "banana"]
时,"app"
是"apple"
的公共前缀,两个词共享a->p->p
的节点路径。 - 高效检索:搜索时间复杂度 O(m)(m为字符串长度),远优于哈希表(需处理哈希冲突)和平衡二叉树(O(m log n))。
- 空间换时间:每个节点需要存储子节点指针(空间开销较大),但换取了高效的前缀操作。
“串操作”的BF算法和KMP算法,更注重模式匹配——查找主串中是否存在某个子串。它并不能提供 Trie 的前缀搜索、排序功能……
Trie的优缺点
优点 | 缺点 |
---|---|
前缀搜索高效(O(m)) | 空间消耗较大(指针数组/映射) |
避免哈希冲突 | 实现比哈希表复杂 |
支持字典序排序(DFS遍历) | 字符集大时空间放大 |
3. 模拟实现
Trie的节点结构
按照 Trie 的结构和功能需求来说:- 存储字符数据,所以需要有一个
char
类型的字段 ch_。 - 因为需要统计单词频率,所以需要有一个
size_t
类型的字段 frequency_ 。同时这个字段还可以作为单词最后一个字符的标识,如果节点的 frequency 大于0,则说明有单词以该字符为结尾。 - 因为是树形结构,所以需要保存当前节点的后续节点,并且为了 快速查找后续节点是否有目标字符、字典序排序,所以需要有
std::map<char, Node*>
类型的字段。
struct TrieNode
{TrieNode(char ch, size_t frequence): ch_(ch), freqs_(frequence){}// 存储字符数据char ch_;// 以当前节点为结尾的字符串出现的频率size_t freqency_;// 当前节点的孩子节点std::map<char, TrieNode*> map_;
};
插入字符串
遍历要插入的字符串:
- 如果当前字符已存在,直接复用
- 如果当前字符不存在,就构造一个节点,并在当前节点添加
<char, Node*>
的映射关系
遍历完字符串后,最后一个节点的 frequency++,表示以当前字符为结尾的单词多了一个。
void insert(const std::string& word)
{TrieNode* cur = root_;for (auto& ch : word){auto iter = cur->map_.find(ch);if (iter != cur->map_.end()){cur = iter->second;}else{// 构造一个节点TrieNode* node = new TrieNode(ch, 0);cur->map_.emplace(ch, node);cur = node;}}cur->frequency_++;
}
查询字符串
遍历字符串,判断当前字符是否存在在字典树中:- 如果不存在,直接返回0
- 如果存在,就继续向下遍历,直至遍历完字符串,最终返回该单词出现的频率。
// 查找单词是否存在
size_t search(const std::string& word)
{TrieNode* cur = root_;for (auto& ch : word){auto iter = cur->map_.find(ch);if (iter == cur->map_.end())return 0; // 不存在,则返回0else{// 遍历下一个节点cur = iter->second;}}return cur->frequency_; // 返回出现的频率
}
字典序
字典序是一种基于字符编码的有序排列规则,核心在于从左到右逐个字符比较。
以 字典序 返回 Trie 中的单词。
因为我们的节点使用std::map<char, Node*>
存储后续节点,map本事就是以 字典序 组织存储的 pair,所以我们只需要对 Trie 进行一次前序遍历即可。
void preOrder_(TrieNode* root, std::string str, std::vector<std::string>& ret)
{if (root != root_){str += root->ch_;if (root->frequency_ > 0){ret.push_back(str);}}for (auto& iter : root->map_){preOrder_(iter.second, str, ret);}
}// 将单词按照字典序进行排序-->前序遍历
void lexicographicalOrder(std::vector<std::string>& ret)
{std::string str;preOrder_(root_, str, ret);
}
前缀匹配
用户给定一个前缀单词,我们需要返回以该单词为前缀的所有字符串。如果该前缀单词不存在,返回空集合。// 串的前缀搜索
std::vector<std::string> searchWith(const std::string& prifixWord)
{// 1. 先查找是否出现这个前缀单词TrieNode* cur = root_;for (auto& ch : prifixWord){auto iter = cur->map_.find(ch);if (iter == cur->map_.end())return {}; // 不存在,则返回0else{// 遍历下一个节点cur = iter->second;}}// 2. 前序遍历获取当前节点后的单词std::vector<std::string> ret;std::string str = prifixWord;for (auto& iter : cur->map_){preOrder_(iter.second, str, ret);}return ret;
}
删除字符串
如字典树先存在这几个单词:app、apple、appear、banana当用户传入一个字符串时,需要先遍历字符串判断字典树中是否存在该字符串,如果不存在就不需要删除,如果存在就分以下三种情况:
- 无公共前缀(如删除 “banana”):直接删除整条路径。
- 存在公共前缀且为包含前缀的字符串(如删除 “apple”):保留 “app” 路径,仅删除 “l” 和 “e” 节点。
- 公共前缀本身(如删除 “app”):取消 “app” 的结束标记,保留路径供 “apple” 使用。
没有公共前缀
从字典树中删除banana
单词,就属于第一种情况。此时直接遍历这个单词,释放节点空间、然后将'b'
这个字符从根节点的map中删除。
// 删除一个字符串
void del(const std::string& word)
{TrieNode* cur = root_;for (auto ch : word){auto iter = cur->map_.find(ch);// 单词不存在,不删除直接返回if (iter == cur->map_.end())return;// 向后移动TrieNode* next = iter->second;delete cur;cur = next;}// 删除根节点的映射关系root_->map_.erase(word[0]);
}
存在公共前缀
前面也说到了,存在公共前缀的字符串,在用户删除时会有两种情况:
- 删除公共前缀
- 删除包含公共前缀的字符串
删除公共前缀app
字符串时,只需要将这个单词最后一个节点的frequency = 0,表示这个单词频率为0(逻辑上这个单词不存在了)。不能直接删除前缀的节点,因为有其他单词使用这个前缀。
如何判断用户要删除的单词,是一个公共前缀?
只需要判断单词最后一个节点的map中是否有元素,如果有元素,就说明存在以"app"为前缀的单词。
现在,遍历过程中,不能边遍历边删除节点,因为需要判断要删除的字符串是否包含公共前缀。我们先记录一下要删除字符串的第一个节点start:
- 遍历过程中(字符串还没有遍历完),如果一个节点的 **frequency > 0,**则说明存在以当前节点为结尾的单词(前缀单词),那么就不能删除当前节点及以前的节点,只能从当前节点的后续节点删除。(如在删除“appear”时,遍历过程中,会检测到“app”这一单词,此时只能从’e’节点开始删除)
- 如果一个节点的 map 中存放多于一个元素,则说明当前节点到根节点是一个前缀,也不能删除。(如果“app”单词从字典树中删除后,再删除“appear”时,会遍历到“app”的第二个’p’节点,此时也只能从’e’节点开始删除)
// 删除一个字符串
void remove(const std::string& word)
{TrieNode* cur = root_, * delStart = root_; // delStart表示要开始删除节点的父节点char delChar = word[0];for (auto ch : word){auto iter = cur->map_.find(ch);// 单词不存在,不删除直接返回if (iter == cur->map_.end())return;if (cur->frequency_ > 0 || cur->map_.size() > 1){// 存在公共前缀delStart = cur;// 要在delStart中删除chdelChar = ch;}// 向后移动cur = iter->second;}// 单词存在,并且cur指向字符串末尾节点if (!cur->map_.empty()){// 表示当前单词是公共前缀cur->frequency_ = 0;}else{// 到这里会有两种情况:// 1. delStart指向根节点,表示当前字符串不包含公共前缀// 2. delStart在遍历过程中移动了,表示当前字符串包含公共前缀(不删除前缀节点)// 遍历删除TrieNode* mov = delStart->map_[delChar];delStart->map_.erase(delChar); // 删除父节点的映射关系while (mov != cur){auto next = mov->map_.begin()->second;delete mov;mov = next;}delete cur;}
}
注意:
auto nextIter = mov->map_.begin(); // 保存迭代器
delete mov; // 破坏迭代器依赖的底层容器
mov = nextIter->second; // 访问已失效的迭代器// 正确写法:
auto nextNode = mov->map_.begin()->second; // 先获取指针
delete mov; // 再删除当前节点
mov = nextNode; // 使用已保存的指针
删除所有节点
在析构函数中,需要删除所有节点。我们可以使用层序遍历的方法删除节点,也可以使用后序遍历的方法删除节点。
private:void clear_(TrieNode* root){if (root->map_.empty()){delete root;return;}for (auto& iter : root->map_){TrieNode* next = iter.second;clear_(next);}delete root;}public:
// 析构函数
#if 0 ~TrieTree(){// 层序遍历释放节点std::queue<TrieNode*> que;que.push(root_);while (!que.empty()){TrieNode* front = que.front();que.pop();for (auto& pair : front->map_){que.push(pair.second);}delete front;}}
#else// 析构函数~TrieTree(){// 递归后续遍历clear_(root_);}
#endif
补充:在C++中,析构函数不能被重载。
1. 析构函数的定义规则
- 名称固定:析构函数的名称必须与类名相同,前面加波浪号
~
,例如~MyClass()
。 - 无参数:析构函数不能带有参数,因此无法通过参数列表的不同来区分多个析构函数。
- 无返回值:析构函数不能有返回类型(包括
void
)。
2. 为什么析构函数不能重载?
- 唯一性需求:析构函数的作用是在对象生命周期结束时自动释放资源(如内存、文件句柄等)。每个对象只能有一种销毁方式,因此不需要多个析构函数。
- 调用时机明确:析构函数由系统自动调用(如对象超出作用域、
delete
操作等),若允许重载,编译器无法确定应该调用哪个析构函数。
3. 对比:构造函数可以重载
构造函数可以有多个重载版本,因为:
- 构造函数的参数列表可以不同,用于支持不同的初始化方式(如默认构造、带参构造、拷贝构造等)。
- 调用构造函数时,用户必须显式指定参数,编译器可以根据参数匹配选择合适的构造函数。
总结
“Trie是一种高效处理字符串集合的树形结构,通过共享前缀节省空间。每个节点代表一个字符,从根节点到叶子节点的路径是一个单词,这一路径上也可能存在其他子单词。插入、搜索、前缀搜索的时间复杂度均为O(m),m为字符串长度。它适合自动补全等场景,但空间开销较大(因为每一个节点需要使用hash表存储后续节点)。”
整体代码
#include <iostream>
#include <vector>
#include <string>
#include <map>
#include <queue>class TrieTree
{
private:struct TrieNode{TrieNode(char ch, size_t frequence): ch_(ch), frequency_(frequence){}// 存储字符数据char ch_;// 以当前节点为结尾的字符串出现的频率size_t frequency_;// 当前节点的孩子节点std::map<char, TrieNode*> map_;};void preOrder_(TrieNode* root, std::string str, std::vector<std::string>& ret){if (root != root_){str += root->ch_;if (root->frequency_ > 0){ret.push_back(str);}}for (auto& iter : root->map_){preOrder_(iter.second, str, ret);}}void clear_(TrieNode* root){if (root->map_.empty()){delete root;return;}for (auto& iter : root->map_){TrieNode* next = iter.second;clear_(next);}delete root;}private:TrieNode* root_ = nullptr;public:// 构造函数TrieTree(){root_ = new TrieNode('\0', 0);}// 析构函数
#if 0 ~TrieTree(){// 层序遍历释放节点std::queue<TrieNode*> que;que.push(root_);while (!que.empty()){TrieNode* front = que.front();que.pop();for (auto& pair : front->map_){que.push(pair.second);}delete front;}}
#else// 析构函数~TrieTree(){// 递归后续遍历clear_(root_);}
#endifvoid insert(const std::string& word){TrieNode* cur = root_;for (auto& ch : word){auto iter = cur->map_.find(ch);if (iter != cur->map_.end()){cur = iter->second;}else{// 构造一个节点TrieNode* node = new TrieNode(ch, 0);cur->map_.emplace(ch, node);cur = node;}}cur->frequency_++;}// 删除一个字符串void remove(const std::string& word){TrieNode* cur = root_, * delStart = root_; // delStart表示要开始删除节点的父节点char delChar = word[0];for (auto ch : word){auto iter = cur->map_.find(ch);// 单词不存在,不删除直接返回if (iter == cur->map_.end())return;if (cur->frequency_ > 0 || cur->map_.size() > 1){// 存在公共前缀delStart = cur;// 要在delStart中删除chdelChar = ch;}// 向后移动cur = iter->second;}// 单词存在,并且cur指向字符串末尾节点if (!cur->map_.empty()){// 表示当前单词是公共前缀cur->frequency_ = 0;}else{// 到这里会有两种情况:// 1. delStart指向根节点,表示当前字符串不包含公共前缀// 2. delStart在遍历过程中移动了,表示当前字符串包含公共前缀(不删除前缀节点)// 遍历删除TrieNode* mov = delStart->map_[delChar];delStart->map_.erase(delChar); // 删除父节点的映射关系while (mov != cur){auto next = mov->map_.begin()->second;delete mov;mov = next;}delete cur;}}// 查找单词是否存在size_t search(const std::string& word){TrieNode* cur = root_;for (auto& ch : word){auto iter = cur->map_.find(ch);if (iter == cur->map_.end())return 0; // 不存在,则返回0else{// 遍历下一个节点cur = iter->second;}}return cur->frequency_; // 返回出现的频率}// 将单词按照字典序进行排序-->前序遍历void lexicographicalOrder(std::vector<std::string>& ret){std::string str;preOrder_(root_, str, ret);}// 串的前缀搜索std::vector<std::string> searchWith(const std::string& prifixWord){// 1. 先查找是否出现这个前缀单词TrieNode* cur = root_;for (auto& ch : prifixWord){auto iter = cur->map_.find(ch);if (iter == cur->map_.end())return {}; // 不存在,则返回0else{// 遍历下一个节点cur = iter->second;}}// 2. 前序遍历获取当前节点后的单词std::vector<std::string> ret;std::string str = prifixWord;for (auto& iter : cur->map_){preOrder_(iter.second, str, ret);}return ret;}};int main()
{TrieTree tree;tree.insert("app");tree.insert("apple");tree.insert("appear");tree.insert("banana");std::vector<std::string> ret;tree.lexicographicalOrder(ret);for (auto ee : ret){std::cout << ee << std::endl;}std::cout << "==========================" << std::endl;tree.remove("apple"); // 没有公共前缀ret.clear();tree.lexicographicalOrder(ret);for (auto ee : ret){std::cout << ee << std::endl;}std::cout << "==========================" << std::endl;return 0;
}
经过以上学习可以解决一下这道题目:208. 实现 Trie (前缀树)
今天的分享就到这里了,如果你感觉这篇博客对你有帮助的话,就点个赞吧!感谢感谢……