【数据结构】【学习笔记】字典树
抱歉,笔者又偷懒了,这个大部分还是AI整理的。
不过核心很简单,就是!记录这个字符是不是终点,然后孩子是下一个字符。
字典树(Trie,又称前缀树 / Prefix Tree)是一种专门用于高效存储和检索字符串数据集的树形数据结构。
它的核心思想是利用字符串的公共前缀来减少无用比较与空间浪费。
字典树存入法:
- 根节点不存字符。
- 节点间的边或路径代表字母:从根节点走到某一节点,沿途字母拼起来就是对应的字符串。
- 结尾标记 isEnd:用布尔值标记当前节点是否为一个完整单词的结尾。
⚙️定义
- 多叉树结构:字典树(Trie)是一种按照字符前缀分叉的多叉树(\(M\)-ary Tree)。
- 树路径即数据:树上的每一个节点代表从根节点出发到该节点所形成的字符串前缀,根节点为空字符串 ""。
- 节点只作标识:数据不保存在节点内部,而是通过节点间的索引(键)形成逻辑路径;isEnd 属性负责将路径切分为独立的单词。
🔎特性
- 极高的时间效率:
- 插入与查询:时间复杂度均为 \(O(L)\)(\(L\) 为字符串长度),与词库中的字符串总数 \(N\) 无关。
- 空间优化与劣势:
- 优势:对含有大量相同公共前缀的数据集,可节省大量重复内存开销。
- 劣势:若数据前缀重合度极低,节点本身的动态开辟与指针/映射开销会导致较高的空间复杂度(最高为 \(O(\Sigma \cdot N \cdot L)\),\(\Sigma\) 为字符集大小)。
- 前缀匹配天花板:
- 比传统哈希表(HashMap)更强的地方在于:支持在 \(O(L)\) 内快速检索“是否存在以某个指定字符串开头的单词”。
🤓实现
#include <unordered_map>
#include <string>
using namespace std;
// 1. 字典树节点定义
struct TrieNode {
unordered_map<char, TrieNode*> children; // 分支路标
bool isEnd = false; // 终点标记
};
// 2. 字典树功能类封装
class Trie {
private:
TrieNode* root;
public:
Trie() {
root = new TrieNode();
}
// 插入单词:耗时 O(L)
void insert(const string& word) {
TrieNode* node = root;
for (char c : word) {
if (!node->children.count(c)) {
node->children[c] = new TrieNode();
}
node = node->children[c];
}
node->isEnd = true; // 标记完整单词结尾
}
// 查询完整单词是否存在:耗时 O(L)
bool search(const string& word) {
TrieNode* node = root;
for (char c : word) {
if (!node->children.count(c)) return false;
node = node->children[c];
}
return node->isEnd; // 必须匹配到单词句号才算存在
}
// 查询是否存在指定前缀:耗时 O(L)
bool startsWith(const string& prefix) {
TrieNode* node = root;
for (char c : prefix) {
if (!node->children.count(c)) return false;
node = node->children[c];
}
return true; // 只要能顺畅走完前缀路径即为 true
}
};
它的精妙全在路径(结构)里,而不是在节点(数据)本身:
-
Children(分支):扮演“路标”的角色。实际上字符本身根本不存在节点里面,而是作为字典的 Key 充当边(Edge)。你从根节点一路往下滑,沿途踩过的 Key 拼起来就是那个单词。
-
IsEnd(终点标记):扮演“句号”的角色。因为前缀树里到处都是半成品的路径(比如 "app" 是 "apple" 的前缀),必须有个布尔值明确告诉你:“走到这里,已经是一个独立且合法的词了”。
❔题目
1. 最长公共前缀
解题思路
最简单的思路,就是把第一个字符串当公共前缀,然后将公共前缀与第二个字符串开始相比较,获得最长公共前缀。
但是这个明显很慢啦。
横向太慢啦,所以我们可以用纵向扫描法。
从第一个字符串的第 0 列字符开始,依次对比所有字符串对应位置的字符。一旦遇到字符不一致或到达某个字符串末尾,即可立刻截取并返回当前前缀。
不过,我们是在学习字典树,所以应该思考下为什么要看这道题呢?
其实这个纵向查找,就是可以接触到一点点字典树的思路了。
实现
function longestCommonPrefix(strs: string[]): string {
if (!strs || strs.length === 0) return "";
// 纵向遍历第一个字符串的每一个字符列 i
for (let i = 0; i < strs[0].length; i++) {
const char = strs[0][i];
// 检查其余字符串在第 i 列上的字符
for (let j = 1; j < strs.length; j++) {
// 走到某个字符串末尾,或出现不匹配字符,直接返回 [0, i) 截取结果
if (i === strs[j].length || strs[j][i] !== char) {
return strs[0].substring(0, i);
}
}
}
return strs[0];
}
public class Solution {
public string LongestCommonPrefix(string[] strs) {
if (strs == null || strs.Length == 0) return "";
// 纵向遍历第一个字符串的每一个字符列 i
for (int i = 0; i < strs[0].Length; i++) {
char c = strs[0][i];
// 检查其余字符串在第 i 列上的字符
for (int j = 1; j < strs.Length; j++) {
// 走到某个字符串末尾,或出现不匹配字符,直接返回 [0, i) 截取结果
if (i == strs[j].Length || strs[j][i] != c) {
return strs[0].Substring(0, i);
}
}
}
return strs[0];
}
}
class Solution {
public:
string longestCommonPrefix(vector<string>& strs) {
if (strs.empty()) return "";
// 纵向遍历第一个字符串的每一个字符列 i
for (int i = 0; i < strs[0].size(); ++i) {
char c = strs[0][i];
// 检查其余字符串在第 i 列上的字符
for (int j = 1; j < strs.size(); ++j) {
// 到达某个字符串末尾,或出现不匹配字符,截取 [0, i) 返回
if (i == strs[j].size() || strs[j][i] != c) {
return strs[0].substr(0, i);
}
}
}
return strs[0];
}
};
复杂度分析
- 时间复杂度:最坏情况下为 \(O(S)\)(\(S\) 为所有字符串字符数之和)。
- 空间复杂度:\(O(1)\)
只使用辅助指针,没有额外空间开销。
2. 词典中最长的单词
解题思路
题目的意思就是——必须从 1 个字母开始接龙,且接龙过程中的每一个中间前缀,都必须是字典里存在的单词。
- 如果一个单词可以被“合法构建”,意味着从根节点走到这个单词末尾的路径上,每一个字母节点都必须标记为 isEnd = true。
- 只要路径上有任何一个中间节点的 isEnd 是 false,说明缺失了某个中间前缀单词,该单词就不合格。
实现
class TrieNode {
children: Map<string, TrieNode> = new Map();
isEnd: boolean = false;
}
function longestWord(words: string[]): string {
const root = new TrieNode();
// 1. 将所有单词插入字典树
for (const word of words) {
let node = root;
for (const char of word) {
if (!node.children.has(char)) {
node.children.set(char, new TrieNode());
}
node = node.children.get(char)!;
}
node.isEnd = true; // 标记完整单词结尾
}
let ans = "";
// 2. 检查每个单词从根节点走过来的路径上,是否每个节点都是完整单词
for (const word of words) {
let node = root;
let isValid = true;
for (const char of word) {
node = node.children.get(char)!;
// 只要沿途有任何一个前缀不是完整单词,直接断链淘汰
if (!node.isEnd) {
isValid = false;
break;
}
}
// 3. 筛选最长且字典序最小的单词
if (isValid) {
if (word.length > ans.length || (word.length === ans.length && word < ans)) {
ans = word;
}
}
}
return ans;
}
public class TrieNode {
public Dictionary<char, TrieNode> Children = new Dictionary<char, TrieNode>();
public bool IsEnd = false;
}
public class Solution {
public string LongestWord(string[] words) {
TrieNode root = new TrieNode();
// 1. 将所有单词插入字典树
foreach (string word in words) {
TrieNode node = root;
foreach (char c in word) {
if (!node.Children.ContainsKey(c)) {
node.Children[c] = new TrieNode();
}
node = node.Children[c];
}
node.IsEnd = true;
}
string ans = "";
// 2. 检验路径上的每一个前缀是否都是合法单词
foreach (string word in words) {
TrieNode node = root; // 每次都从树根出发
bool isValid = true; // 默认假设这个单词可以成功接龙
// 逐个字符在树上往下走
foreach (char c in word) {
node = node.Children[c]; // 移动到子节点
// 【最关键的一行】
// 只要沿途遇到的某个字符节点 isEnd 为 false
// 说明缺失了某个中间前缀单词,接龙断裂!
if (!node.IsEnd) {
isValid = false; // 标记为无效
break; // 后面不用再看了,直接放弃这个单词
}
}
// 如果一路顺畅走完,说明每一个前缀都是合法单词
if (isValid) {
// 更新答案:找最长的;如果长度一样,找字典序更小的
if (word.Length > ans.Length || (word.Length == ans.Length && string.Compare(word, ans) < 0)) {
ans = word;
}
}
}
return ans;
}
}
struct TrieNode {
unordered_map<char, TrieNode*> children;
bool isEnd = false;
};
class Solution {
public:
string longestWord(vector<string>& words) {
TrieNode* root = new TrieNode();
// 1. 将所有单词插入字典树
for (const string& word : words) {
TrieNode* node = root;
for (char c : word) {
if (!node->children.count(c)) {
node->children[c] = new TrieNode();
}
node = node->children[c];
}
node->isEnd = true;
}
string ans = "";
// 2. 校验每个单词从根节点下探的路径
for (const string& word : words) {
TrieNode* node = root;
bool isValid = true;
for (char c : word) {
node = node->children[c];
if (!node->isEnd) {
isValid = false;
break;
}
}
// 3. 长度优先;长度相同时取字典序较小者
if (isValid) {
if (word.length() > ans.length() || (word.length() == ans.length() && word < ans)) {
ans = word;
}
}
}
return ans;
}
};
复杂度分析
- 时间复杂度:\(O(\sum L)\),其中 \(\sum L\) 为所有单词的字母总数。
建树耗时 \(O(\sum L)\),查询校验耗时 \(O(\sum L)\)。 - 空间复杂度:\(O(\sum L)\)
用于存储字典树节点。
3. 字典序的第K小数字
解题思路
首先得理解题目到底在讲个啥:什么是数字的字典序?
把数字按字母表(字典)的方式排序。比较时从左到右逐位比较:
- 当 \(n = 13\) 时,普通数值排序是:1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13
- 字典序排序后是:1, 10, 11, 12, 13, 2, 3, 4, 5, 6, 7, 8, 9
- 为什么 "10" 排在 "2" 前面?因为 "10" 开头是字符 '1',比 '2' 小。
解法:树上跳跃算法(计数与跳跃)
假设当前指针在节点 curr 上:
- 统计子树节点数:算出以 curr 为根节点的子树里,有多少个不超过 \(n\) 的节点(设节点数为 steps)。
- 分支判断:
- 若 steps <= k:说明第 \(k\) 个数字不在当前子树里。我们可以直接跳过这棵子树,指针右移到邻居 curr = curr + 1,并扣除步数 k = k - steps。
- 若 steps > k:说明第 \(k\) 个数字一定在当前子树里。
我们向下深入一层 curr = curr * 10,并扣除 1 步 k = k - 1(消耗掉 curr 节点本身)。
实现
function findKthNumber(n: number, k: number): number {
// 计算以 curr 为根的子树包含的数字个数
function getSteps(curr: number, n: number): number {
let steps = 0;
let first = curr;
let last = curr;
while (first <= n) {
steps += Math.min(n, last) - first + 1;
first *= 10;
last = last * 10 + 9;
}
return steps;
}
let curr = 1;
k--; // 扣除起点 1 的步数
while (k > 0) {
const steps = getSteps(curr, n);
if (steps <= k) {
k -= steps;
curr++; // 向右跳到同层相邻节点
} else {
k -= 1;
curr *= 10; // 向下深入子树
}
}
return curr;
}
public class Solution {
// 【核心辅助函数】:计算在 [1, n] 的范围内,以 curr 为前缀(根节点)的子树一共有多少个节点
private long GetSteps(long curr, long n) {
long steps = 0; // 统计节点总数
// first 指向当前层最左边的节点(最小值)
// last 指向当前层最右边的节点(最大值,不考虑 n 的限制时)
long first = curr;
long last = curr;
// 只要当前层的最左节点还没有超出上限 n,就可以继续向下按层统计
while (first <= n) {
// 当前层的实际有效节点个数:
// 因为不能超过上限 n,所以右边界取 Math.Min(n, last)
// 节点数 = 右边界 - 左边界 + 1
steps += Math.Min(n, last) - first + 1;
// 深入到下一层:
// 例如 curr = 1,当前层 [1, 1] ➔ 下一层最左变成 10,最右变成 19
first *= 10;
last = last * 10 + 9;
}
return steps; // 返回该子树包含的所有节点个数
}
public int FindKthNumber(int n, int k) {
long curr = 1; // 字典序最小的起始数字永远是 1(树的第一个主根节点)
k--; // 已经站在数字 1 上了,相当于消耗掉了第 1 个名额,还需要走 k - 1 步
// 当 k > 0 时,说明还没有到达目标位置,继续在树上导航
while (k > 0) {
// 1. 计算以当前节点 curr 为根的子树里,一共有多少个不超过 n 的节点
long steps = GetSteps(curr, n);
// 2. 情况一:子树节点总数 <= 剩需要的步数 k
// 说明第 k 个目标【不在】这棵子树里,我们可以直接“横向跳过”整棵子树
if (steps <= k) {
k -= (int)steps; // 扣除这棵子树里的所有节点数量
curr++; // 指针向右平移,移动到同层的下一个相邻节点(例如从 1 跳到 2)
}
// 3. 情况二:子树节点总数 > 剩需要的步数 k
// 说明第 k 个目标【一定在】这棵子树内部,必须“向下深入”寻找
else {
k -= 1; // 消耗掉当前节点 curr 本身(深入一层相当于走了一步)
curr *= 10; // 指针向下深入到下一层的第一个子节点(例如从 1 深入到 10)
}
}
return (int)curr; // k 减为 0 时,当前的 curr 即为字典序第 k 小的数字
}
}
class Solution {
// 计算以 curr 为根节点的子树在 [1, n] 范围内总共有多少个节点
long getSteps(long curr, long n) {
long steps = 0;
long first = curr, last = curr;
while (first <= n) {
steps += min(n, last) - first + 1; // 累加当前层的节点数
first *= 10; // 下一层的最左节点
last = last * 10 + 9; // 下一层的最右节点
}
return steps;
}
public:
int findKthNumber(int n, int k) {
long curr = 1;
k--; // 起点已经是 1,消耗 1 步
while (k > 0) {
long steps = getSteps(curr, n);
if (steps <= k) {
// 目标不在当前子树,向右跨越整棵子树
k -= steps;
curr++;
} else {
// 目标在当前子树内,向下深入一层
k -= 1;
curr *= 10;
}
}
return curr;
}
};
复杂度分析
- 时间复杂度:\(O((\log_{10} n)^2)\)
外层循环控制树上的移动次数,最多移动 \(O(\log_{10} n)\) 次;
内层 getSteps 统计子树节点数量耗时 \(O(\log_{10} n)\)。 - 空间复杂度:\(O(1)\)
完全在指针上做迭代计算,无额外开销。
引用
[1] 力扣探索模式
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

浙公网安备 33010602011771号