概述
trie是一种多叉树,它专门为字符串设计的。
如果对于一个英语字典来说,有n个条目。以查询这个字典中的某个条目来看,我使用映射结构(底层是树结构),查询的时间复杂度是O(logn)
使用tire的话,查询每个条目的时间复杂度,和字典中一共有多少条目无关!时间复杂度为O(w)w为查询单词的长度。这个是非常有优势。
tire的结构类似于这样
trie没有将字符串作为一个整体,而是将其拆开,每遍历到一个叶子节点就形成了一个英语单词,每个节点最多有26个指向下个节点的指针。
如果考虑到具体的业务场景,比如说网址,还需要有特殊的字符,这个每个节点最多指向下个节点的指针的数量可能是大于26的。所以为了tire的灵活性,一般不会限定tire有每个节点多少个指向下个节点的指针。
当然,有的单词还没到叶子节点就结束了,类似于这种 所以要有一个表示isWord
实现
import java.util.TreeMap;
//默认trie的节点为字符 进行演示
public class Trie {
//trie节点的类设计
private class Node{
public boolean isWord;
public TreeMap<Character, Node> next; //映射
public Node(boolean isWord){
this.isWord = isWord;
next = new TreeMap<>();
}
public Node(){
this(false);
}
}
private Node root; //根节点
private int size; //字符数量
public Trie(){
root = new Node();
size = 0;
}
// 获得Trie中存储的单词数量
public int getSize(){
return size;
}
// 向Trie中添加一个新的单词word
public void add(String word){
Node cur = root;
for(int i = 0 ; i < word.length() ; i ++){
char c = word.charAt(i);
if(cur.next.get(c) == null)
cur.next.put(c, new Node());
cur = cur.next.get(c);
}
//遍历到最后一个字符
if(!cur.isWord){
cur.isWord = true;
size ++;
}
}
// 查询单词word是否在Trie中
//与添加的逻辑 基本上一致
public boolean contains(String word){
Node cur = root;
for(int i = 0 ; i < word.length() ; i ++){
char c = word.charAt(i);
if(cur.next.get(c) == null)
return false;
cur = cur.next.get(c);
}
return cur.isWord;
}
// 查询是否在Trie中有单词以prefix为前缀
public boolean isPrefix(String prefix){
Node cur = root;
for(int i = 0 ; i < prefix.length() ; i ++){
char c = prefix.charAt(i);
if(cur.next.get(c) == null)
return false;
cur = cur.next.get(c);
}
return true;
}
//删除操作
// 1. 如果单词是另一个单词的前缀,只需要把该word的最后一个节点的isWord的改成false
// 2. 如果单词的所有字母的都没有多个分支,删除整个单词
// 3,如果单词的除了最后一个字母,其他的字母有多个分支,
public boolean remove(String word) {
Node multiChildNode = null;
int multiChildNodeIndex = -1;
Node current = root;
for (int i = 0; i < word.length(); i++) {
Node child = current.next.get(word.charAt(i));
//如果Trie中没有这个单词
if (child == null) {
return false;
}
//当前节点的子节点大于1个
if (child.next.size() > 1) {
multiChildNodeIndex = i;
multiChildNode = child;
}
current = child;
}
//如果单词后面还有子节点
if (current.next.size() > 0) {
if (current.isWord) {
current.isWord = false;
size--;
return true;
}
//不存在该单词,该单词只是前缀
return false;
}
//如果单词的所有字母的都没有多个分支,删除整个单词
if (multiChildNodeIndex == -1) {
root.next.remove(word.charAt(0));
size--;
return true;
}
//如果单词的除了最后一个字母,其他的字母有分支
if (multiChildNodeIndex != word.length() - 1) {
multiChildNode.next.remove(word.charAt(multiChildNodeIndex + 1));
size--;
return true;
}
return false;
}
}
上面实现的Trie中,使用TreeMap来保存节点的所有的子节点,也可以使用HashMap来保存所有的子节点,效率更高:
public Node() {
next = new HashMap<>();
}
当然 我们也能用一个定长的数组来存储所有的子节点,效率比HashMap更高,因为不需要使用hash函数:
public Node(boolean isWord){
this.isWord = isWord;
next = new Node[26];//只能存储26个小写字母
}
trie查询效率非常高,但是对空间的消耗还是挺大的,这也是典型的空间换时间。
可以使用 压缩字典树(Compressed Trie) ,但是维护相对来说复杂一些。
前面也说了,如果我们不止存储英文单词,还有其他特殊字符,那么维护子节点的集合可能会更多。可以对Trie字典树做些限制,比如每个节点只能有3个子节点,左边的节点是小于父节点的,中间的节点是等于父节点的,右边的子节点是大于父节点的,这就是三分搜索Trie字典树(Ternary Search Trie)。
还有一种字符串模式识别的结构叫做后缀树。还有子串查询的相关问题,比如说KMP, Boyer-Moore,Rabin-Karp等。还有文件压缩相关和模式匹配的问题。更宏观的来看,我们写的程序代码也都是字符串,解析成可运行的程序,用到的是编译原理。
字符串是非常非常重要的,学习相关的数据结构与算法就是修炼内功。