问题
已知一个字符串数组words=['accommodate','accompany','accord','account','accurate','adjoin','bang','bare','duplicate','migration','account','accurate','adjoin','bang' ]要解决下面几个问题
- 对字符串数组words进行排序
- 统计字符串'bang'在数组words里出现的次数
- 统计字符串数组words前缀为'ba'的字符串个数
也许你能想出好几种解决方案,有些方案也很不错。但想过没有他们其实可以一种方案去解决,效果并且都还不错,这种方案就是前缀树。
什么是前缀树
前缀树也叫Trie、字典树、单词查找树,它是由海量的单词构成多子节点的树。
性质:
- 根节点不包含任何key
- 每个子节点包含的key都不相同
核心思想:利用相同前缀,减少重复比较,提高查找效率
缺点:Trie树的空间消耗大(如果一个节点有26个孩子,每个孩子又有26个孩子 26 * 26 * 26 *……,指数级增加)
优点:利用相同前缀,提高比对效率,减少无用功
构建前缀树
利用字符串数组words去构建一个前缀树
图形展示前缀树
这张图就是字符串数组构建前缀树,没有标注path和end
代码实现
代码的实现没什么复杂逻辑,就是将字符串拆成单个字符,然后按照一条路径去插入
//节点信息
private char key;//
private int path;//表示路过该节点
private int end;//表示以该节点作为结尾
private TrieNode[] childs = new TrieNode[26];
public TrieNode getIndexNode(int index){
return childs[index];
}
public TrieNode getIndexNodeNoEmpty(int index){
if(childs[index] ==null){
childs[index] = new TrieNode();
}
return childs[index];
}
public void updatePath(boolean isUp){
if(isUp){
path++;
}else {
path--;
}
}
public void updateEnd( boolean isUp){
if(isUp) {
end++;
}else {
end --;
}
}
节点信息里,path是用来统计经过该节点的单词个数;end表示以该节点结尾单词个数;childs是所有的孩子节点;
//构建前缀树,也是插入字符串
public void buildTrie(String value){
if(value == null ||value.length() == 0){
return;
}
if(root == null){
root = new TrieNode();
}
char[] chars = value.toCharArray();
TrieNode current = root;
int length = chars.length;
for(int i = 0;i<length;i++){
TrieNode node = current.getIndexNodeNoEmpty(chars[i]-'a');
node.setKey(chars[i]);
node.updatePath(true);//增加途经的次数
current =node;
}
current.updateEnd(true);//增加结尾的个数
}
Trie代码实现就是将单词拆分成字符数组,然后顺着字符的路径去创建或者公共已有的节点就可以了
Trie的基础操作和应用
基础操作主要增加、删除、查找,外加一个遍历
- 增加:增加就是也就是将字符串加入前缀树里,也就是构建,参考上一节
- 更改: 更改可以看做删除和增加的和操作
- 查找和删除:这俩个操作的代码实现的逻辑和增加操作差不多,查找只返回结果就可以了,删除是将经过的路径进行减一操作
- 遍历:跟树遍历大同小异
查找
public int searchValue(String value){
if(value == null ||value.length() == 0){
return 0;
}
if(root == null){
return 0;
}
char[] chars = value.toCharArray();
TrieNode current = root;
int length = chars.length;
for(int i = 0;i<length;i++){
TrieNode node = current.getIndexNode(chars[i]-'a');
if(node == null){
return 0;
}
current = node;
}
return current.getEnd();
}
删除
public void delete(String value){
if(value == null ||value.length() == 0){
return;
}
char[] chars = value.toCharArray();
TrieNode current = root;
int length = chars.length;
for(int i = 0;i<length;i++){
TrieNode node = current.getIndexNode(chars[i]-'a');
if(node !=null) {
node.updatePath(false);//减少途经的次数
current = node;
}
}
current.updateEnd(false);//减少结尾的个数
}
遍历
这里遍历排除了重复字符串,如果需要包含所有字符串,添加到list的时候循环一下end个数就可以了
//挨个遍历root的子孩子
public List travelTrie(){
if(root == null){
return null;
}
if(result == null){
result = new ArrayList<>();
}
result.clear();
for(int i = 0;i<26;i++){
travel(root.getIndexNode(i),"");
}
return result;
}
//递归遍历
private void travel(TrieNode node ,String value){
if(node == null){
return;
}
value = value+node.getKey();
if(node.getEnd()>0){
result.add(value);
}
for(int i =0;i<26;i++){
if(node.getIndexNode(i)!=null){
travel(node.getIndexNode(i),value);
}
}
}
应用(解决文章开头的问题)
看到这里,也许也没感觉到开头问题用前缀树去解决的优越性,假如words数组变成几千甚至几万并且数组会随时增加减少,查询的字符串也在变化,前缀树的优越性优越性是不是就体现出来了
- 问题1 :对字符串数组words进行排序 解决方案: 保证childs是有序的,通过遍历Trie,将走过路径合成字符串,根据结束标签输出字符串,就可以得到一个有序的字符串数组
- 问题2: 统计字符串'bang'在数组words里出现的次数
解决方案:这个就比较好办了,就是查找然后输出end就可以了,就是searchValue操作就可以
- 问题3: 统计字符串数组words前缀为'ba'的字符串个数
解决方案:和问题2很相似,只是将end改成path就可以了
压缩
看着第一张图是不是节点很多,有些节点就没有共用。像单词migration整个路径上就它自己,没有分叉,可以把那些连续没有分叉的节点合成一个节点,来节省空间,这就是空间的优化。这样优化的好处很明显,空间优化很大,坏处也很明显会增加编码的工作量。下图就是上面那张图压缩后的效果图
这张图是空间压缩之后的结果
总结
前缀树是处理字符串的利器,能解决包括但不限于文章中提到的问题,它的效率不错,算法理解起来也很容易,重点和难点是如何将遇到的问题抽象成前缀树,用前缀树的思想去解决遇到的问题。