惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Google DeepMind News
Google DeepMind News
大猫的无限游戏
大猫的无限游戏
S
Securelist
The Hacker News
The Hacker News
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
F
Fortinet All Blogs
Jina AI
Jina AI
K
Kaspersky official blog
T
Threat Research - Cisco Blogs
Stack Overflow Blog
Stack Overflow Blog
Webroot Blog
Webroot Blog
有赞技术团队
有赞技术团队
T
The Blog of Author Tim Ferriss
量子位
S
Schneier on Security
Latest news
Latest news
D
Darknet – Hacking Tools, Hacker News & Cyber Security
O
OpenAI News
云风的 BLOG
云风的 BLOG
M
MIT News - Artificial intelligence
博客园 - 叶小钗
L
LINUX DO - 最新话题
V
Visual Studio Blog
U
Unit 42
Hacker News - Newest:
Hacker News - Newest: "LLM"
S
Security Affairs
AWS News Blog
AWS News Blog
S
Secure Thoughts
腾讯CDC
Cloudbric
Cloudbric
H
Help Net Security
The GitHub Blog
The GitHub Blog
阮一峰的网络日志
阮一峰的网络日志
C
Cyber Attacks, Cyber Crime and Cyber Security
WordPress大学
WordPress大学
The Last Watchdog
The Last Watchdog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
博客园 - 【当耐特】
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
D
DataBreaches.Net
A
About on SuperTechFans
G
GRAHAM CLULEY
Forbes - Security
Forbes - Security
Hugging Face - Blog
Hugging Face - Blog
Martin Fowler
Martin Fowler
Vercel News
Vercel News
Cisco Talos Blog
Cisco Talos Blog
NISL@THU
NISL@THU
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Know Your Adversary
Know Your Adversary

博客园_首页

Plist 二进制格式 Milvus 和 PGVector,哪个更好? OpenClaw 已过时?在 VS Code 中运行 Hermes Agent! 第30篇文章:一个大三计科生的自白 Manim如何在数学公式中完美显示中文? Docker 部署 RocketMQ 5 并发编程核心概念辨析 C#事务处理最佳实践:别再让“主表存了、明细丢了”的破事发生 CLI 是什么?为什么大厂突然集体卷命令行? 【从0到1构建一个ClaudeAgent】协作-自主Agent UIImageView 设置图片不生效的原因排查 最小二乘问题详解20:无先验约束下的增量式SFM自由网平差 痞子衡嵌入式:大话双核i.MXRT1180之XIP应用里借助MU实现可靠Flash IAP的方法 AI Chat 封装, SemanticKerne.AiProvider.Unified 已发布 Windows下右键编辑js文件无法打开记事本——在注册表中使用环境变量 在后台服务中使用 Scoped 服务,为什么总是报错? H200 安装驱动并使用sglang启动模型 wireshark 抓包Trap上报告警内容 我用 AI 辅助开发了一系列小工具(2):图片压缩工具 [A Primer On MC and CC] 2.1 Memory Consistency 1 - 指令重排序和 SC 模型 Oracle数据库SCN推进技术详解与实践指南 玩转控件:封装个带图片的Label控件 Claude Code 4.7 真正该升级的不是模型,而是你的工作流 前端小白一句话,AI 帮我做了个颜值拉满的桌面媒体播放器。当代码不再是门槛,一句话编程就是现实。 5. WorkBuddy: 小龙虾的灵魂三件套,让你的小龙虾不只是工具 SQLite 分片方案实战:三种分片策略的深度对比 告别简陋 UI!一款基于 Fluent Design 和基于 WinUI 的开源免费、现代化的 Avalonia UI 控件库 关于二进制排列组合枚举的总结 AI开发-python-LangGraph框架(3-27-LangGraph从零实现大模型智能决策工作流) ElasticSearch主分片和副本分片概念详解 【002】HTTPS 粗解:证书、TLS 握手与对后端配置的影响 Hermes Agent 一周暴涨五万 Star,但我劝你别急着追 明明连接的是Redis的DB0,为什么能查到DB3的数据? 【从0到1构建一个ClaudeAgent】协作-Agent团队 熟悉电子元器件之后,电子小白下一步该怎么走? MAF快速入门(23)通过C#类定义Skills .NET 高级开发 | 手写一个对象映射框架 FastAPI数据库ORM怎么选?我肝了三个Demo后,终于不再纠结了 mysqldump 参数拾遗:在遗忘与铭记之间 C# .NET 周刊|2026年3月5期 Claude code入门 - 陈彦斌 一文学习入门 ThingsBoard 开源物联网平台 GitHub 热门项目 | 2026年04月16日 如何为GIT设置全局勾子,为每次提交追加信息 Number.isFinite和isFinite与isNaN()和Number.isNaN的区别 PortSwigger SQL注入LAB2 推荐一个测试人必备的Skills,从功能到性能全搞定(附详细实操和安装下载方式) 筑基期:掌握Odoo基础核心知识点02(Odoo XML 开发方式详解) GLM模型这么火,咱们用vllm也咧一个呗! 深入理解 AbortController:从底层原理到跨语言设计哲学 字符串学习笔记 多租户系统框架的基础模块设计和分析设计 Apache SeaTunnel Zeta 为什么能做到“又快又稳”? AI开发-python-LangGraph框架(3-26-LangGraph基本概念及第一个简单样例) Vue 3 组件通信,别只会用 Props 和 Emits 了,这几个狠活儿你得看看 ElasticSearch7.X版本配置密码 用Manim实现动态交点计算--从一个动点问题说起 团结引擎+Addressable+Instant Game打包抖音小游戏 function call 实战:让 LLM 自动判断 pod 异常、调用日志工具并完成故障分析 bubseek —— 让 Agent 的足迹,变成团队的洞察 通过 C# 读取并导出 PDF 书签 如何用 GitHub Actions 实现 Steam 自动化发布 【从0到1构建一个ClaudeAgent】并发-后台任务 .NET 高级开发 | 定制 ASP.NET Core 框架 电子小白:什么是运算放大器(运放) zero2Agent:面向大厂面试的 Agent 工程教程,从概念到生产的完整学习路线 堆上的ORW HC32F460 USB CDC通信异常:非对齐访问异常排查 20260413-Hyperbridge 攻击事件:发生在默克尔山上的验证绕过 那些喊着AI 要淘汰你的人,正在靠你的焦虑赚大钱! 深度学习进阶(八)Swin Transformer 最小二乘问题详解19:带先验约束的增量式SFM优化与实现 SnapTranslate 3.0 正式发布:全局划词翻译 + 完整英语学习闭环,一站式搞定查词、记词、复习 工作的意义、工作的困难认知再思考 .NET + AI 进阶实战:基于类的技能开发 - 打造可治理的 Agent 能力模块 【从0到1构建一个ClaudeAgent】规划与协调-技能 上周热点回顾(4.6-4.12) 电子小白的工具三件套:面包板、杜邦线、万能板 单表五亿数据的查询优化 | Mysql、StarRocks 2. WorkBuddy:从“我是谁”到“帮我干活” C# 如何减少代码运行时间:7 个实战技巧 基于HelixToolkit.SharpDX 渲染3D模型 - 笺上知微 从零开始的双臂具身VLA起源及现阶段发展综述 - SkyXZ 记对 xonsh shell 的使用, 脚本编写, 迁移及调优 - pluvium27 受够了Vibe Coding的失控?换个起点,让AI事半功倍 从开始配置漏洞环境到漏洞复现流程 - 難しい 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 - 虚无境 Any metadata 的内存布局 C# .NET 周刊|2026年3月2期 - InCerry 我帮你测过了,测试圈排名第二的 Skill 依然很牛逼 Skill Discovery | 无监督技能发现的经典工作总结 - MoonOut 上下文工程是什么?过时了么?一文讲明白! - 一枫说码 开了 TUN 模式还是直连?90% 的人都踩过这个坑 AScript扩展多种脚本语言 - rockey627 AI 学习笔记:Agent 的记忆机制 你能被装进一个文件里吗?——7 万人把同事"蒸馏"成了 AI - 我没有三颗心脏 Claude Code 通关手册(七):给 AI 装上技能包——Skills 完全指南 - 暮色之狐 在浏览器中快速编辑代码:VSCode Web 集成实践 - Newbe36524 蒸馏自己 skill?基于 Deepseek 的蒸馏器,丐版蒸馏方式,简单便捷 - To_Carpe_Diem Spring AI Aliababa和AgentScope,哪个更好? - 苏三说技术
前缀树(字典树、Trie)
myLv · 2026-05-08 · via 博客园_首页

一、概念

前缀树,英文名为Trie,也常被称作字典树、查找树,是一种专门用于存储、检索字符串集合的多叉树形数据结构。它的核心设计思想为共享公共前缀:将多个拥有相同前缀的字符串,共用树中相同的前缀节点,以此压缩存储空间、降低字符串查询的时间复杂度。最典型的特征是按字符分层存储,区别于普通二叉树,它没有固定的子节点数量,子节点由字符集决定(英文单词通常为26个小写英文字母)。

典例:输入法、搜索框自动补全/联想推荐(输入“我的世界”几乎是立刻显示出来以它为前缀的内容)
屏幕截图 2026-05-07 230834


二、性质

  1. 根节点不存储任何字符(一般用空格),仅作为入口
  2. 其他节点中,每个只存储一个字符,并且有一个结束标志freqs,以确定一个完整的单词(等于0说明从此开始往上直到根节点不是一个完整的单词,大于0说明是)
  3. 有相同前缀的字符串共享前缀节点,比如hello与he,有共同前缀he,那么存储h, e这两个字符的节点被它们共享:root--->h--->e--->l--->l--->o // 为了省空间所以横着了
  4. 字符唯一:同一个父节点下,不会出现重复的子字符
  5. 查询高效:字符串查询时间复杂度为 O(m),m为要查询的字符串的长度,和存入的单词总数无关
  6. 无回溯冗余:每条从根到结束节点的路径,唯一对应一个字符串

三、功能实现

// 节点
struct Node {
    char _c;    // 字符
    int _freqs; // 单词添加次数(_c为单词最后一个字母)
    map<char, Node*> _childNodes; // 存储子节点(map用的是红黑树,可以自动将字母排好序,a~z)
    Node(char c, int freqs) : _c(c), _freqs(freqs) {}
};

1)添加

要添加word,从根节点开始遍历,如果word[i]已经在树中了,那就继续遍历下一个字符,若下一个字符不在树中,即it == cur->_childNodes.end(),则创建含这个字符的节点并加入到前一个字符节点指向的map中
示例:添加a, app
屏幕截图 2026-05-07 235441
再添加apple, apply, ban, bank, cat, can,结果如图所示:
屏幕截图 2026-05-07 235713

C++实现代码:

void add(const string& word)
{
  Node *cur = _root;
    for (int i = 0; i < (int)word.size(); i++)
    {
        auto it = cur->_childNodes.find(word[i]);
        if (it == cur->_childNodes.end()) // 没有该字母,创建字母为word[i]的节点
        {
            Node *newNode = new Node(word[i], 0);
            cur->_childNodes.emplace(word[i], newNode);
            cur = newNode;
        } else cur = it->second;
    }
    cur->_freqs++; // 添加次数加1
}

2)删除

这个是最麻烦的步骤(只是相对而言,其实一点也不复杂✿◕‿◕✿)
分3种情况讨论:(我也说不清楚为什么只有这三种,感觉越说越模糊,所以读者可以自己分析吧🐔)

  1. 要删除的单词是某个单词的前缀
  2. 删除某个单词,但是该单词的前缀是另外一个单词
  3. 要删除某个单词,但是它和另外一个或多个单词有公共前缀
    屏幕截图 2026-05-08 000719
对于情况1:

因为如果直接删掉a,那么appleapply将一起消失,并且处理不当(没有delete掉p、p、l、e、y)还会造成内存泄漏,那怎么办呢?
还记得每个节点都有一个结束标志freqs吗?我们在添加a的时候,会将a节点freqs++,以此表示添加的单词在此结束,那么删除的话只要freqs = 0就可以了。

对于情况2和情况3:

首先,你可能会疑惑为什么2和3在一起讨论呢?因为它们的本质是一样的——不能删除公共前缀,只不过情况2中的前缀是一个单词,其末尾的freqs大于0,而情况3中前缀的freqs为0。那么,怎么处理这两种情况呢?
我们可以这么想:要删除某一段(即要删除的word-prefix部分),我们需要知道两个信息:(1)从哪里开始删;(2)删到哪里结束。接下来我们来分析怎么获知这两个信息。

情况2,要删除bank,但是不能去掉ban,直接利用freqs即可,因为字母n作为单词的最后一个字母,它的结束标志大于0:n->freqs > 0,所以可以利用这个信息,定义一个指针指向它Node *del = n,遍历到一个节点的freqs > 0,就让del = 该节点,这样的话我们就知道了开始的节点是n的map中单词bank对应的下一个字母k,而结束节点则是在遍历过程中就一直移动,使用cur表示,for循环遍历结束cur就指向最后一个节点。
情况3,要删除can,但是ca是公共的并且a->freqs == 0,所以不能使用freqs了!但是想想:有分叉那么a->map.size()一定大于1,所以遍历到一个节点的map.size() > 0,让del = 该节点,而结束节点和情况2一样。
最后:为了开始删除的时候不要再去遍历map找到从哪个字母开始删除,可以定义一个delCh来记录这个字母delCh = word[i]

Node *cur = _root; // 指向要删除单词的最后一个字母所在节点
Node *del = _root; // 指向要删除单词的首字母所在节点,即从del开始删
char delCh = word[0];
// 找出单词word的最后一个字母以及要开始删除的字母所在位置
for (int i = 0; i < (int)word.size(); i++)
{
    auto it = cur->_childNodes.find(word[i]);
    if (it == cur->_childNodes.end())
    {
        cout << "The word " << word << " does not exit in the Trie Tree!" << endl;
        return;
    }
    // 应对情况2和3
    if (cur->_freqs > 0 || cur->_childNodes.size() > 1)
    {
        del = cur;
        delCh = word[i];
    }
    cur = it->second;
}

然后是删除过程,情况1已经说了,情况2、3就用层次遍历的方式一个一个删即可:

if (cur->_childNodes.empty())
{
    Node *start = del->_childNodes[delCh];
    del->_childNodes.erase(delCh);

    queue<Node*> q;
    q.push(start);
    while (!q.empty())
    {                
        Node *cur = q.front();
        q.pop();
        for (const auto &p : cur->_childNodes)
            q.push(p.second);
        delete cur;
    }
}
// 情况1
else cur->_freqs = 0;

要函数整体的话直接加上void remove(const string& word)和一对花括号就可以了


3)查询

个人觉得这个没什么好讲的,和删除里的哪个for循环一样只不过不需要deldelCh部分了,返回类型设为int,返回单词的freqs,代码:

int query(const string& word)
{
    Node *cur = _root;
    for (int i = 0; i < (int)word.size(); i++)
    {
        const auto it = cur->_childNodes.find(word[i]);
        if (it == cur->_childNodes.end()) return 0; // 单词不存在
        else cur = it->second; // 当前遍历到的字母存在,继续查询下一个字母
    }
    return cur->_freqs;
}

4)遍历

这里的遍历主要是为了依据前缀查询的时候能够按字典序返回字符串,即搜索框中显示的是和你输入的字符串最相似的,所以使用先序遍历。得益于使用map来存储子节点,每个子节点的字母已经是排好序了,所以直接用一个for循环遍历每个节点再递归就OK了,和二叉树的的先序遍历差不多。但要注意查看节点的freqs是不是大于0,是的话要将这个单词记录;还有一个是根节点,不能把根节点的字符算进去。

/* 先序遍历递归接口 */
void preOrder(Node* node, string word, vector<string>& words)
{
    if (node != _root)
    {
        word.push_back(node->_c);
        if (node->_freqs > 0) words.emplace_back(word); // 遍历完一个单词
    }
    for (const auto &p : node->_childNodes)
        preOrder(p.second, word, words); // 递归遍历子节点
}

/* 先序遍历 */
void preOrder()
{
    vector<string> words;
    string word;
    preOrder(_root, word, words);
    cout << "\nPreorder traversal result:" << endl;
    cout << "-----------------------------------" << endl;
    for (const auto& w : words)
        cout << w << endl;
    cout << "-----------------------------------\n\n";
}

5)依据前缀查询

这个功能就是开头看到的输入“我的世界”显示出来以它为前缀的内容。它分为两个部分:(1)查询有没有以prefix为前缀的单词;(2)从prefix的最后一个字符开始,进行先序遍历,把所有以prefix为前缀的单词全部记录下来。

vector<string> findPrefix(const string& prefix)
{
    Node *cur = _root;
    for (int i = 0; i < (int)prefix.size(); i++)
    {
        const auto it = cur->_childNodes.find(prefix[i]);
        if (it == cur->_childNodes.end()) return {};
        else cur = it->second;
    }

    // for循环结束后cur就指向prefix的最后一个字母所在节点
    // 从该节点开始进行先序遍历
    vector<string> words;
    string start = prefix.substr(0, prefix.size() - 1);
    preOrder(cur, start, words); // 不能用prefix!因为最后一个字母会重复!

    return words;
}

四、注意事项

  1. 我只写了适用于英文字符的,中文的没有,感兴趣的话可以自己更改代码以实现。
  2. 完整的测试代码在最后,可以复制来看
  3. 博主是大学生,希望提高自己的水平,内容难免有错误和不合理的地方,如果亲爱的读者看到了,希望能够告知,谢谢❤️❤️
  4. 此外如果你觉得我的内容不错的话,能不能给我点个👍,你的支持与鼓励是我一步一步向前的重要动力🫶🫶

五、总结

从前缀树的核心概念、节点结构,到以a、app、apple等单词为例的分步插入演示,我们不难发现,这一专为字符串处理而生的树形结构,其核心魅力在于通过共享公共前缀实现空间压缩,同时让前缀查询、自动补全等操作的时间复杂度稳定在字符串长度级别,成为输入法联想、敏感词过滤、路由匹配等场景的高效解决方案;尽管它的实现逻辑在纯英文场景下直观易懂,但其“共享公共结构、优化重复数据处理”的思想,早已拓展至多字符集乃至更复杂的数据场景,不仅是算法面试中的高频考点,更是理解字符串高效处理的经典范式,为我们解决重复字符串的存储与检索问题提供了兼具空间与时间优势的清晰思路。

六、完整代码示例

#include <iostream>
#include <map>
#include <string>
#include <vector>
#include <queue>
using namespace std;

class TrieTree {
private:
    /* 节点 */
    struct Node {
        char _c;    // 字符
        int _freqs; // 单词添加次数(_c为单词最后一个字母)
        map<char, Node*> _childNodes; // 存储子节点(map用的是红黑树,可以自动将字母排好序,a~z)
        Node(char c, int freqs) : _c(c), _freqs(freqs) {}
    };

    Node *_root; // 根节点,存储的字母为空格

public:
    TrieTree()
    {
        _root = new Node('\0', 0);
    }

    ~TrieTree()
    {
        queue<Node*> q;
        q.push(_root);
        while (!q.empty())
        {
            Node *cur = q.front();
            q.pop();
            for (const auto& p : cur->_childNodes)
                q.push(p.second);
            delete cur;
        }
    }

private:
    /* 先序遍历递归接口 */
    void preOrder(Node* node, string word, vector<string>& words)
    {
        if (node != _root)
        {
            word.push_back(node->_c);
            if (node->_freqs > 0) words.emplace_back(word); // 遍历完一个单词
        }
        for (const auto &p : node->_childNodes)
            preOrder(p.second, word, words); // 递归遍历子节点
    }

public:
    /* 添加 */
    void add(const string& word)
    {
        Node *cur = _root;
        for (int i = 0; i < (int)word.size(); i++)
        {
            auto it = cur->_childNodes.find(word[i]);
            if (it == cur->_childNodes.end()) // 没有该字母,创建字母为word[i]的节点
            {
                Node *newNode = new Node(word[i], 0);
                cur->_childNodes.emplace(word[i], newNode);
                cur = newNode;
            } else cur = it->second;
        }
        cur->_freqs++; // 添加次数加1
    }

    /* 删除 */
    void remove(const string& word)
    {
        Node *cur = _root; // 指向要删除单词的最后一个字母所在节点
        Node *del = _root; // 指向要删除单词的首字母所在节点,即从del开始删
        char delCh = word[0];

        // 找出单词word的最后一个字母以及开始删除的字母所在位置
        for (int i = 0; i < (int)word.size(); i++)
        {
            auto it = cur->_childNodes.find(word[i]);
            if (it == cur->_childNodes.end())
            {
                cout << "The word " << word << " does not exit in the Trie Tree!" << endl;
                return;
            }
            // 应对情况2和3
            if (cur->_freqs > 0 || cur->_childNodes.size() > 1)
            {
                del = cur;
                delCh = word[i];
            }

            cur = it->second;
        }

        // 情况2、3
        if (cur->_childNodes.empty())
        {
            Node *start = del->_childNodes[delCh];
            del->_childNodes.erase(delCh);

            queue<Node*> q;
            q.push(start);
            while (!q.empty())
            {
                Node *cur = q.front();
                q.pop();
                for (const auto &p : cur->_childNodes)
                    q.push(p.second);
                delete cur;
            }
        }
        // 情况1
        else cur->_freqs = 0;
        cout << "You have removed " << word << '.' << endl;
    }

    /* 查询 */
    int query(const string& word)
    {
        Node *cur = _root;
        for (int i = 0; i < (int)word.size(); i++)
        {
            const auto it = cur->_childNodes.find(word[i]);
            if (it == cur->_childNodes.end()) return 0; // 单词不存在
            else cur = it->second; // 当前遍历到的字母存在,继续查询下一个字母
        }
        return cur->_freqs;
    }

    /* 前缀搜索 */
    vector<string> findPrefix(const string& prefix)
    {
        Node *cur = _root;
        for (int i = 0; i < (int)prefix.size(); i++)
        {
            const auto it = cur->_childNodes.find(prefix[i]);
            if (it == cur->_childNodes.end()) return {};
            else cur = it->second;
        }

        // for循环结束后cur就指向prefix的最后一个字母所在节点
        // 从该节点开始进行先序遍历
        vector<string> words;
        string start = prefix.substr(0, prefix.size()-1);
        preOrder(cur, start, words); // 不能用prefix!因为最后一个字母会重复!

        return words;
    }

    /* 先序遍历 */
    void preOrder()
    {
        vector<string> words;
        string word;
        preOrder(_root, word, words);
        cout << "\nPreorder traversal result:" << endl;
        cout << "-----------------------------------" << endl;
        for (const auto& w : words)
            cout << w << endl;
        cout << "-----------------------------------\n\n";
    }
};

int main()
{
    TrieTree trie;
    trie.add("TrieTree");
    trie.add("TrieTree");
    trie.add("Trie");
    trie.add("Hello");
    trie.add("He");
    trie.add("Helloo");
    trie.add("Avril");
    trie.add("Avril");
    trie.add("Av");
    trie.add("Avril");
    trie.add("Taylor");

    cout << trie.query("TrieTree")     << ' ';
    cout << trie.query("Trie")     << ' ';
    cout << trie.query("He")     << ' ';
    cout << trie.query("Hello")  << ' ';
    cout << trie.query("Avril")  << ' ';
    cout << trie.query("Taylor") << ' ';
    cout << trie.query("Av")     << endl;

    trie.preOrder();
    string remove = "Taylor";
    trie.remove(remove);
    trie.preOrder();

    string prefix = "Trie";
    vector<string> prefixes = trie.findPrefix(prefix);
    cout << "These words own the prefix: " << prefix << endl;
    cout << "-----------------------------------" << endl;
    for (const auto &word : prefixes)
        cout << word << endl;
    cout << "-----------------------------------\n\n";

    return 0;
}