一、哈希树概述
1.1.、其他树背景
二叉排序树,平衡二叉树,红黑树等二叉排序树。在大数据量时树高很深,我们不断向下找寻值时会比较很多次。二叉排序树自身是有顺序结构的,每个结点除最小结点和最大结点外都有前驱和后继,不论是排序还是搜索它的综合性能比较好,但是单独在搜索这一方面二叉排序树的性能就可能没有Hash树快。
1.2、基础理论
1.2.1、质数分辨定理
什么是质数 : 即只能被 1 和 本身 整除的数。
为什么用质数:因为N个不同的质数可以 ”辨别“ 的连续整数的数量,与这些质数的乘积相同。
百度文库解答:https://wenku.baidu.com/view/16b2c7abd1f34693daef3e58.html
示例、从2起的连续质数,连续10个质数就可以分辨大约M(10) =2*3*5*7*11*13*17*19*23*29= 6464693230 个数,已经超过计算机中常用整数(32bit)的表达范围。连续100个质数就可以分辨大约M(100) = 4.711930 乘以10的219次方。
而按照目前的CPU水平,100次取余的整数除法操作几乎不算什么难事。在实际应用中,整体的操作速度往往取决于节点将关键字装载内存的次数和时间。一般来说,装载的时间是由关键字的大小和硬件来决定的;在相同类型关键字和相同硬件条件下,实际的整体操作时间就主要取决于装载的次数。他们之间是一个成正比的关系。
1.2.2、余数分辨定理
这个定理可以简单的表述为:n个不同的数可以“分辨”的连续整数的个数不超过他们的最小公倍数。超过这个范围就意味着冲突的概率会增加。定理1是定理2的一个特例。
1.3、基于上述理论分辨算法创建哈希树
可以选择质数分辨算法来建立一棵哈希树。
选择从2开始的连续质数来建立一个十层的哈希树。第一层结点为根结点,根结点下有2个结点;第二层的每个结点下有3个结点;依此类推,即每层结点的子节点数目为连续的质数【1,2,3,5,7,11……】。到第十层,每个结点下有29个结点。如下图所示:
原则:求余看对应位置的结点,如果为空则在空处插入一个新节点,如果被逻辑删除了替换值再逻辑恢复,如果有值就继续往下找,继续求余判断。
示例-插入:有一组元素,按照顺序向hash树中插入元素,取余找位置插入。我们以下图中的 “68” 为例子,68先对2取余得0,但是0位置上有人了,继续对3取余得2,2得位置上也有人了,那就继续对5取余得3,3得位置上没有人则插入 到3的位置。
示例-查询:查询68,我们从2开始取余查找,找对应位置的值是否和它相同,不相同则继续向下取余,直到找到和68相同的数值或者直到为空为止。
1.4、哈希树的应用
哈希树是一种可以自适应的树。通过给出足够多的不同质数,我们总可以将所有已经出现的关键字进行区别。而质数本身就是无穷无尽的。这种方式使得关键字空间和地址空间不再是压缩对应方式,而是完全可以等价的。
哈希树可以广泛应用于那些需要对大容量数据进行快速匹配操作的地方。例如:数据库索引系统、短信息中的收条匹配、大量号码路由匹配、信息过滤匹配。程序员可以利用各种代码来实现哈希树结构。它可以为程序员提供一种使用起来更加方便,更加简单的快速数据存储方式。
1.5、优点和缺点
优点:结构简单 注意hash树是单向增加的,即使删除也不会减少hash树的结构
查找迅速 对于int的数据 最多查找10次
结构不变 即使删除属于也不会改变结构,
缺点:非排序性 就是数据时没有顺序的
代码地址:地址 中的data-004-tree中 HashTree
参看地址:
https://blog.csdn.net/xushiyu1996818/article/details/89396909
https://blog.csdn.net/winter_wu_1998/article/details/79555936
二、字典树
2.1、概述
Tire树称为字典树,又称单词查找树,Trie树,是一种树形结构,是一种哈希树的变种。
2.2、应用场景
典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。
它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。
Tire树的应用:
1) 串的快速检索
给出N个单词组成的熟词表,以及一篇全用小写英文书写的文章,请你按最早出现的顺序写出所有不在熟词表中的生词。
在这道题中,我们可以用数组枚举,用哈希,用字典树,先把熟词建一棵树,然后读入文章进行比较,这种方法效率是比较高的。
2) “串”排序
给定N个互不相同的仅由一个单词构成的英文名,让你将他们按字典序从小到大输出。用字典树进行排序,采用数组的方式创建字典树,这棵树的每个结点的所有儿子很显然地按照其字母大小排序。对这棵树进行先序遍历即可。
3) 最长公共前缀
对所有串建立字典树,对于两个串的最长公共前缀的长度即他们所在的结点的公共祖先个数,于是,问题就转化为求公共祖先的问题。
4)作为其他数据结构和算法的辅助结构:如后缀树,AC自动机等。
代码地址:地址 中的data-004-tree中 TrieTree
参看地址:
https://www.cnblogs.com/xujian2014/p/5614724.html
三、后缀树
3.1、后缀树的性质
- 存储所有 n(n-1)/2 个后缀需要 O(n) 的空间,n 为的文本(Text)的长度;
- 构建后缀树需要 O(dn) 的时间,d 为字符集的长度(alphabet);
- 对模式(Pattern)的查询需要 O(dm) 时间,m 为 Pattern 的长度;
3.2、字典树→压缩字典树→后缀树
根据集合 {bear, bell, bid, bull, buy, sell, stock, stop} 所构建的 Trie 树。
观察上面这颗 Trie,对于关键词 "bear",字符 "a" 和 "r" 所在的节点没有其他子节点,所以可以考虑将这两个节点合并,如下图所示。
这样,我们就得到了一棵压缩过的 Trie,称为压缩字典树(Compressed Trie)。
后缀树(Suffix Tree)则首先是一棵 Compressed Trie,其次,后缀树中存储的关键词为所有的后缀。这样,实际上我们也就得到了构建后缀树的抽象过程:
- 根据文本 Text 生成所有后缀的集合;
- 将每个后缀作为一个单独的关键词,构建一棵 Compressed Trie。
示例、后缀树
对于文本 "banana ",其中 " " 作为文本结束符号。下面是该文本所对应的所有后缀。
banana anana0 nana0 ana0 na0 a0 0
将每个后缀作为一个关键词,构建一棵 Trie
然后,将独立的节点合并,形成 Compressed Trie。
则上面这棵树就是文本 "banana " 所对应的后缀树。
3.3、显式后缀树(Explicit Suffix Tree)和隐式后缀树(Implicit Suffix Tree)
1、隐式后缀树
隐式后缀树(implicit suffix tree):后缀可能终止于叶子结点,也可能隐藏在内部结点中。如果输入串中最后一个字符不同于其他字符,那么所有的后缀都终止于叶子结点,不会有后缀隐藏在内部结点中。这就是为什么ukk算法中最后一个字符必须是特殊字符的原因。
使用上述banana说明
banana
anana
nana
ana
na
a
后缀 "ana" 、”na“和 "a" 已经分别包含在后缀 "anana" 、”nana“和 "anana" 的前缀中,这样构造出来的后缀树称为隐式后缀树(Implicit Suffix Tree)。
2、显示后缀树
如果不希望这样的上述情形发生,可以在每个后缀的结尾加上一个特殊字符,比如 "$" 或 "#" 等,这样我们就可以使得后缀保持唯一性。
banana$
anana$
nana$
ana$
na$
a$
$
3.4、使用Ukkonen算法构建后缀树
上述是使用一种分析的方式来构建一个后缀树,但是如果字符串长度较大,上述方式可能不太可能,故ukk算法可以不用罗列枚举的解决上述问题
Ukkonen算法(简称ukk算法)是一个online算法,它与mcc算法的一个显著区别是每次只对S的一个前缀生成隐式后缀树(implicit suffix tree),然后考虑S的下一个字符S[i+1]并将S[0...i+1]的所有后缀加入到上一个阶段中生成的隐式后缀树中,形成一个新的隐式后缀树。最后用一个特殊字符将隐式后缀树自动转换成真实的后缀树。这样ukk的一个最大优点就是不需要事先知道输入字串的全部内容,只需使用增量方式生成后缀树。和mcc算法类似,也是采用压缩存储Trie,以达到节省空间的目的。通过使用implicit extensions和suffix link两大技巧,时间复杂度可以达到线性。
在 1995 年,Esko Ukkonen 发表了论文《On-line construction of suffix trees》,描述了在线性时间内构建后缀树的方法。
Suffix Tree 与 Trie 的不同在于,边(Edge)不再只代表单个字符,而是通过一对整数 [from, to] 来表示。其中 from 和 to 所指向的是 Text 中的位置,这样每个边可以表示任意的长度,而且仅需两个指针,耗费 O(1) 的空间。
示例一、分析构建text=abc后缀树
不用使用上述。使用ukk,可以理解为这个单词从左至右进入树
1、录入a:第 1 个字符是 "a",创建一条边从根节点(root)到叶节点,以 [0, #] 作为标签代表其在 Text 中的位置从 0 开始。使用 "#" 表示末尾,可以认为 "#" 在 "a" 的右侧,位置从 0 开始,则当前位置 "#" 在 1 位。
其代表的后缀意义如下。
2、录入b:开始处理第 2 个字符 "b"。涉及的操作包括:
- 扩展已经存在的边 "a" 至 "ab";
- 插入一条新边以表示 "b";
其代表的后缀意义如下。
这里,我们观察到了两点:
- "ab" 边的表示 [0, #] 与之前是相同的,当 "#" 位置由 1 挪至 2 时,[0, #] 所代表的意义自动地发生了改变。
- 每条边的空间复杂度为 O(1),即只消耗两个指针,而与边所代表的字符数量无关;
3、录入c:再处理第 3 个字符 "c",重复同样的操作,"#" 位置向后挪至第 3 位:
其代表的后缀意义如下。
小结:
操作步骤的数量与 Text 中的字符的数量一样多;
每个步骤的工作量是 O(1),因为已存在的边都是依据 "#" 的挪动而自动更改的,仅需为最后一个字符添加一条新边,所以时间复杂度为 O(1)。则,对于一个长度为 n 的 Text,共需要 O(n) 的时间构建后缀树。
示例二、分析abcabxabcd
abc开始的,接着重复ab ,紧跟着x,再接着重复abc,紧跟着d
步骤1到3:经过前三步后,我们拥有和前面那个例子一样的树:
步骤4:我们移动#到位置4。所有已经存在的边隐含地修改如下:
如之前步骤所言,我们还需要在根节点插入当前步骤的最后一个后缀a。
我们在插入最后一个后缀a之前,我们引入除#之外的两个或者两个更多的变量,当然这些变量一直都存在,只是我们目前为止没有使用它们而已:
- 活动点(active point),它是一个三元组(active_node,active_edge, active_ length)
- 剩余后缀数(remainder),它是一个整数,表名我们还需要插入多少个新的后缀。
两个变量的含义 在那个简单的abc例子里,活动点总是(root,’0x’,0),也就是说,active_node是根节点,active_edge总是被指定为空字符’0x’,active_ length是0。
这么做的结果是我们在每一步插入的那一条新边是作为新创建的边插入到根节点。不久我们就会明白为什么需要三元组表示这些信息。 在每步开始时remainder 总是设置为1。这样做的意义是,在每一步结束后我们不得不主动插入的后缀数目为1(总是最后一个字符)。
现在将会发生变化了,当我们给根节点插入当前最后一个字符a的时候,我们注意到已经存在一条以a开始的边:abca。在这种情况下我们做如下工作:
- 我们不会在根节点插入一条新边[4,#]。相反,我们只是注意到后缀a已经在我们的树里。它会终止在更长的边的中间位置,对此我们并不疑惑,我们还是保留它们原来的样子。
- 我们设置活动点为(root,’a’,1)。这意味着活动点现在是在根节点的以a开始的向外的边的中间某个位置,具体地指这条边的位置1之后。我们注意到这条边只是由它的首个字符a来声明的。这就足够了,因为以一个特定的字符开始的只有一条边(通读整个文档之后可以确定这是真的)。
- 我们还增加了remainder, 那么在下一步骤开始的时候,remainder为2。
注意:当发现我们需要插入的最终后缀已经存在在这棵树里的时候,这棵树不会发生任何改变(我们只是修改了活动节点和remainder)。这棵树就不再精确的表示当前位置的后缀树了,但是它已经包含了所有的后缀了(因为最终的后缀a也隐含地包含在里面了)。因此,除了修改变量外(所有这些变量都是定长的,因此空间复杂度是 O(1)),在这一步里没有做其他工作。
步骤5:我们修改#当前的位置为5。这将自动地更新这棵树如下:
而且由于remainder为2 ,我们需要在目前位置需要插入两个最终后缀:ab和b。这主要是因为:
- 在上一步中的后缀a就从来没有真正地插入树中(只是用变量表示而已)。因此它一直保留着,然而由于我们已经向前走了一步,它现在由a变为ab。
- 还有,我们需要插入新的最终边b。
实际上,我们只需要修改活动点(它现在指向的是边abcab中a之后),而且插入当前的最后一个字符b, 不过:同时它也证明b也 已经出现在同一条边里。(But: Again, it turns out that b is also already present on that same edge.)
因此,我们再次不修改这棵树,我们只是:
- 修改活动点为(root,’a’,2)(是与前面相同的节点和边,只不过现在我们指向到b之后)。
- 增加remainder为3,因为我们仍然不能插入前一步里的最终边,同时我们也不能插入当前的最终边。
为了清晰地说明:我们不得不在当前这一步插入ab和b,但是因为ab已经找到,我们只是修改了活动点,甚至都没试图插入b。为什么?因为如果ab处于这棵树里,那么它的每个后缀(包括b)也一定在这棵树里。也许仅仅是隐含性的,不过它一定在这棵树里,因为这是我们迄今为止建立这棵树所采用的方法。
步骤6:我们继续增加#,这棵树自动修改如下:
由于remainder是3 ,我们不得不插入abx,bx和x。活动点告诉我们ab在哪结束,因此我们仅仅需要跳过这儿,然后插入x。x确实还不在这棵树里,因此我们拆分边abcabx,插入一个内部节点:
这条边表示的仍然是指向文本内部的指针,因此拆分和插入内部节点的时间复杂度为O(1)。
这时我们处理了abx,并且把remainder减为2。现在我们需要插入下一个保留的后缀bx。但是在我们做这些之前,我们需要修改活动节点。拆分并插入一条边遵循的规则称作规则1,如下,而且它适用于活动节点是根节点的情况(针对下面后续的其他情况,我们将要了解规则3)。规则1如下:
向根节点插入后,
- active_node 保留为根节点
- active_edge 为我们需要插入的新后缀的第一个字符,也就是 b。
- active_length 减1
因此,新的活动节点三元组为(root,’b’,1)表明下一个插入在bcabx边,第一个字符之后,也就是 b之后。我们可以确定插入点的时间复杂度为 O(1),并且检查x是否已经出现在b之后。如果它出现,我们将结束当前的步骤,保持一切为原样。然而如果x没有出现,那么我们拆分这条边而插入它:
再此说明,它的时间复杂度为 O(1),而且我们按照规则1所示把remainder修改为1,活动节点修改为(root,’x’,0)。
不过还有一件事情需要我们必须做。我们称它为规则2:
如果我们拆分一条边并插入新的节点,如果它不是在当前这一步里创建的第一个节点的话,我们通过特殊的指针,即后缀连接(suffix link),把 以前插入的节点和新增的节点连接起来。后面我们将会知道这么做是有用的。这儿我们要明白:后缀连接用虚线边表示:
我们仍然需要插入当前步骤的最终后缀x。因为活动节点的active_length已经减为0了,因此直接插入到根节点上。由于根节点上没有以x开始的边,所以我们插入了新边:
正如我们所能看到的那样,在当前这一步里插入了所有剩余的后缀。
步骤7: 我们设置#=7,这将像往常一样自动添加下一个字符a到所有的叶子边上。然后我们试图插入新的最终字符到活动节点(根节点),然后发现已经在这棵树里了。因此我们结束当前的步骤,不插入任何边,并且修改活动点为(root,’a’,1)。
步骤8:设置#=8,我们像往常一样添加字符b,这仅仅意味着我们修改活动点为(root,’a’,2) ,增加remainder,其他事情都不需要做。因为b已经出现在这棵树里。然而我们(在 O(1)时间复杂度里)注意到活动节点现在是一条边的结尾(However, we notice (in O(1) time) that the active point is now at the end of an edge. )。我们通过重置活动节点为(node1,’