一、前缀树介绍
(注:本节内容来源于网络)
定义:
所有含有公共前缀的字符串将挂在树中同一个结点下。实际上trie简明的存储了存在于串集合中的所有公共前缀。
假如有这样一个字符串集合X{bear,bell,bid,bull,buy,sell,stock,stop}。它的标准Trie树如下图:
如上图(蓝色圆形结点为内部结点,红色方形结点为外部结点),我们可以很清楚的看到字符串集合X构造的Trie树结构。其中从根结点到红色方框叶子节点所经历的所有字符组成的串就是字符串集合X中的一个串。
注意这里有一个问题:如果X集合中有一个串是另一个串的前缀呢?比如,X集合中加入串bi。那么上图的Trie树在绿色箭头所指的内部结点i就应该也标记成红色方形结点。这样话,一棵树的枝干上将出现两个连续的叶子结点(这是不合常理的)。
也就是说字符串集合X中不存在一个串是另外一个串的前缀。如何满足这个要求呢?我们可以在X中的每个串后面加入一个特殊字符$(这个字符将不会出现在字母表中)。这样,集合X{bear$、bell$、....bi$、bid$}一定会满足这个要求。
总结:一个存储长度为n,来自大小为d的字母表中s个串的集合X的标准trie具有性质如下:
(1)树中每个内部结点至多有d个子结点。
(2)树有s个外部结点。
(3)树的高度等于X中最长串的长度。
(4)树中的结点数为O(n)。
标准Trie树的查找:
对于英文单词的查找,我们完全可以在内部结点中建立26个元素组成的指针数组。如果要查找a,只需要在内部节点的指针数组中找第0个指针即可(b=第1个指针,随机定位)。时间复杂度为O(1)。
查找过程:假如我们要在上面那棵Trie中查找字符串bull(b-u-l-l)。
(1)在root结点中查找第('b'-'a'=1)号孩子指针,发现该指针不为空,则定位到第1号孩子结点处——b结点。
(2)在b结点中查找第('u'-'a'=20)号孩子指针,发现该指针不为空,则定位到第20号孩子结点处——u结点。
(3)...一直查找到叶子结点出现特殊字符'$'位置,表示找到了bull字符串
如果在查找过程中终止于内部结点,则表示没有找到待查找字符串。
效率:
对于有n个英文字母的串来说,在内部结点中定位指针所需要花费O(d)时间,d为字母表的大小,英文为26。由于在上面的算法中内部结点指针定位使用了数组随机存储方式,因此时间复杂度降为了O(1)。但是如果是中文字,下面在实际应用中会提到。因此我们在这里还是用O(d)。查找成功的时候恰好走了一条从根结点到叶子结点的路径。因此时间复杂度为O(d*n)。
但是,当查找集合X中所有字符串两两都不共享前缀时,trie中出现最坏情况。除根之外,所有内部结点都自由一个子结点。此时的查找时间复杂度蜕化为O(d*(n^2))
二、前缀树的java实现
在算法中使用了java的map集合对象来构造树结构,使用map有如下优缺点:
优点:实现简单,操作简单
缺点:比较浪费内存空间。因为map对象在初始化时默认会分配16个entry空间,map对象有一个加载因子(load factor),加载因子的默认值是0.75,其意思是说当分配给map对象的内存空间占用率达到3/4时,map对象的内存空间会翻倍。例如分配给map对象的内存空间为16个entry,当内存空间占用达到12个entry时,map对象的内存空间就会变为32个entry。因此,map的这种机制在某些情况下会严重占用内存空间。可以增加加载因子来节省内存空间,但是这样会导致map的hash空间变小,那么hash时冲突就会增加,最后会影响到搜索的效率。因此,要根据具体的情况来选择合适的加载因子,以平衡空间利用率和效率。
打算做一个比较通用的字典树程序包,初步打算实现三种比较常用的trie树:前缀树、压缩树、后缀树。
设计类图如下所示:
(目前只初步实现了前缀树)
其代码如下所示:
测试结果是正确的,但是可能没测试完全。
接口中有两个方法save和read暂时还没有想到怎么去实现,还有待研究。
三、算法可以改进的地方
初步实现的算法将每个单词的每个字符都存在了一个结点中,这无疑会非常浪费存储空间。
改进的方法:
可以将没有分支的单词后缀作为字符串存储在叶节点中,另如一个单词abcdefg$,如果在字符‘c’处分支后,后面没有其它单词与它拥有共同的前缀,那么就可以将“defg$”作为一个符串存在叶节点中。当插入一个单词abcdemn$时,可以将公共的de两个字符分出来分别存在分支结点中。
当然如果像这样改进,程序的数据结构及算法要做出相应的改变。
四、基础知识补习
在算法中涉及到继承和多态方面的知识,最开始我以为当把一个父类对象句柄指向它的子类时,父类对象句柄可以调用子类拥有而父类没有的方法,没想到我这种想法是错误的,结果是不可以调用的。下面我在网上找了一些相关的资料,感觉很有用:
======================以下内容摘自网络==============================
面向对象编程有三个特征,即封装、继承和多态。
封装隐藏了类的内部实现机制,从而可以在不影响使用者的前提下改变类的内部结构,同时保护了数据。
继承是为了重用父类代码,同时为实现多态性作准备。那么什么是多态呢?
方法的重写、重载与动态连接构成多态性。Java之所以引入多态的概念,原因之一是它在类的继承问题上和C++不同,后者允许多继承,这确实给其带来的非常强大的功能,但是复杂的继承关系也给C++开发者带来了更大的麻烦,为了规避风险,Java只允许单继
承,派生类与基类间有IS-A的关系(即“猫”isa“动物”)。这样做虽然保证了继承关系的简单明了,但是势必在功能上有很大的限制,所以,Java引入了多态性的概念以弥补这点的不足,此外,抽象类和接口也是解决单继承规定限制的重要手段。同时,多态也是面向对象编程的精髓所在。
要理解多态性,首先要知道什么是“向上转型”。
我定义了一个子类Cat,它继承了Animal类,那么后者就是前者是父类。我可以通过Catc=newCat();实例化一个Cat的对象,这个不难理解。但当我这样定义时:Animala=newCat();这代表什么意思呢?
很简单,它表示我定义了一个Animal类型的引用,指向新建的Cat类型的对象。由于Cat是继承自它的父类Animal,所以Animal类型的引用是可以指向Cat类型的对象的。那么这样做有什么意义呢?因为子类是对父类的一个改进和扩充,所以一般子类在功能上较父类更强大,属性较父类更独特,定义一个父类类型的引用指向一个子类的对象既可以使用子类强大的功能,又可以抽取父类的共性。
所以,父类类型的引用可以调用父类中定义的所有属性和方法,而对于子类中定义而父类中没有的方法,它是无可奈何的;
同时,父类中的一个方法只有在在父类中定义而在子类中没有重写的情况下,才可以被父类类型的引用调用;
对于父类中定义的方法,如果子类中重写了该方法,那么父类类型的引用将会调用子类中的这个方法,这就是动态连接。
对于多态,可以总结它为:
一、使用父类类型的引用指向子类的对象;
二、该引用只能调用父类中定义的方法和变量;
三、如果子类中重写了父类中的一个方法,那么在调用这个方法的时候,将会调用子类中的这个方法;(动态连接、动态调用)
四、变量不能被重写(覆盖),”重写“的概念只针对方法,如果在子类中”重写“了父类中的变量,那么在编译时会报错。
压缩树与后缀树的介绍及实现待续...