字典树
字典树(Trie)
字典树(Trie),也称为“前缀树”,是一种特殊的树状数据结构,对于解决字符串相关问题非常有效。它能够提供快速检索,主要用于搜索字典中的单词,在搜索引擎中自动提供建议,甚至被用于 IP 的路由。
它是一种多叉树,即把前缀相同的字符串合并在一起,根节点默认不存储字符。
这里,我们用一棵字典树来展示:
可以发现,这棵字典树用边来代表字母,而从根结点到树上某一结点的路径就代表了一个字符串。
例如,\(1\to4\to 8\to 12\) 表示的就是字符串 \(caa\)。
可以看出 Trie树 本质就是利用字符串之间的公共前缀,将重复的前缀合并在一起,根据这个特点,我们可以通过查询字符的前缀,快速地查询到具有相同前缀的单词。
\(trie\) 的结构非常好懂,我们用 \(\delta(u,c)\) 表示结点 \(u\) 的 \(c\) 字符指向的下一个结点,或着说是结点 \(u\) 代表的字符串后面添加一个字符 \(c\) 形成的字符串的结点。
有时需要标记插入进 \(trie\) 的是哪些字符串,每次插入完成时,在这个字符串所代表的节点处打上标记即可。
力扣上的典型应用如下:
序号 | 题目 |
---|---|
1 | 642. 设计搜索自动补全系统 |
2 | 1268. 搜索推荐系统 |
应用
应用1:Leetcode.642
题目
题目分析
只要涉及单词自动补全相关的,都可以使用字典树作为底层数据结构。
算法思路:
-
使用字典树
trie
记录所有的输入字符串,对于每一个子节点我们使用一个 \(hash\) 表记录字符串与出现次数; -
使用一个
_input
作为输入字符的缓冲区,对于每一个输入的字符:-
如果它不是结束符(
"#"
),则在字典树上进行一次查询,得到当前字符串对应的结果集 \(S\); -
如果它是结束符(
"#"
),则将缓冲区中的字符拼接为一个字符串,并保存到字典树上,同时,将该字符串出现的次数加 \(1\);
-
-
将结果集 \(S\) 中的字符串,按照出现次数与字母顺序排序,取前 \(3\) 个结果,作为答案返回;
代码实现
from typing import List, Dict
class Node(object):
def __init__(self):
self.sentences = dict()
self.children = dict()
def update_times(self, sentence: str, times: int = 1):
""" 更新次数 """
self.sentences.update({sentence: self.sentences.get(sentence, 1) + times})
class Trie(object):
def __init__(self):
self.root = Node()
self._input = list()
def input(self, content: str):
self._input.append(content)
sentence = "".join(self._input)
if content.endswith("#"):
self._input = list()
sentence = sentence[:-1]
self.add(sentence)
return list()
result = self._search(sentence)
return self._top_k(result)
def add(self, sentence: str, times: int = 1):
root = self.root
for char in sentence:
if char not in root.children:
root.children[char] = Node()
root = root.children[char]
root.update_times(sentence, times)
return
def _search(self, sentence: str) -> Dict[str, int]:
root = self.root
for char in sentence:
if char not in root.children:
return dict()
root = root.children[char]
return root.sentences
def _top_k(self, sentences: Dict[str, int], k: int = 3):
result = sorted(sentences.items(), key=lambda x: (-x[1], x[0]))
return [candidate[0] for candidate in result[:k]]
class AutocompleteSystem:
def __init__(self, sentences: List[str], times: List[int]):
self.trie = Trie()
for i in range(len(sentences)):
self.trie.add(sentence=sentences[i], times=times[i])
def input(self, c: str) -> List[str]:
return self.trie.input(content=c)
if __name__ == "__main__":
def test_case1():
sentences = ["i love you", "island", "iroman", "i love leetcode"]
times = [5, 3, 2, 2]
obj = AutocompleteSystem(sentences=sentences, times=times)
print(obj.input("i"))
print(obj.input(" "))
print(obj.input("a"))
print(obj.input("#"))
def test_case2():
sentences = ["i love you", "island", "iroman", "i love leetcode"]
times = [5, 3, 2, 2]
obj = AutocompleteSystem(sentences=sentences, times=times)
print(obj.input("i"))
print(obj.input(" "))
print(obj.input("a"))
print(obj.input("#"))
print(obj.input("i"))
print(obj.input(" "))
print(obj.input("a"))
print(obj.input("#"))
print(obj.input("i"))
print(obj.input(" "))
print(obj.input("a"))
print(obj.input("#"))
test_case2()
应用2:Leetcode.1268
题目
分析
算法实现思路:
- 使用一个字典树
trie
记录所有的输入字符串,对于每一个子节点,使用一个列表words
记录以当前路径为前缀的所有字符串; - 对于每一个输入的字符串
word
,将其添加到字典树trie
中,添加的过程:- 字典树
trie
的根节点作为起点,遍历字符串word
中的每一个字符; - 如果当前字符
char
,如果它不是当前节点的子节点,则创建一个新的节点; - 将当前字符串保存到子节点中
- 字典树
- 遍历
products
中的每一个单词,对于每一个单词,查找排序后的前 3 的个匹配结果。
代码实现
【Java 实现】
class Solution {
class TrieNode {
public List<String> words;
public Map<Character, TrieNode> children;
public TrieNode() {
words = new ArrayList<>();
children = new HashMap<>();
}
}
class Trie {
public TrieNode root;
public Trie() {
root = new TrieNode();
}
public void add(String word) {
TrieNode node = root;
for (char ch : word.toCharArray()) {
if (!node.children.containsKey(ch)) {
node.children.put(ch, new TrieNode());
}
node = node.children.get(ch);
node.words.add(word);
}
}
public List<List<String>> getTopK(String target) {
List<List<String>> result = new ArrayList<>();
TrieNode node = root;
// 如果遇到一个不匹配的字符,后续的所有字符都会不匹配,需要在每个位置上都添加一个空集
boolean addEmptyList = false;
for (char ch : target.toCharArray()) {
if (addEmptyList || !node.children.containsKey(ch)) {
result.add(new ArrayList<>());
addEmptyList = true;
} else {
node = node.children.get(ch);
Collections.sort(node.words);
result.add(node.words.subList(0, Math.min(3, node.words.size())));
}
}
return result;
}
}
public List<List<String>> suggestedProducts(String[] products, String searchWord) {
Trie trie = new Trie();
for (String product : products) {
trie.add(product);
}
return trie.getTopK(searchWord);
}
}
【Python 实现】
from typing import List
class TrieNode:
def __init__(self):
# 用Hash表保存子节点
self.child = dict()
# 保存当前节点的值
self.words = list()
class Trie(object):
def __init__(self):
self.root = TrieNode()
def add(self, word: str):
root = self.root
for char in word:
if char not in root.child:
root.child[char] = TrieNode()
root = root.child[char]
root.words.append(word)
def get_top_k(self, target: str):
result = list()
root = self.root
flag = False
for char in target:
if flag or char not in root.child:
result.append(list())
flag = True
else:
root = root.child[char]
root.words.sort()
result.append(root.words[:3])
return result
class Solution:
def suggestedProducts(self, products: List[str], searchWord: str) -> List[List[str]]:
trie = Trie()
for word in products:
trie.add(word)
return trie.get_top_k(searchWord)
总结
Trie 树构建时,需要遍历所有的字符串,因此时间复杂度为所有字符串长度总和 n,时间复杂度为 O(n)。
但是,Trie 树的查找效率很高,如果字符串长度为 k,那么时间复杂度 为 O(k)。
Trie 是一种以空间换时间的结构,当字符集较大时,会占用很多空间,同时如果前缀重合较少,空间会占用更多。所以其比较适合查找前缀匹配。
可以看到, Trie 树的内存消耗还是比较大的,它对要处理的字符串有极其严苛的要求:
-
字符串中包含的字符集不能太大,如果字符集太大,存储空间就会浪费严重,即使进行优化,也会牺牲查询、插入效率;
-
字符串的前缀重合要比较多,否则空间消耗会比较大;
-
Trie 树使用了指针进行存储,对缓存不友好;
-
在工程中,可能要我们自行实现一个 Trie 树 ,这会让工程变得复杂。
所以,对于在一组字符串中查找字符串的问题,在工程中一般更倾向于使用散列表(Hash)或者红黑树(RBT)。
字典树的应用
Trie 树比较适合这样的应用场景:当我们需要使用前缀快速查找相关的字符串的时候,例如,搜索引擎的自动补全功能、输入法的自动补全功能、IDE代码编辑器自动补全功能、浏览器网址输入的自动补全功能等。
Tire 树的典型应用场景:
-
串的快速检索
给出N个单词组成的熟词表,以及一篇全用小写英文书写的文章,请你按最早出现的顺序写出所有不在熟词表中的生词。在这道题中,我们可以用数组枚举,用哈希,用字典树,先把熟词建一棵树,然后读入文章进行比较, 这种方法效率是比较高的。
-
“串”排序
给定N个互不相同的仅由一个单词构成的英文名,让你将他们按字典序从小到大输出。用字典树进行排序, 采用数组的方式创建字典树,这棵树的每个结点的所有儿子很显然地按照其字母大小排序。 对这棵树进行先序遍历即可。
-
最长公共前缀
对所有串建立字典树,对于两个串的最长公共前缀的长度即他们所在的结点的公共祖先个数, 于是,问题就转化为求公共祖先的问题。
参考: