AIGC标识 【数据结构】【学习笔记】字典树

抱歉,笔者又偷懒了,这个大部分还是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 上:

  1. 统计子树节点数:算出以 curr 为根节点的子树里,有多少个不超过 \(n\) 的节点(设节点数为 steps)。
  2. 分支判断:
    • 若 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 辅助整理。

posted @ 2026-08-31 00:32  SEHOD  阅读(0)  评论(0)    收藏  举报