字典树(trie)之习题分析
一、字典树(trie)的概念
字典树又称单词查找树,Trie树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。
二、字典树(trie)的使用场景
(一)、字符串检索
事先将已知的一些字符串(字典)的有关信息保存到trie树里,查找另外一些未知字符串是否出现过或者出现频率。
举例:
- 给出N 个单词组成的熟词表,以及一篇全用小写英文书写的文章,请你按最早出现的顺序写出所有不在熟词表中的生词。
- 给出一个词典,其中的单词为不良单词。单词均为小写字母。再给出一段文本,文本的每一行也由小写字母构成。判断文本中是否含有任何不良单词。例如,若rob是不良单词,那么文本problem含有不良单词。
- 1000万字符串,其中有些是重复的,需要把重复的全部去掉,保留没有重复的字符串。
(二)、文本预测、自动完成,see also,拼写检查
(三)、词频统计
-
有一个1G大小的一个文件,里面每一行是一个词,词的大小不超过16字节,内存限制大小是1M。返回频数最高的100个词。
-
一个文本文件,大约有一万行,每行一个词,要求统计出其中最频繁出现的前10个词,请给出思想,给出时间复杂度分析。
-
寻找热门查询:搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。假设目前有一千万个记录,这些查询串的重复度比较高,虽然总数是1千万,但是如果去除重复,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就越热门。请你统计最热门的10个查询串,要求使用的内存不能超过1G。
- 请描述你解决这个问题的思路;
- 请给出主要的处理流程,算法,以及算法的复杂度。
- ==》若无内存限制:Trie + “k-大/小根堆”(k为要找到的数目)。
- 否则,先hash分段再对每一个段用hash(另一个hash函数)统计词频,再要么利用归并排序的某些特性(如partial_sort),要么利用某使用外存的方法。参考
“海量数据处理之归并、堆排、前K方法的应用:一道面试题” http://www.dataguru.cn/thread-485388-1-1.html"
“算法面试题之统计词频前k大” http://blog.csdn.net/u011077606/article/details/42640867"
(四)、排序
Trie树是一棵多叉树,只要先序遍历整棵树,输出相应的字符串便是按字典序排序的结果。
比如给你N 个互不相同的仅由一个单词构成的英文名,让你将它们按字典序从小到大排序输出。
(五)、字符串最长公共前缀
Trie树利用多个字符串的公共前缀来节省存储空间,当我们把大量字符串存储到一棵trie树上时,我们可以快速得到某些字符串的公共前缀。
举例:
给出N 个小写英文字母串,以及Q 个询问,即询问某两个串的最长公共前缀的长度是多少?
解决方案:首先对所有的串建立其对应的字母树。此时发现,对于两个串的最长公共前缀的长度即它们所在结点的公共祖先个数,于是,问题就转化为了离线(Offline)的最近公共祖先(Least Common Ancestor,简称LCA)问题。
- 而最近公共祖先问题同样是一个经典问题,可以用下面几种方法:
- 利用并查集(Disjoint Set),可以采用采用经典的Tarjan 算法;
- 求出字母树的欧拉序列(Euler Sequence )后,就可以转为经典的最小值查询(Range Minimum Query,简称RMQ)问题了;
(六)、字符串搜索的前缀匹配
trie树常用于搜索提示。如当输入一个网址,可以自动搜索出可能的选择。当没有完全匹配的搜索结果,可以返回前缀最相似的可能。
Trie树检索的时间复杂度可以做到n,n是要检索单词的长度,
如果使用暴力检索,需要指数级O(n2)的时间复杂度。
(七)、作为其他数据结构和算法的辅助结构
如后缀树,AC自动机等
后缀树可以用于全文搜索
以上应用场景参考《Trie(前缀树/字典树)及其应用》(https://www.cnblogs.com/bonelee/p/8830825.html)
三、如何实现trie
(一)、题目需求
实现一个 Trie (前缀树),包含 insert, search, 和 startsWith 这三个操作。
示例:
Trie trie = new Trie();
trie.insert("apple");
trie.search("apple"); // 返回 true
trie.search("app"); // 返回 false
trie.startsWith("app"); // 返回 true
trie.insert("app");
trie.search("app"); // 返回 true
(二)、解法
public class Trie {
public static void main(String[] args) {
Trie trie = new Trie();
trie.insert("apple");
trie.search("apple");
trie.search("app");
trie.startsWith("app");
}
class TrieNode {
char c;
HashMap<Character, TrieNode> children = new HashMap<>();
boolean hasWord = false;
public TrieNode(char c) {
this.c = c;
}
public TrieNode() {
}
}
private TrieNode root;
/**
* Initialize your data structure here.
*/
public Trie() {
root = new TrieNode();
}
/**
* Inserts a word into the trie.
*/
public void insert(String word) {
TrieNode current = root;
HashMap<Character, TrieNode> children = current.children;
char[] wordArray = word.toCharArray();
for (int i = 0; i < wordArray.length; i++) {
if (!children.containsKey(wordArray[i])) {
TrieNode child = new TrieNode(wordArray[i]);
children.put(wordArray[i], child);
current = child;
} else {
current = children.get(wordArray[i]);
}
children = current.children;
if (i == wordArray.length - 1) {
current.hasWord = true;
}
}
}
/**
* Returns if the word is in the trie.
*/
public boolean search(String word) {
TrieNode current = root;
HashMap<Character, TrieNode> children = current.children;
char[] wordArray = word.toCharArray();
for (int i = 0; i < wordArray.length; i++) {
if (children.containsKey(wordArray[i])) {
current = children.get(wordArray[i]);
children = current.children;
} else {
return false;
}
}
return current.hasWord;
}
/**
* Returns if there is any word in the trie that starts with the given prefix.
*/
public boolean startsWith(String prefix) {
TrieNode current = root;
HashMap<Character, TrieNode> children = current.children;
char[] wordArray = prefix.toCharArray();
for (int i = 0; i < wordArray.length; i++) {
if (children.containsKey(wordArray[i])) {
current = children.get(wordArray[i]);
children = current.children;
} else {
return false;
}
}
return true;
}
}
(三)、代码分析
1、定义TrieNode节点
- c:存储该节点的字符
- children:存储该节点的子节点所有情况
- hasword:标记该节点是否为单词节点
class TrieNode {
char c;
HashMap<Character, TrieNode> children = new HashMap<>();
boolean hasWord = false;
public TrieNode(char c) {
this.c = c;
}
public TrieNode() {
}
}
2、通过遍历该单词的每个字符,若该字符存在于树中,则将children改为该节点下的继续判断,若有字符不存在于树中,则创建一个新的TrieNode,并将其加入children中。并在最后一个字符时,将该节点标记为单词节点
public void insert(String word) {
TrieNode current = root;
HashMap<Character, TrieNode> children = current.children;
char[] wordArray = word.toCharArray();
for (int i = 0; i < wordArray.length; i++) {
if (!children.containsKey(wordArray[i])) {
TrieNode child = new TrieNode(wordArray[i]);
children.put(wordArray[i], child);
current = child;
} else {
current = children.get(wordArray[i]);
}
children = current.children;
if (i == wordArray.length - 1) {
current.hasWord = true;
}
}
}
3、思路同插入大同小异,主要是少了插入的步骤。
public boolean search(String word) {
TrieNode current = root;
HashMap<Character, TrieNode> children = current.children;
char[] wordArray = word.toCharArray();
for (int i = 0; i < wordArray.length; i++) {
if (children.containsKey(wordArray[i])) {
current = children.get(wordArray[i]);
children = current.children;
} else {
return false;
}
}
return current.hasWord;
}
4、判断是否存在该前缀,思路同search大同小异,只有部分区别。
public boolean startsWith(String prefix) {
TrieNode current = root;
HashMap<Character, TrieNode> children = current.children;
char[] wordArray = prefix.toCharArray();
for (int i = 0; i < wordArray.length; i++) {
if (children.containsKey(wordArray[i])) {
current = children.get(wordArray[i]);
children = current.children;
} else {
return false;
}
}
return true;
}
四、添加与搜索单词 - 数据结构设计
(一)、题目需求
请你设计一个数据结构,支持 添加新单词 和 查找字符串是否与任何先前添加的字符串匹配 。
实现词典类 WordDictionary :
WordDictionary() 初始化词典对象
void addWord(word) 将 word 添加到数据结构中,之后可以对它进行匹配
bool search(word) 如果数据结构中存在字符串与 word 匹配,则返回 true ;否则,返回 false 。word 中可能包含一些 ‘.’ ,每个 . 都可以表示任何一个字母。
示例:
输入:
["WordDictionary","addWord","addWord","addWord","search","search","search","search"]
[[],["bad"],["dad"],["mad"],["pad"],["bad"],[".ad"],["b.."]]
输出:
[null,null,null,null,false,true,true,true]
解释:
WordDictionary wordDictionary = new WordDictionary();
wordDictionary.addWord("bad");
wordDictionary.addWord("dad");
wordDictionary.addWord("mad");
wordDictionary.search("pad"); // return False
wordDictionary.search("bad"); // return True
wordDictionary.search(".ad"); // return True
wordDictionary.search("b.."); // return True
提示:
1 <= word.length <= 500
addWord 中的 word 由小写英文字母组成
search 中的 word 由 ‘.’ 或小写英文字母组成
最调用多 50000 次 addWord 和 search
(二)、解法
public class WordDictionary {
private class TrieNode {
char c;
boolean hasWord;
HashMap<Character, TrieNode> children = new HashMap<>();
public TrieNode(char c) {
this.c = c;
}
public TrieNode() {
}
}
private TrieNode root;
/**
* Initialize your data structure here.
*/
public WordDictionary() {
root = new TrieNode();
}
/**
* Adds a word into the data structure.
*/
public void addWord(String word) {
TrieNode current = root;
HashMap<Character, TrieNode> children = root.children;
char[] wordArray = word.toCharArray();
for (int i = 0; i < wordArray.length; i++) {
if (children.containsKey(wordArray[i])) {
current = children.get(wordArray[i]);
children = current.children;
} else {
TrieNode child = new TrieNode(wordArray[i]);
children.put(wordArray[i], child);
current = child;
children = current.children;
}
if (i == wordArray.length - 1) {
current.hasWord = true;
}
}
}
/**
* Returns if the word is in the data structure. A word could contain the dot character '.' to represent any one letter.
*/
public boolean search(String word) {
return searchWord(word, 0, root);
}
private boolean searchWord(String word, Integer num, TrieNode root) {
if (num == word.length()) {
return root.hasWord;
}
TrieNode current = root;
HashMap<Character, TrieNode> children = root.children;
char[] wordArray = word.toCharArray();
if (wordArray[num] != '.') {
if (!children.containsKey(wordArray[num])) {
return false;
} else {
return searchWord(word, num + 1, children.get(wordArray[num]));
}
} else {
List<Character> childrenArray = new ArrayList<>();
for (char j = 'a'; j <= 'z'; j++) {
if (children.containsKey(j)) {
childrenArray.add(j);
}
}
for (Character ch : childrenArray) {
if (searchWord(word, num + 1, children.get(ch))) {
return true;
}
}
return false;
}
}
}
(三)、代码分析
1、首先该题与上题的具体区别在于多了通配符“.”,面对通配符“.”,则需要遍历该位置的前一位对应的节点的所有children。
2、在插入方面,与上一题无差异。
public void addWord(String word) {
TrieNode current = root;
HashMap<Character, TrieNode> children = root.children;
char[] wordArray = word.toCharArray();
for (int i = 0; i < wordArray.length; i++) {
if (children.containsKey(wordArray[i])) {
current = children.get(wordArray[i]);
children = current.children;
} else {
TrieNode child = new TrieNode(wordArray[i]);
children.put(wordArray[i], child);
current = child;
children = current.children;
}
if (i == wordArray.length - 1) {
current.hasWord = true;
}
}
}
3、由于采用递归的方式进行查找,所以当查找完毕时需要判断,该节点是否为单词节点。其中num表示每次要查找字符在单词中的位置,当num == word.length() 时表示查找完毕。
if (num == word.length()) {
return root.hasWord;
}
4、查找方面,首先判断不是通配符的情况,该情况与上题一致。但由于存在通配符的情况,因此采取递归的方式进行判断,即若该位置对应字符并非通配符,则直接判断它是否存在于children中,若不存在则返回false。若存在则通过递归判断下一位置字符。
if (wordArray[num] != '.') {
if (!children.containsKey(wordArray[num])) {
return false;
} else {
return searchWord(word, num + 1, children.get(wordArray[num]));
}
}
5、当遇到当前字符为通配符时,首先需要得到当前根节点的所有children节点。在得到所有存在的节点后,遍逐个遍历这些节点,若满足条件则返回true。若遍历结束仍未有返回true,则说明所有节点皆不可以得到该单词,于是返回false。
List<Character> childrenArray = new ArrayList<>();
for (char j = 'a'; j <= 'z'; j++) {
if (children.containsKey(j)) {
childrenArray.add(j);
}
}
for (Character ch : childrenArray) {
if (searchWord(word, num + 1, children.get(ch))) {
return true;
}
}
return false;