答应我 你一定要学会字典树
写在前面
大概是一周前的每日一题吧,当初初遇字典树,惊讶于其精妙,昨晚睡前的时候又看到字典树用于解决最长公共子前缀问题,想想具体实现,发现又给忘了,故今日抓紧时间整理,方便日后查看。
本文章包括字典树概念的说明、使用场景以及leetcode题解的具体分析。
字典树详解
字典树概念
先上个图,此图来自维基百科,先看看较为官方的定义:在计算机科学中,trie,又称前缀树或字典树,是一种有序树,用于保存关联数组,其中的键通常是字符串。 与二叉查找树不同,键不是直接保存在节点中,而是由节点在树中的位置决定。 一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,而根节点对应空字符串。
字典树特点
按照个人理解,字典树主要用于统计、排序、保存大量字符串和查找等,优点在于能够利用字符串的公共前缀来减少查询的时间,最大限度地减少字符串的比较,是一种效率很高的数据结构。
Trie树的特点如下:
- 每个节点都有一个指向26个字母的数组,而从上往下连成的字符串就会代表一个单词,而每个单词的最后一个字母的节点也会相应地标记代表结束。
- 根节点是不保存字符的,除根节点外的每一个子节点都包含一个字符。
- 每个节点的所有子节点包含的字符互不相同(最多26个子节点,看看第1点)
字典树应用
- 前缀匹配
- 字符串检索
- 词频统计
- 字符串排序
字典树具体实现(Java)
这里使用Java来对字典树进行一个简单的实现,使用一个Node来声明字典树的每一个节点,节点包括一个int的数组和一个int值。int数组的大小为26,代表26个字母。int值flag作为当前节点是否为最后一个节点的标记,默认值为-1,在构建字典树时可以拿来存对应字符串在字符串数组的下标。(注:此种做法是参照leetcode官方题解的,当然你也可以声明一个包含26个Node的数组)
class Node {
int[] ch = new int[26];
int flag;
public Node() {
flag = -1;
}
}
这里的Node搭配一个ArrayList来进行使用,是一种单链表形式的字典树,具体可以看下面的题解结合理解。
Leetcode原题结合理解
class Solution {
//传说中的字典结点 每个结点都包含着结点对应的值和26个链接
//对应的值其实就是该值在数组的的下标
//此数据结构其实不会保存存储任何的字符串或字符 只保存链接数组和值
class Node {
int[] ch = new int[26];
int flag;
public Node() {
flag = -1;
}
}
//单链表形式的字典树吧
//每一次循环insert 都是往单链表中加上字符串长度-1的结点
//根结点保存着每一个字符串首位字母以及开始的下标
List<Node> tree=new ArrayList<>();
//往字典树中生成具体结点 并插入树中
public void insert(String s,int id){
//字符串长度
int len=s.length(),add=0;
//此循环中 如果是第一个词 add的值会从0慢慢递增到len
for(int i=0;i<len;i++){
//使用int数组代替26个字母 直接减去a取得ascill码值
int x=s.charAt(i)-'a';
//如果是从根结点开始 那么生成一个结点来保存当前的x
if(tree.get(add).ch[x]==0){
tree.add(new Node());
tree.get(add).ch[x]=tree.size()-1;
}
//其实add代表的应该是当前字典树的深度-1 也就是List的长度-1
add=tree.get(add).ch[x];
}
//该字符对应的字典树节点链接生成并在最后一个空结点设置值 最后一个结点int数组全为0
tree.get(add).flag=id;
}
//寻找字典树中是否有与传入字符串构成回文串的字符串
public int findWord(String s,int left,int right){
int add=0;
//因为找的是回文串 因此从右往左匹配字典树
for(int i=right;i>=left;i--){
int x=s.charAt(i)-'a';
if(tree.get(add).ch[x]==0) return -1;
//定位到下一个字母的node节点上
add=tree.get(add).ch[x];
}
return tree.get(add).flag;
}
public List<List<Integer>> palindromePairs(String[] words) {
//给树生成一个根结点
tree.add(new Node());
int n=words.length;
for(int i=0;i<n;i++){
insert(words[i],i);
}
List<List<Integer>> res=new ArrayList<List<Integer>>();
for(int i=0;i<n;i++){
//当前字符串长度
int m=words[i].length();
for(int j=0;j<=m;j++){
//如果从j到末尾是回文串
if(isPalindrome(words[i], j, m - 1)){
//那么寻找能与0到j-1位置组成回文串的字符串
int leftId=findWord(words[i],0,j-1);
//不等于i表示不能是自己
if(leftId!=-1&&leftId!=i){
res.add(Arrays.asList(i,leftId));
}
}
//如果0到j是回文串 注意这个串至少长度要为1
if(j!=0&&isPalindrome(words[i],0,j-1)){
int rightId=findWord(words[i],j,m-1);
if(rightId!=-1&&rightId!=i){
res.add(Arrays.asList(rightId,i));
}
}
}
}
return res;
}
//判断是否为回文 双指针法
public boolean isPalindrome(String s,int left,int right){
int len = right - left + 1;
for (int i = 0; i < len / 2; i++) {
if (s.charAt(left + i) != s.charAt(right - i)) {
return false;
}
}
return true;
}
}
以上是从官方搬运过来的题解以及自己的一些理解,如有错误,还请不吝赐教。
按照我的理解,这份题解使用的是ArrayList的单链表形式来实现字典树,这跟刚刚前面字典树有点类似多叉树的形态略微有些不同,但具体的实现还是遵循字典树的定义的。
字典树的使用,主要包括字典树的生成和对字典树进行删除查找两方面。这里我主要分析下这段代码中生成字典树的部分。
//往字典树中生成具体结点 并插入树中
public void insert(String s,int id){
//字符串长度
int len=s.length(),add=0;
//此循环中 如果是第一个词 add的值会从0慢慢递增到len
//如果是后面的词 则add会从0直接跳到对应的节点再递增
for(int i=0;i<len;i++){
//使用int数组代替26个字母 直接减去a取得ascill码值
int x=s.charAt(i)-'a';
//如果该字符从未出现 那么我们需要新建节点来保存了
if(tree.get(add).ch[x]==0){
tree.add(new Node());
tree.get(add).ch[x]=tree.size()-1;
}
//其实add代表的应该是当前List的长度
add=tree.get(add).ch[x];
}
这里需要注意,我们刚刚说字典树每一个节点都会存一个字符,在这段代码里其实是以整型数组中对应位置存的数字来体现的,这里的数字代表List中的下标,表示的是当前单词会从List中的哪一个位置开始。
也就是说,节点中的数组会帮助我们跳往相应的下标开始插入,这里巧妙的利用了List的长度来唯一表示一个节点的位置,从而保证他们一一对应。
感觉这个链表的命名有点误导人,这里应该是单链表而不是树形结构。
可能听完你还是很懵,那么我只好祭出我的名作来帮助你理解了!
根据本题目的用例,我们来尝试往字典树中插入“abcd”和“lls”两个字符串,看看会是什么样的结果。
丑是丑了点,凑合看吧…我们可以看到,根节点其实只是保存了每个字符串的第一个字符的开始位置,在循环中,add会帮我们定位到该位置进行插入操作。这里节点上的字符其实应该是不存在的,只是我为了容易看才补上的,实际上保存节点字符的应该是上一节点的整型数组!而节点的flag,则是保存当前单词处于用例中的下标位置。
写在最后
这里所分享的字典树实现方式,好像还是比较奇葩的一种?但我觉得一种数据结构,重要的是能够用来解决问题跟达到预期的效果,而如何实现则应该结合实际情况来进行调整跟优化。就比如本题中需要字符串在字符串数组中的下标以返回,就需要存入下标而不是布尔值来标记是否为最后一个字符。
以上,有空的话我会尝试着使用HashMap和Node数组对字典树进行实现并更新上来,希望大家一起加油!若本文有错漏,请不吝指出。