详细介绍了前缀树Trie的实现原理以及Java代码的实现。
文章目录
- Trie的概念
- Trie的实现
- 基本结构
- 构建Trie
- 查找字符串
- Trie的总结
Trie的概念
Trie(发音类似 “try”)又被称为前缀树、字典树。Trie利用字符串的公共前缀来高效地存储和检索字符串数据集中的关键词,最大限度地减少无谓的字符串比较,其核心思想是用空间换时间。
Trie树可被用来实现字符串查询、前缀查询、词频统计、自动拼写、补完检查等等功能。
Trie树的三个性质:
- 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
- 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
- 每个节点的所有子节点包含的字符都不相同。
Trie的实现
Trie是一颗非典型的多叉树形结构,多叉树是因为一个节点可以有多个字节点,而非典型在于节点中没有专门的字段直接保存此节点的值,而是通过一个数组或者map保存了当前节点的所有下层子节点的值,也正是因此,根节点不表示任何字符。
基本结构
最简单的前缀树的结构如下:
- 内部包含一个哈希表next,存储着子节点的值到对应的节点的映射关系。
- 还有一个布尔值isEnd,用来标识该节点是否是一个字符串的结束。
- 调用无参构造器将会初始化这两个属性。
实际上,还可以包含其他的属性以实现特定的功能,例如加入count表示以当前单词结尾的单词数量,加入prefix表示以该处节点之前的字符串为前缀的单词数量。
另外,对于下层子节点的存储,如果字符串仅包含小写字母,或者固定范围的字符,那么我们可以使用定长(例如26)的数组来表示next,这样省去了hash操作的开销,但同样可能造成空间的浪费。
class Trie {
/**
* 经过该节点的字符串的下层节点
*/
Map<Character, Trie> next;
/**
* 该节点是否是一个字符串的结束
*/
boolean isEnd;
public Trie() {
this.next = new HashMap<>();
this.isEnd = false;
}
}
构建Trie
通过调用构造器初始化一个Trie的根节点,通过insert操作向前缀树中插入关键词字符串(模式串)。
可以看到其实现的方法比较简单:将字符串转换为char数组,顺序遍历char数组的每个字符,然后从根节点开始判断该节点的下层子节点映射next:
- 如果不包含此字符,那么加入一个新子节点进去,值对应着当前字符。然后使用该子节点,进入下一次循环判断下一个字符。
- 如果包含此字符或者新插入了节点,那么当前字符获取对应的子节点,进入下一次循环判断下一个字符。
循环完毕,我们完成了当前字符串的Trie构建,那么还需要将最后一个节点的isEnd改为true,表示该节点是一个字符串的结束。
public void insert(String word) {
//初始默认为根节点,根节点不包含任何字符
Trie cur = this;
//遍历该字符串的字符数组
for (char c : word.toCharArray()) {
//如果该节点的下层不包含此字符,那么加入一个新节点进去
if (!cur.next.containsKey(c)) {
cur.next.put(c, new Trie());
}
//查找下一层节点
cur = cur.next.get(c);
}
//遍历字符串完毕,最后的节点isEnd置为true,表示一个字符串的结束
cur.isEnd = true;
}
查找字符串
基于Trie结构可以查找字符串、匹配前缀、查找出现次数等等,这里我们给出查找字符串和查找前缀的方法。
比较简单,我们从字典树的根开始,查找前缀:
- 如果子节点存在。沿着指针移动到子节点,继续搜索下一个字符。
- 如果子节点不存在。说明字典树中不包含该前缀,返回空指针。
可以看到查找字符串相比于匹配前缀,仅仅是多了一个最下层子节点是否是一个字符串的结束的判断而已。
/**
* 查找字符串
*/
public boolean search(String word) {
Trie end = searchPrefix(word);
return end != null && end.isEnd;
}
/**
* 匹配前缀
*/
public boolean startsWith(String prefix) {
return searchPrefix(prefix) != null;
}
private Trie searchPrefix(String prefix) {
//初始默认为根节点,根节点不包含任何字符
Trie cur = this;
//遍历该字符串的字符数组
for (char c : prefix.toCharArray()) {
//如果该节点的下层不包含此字符,那么直接返回null
if (!cur.next.containsKey(c)) {
return null;
}
//查找下一层节点
cur = cur.next.get(c);
}
return cur;
}
Trie的总结
假设我们加入了she、he、his、her这四个字符串,那么Trie的结构如下,其中红色节点表示其属于某个字符串的尾部节点。
Trie时间复杂度:初始化为O(1),每次操作为O(N),N为插入或查找的字符串的长度。
Trie空间复杂度:O(N),N表示Trie结点数量,或者说所有插入字符串的长度之和(减去相同前缀长度)。如果是采用定长数组表示next,那么空间复杂度为O(N*M),M表示字符集的大小,即数组长度。
可以看到,使用Trie的数据结构使得插入、查询全词、查询前缀的时间复杂度与已插入的单词数目和长度无关,这是它的一个优点。
但是,Trie又名前缀树,因为它只能基于前缀匹配实现某些功能。另一些功能,例如判断一段字符串中是否包含某些关键词,不需要前缀匹配,此时就无法使用Trie了。
完整实现如下:
class Trie {
/**
* 经过该节点的字符串的下层节点
*/
Map<Character, Trie> next;
/**
* 该节点是否是一个字符串的结束
*/
boolean isEnd;
public Trie() {
this.next = new HashMap<>();
this.isEnd = false;
}
public void insert(String word) {
//初始默认为根节点,根节点不包含任何字符
Trie cur = this;
//遍历该字符串的字符数组
for (char c : word.toCharArray()) {
//如果该节点的下层不包含此字符,那么加入一个新节点进去
if (!cur.next.containsKey(c)) {
cur.next.put(c, new Trie());
}
//查找下一层节点
cur = cur.next.get(c);
}
//遍历字符串完毕,最后的节点isEnd置为true,表示一个字符串的结束
cur.isEnd = true;
}
/**
* 查找字符串
*/
public boolean search(String word) {
Trie end = searchPrefix(word);
return end != null && end.isEnd;
}
/**
* 匹配前缀
*/
public boolean startsWith(String prefix) {
return searchPrefix(prefix) != null;
}
private Trie searchPrefix(String prefix) {
//初始默认为根节点,根节点不包含任何字符
Trie cur = this;
//遍历该字符串的字符数组
for (char c : prefix.toCharArray()) {
//如果该节点的下层不包含此字符,那么直接返回null
if (!cur.next.containsKey(c)) {
return null;
}
//查找下一层节点
cur = cur.next.get(c);
}
return cur;
}
public static void main(String[] args) {
Trie trie = new Trie();
trie.insert("你是xxl吗?");
}
}