【数据结构与算法系列】——前缀树(字典树)、后缀树,主要用于海量数据的查找和去重问题
前缀树
LeetCode题目中有关前缀树的描述:Trie前缀树-字典树
-
前缀树
Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:最大限度地减少无谓的字符串比较。
Trie的核心思想是空间换时间。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
Trie树也有它的缺点,Trie树的内存消耗非常大。
性质:不同字符串的相同前缀只保存一份。
操作:查找,插入,删除。
前缀树的3个基本性质:
- 根节点不包含字符,除根节点外每一个节点都只包含一个字符。
- 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
- 每个节点的所有子节点包含的字符都不相同。
- 每条边对应一个字母。每个节点对应一项前缀。叶节点对应最长前缀,即单词本身。
字典序排序算法:
是一种基于字典序对随机序列生成全排列的排序方法,通常用于解决这样的问题:给定其中一种排列,求基于字典序的下一种排列(要求下一种排列必须比原排列大,并且二者之间不能存在第三种排列,即下一种排列为大于原排列的最小排列)。
比如给定的排列为 afhdceb
,则它的下一种排列为 afhdebc
;又比如给定的排列为 5482631
,则它的下一种排列为 5483126
。
-
算法的求解步骤
以上述的
5482631
为例,字典序排序算法的求解步骤如下:
(1)在原排列中,从右往左找出数组(序列)中第一个正序(即左边数字小于右边数字),计左边数字的下标为left,计当前左边数字为leftValue,此处left=3
,leftValue=2
。
(2)在原排列中,再次从右往左找出第一个比leftValue大的数,此时该数的下标记为right,此处right=5
,其对应的值为3。
(3)将原排列中第left位与第right位交换(即 2 与 3 交换)后,原排列变为5483621
。
(4)将原排列从left+1到数组(序列)的末尾的数按从小到大排序,得到最小排列126
,因此求得原排列的下一个排列为5483126
。
如果你跟我一样不理解,那就和我一起多看几个例子:
如果当前排列是124653,找它的下一个排列的方法是,从这个序列中从右至左找第一个左邻小于右邻的数,
如果找不到,则所有排列求解完成,如果找得到则说明排列未完成。
本例中将找到46,计4所在的位置为i,找到后不能直接将46位置互换,而又要从右到左到第一个比4大的数,
本例找到的数是5,其位置计为j,将i与j所在元素交换125643,
然后将i+1至最后一个元素从小到大排序得到125346,这就是124653的下一个排列。
总结得出字典排序算法四步法:
-
字典排序:
第一步:从右至左找第一个左邻小于右邻的数,记下位置i,值list[a]
第二部:从右边往左找第一个右边大于list[a]的第一个值,记下位置j,值list[b]
第三步:交换list[a]和list[b]的值
第四步:将i以后的元素重新按从小到大的顺序排列 -
举例:125643的下一个字典序列
第一步:右边值大于左边的3<4,4<6,6>5,则i=2,list[a]=5
第二步:从右往左找出第一个右边大于list[a]=5的值,找到6>5,j=3;list[b]=6;
第三步:交换list[a]和list[b]的值,序列125643->126543
第四步:将位置2以后的元素重新排序,126543->126345;
结束: 126345即125643的下一个序列
前缀树的应用:
(1)字符串的快速检索
字典树的查询时间复杂度是O(logL),L是字符串的长度。所以效率还是比较高的。字典树的效率比hash表高。
hash表:
通过hash函数把所有的单词分别hash成key值,查询的时候直接通过hash函数即可,都知道hash表的效率是非常高的为O(1),当然这是对于如果我们hash函数选取的好,计算量少,且冲突少,那单词查询速度肯定是非常快的。那如果hash函数的计算量相对大呢,且冲突律高呢?这些都是要考虑的因素。
还有就是hash表不支持动态查询,什么叫动态查询,当我们要查询单词apple时,hash表必须等待用户把单词apple输入完毕才能hash查询。当你输入到appl时肯定不可能hash吧。
字典树(tries树):
对于单词查询这种,还是用字典树比较好,但也是有前提的,空间大小允许,字典树的空间相比较hash还是比较浪费的,毕竟hash可以用bit数组。
(2)字符串排序
从下面例子我们很容易看出单词是排序的,先遍历字母序在前面。
减少了没必要的公共子串。
(3)最长公共前缀
abc和abd的最长公共前缀是ab,遍历字典树到字母c时,此时这些单词的公共前缀是ab。
(4)自动匹配前缀显示后缀
我们使用辞典或者是搜索引擎的时候,输入appl,后面会自动显示一堆前缀是appl的东东吧。
那么有可能是通过字典树实现的,前面也说了字典树可以找到公共前缀,我们只需要把剩余的后缀遍历显示出来即可。
举例:
题目:给你100000个长度不超过10的单词。对于每一个单词,我们要判断他出没出现过,如果出现了,求第一次出现在第几个位置。
如果我们使用一般的方法,没查询一个单词都去遍历一遍,那么时间复杂度将为O(n^2),这对于100000这么大的数据是不能够接受的。假如我们要查找单词student。那我们通过前缀树只需要查找s开头的即可,然后接下来查询t开头的即可,对于大量的数据可以省去不少的时间。
树结构:
其中count表示以当前单词结尾的单词数量。
prefix表示以该处节点之前的字符串为前缀的单词数量。
public class TrieNode {
int count;
int prefix;
TrieNode[] nextNode=new TrieNode[26];
public TrieNode(){
count=0;
prefix=0;
}
}
-
前缀树的创建
假设有b,abc,abd,bcd,abcd,efg,hii 这6个单词,那我们创建trie树就得到
//插入一个新单词 public static void insert(TrieNode root,String str){ if(root==null||str.length()==0){ return; } char[] c=str.toCharArray(); for(int i=0;i<str.length();i++){ //如果该分支不存在,创建一个新节点 if(root.nextNode[c[i]-'a']==null){ root.nextNode[c[i]-'a']=new TrieNode(); } root=root.nextNode[c[i]-'a']; root.prefix++;//注意,应该加在后面 } //以该节点结尾的单词数+1 root.count++; }
-
查询以str开头的单词数量,查询单词str的数量
//查找该单词是否存在,如果存在返回数量,不存在返回-1
public static int search(TrieNode root,String str){
if(root==null||str.length()==0){
return -1;
}
char[] c=str.toCharArray();
for(int i=0;i<str.length();i++){
//如果该分支不存在,表名该单词不存在
if(root.nextNode[c[i]-'a']==null){
return -1;
}
//如果存在,则继续向下遍历
root=root.nextNode[c[i]-'a'];
}
//如果count==0,也说明该单词不存在
if(root.count==0){
return -1;
}
return root.count;
}
//查询以str为前缀的单词数量
public static int searchPrefix(TrieNode root,String str){
if(root==null||str.length()==0){
return -1;
}
char[] c=str.toCharArray();
for(int i=0;i<str.length();i++){
//如果该分支不存在,表名该单词不存在
if(root.nextNode[c[i]-'a']==null){
return -1;
}
//如果存在,则继续向下遍历
root=root.nextNode[c[i]-'a'];
}
return root.prefix;
}
-
在主函数中测试
public static void main(String[] args){ TrieNode newNode=new TrieNode(); insert(newNode,"hello"); insert(newNode,"hello"); insert(newNode,"hello"); insert(newNode,"helloworld"); System.out.println(search(newNode,"hello")); System.out.println(searchPrefix(newNode,"he")); } /** 输出:3 4 **/
后缀树
后缀树,就是把一串字符的所有后缀保存并且压缩的字典树。相对于字典树来说,后缀树并不是针对大量字符串的,而是针对一个或几个字符串来解决问题。比如字符串的回文子串,两个字符串的最长公共子串等等。
性质:一个字符串构造了一棵树,树中保存了该字符串所有的后缀。
操作:就是建立和应用。
(1)建立后缀树
比如单词banana,它的所有后缀显示到下面的。0代表从第一个字符为起点,终点不用说都是字符串的末尾。
以上面的后缀,我们建立一颗后缀树。如下图,为了方便看到后缀,我没有合并相同的前缀。
前面简介的时候我们说了,后缀树是把一个字符串所有后缀压缩并保存的字典树。所以我们把字符串的所有后缀还是按照字典树的规则建立,就成了下图的样子。这就是后缀树的压缩,可见更加节省空间。
注意还是和字典树一样,根节点必须为空。
(2)后缀树的应用
-
查找某个字符串s1是否在另外一个字符串s2中
这个很简单,如果s1在字符串s2中,那么s1必定是s2中某个后缀串的前缀。
理解以下后缀串的前缀这个词,其实每个后缀串也就是起始地点不同而已,前缀也就是从开头开始结尾不定。
后缀串的前缀就可以组合成该原先字符串的任意子串了。
比如banana,anan是anana这个后缀串的前缀。
-
指定字符串s1在字符串s2中重复的次数
比如说banana是s1,an是s2,那么计算an出现的次数实际上就是看an是几个后缀串的前缀。
上图的a节点是保存所有起始为a字母的后缀串,我们看a字母后的n字母的引用计数即可。
-
两个字符串S1,S2的最长公共部分(广义后缀树)
-
最长回文串(广义后缀树)
前缀树查询和哈希查询的比较(示相对情况而定):
hash表,通过hash函数把所有的单词分别hash成key值,查询的时候直接通过hash函数即可,
hash:
当然对于单词查询,如果我们hash函数选取的好,计算量少,且冲突少,那单词查询速度肯定是非常快的。那如果hash函数的计算量相对大呢,且冲突律高呢?
这些都是要考虑的因素。且hash表不支持动态查询,什么叫动态查询,当我们要查询单词apple时,hash表必须等待用户把单词apple输入完毕才能hash查询。
当你输入到appl时肯定不可能hash吧。
字典树(tries树):
对于单词查询这种,还是用字典树比较好,但也是有前提的,空间大小允许,字典树的空间相比较hash还是比较浪费的,毕竟hash可以用bit数组。
那么在空间要求不那么严格的情况下,字典树的效率不一定比hash若,它支持动态查询,比如apple,当用户输入到appl时,字典树此刻的查询位置可以就到达l这个位置,那么我在输入e时光查询e就可以了(更何况如果我们直接用字母的ASCII作下标肯定会更快)!字典树它并不用等待你完全输入完毕后才查询。