LeetoCode地址: . - 力扣(LeetCode)
题目已经清晰的告诉了我们要实现Trie,以及它的优点,那么这些优点解决了什么问题,为什么传统的方法不行?
现在让我们还原一下问题: 保存一些字符串,并判定新给出的字符串是否是这些字符串中的一员,或者是其中某一员的前缀。
举个例子,保存"app", "apple", "application"这三个字符串,并判断"app", "append"是否是这些字符串组成的集合里的元素,判断"app", "appl" "appc" 是否是这些字符串组成集合的某个元素的前缀。
最直观的思路,是将这三个字符串都保存到HashMap elementMap里,从而达到最好情况下常数级别时间复杂度的查询;
elementMap = {"app", "apple", "application"}
对于前缀,我们可以将所有元素的所有前缀都保存到另一个HashMap prefixMap里,从而也能达到最好情况下常数级别时间复杂度的查询;
prefixMap = {"a", "ap", "app", "appl", "apple", "appli", "applic", "applica", "applicat", "applicati", "applicatio", "application"};
看起来它能够正常的工作,问题解决了,对嘛?对,也不完全对。
首先,这两个HashMap占据了巨大的存储空间,每增加一个长度为n的字符串,我们都需要额外存储"1+2+...+n"=1/2*n^2 + 1/2*n个字符, 这个增长速度是很恐怖的,而且存储内容上有非常多的相似度。
其次,这种解决思路是单向的,即只能知道集合是否包含某个前缀,却不能知道某个前缀的字符串,譬如查询以"ap"开头的所有字符串。
要解决这些问题,我们必须高效利用每个字符,让其携带尽可能多的信息,比如是否有字符以该字符结尾,是否有其他字符在该字符后出现?
譬如第一个字符串"app", 有三个字符"a", "p", "p",易知对于第一个"a"来说,后续出现了"p", 而对于第一个"p"来说,后续出现了"p";而对于第二个"p",它是结尾字符。如果使用链表将这个字符串表示出来,并将结尾字符通过红色区分出来,则如下图所示:
类似的,"apple" 和"application"的图示如下:
可以清楚的看到,这三个链表的前三个节点是重复的,都是a, p, p, 后两个链表的前4的节点都是重复的,都是a,p,p,l。而l之后出现了分叉,在"apple"中,l指向了e,在"application"中,l指向了i,如果我们将三个链表合并,并允许节点可以指向多个其他节点的话,图示就如下所示:
注意图中有三个节点是红色的,意味着有字符串是以该节点结尾的。
现在,链表里的元素的都是a开头的,如果我们此时插入了一个字符串是"book",又会怎样呢?
需要引入一个代表根的root节点,该节点表明了当前我们集合里有以哪些首字符开端的字符串。加入"book"之后的链表如下图所示:
使用这个链表结构,我们就查询任意字符串是否存在于集合之中,以及前缀是否包含与集合之中。
比如我想查询"china", 首字符是"c",而我们发现,从root节点的下一个节点之中,只有"a","b", 所以我们可以断言,"china"不在我们的集合里。
相反的,如果我们想查询"apple",则可以从root一路走到红色e节点,即表明链表中存在"apple"字符串。
这个链表结构就是Trie前缀树。
最直观的想法是,每个节点就是一个Trie对象,该对象代表的是某一个字符,它有一个成员变量,保存了所有的下一个节点,由于下一个字符只可能是'a'到'z'这些字符,可以直接通过一个HashMap保存起来,在search的时候,从首字符开始,通过root节点开始不断的从hashMap中查询下一个字符对应的Trie对象,如果不是最后一个字符,则递归调用search方法,直到匹配最后一个字符。
插入的时间复杂度 O(n), n 为字符串长度
搜索的时间复杂度O(n), n 为字符串长度
额外空间复杂度: O(n * m), n为平均字符串长度,m为字符串个数。
class Trie {
HashMap charMap;
Boolean finished;
public Trie() {
charMap = new HashMap<>();
finished = false;
}
public void insert(String word) {
if (word.length() == 1) {
Trie orDefault = charMap.getOrDefault(word, new Trie());
orDefault.finished = true;
charMap.putIfAbsent(word, orDefault);
return;
}
String firstChar = word.substring(0, 1);
Trie orDefault = charMap.getOrDefault(firstChar, new Trie());
charMap.putIfAbsent(firstChar, orDefault);
orDefault.insert(word.substring(1));
}
public boolean search(String word) {
if (word.length() == 1) {
return charMap.get(word) != null && charMap.get(word).finished;
}
String firstChar = word.substring(0, 1);
if (!charMap.containsKey(firstChar)) {
return false;
}
return charMap.get(firstChar).search(word.substring(1));
}
public boolean startsWith(String prefix) {
if (prefix.length() == 1) {
return charMap.get(prefix) != null && charMap.get(prefix).charMap.size() > 0;
}
String firstChar = prefix.substring(0, 1);
if (!charMap.containsKey(firstChar)) {
return false;
}
return charMap.get(firstChar).startsWith(prefix.substring(1));
}
}
由于可能的每一个字符只有a到z这26个,所以完全可以将HashMap替换成Array,而且在search时也无需递归,可以通过for循环进行字符的遍历。
插入的时间复杂度 O(n), n 为字符串长度
搜索的时间复杂度O(n), n 为字符串长度
额外空间复杂度: O(n * m), n为平均字符串长度,m为字符串个数。
相比于递归实现,少了方法栈帧的开销。
class Trie {
class TreeNode {
TreeNode[] nextNodeArray;
Boolean finished;
public TreeNode() {
this.nextNodeArray = new TreeNode[26];
this.finished = false;
}
}
TreeNode root;
public Trie() {
root = new TreeNode();
}
public void insert(String word) {
TreeNode current = root;
for (int i = 0; i