字典树
字典树:又称单词查找树(Trie树,又称前缀树),是一种哈希树的变种。典型应用是用于统计、排序和保存大量的字符串(但不 仅限于字符串),所以常被用于文本词频统计。 可用于快速查询「某个字符串/字符前缀」是否存在的数据结构。还可用于高效的存储和检索字符串数据集中的键。其核心是使用"边"来代表有无字符,使用"点"来记录是否为"单词结尾"以及“其后续字符串的字符是什么”。
优点:利用字符串的公共前缀来减少查询时间,尽量减少无谓的字符串比较,查询效率比哈希树高。
性质:
根节点不包含字符串,除根节点外每一个节点都只包含一个字符;
从根节点到某一结点,路径上经过的字符连接起来,为该节点对应的字符串;
每个节点的所有子节点包含的字符都不相同;
基本操作:查找、插入、删除(少见);
实现方法:
从根节点开始一次搜索;
取得要查找的第一个字母,并根据该字母选择对应的子树并转到该子树继续进行检索;
在相应的子树上,取得要查找关键词的第二个字母,并进一步选择对应的子树进行检索;
迭代过程……;
在某个节点处,关键词的所有字母已被取出,则读取附在该节点上的信息,即完成查找; 其它操作类似处理…
应用
串的快速检索
给出N个单词组成的熟词表,以及一篇全用小写英文书写的文章,请你按最早出现的顺序写出所有不在熟词表中的生词。 —— 在这道题中,我们可以用数组,枚举,用哈希,用字典树,先把熟词建一棵树,然后读入文章进行比较, 这种方法效率是比较高的。
“串”排序
给定N个互不相同的仅由一个单词构成的英文名,让你将他们按字典序从小到大输出;—— 用字典树进行排序,采用数组的方式创建字典树,这棵树的每个结点的所有儿子很显然地按照其字母大小 排序。对这棵树进行先序遍历即可。
最长公共前缀
对所有串建立字典树,对于两个串的最长公共前缀的长度即他们所在的结点的公共祖先个数,于是,问题 就转化为当时公共祖先问题。
示例图
题目描述 —— 力扣208
法一:
直接使用[二维数组]来实现Trie树;
使用二维数组 tire[] 来存储我们所有的单词字符;
使用 index 来自增记录我们到底用了多少个格子(相当于给被用到的格子进行编号)。
使用 count[] 数组记录某个格子被「被标记为结尾的次数」(当idx编号的格子被标记了n次,则有 cnt[idx] = n)
class Trie{
int N = 100009; //10E5
int[][] trie;
int[] count;
int index;
public Trie(){
trie = new int[N][26];
count = new int[N];
index = 0;
}
public void insert(String s){
int p = 0;
for(int i = 0; i < s.length(); ++i){
int u = s.charAt(i) - 'a';
if(trie[p][u] == 0){
trie[p][u] = ++index;
}
p = trie[p][u];
}
++count[p];
}
public boolean search(String s) {
int p = 0;
for (int i = 0; i < s.length(); ++i) {
int u = s.charAt(i) - 'a';
if (trie[p][u] == 0) {
return false;
}
p = trie[p][u];
}
return count[p] != 0;
}
public boolean startsWith(String s) {
int p = 0;
for (int i = 0; i < s.length(); i++) {
int u = s.charAt(i) - 'a';
if (trie[p][u] == 0) {
return false;
}
p = trie[p][u];
}
return true;
}
};
为什么上面的N=100009?
首先要理解「二维数组」是如何工作的?
在「二维数组」中,我们是通过 index 自增来控制使用了多少行的。当有一个新的字符需要记录,我们会将index自增(代表用到了新的一行),然后将新行的下标记录到当前某个前缀的格子中。
举个例子:
假设我们先插入字符串 abc 的时候,前面3行会被占掉。
第0行 ‘a’所对应的下标值为1,代表前缀 a 后面接的字符串会被记录在下标为 1 的行内。
第1行 ‘b’所对应的下标值为2,代表前缀 ab 后面接的字符串会被记录在下标为 2 的行内。
第2行 ‘c’所对应的下标值为3,代表前缀 abc 后面接的字符串会被记录在下标为 3 的行内。
当再插入 abcl 的时候,则会定位到 abc 的前缀行(第3行),将 l 的下标更新为 4,代表 abcl 被加入到前缀树中,并且前缀 abcl 接下来会用到第 4 行 进行记录。 但是当插入 abl 时,则会定位到 ab 的前缀行(第2行),然后将 l 的下标更新为5,代表 abl 被加入前缀树,并且前缀 abl 接下来会使用第5 行进行记录。
当搞清楚了「二维数组」是如何工作之后,我们就能开始估算会用到多少行了,调用次数为10^4 ,传入的字符串长度为 10^3,假设每一次的调用都是insert ,并且每一次调用都会使用到新的103行。那么我们的行数需要开到107。
法二:
相比二维数组,更加常规的做法是建立TrieNode结构节点。随着数据的不断插入,根据需要 不断创建TrieNode节点。
class Trie{
calss TrieNode{
boolean end;
TrieNode[] tns = new TrieNode[26];
}
TrieNode root;
public Trie(){
root = new TrieNode();
}
public void insert(String s){
TrieNode p = root;
for(int i = 0; i < s.length(); ++i){
int u = s.charAt(i) - 'a';
if(p.tns[u] == null){
p.tns[u] = new TrieNode();
}
p = p.tns[u];
}
p.end = true;
}
public boolean search(String s){
TreeNode p = root;
for(int i = 0; i < s.length(); ++i){
int u = s.charAt(i) - 'a';
if(p.tns[u] == null){
return false;
}
p = p.tns[u];
}
return p.end;
}
public boolean startWith(String s){
TrieNode p = root;
for(int i = 0; i < s.length(); ++i){
int u = s.charAt(i) - 'a';
if(p.tns[u] == null){
return false;
}
p = p.tns[u];
}
return true;
}
}
关于 Trie 的应用面
首先,在纯算法领域,前缀树算是一种较为常用的数据结构。
不过如果在工程中,不考虑前缀匹配的话,基本上使用 hash 就能满足。
如果考虑前缀匹配的话,工程也不会使用 Trie 。
一方面是字符集大小不好确定(题目只考虑 26 个字母,字符集大小限制在较小的 26 内)因此可以使用 Trie,但是工程一般兼容各种字符集,一旦字符集大小很大的话,Trie 将会带来很大的空间浪费。
另外,对于个别的超长字符 Trie 会进一步变深。
这时候如果 Trie 是存储在硬盘中,Trie 结构过深带来的影响是多次随机 IO,随机 IO 是成本很高的操作。
同时 Trie 的特殊结构,也会为分布式存储将会带来困难。
因此在工程领域中 Trie 的应用面不广。
至于一些诸如「联想输入」、「模糊匹配」、「全文检索」的典型场景在工程主要是通过 ES (ElasticSearch) 解决的。
而 ES 的实现则主要是依靠「倒排索引」。
结构过深带来的影响是多次随机 IO,随机 IO 是成本很高的操作。
同时 Trie 的特殊结构,也会为分布式存储将会带来困难。
因此在工程领域中 Trie 的应用面不广。
至于一些诸如「联想输入」、「模糊匹配」、「全文检索」的典型场景在工程主要是通过 ES (ElasticSearch) 解决的。
而 ES 的实现则主要是依靠「倒排索引」。