diff --git a/Trie/01-introduction.md b/Trie/01-introduction.md new file mode 100755 index 0000000..408ccab --- /dev/null +++ b/Trie/01-introduction.md @@ -0,0 +1,42 @@ +# Trie 算法 + +作者:liuhaotian;审核: + +## Trie 简介 + +Trie 树,又称字典树、前缀树,是一种实现字符串快速检索的多叉树结构。典型应用是统计、排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计,也被用于编译器的代码自动补全功能。 + +## Trie 题型 + +1、串的快速检索 + +给出 $N$ 个单词组成的熟词表,以及一篇全用小写英文书写的文章,请你按最早出现的顺序写出所有不在熟词表中的生词。 + +在这道题中,我们可以用数组枚举,用哈希,但是用字典树,先把熟词建一棵树,然后读入文章进行比较,这种方法效率是比较高的。 + +2、串排序 + +给定 $N$ 个互不相同的仅由一个单词构成的英文名,让你将他们按字典序从小到大输出。 + +用字典树进行排序,采用数组的方式创建字典树,这棵树的每个结点的所有儿子很显然地按照其字母大小排序。对这棵树进行先序遍历即可。 + +3、最长公共前缀 + +对所有串建立字典树,对于两个串的最长公共前缀的长度即他们所在的结点的公共祖先个数,于是,问题就转化为当时公共祖先问题。 + +## 时间与空间复杂度 + +我们知道,字符串有很多都有相同的前缀,对于相同的前缀,Trie 树只存储一次,虽然也耗费很大空间,但在某些情况下,Trie 树更省空间。 + +Trie 树利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较。在 Trie 树中查找字符串的时间复杂度只与树的深度有关,与有多少个字符串无关,而树的深度只跟字符串的长度有关,例如当用 Trie 树来存储单词时,因为超过 30 个拉丁字母的英文单词微乎其微,所以在某些问题中查找字符串的时间复杂度只有 $O(1)$ 。 + +## Trie 的两个模板 + ++ 模板一:用数组模拟 Trie ; ++ 模板二:用链表模拟 Trie ; + +二者原理相同,本文在数组模拟 Trie 的文章中给出推理过程与模板,在链表模拟 Trie 的文章中直接给出模板(因为原理与数组模拟 Trie 相同),算法竞赛时用数组模拟 Trie 的情况较多,面试找工作中用链表模拟 Trie 的情况较多。 + +## 学习建议 + +Trie 一共有三种基本操作:插入、查询、删除,由于删除很少见,所以这里只给出了插入和查询的模板,写成模板只是为了方便大家学习,但是学习算法更重要的是掌握思想,大家一定要手动敲 $+∞$ 遍模板,烂熟于胸以后,其他的基础操作(例如查询前缀)都是手到擒来罢了。熟悉以后,这些模板都无需且不应该记忆,编码应该是十分自然的事情。 diff --git a/Trie/02-template-1.md b/Trie/02-template-1.md new file mode 100755 index 0000000..941581a --- /dev/null +++ b/Trie/02-template-1.md @@ -0,0 +1,66 @@ +# 用数组模拟 Trie + +## 思想与模板 + +Trie 的每个节点都拥有若干个字符指针,若在插入或检索字符串时扫描到一个字符 $C$ ,就沿着当前节点的 $C$ 字符指针,走到该指针指向的节点。下面我们来详细讨论 Trie 的基本操作过程: + +1. 初始化:一棵空 Trie 仅包含一个根节点,该节点的字符指针均指向空。 +2. 插入:当插入一个字符串 $S$ 时,我们令一个指针 $P$ 起初指向根节点,然后依次扫描 $S$ 中的每个字符 $C$ : + 1. 若 $P$ 的 $C$ 字符指针指向一个已经存在的节点 $Q$ ,则令 $P=Q$ ; + 2. 若 $P$ 的 $C$ 字符指针指向空,则新建一个节点 $Q$ ,令 $P$ 的 $C$ 字符指针指向 $Q$,然后令 $P=Q$ ; + 3. 当 $S$ 中的字符扫描完毕时,在当前 $P$ 节点上标记它是一个字符串的末尾; +3. 检索:当需要检索一个字符串 $S$ 在 Trie 中是否存在时,我们令一个指针 $P$ 起初指向根节点,然后依次扫描 $S$ 中的每个字符 $C$ 。 + 1. 若 $P$ 的 $C$ 字符指针指向空,则说明 $S$ 没有被插入过 Trie ,结束检索; + 2. 若 $P$ 的 $C$ 字符指针指向一个已经存在的节点 $Q$ ,则令 $P=Q$ ; + 3. 当 $S$ 中的字符串扫描完毕时,若当前节点 $P$ 被标记为一个字符串的末尾,则说明 $S$ 在 Trie 中存在,否则说明 $S$ 没有被插入过 Trie ; + +![](https://ae01.alicdn.com/kf/H028b6926ac04486482df0d116e570122e.jpg) + +在上图所示的例子中,需要插入和检索的字符串都由小写字母构成,所以 Trie 的每个节点具有 26 个字符指针,分别为 $a$ 到 $z$ 。上图展示了在一棵空树中依次插入``code``、``cool``、``coder``后的 Trie 形态,绿色标记了单词的末尾节点。可以看出在 Trie 中,字符数据都体现在树的边(指针)上,树的节点仅保存一些额外信息,例如单词结尾标记等,其空间复杂度是 $O(NC)$ ,其中 $N$ 是节点个数,$C$ 是字符集的大小。 + +如果按上文所说,我们可以在每个节点设一个布尔类型的值代表其是否为结尾,但是这种操作无法记录以此节点为末尾的字符串的数量,所以我们可以在每个节点用一个整型的值来代替其布尔类型的值,记录以此节点为末尾的字符串的数量。 + + + +#### **C++** + +```C++ +// son[][]存储树中每个节点的子节点 +int son[N][26]; +// cnt[]存储以每个节点结尾的单词数量 +int cnt[N]; +// idx为节点的编号 +int idx; + +// 0号点既是根节点,又是空节点 + +// 插入一个字符串 +void insert(char *str) { + int p = 0; + for (int i = 0; str[i]; i++) { + int u = str[i] - 'a'; + if (!son[p][u]) son[p][u] = ++idx; + p = son[p][u]; + } + cnt[p]++; +} + +// 查询字符串出现的次数 +int query(char *str) { + int p = 0; + for (int i = 0; str[i]; i++) { + int u = str[i] - 'a'; + if (!son[p][u]) return 0; + p = son[p][u]; + } + return cnt[p]; +} +``` + + + +## 练习题 + +「力扣」第 208 题:[实现Trie(前缀树)](https://leetcode-cn.com/problems/implement-Trie-prefix-tree/)。 + +此题为实现一个 Trie 树,通过此题可以检验大家的理解程度,也可以待掌握链表模拟 Trie 后写。 \ No newline at end of file diff --git a/Trie/03-template-2.md b/Trie/03-template-2.md new file mode 100755 index 0000000..abd8c74 --- /dev/null +++ b/Trie/03-template-2.md @@ -0,0 +1,50 @@ +# 用链表模拟 Trie + +## 模板 + + + +#### **C++** + +```C++ +struct Trie { + int isEnd; + Trie* next[26]; + Trie() { + isEnd = 0; + memset(next, 0, sizeof next); + } +}; + +Trie* root = new Trie(); + +// 插入函数 +void insert(const string& word) { + Trie* node = root; + for (char c : word) { + if (node->next[c - 'a'] == nullptr) { + node->next[c - 'a'] = new Trie(); + } + node = node->next[c - 'a']; + } + node->isEnd++; +} + +// 查询函数 +bool search(const string& word) { + Trie* node = root; + for (const auto& w : word) { + if (node->next[w - 'a'] == nullptr) return false; + node = node->next[w - 'a']; + } + return node->isEnd; +} +``` + + + +## 练习题 + +「力扣」第 208 题:[实现Trie(前缀树)](https://leetcode-cn.com/problems/implement-trie-prefix-tree/)。 + +此题为实现一个 Trie 树,通过此题可以检验大家的理解程度。 \ No newline at end of file diff --git a/Trie/04-examples.md b/Trie/04-examples.md new file mode 100755 index 0000000..8251abc --- /dev/null +++ b/Trie/04-examples.md @@ -0,0 +1,174 @@ +# 精选例题 + +这里给出两道经典例题的思路,熟练掌握思想之后,「力扣」中等难度的 Trie 类型题应该没有问题了。 + +## 例题1 + +*** + +给定 $N$ 个字符串 $S1,S2…SN$ ,接下来进行 $M$ 次询问,每次询问给定一个字符串 $T$ ,求 $S1~SN$ 中有多少个字符串是 $T$ 的前缀。输入字符串的总长度不超过 $10^6$ ,仅包含小写字母。 + +输入格式 + +第一行输入两个整数 $N$ ,$M$ 。 + +接下来 $N$ 行每行输入一个字符串 $Si$。 + +接下来 $M$ 行每行一个字符串 $T$ 用以询问。 + +输出格式 + +对于每个询问,输出一个整数表示答案。 + +每个答案占一行。 + +*** + +把这 $N$ 个字符串插入一棵 Trie 树,Trie 树的每个节点上存储一个整数 $cnt$ ,记录该节点是多少个字符串的末尾节点(为了处理插入重复字符串的情况,这里要记录个数,而不能只做结尾标记),对于每个询问,在 Trie 树中检索要查询的串的每个子串,在检索的过程中累加每次子串查询得到的 $cnt$ 值,最后得到最终答案 + +*** + +```C++ +#include +using namespace std; + +const int N = 1e6 + 10; +int son[N][26], cnt[N], idx; + +void insert(string str) { + int p = 0; + for (int i = 0; str[i]; ++i) { + int temp = str[i] - 'a'; + if (!son[p][temp]) son[p][temp] = ++idx; + p = son[p][temp]; + } + cnt[p]++; +} + +int query(string str) { + int p = 0; + for (int i = 0; str[i]; ++i) { + int temp = str[i] - 'a'; + if (!son[p][temp]) return 0; + p = son[p][temp]; + } + return cnt[p]; +} + +int main() { + int n, m; + cin >> n >> m; + for (int i = 0; i < n; i++) { + string s; + cin >> s; + insert(s); + } + while (m--) { + string s; + cin >> s; + int ans = 0; + for (int i = 0; s[i]; i++) { + ans += query(s.substr(0, i + 1)); + } + cout << ans << endl; + } + return 0; +} +``` + +## 练习题 + +「力扣」第 648 题:[单词替换](https://leetcode-cn.com/problems/replace-words/)。 + +## 例题2 + +「力扣」第 421 题:[数组中两个数的最大异或值](https://leetcode-cn.com/problems/maximum-xor-of-two-numbers-in-an-array/)。 + +*** + +在给定的 $N$ 个整数 $A1,A2……AN$ 中选出两个进行 $xor$(异或)运算,得到的结果最大是多少? + +输入格式 + +第一行输入一个整数 $N$ 。 + +第二行输入 $N$ 个整数 $A1~AN$。 + +输出格式 + +输出一个整数表示答案。 + +数据范围 + +$1≤N≤10^5, 0≤Ai<2^{31}$ + +*** + +我们首先想到的是朴素算法,暴力的在所有数中枚举两个数使这两个数做 $XOR$ 运算的值最大,但是由于数据范围过大,这种算法是超时的,所以我们需要考虑其他思路。 + +朴素的算法是两层嵌套的 ``for`` 循环,我们可以优化朴素算法,借助 Trie 把第二层 ``for`` 循环从 ``O(N)`` 优化到 $O(31)$ ,这样其时间复杂度就由 $O(N^2)$ 变成了 $O(31*N)$。 + +我们可以把每个整数看作其二进制位数为 31 的 ``01`` 字符串,当数值较小时在前补 ``0``(因为题目要求所有数大于 ``0`` ,而最高位存储的是符号位,所以最高位一定为 ``0`` ,做异或运算无意义,因此为 31 位),我们把每个数的二进制串插入到 Tire 中(其中叶子节点为最低位),接下来假如第一重 ``for`` 循环枚举到 ``Ai`` ,那我们需要找到与 ``Ai`` 对应的整数,使其与 ``Ai`` 做异或运算的值最大,当我们从最高位开始找 ``Ai`` 对应的整数时,因为 ``XOR`` 运算“相同得 ``0`` ,不同得 ``1`` ”的性质,我们每次都希望找到与 ``Ai`` 对应位的相反的数(为 ``0`` 找 ``1`` ,为 ``1`` 找 ``0`` ),这样才能使两个数对应位做异或运算后为 ``1`` ,得到的值才尽可能大,如果“与 ``Ai`` 的当前为相反的字符指针”指向空节点,则只好访问与 ``Ai`` 当前位相同的字符指针,这样就可以找到所有数中和 ``Ai`` 做异或运算值最大的数。 + +![](https://ae01.alicdn.com/kf/H49d1b2d9a173404ab50a08f5f004d0b84.jpg) + +这就是这道题的思路 + +*** + +```C++ +#include +using namespace std; + +const int N = 1e5 + 10; +const int M = 3100000; + +int son[M][2], a[N], idx; + +void insert(int x) { + int p = 0; + for (int i = 30; ~i; --i) { + int u = (x >> i) & 1; + if (!son[p][u]) son[p][u] = ++idx; + p = son[p][u]; + } +} + +int search(int x) { + int p = 0, ans = 0; + for (int i = 30; ~i; --i) { + int u = (x >> i) & 1; + if (son[p][!u]) { + ans += (1 << i); + p = son[p][!u]; + } else { + p = son[p][u]; + } + } + return ans; +} + +int main() { + int n; + cin >> n; + for (int i = 0; i < n; i++) { + cin >> a[i]; + insert(a[i]); + } + + int ans = 0; + + for (int i = 0; i < n; i++) { + ans = max(ans, search(a[i])); + } + + cout << ans << endl; + return 0; +} +``` + +## 练习题 + +「力扣」第 745 题:[前缀和后缀搜索](https://leetcode-cn.com/problems/prefix-and-suffix-search/)。 + +这道题有一定难度,但是思想与本题一样,既然数字可以灵活转换为二进制存储,那么这道题你有什么想法呢? \ No newline at end of file diff --git a/Trie/05-summary.md b/Trie/05-summary.md new file mode 100755 index 0000000..15f272f --- /dev/null +++ b/Trie/05-summary.md @@ -0,0 +1,35 @@ +# 总结 + +## 理解 + +建立一棵树,从根节点开始,判断有没有该类字符,有就向下,没有就添加叶节点,依次存储,把所有结尾点标记一下,然后用 Trie 高速查找某一个字符出现的次数。 + +## 存储类型 + +1、当 Trie 存数字时,按二进制位从高位到低位存(题目数据会小于第 32 位符号位的) + +2、当 Trie 存 26 位小写字母组成的字符串时,从字符串的第 0 位开始存 + +## 核心思想 + +Trie 的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。 + +## 生活应用 + +在已经基本掌握了 Trie 结构后,再提起一些应用场景来大家就会大概明白些 + +1、 自动补全 + +2、 拼写检查 + +3、IP 路由 (最长前缀匹配) + +## 比较 + +还有其他的数据结构,如平衡树和哈希表,使我们能够在字符串数据集中搜索单词。为什么我们还需要 Trie 树呢?尽管哈希表可以在 $O(1)$ 时间内寻找键值,却无法高效的完成以下操作: + +找到具有同一前缀的全部键值。 + +按词典序枚举字符串的数据集。 + +Trie 树优于哈希表的另一个理由是,随着哈希表大小增加,会出现大量的冲突,时间复杂度可能增加到 $O(n)$ ,其中 $n$ 是插入的键的数量。与哈希表相比,Trie 树在存储多个具有相同前缀的键时可以使用较少的空间。此时 Trie 树只需要 $O(m)$ 的时间复杂度,其中 $m$ 为键长。而在平衡树中查找键值需要 $O(mlogn)$ 时间复杂度。 diff --git a/Trie/06-practices.md b/Trie/06-practices.md new file mode 100755 index 0000000..b702448 --- /dev/null +++ b/Trie/06-practices.md @@ -0,0 +1,22 @@ +# 精选练习 + +「力扣」上关于 Trie 的题型一共 18 道,不是很多,推荐大家都做完。 + +## 下面列出必做题 + +| 题目 | 提示 | +| ------------------------------------------------------------ | -------------------------------------- | +| [208. 实现 Trie (前缀树)](https://leetcode-cn.com/problems/implement-trie-prefix-tree/)(必做) | 非常好的使用模板的练习。| +| [648. 单词替换](https://leetcode-cn.com/problems/replace-words/)(必做) | 掌握了这道题就基本掌握了前缀查询思想。| +| [677. 键值映射](https://leetcode-cn.com/problems/map-sum-pairs/)(必做) | 一道基础练手题。| +| [211. 添加与搜索单词 - 数据结构设计](https://leetcode-cn.com/problems/add-and-search-word-data-structure-design/)(必做) | 一道很灵活的基础练手题。| +| [720. 词典中最长的单词](https://leetcode-cn.com/problems/longest-word-in-dictionary/)(必做)|一道很灵活的基础练手题。| +(本文完) + +参考文献: + +1、算法进阶指南(李煜东) + +2、力扣官方题解 + +3、百度百科 \ No newline at end of file diff --git a/_sidebar.md b/_sidebar.md index ea8e348..5775970 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -5,4 +5,15 @@ - [模板三](/BinarySearch/04-template-3.md) - [例题](/BinarySearch/05-examples.md) - [练习](/BinarySearch/06-practices.md) +- Trie + - [01-introduction](/Trie/01-introduction.md) + - [02-template-1](/Trie/02-template-1.md) + - [03-template-2](/Trie/03-template-2.md) + - [04-examples](/Trie/04-examples.md) + - [05-summary](/Trie/05-summary.md) + - [06-practices](/Trie/06-practices.md) + + + +