当前位置: 首页 > news >正文

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 的结构和功能需求来说:
  1. 存储字符数据,所以需要有一个char类型的字段 ch_。
  2. 因为需要统计单词频率,所以需要有一个size_t类型的字段 frequency_ 。同时这个字段还可以作为单词最后一个字符的标识,如果节点的 frequency 大于0,则说明有单词以该字符为结尾。
  3. 因为是树形结构,所以需要保存当前节点的后续节点,并且为了 快速查找后续节点是否有目标字符、字典序排序,所以需要有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_;
};

插入字符串

遍历要插入的字符串:

  1. 如果当前字符已存在,直接复用
  2. 如果当前字符不存在,就构造一个节点,并在当前节点添加<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_++;
}

查询字符串

遍历字符串,判断当前字符是否存在在字典树中:
  1. 如果不存在,直接返回0
  2. 如果存在,就继续向下遍历,直至遍历完字符串,最终返回该单词出现的频率。
// 查找单词是否存在
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

当用户传入一个字符串时,需要先遍历字符串判断字典树中是否存在该字符串,如果不存在就不需要删除,如果存在就分以下三种情况:

  1. 无公共前缀(如删除 “banana”):直接删除整条路径。
  2. 存在公共前缀且为包含前缀的字符串(如删除 “apple”):保留 “app” 路径,仅删除 “l” 和 “e” 节点。
  3. 公共前缀本身(如删除 “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]);
}

存在公共前缀

前面也说到了,存在公共前缀的字符串,在用户删除时会有两种情况:

  1. 删除公共前缀
  2. 删除包含公共前缀的字符串

删除公共前缀app字符串时,只需要将这个单词最后一个节点的frequency = 0,表示这个单词频率为0(逻辑上这个单词不存在了)。不能直接删除前缀的节点,因为有其他单词使用这个前缀。

如何判断用户要删除的单词,是一个公共前缀?

只需要判断单词最后一个节点的map中是否有元素,如果有元素,就说明存在以"app"为前缀的单词。


现在,遍历过程中,不能边遍历边删除节点,因为需要判断要删除的字符串是否包含公共前缀。我们先记录一下要删除字符串的第一个节点start:

  1. 遍历过程中(字符串还没有遍历完),如果一个节点的 **frequency > 0,**则说明存在以当前节点为结尾的单词(前缀单词),那么就不能删除当前节点及以前的节点,只能从当前节点的后续节点删除。(如在删除“appear”时,遍历过程中,会检测到“app”这一单词,此时只能从’e’节点开始删除)
  2. 如果一个节点的 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 (前缀树)


今天的分享就到这里了,如果你感觉这篇博客对你有帮助的话,就点个赞吧!感谢感谢……

相关文章:

  • swift-22-面向协议编程、响应式编程
  • PH热榜 | 2025-06-29
  • MySQL的调控按钮
  • stm32之测量周期
  • JVM中的垃圾收集(GC)
  • idea运行到远程机器 和 idea远程JVM调试
  • 【C++】C++中的友元函数和友元类
  • 【科技核心期刊推荐】《计算机与现代化》
  • PaddleNLP
  • MongoDB05 - MongoDB 查询进阶
  • 极限平衡法和应力状态法无限坡模型安全系数计算
  • 阿里云-接入SLS日志
  • Wpf布局之Border控件!
  • ​扣子Coze飞书多维表插件-创建数据表
  • GPT,GPT-2,GPT-3 论文精读笔记
  • mapstate
  • 打通Dify与AI工具生态:将Workflow转为MCP工具的实践
  • 养老保险交得越久越好
  • 【ad-hoc】# P12414 「YLLOI-R1-T3」一路向北|普及+
  • 《弦论视角下前端架构:解构、重构与无限延伸的可能》