哈希树(HashTree)查找算法【2】
罗堃1
多年前,作者已公开发表过一篇关于哈希树查找算法2的文章。在该文章中,作者已经初步分析和讨论过哈希树查找算法有关的理论依据、基本定义、构建方式以及相关特点。经过多年的公开检验,该算法已经得到了初步的理解和认可。作者同时也注意到哈希树查找算法中还存在诸多问题,需要进一步明确和补充,以方便读者理解和应用哈希树查找算法。
本文是对之前所发表文章的删减、修改、完善和补充,与之前所发表的文章差异甚大,读者要注意阅读和区分。对于文中熟悉的部分,读者可以快速跳过。另外后面为了方便叙述算法或程序步骤,本文所有代码一律以Java语言的方式予以说明。
一、查找算法
在各种数据集合(包括数组、线性表、树等数据结构)中,数据记录3在集合中的实际位置4是随机的,和数据记录的关键字之间不存在确定的关系。因此在数据集合中查找数据记录时需要进行一系列关键字的比较。查找算法大多数是建立在关键字比较的基础上。
在顺序查找时,比较的结果为" = { = } =“与” ≠ {\ne} =“两种可能。在折半查找、二叉排序树查找和树查找时,比较的结果为” < { < } <"、" = { = } =“与” > { > } >"三种可能。查找的效率主要决定于查找过程中所进行的比较次数和时长。在同等关键字模式5下,又以比较次数为最主要的因素。
为确定数据记录在被查找集合中的实际位置,需和给定值进行比较的关键字个数(也即比较次数)的期望值称为:查找算法在查找成功时的平均查找长度(Average
Search
Length)。下面简单讨论一下在含有个数据记录的集合中,几种常见查找算法的平均查找长度。
在等概率的情况下,顺序查找的平均查找长度为:
A S L s s = n + 1 2 { AS{L_{ss}} = \frac{
{n + 1}}{2} } ASLss=2n+1
对于折半查找(表要求是顺序表),在 n { n } n比较大( n > 50 { n > 50 } n>50)的时候,可有以下近似结果:
A S L b s = log 2 ( n + 1 ) − 1 { AS{L_{bs}} = {\log _2}(n + 1) - 1 } ASLbs=log2(n+1)−1
在随机情况下(二叉树查找可能退化为顺序查找),对于二叉树,其平均查找长度为:
A S L b = 1 + 4 log n { AS{L_b} = 1 + 4\log n } ASLb=1+4logn
在平衡二叉树上进行查找,其平均查找长度为:
A S L b b = log ϕ ( 5 ( n + 1 ) ) − 2 { ASL{}_{bb} = {\log _\phi }(\sqrt 5 (n + 1)) - 2 } ASLbb=logϕ(5(n+1))−2,其中 ϕ = 1 + 5 2 { \phi = \frac{
{1 + \sqrt 5 }}{2} } ϕ=21+5
对于一颗阶的树,从根节点到关键字所在节点的路径上涉及的节点数可以说是平均查找长度的上限:
A S L B − ≤ log m / 2 ( n + 1 2 ) + 1 { AS{L_{
{B^ - }}} \le {\log _{m/2}}(\frac{
{n + 1}}{2}) + 1 } ASLB−≤logm/2(2n+1)+1
总的来说,这些查找算法的效率都将随着数据记录数的增长而下降。仅仅是有的比较快(时间复杂度为 O ( n ) { O(n) } O(n)),有的比较慢(时间复杂度是 O ( l o g n ) { O(logn) } O(logn))而已。
这些查找算法的平均查找长度是在一种比较理想的情况下获得的。在实际应用当中,对数据结构6中数据记录的频繁增加和删除将不断地改变着原有的数据结构。这些操作将可能导致某些数据结构退化为链表结构,那么其性能必然将下降。为了避免出现这种情况而采取的调整措施,又不可避免的增加了程序的复杂程度以及操作的额外时间。
二、哈希函数
2.1定义
最理想的查找算法是不经过任何比较,一次操作便能得到所查的数据记录。那就必须在数据记录的实际位置和它的关键字之间建立一个唯一确定的对应关系 f { f } f,使每个关键字和数据集合中某个唯一的实际位置相对应。
在依据给定值 k { k } k查找时,只要根据这个对应关系 f { f } f可以得到给定值的像。若数据集合中存在关键字 k e y { key } key和给定值 k { k } k相等的数据记录,则数据记录必定在 f ( k ) { f(k) } f(k)所指的实际位置上。由此,不需要进行比较便可直接取得所查的数据记录。在此称这个对应关系 f { f } f为哈希(Hash)函数。哈希函数的平均查找长度是1,时间复杂度是 O ( 1 ) { O(1) } O(1)。
哈希函数是一种运用于计算机查找算法的函数,是一个实际应用。所有针对哈希函数的讨论,均是基于整数范围内的讨论。也即:给定值 k { k } k、关键字 k e y { key } key以及 f ( k ) { f(k) } f(k)应均为整数或整数序列。如果给定值 k { k } k、关键字 k e y { key } key或者 f ( k ) { f(k) } f(k)不为整数(例如:浮点数、字符串、字节数组等),均可以在计算机中"转化"为适用的整数或者整数序列予以表达。
2.1.1单调哈希函数
在某个关键字数值区间内,随着关键字数值的增加,哈希函数 f ( k ) { f(k) } f(k)单调递增或者单调递减,则称该哈希函数为单调哈希函数。单调函数保证了关键字集合和压缩映像集合的"一一映射"关系。适用于单调哈希函数的性质和定理同样适用于能"一一映射"的哈希函数。
例如:哈希函数 f ( k ) = k m o d n { f(k) = k\bmod n } f(k)=kmodn( n ∈ N ∗ { n \in {N^*} } n∈N∗),在区间内 [ 0 , n − 1 ] { \left[ { {\rm{0,}}n - 1} \right] } [0,n−1]就是单调递增函数。
定义如下哈希函数 s q r t ( k ) { sqrt(k) } sqrt(k):对于所有正整数关键字进行开方运算,取小数点后三位的有效数字组成新的数字。显然这个哈希函数在区间内 [ 0 , 100 ] { \left[ { {\rm{0,}}100} \right] } [0,100]不是单调函数。
k { k } k | k { \sqrt k } k | s q r t ( k ) { sqrt(k) } sqrt(k) |
---|---|---|
0 | 0 | 0 |
1 | 1 | 0 |
2 | 1.414 | 414 |
3 | 1.732 | 732 |
4 | 2 | 0 |
5 | 2.236 | 236 |
6 | 2.449 | 449 |
7 | 2.645 | 645 |
8 | 2.828 | 828 |
9 | 3 | 0 |
10 | 3.162 | 162 |
… | … | … |
100 | 10 | 0 |
2.1.2字符串取余操作
当关键字 k e y { key } key是一个字符串时,可以将该字符串转化为字节数组。如果关键字 k e y { key } key的字符串中没有任何本地语言字符(即全部均为ASCII码),则直接可以转换成字节数组;如果关键字的字符串中可能有本地语言字符,则可以依据本地字符集编码或者通用字符集编码,将字符串转换成字节数组。转化为字节数组后,即可按照字节数组进行取余操作。
在Java程序中,字符串可以按照char类型(unicode编码)进行访问,将char类型直接按照short类型进行处理。
1./**
2. * Get residue of string.
3. *
4. * @param value Value of string.
5. * @param prime A prime for dividing.
6. * @return
7. * <p>Residue of string.</p>
8. */
9.public static
10. int getResidue(String value,int prime)
11.{
12. //Residue
13. int residue = 0;
14. //Each char is divided by prime.
15. for(int i = 0;i < value.length();i ++)
16. {
17. //Get residue.
18. residue = (value.charAt(i) + (residue << 16)) % prime;
19. }
20. //Return residue.
21. return residue;
22.}
2.1.3字节数组取余操作
1. /\*\*
2. \* Get residue of byte array.
3. \*
4. \* \@param bytes Byte array.
5. \* \@param prime A prime for dividing.
6. \* \@return
7. \* \<p\>Residue of byte array.\</p\>
8. \*/
9. public static
10. int getResidue(byte[] bytes,int prime)
11. {
12. //Residue
13. int residue = 0;
14. //Each byte is divided by prime.
15. for(int i = 0;i < bytes.length;i ++)
16. {
17. //Get residue.
18. residue = (bytes[i] + (residue << 8)) % prime;
19. }
20. //Return residue.
21. return residue;
22. }
2.2冲突
在哈希函数中对于不同数值的关键字有可能得到同一实际地址值,即 k 1 ≠ k 2 { {k_1} \ne {k_2} } k1=k2,而 f ( k 1 ) = f ( k 2 ) { f({k_1}) = f({k_2}) } f(k1)=f(k2)。这种现象称做冲突(Collision)。
哈希函数是从关键字集合到实际地址集合的映射7。通常关键字的集合比较大,包括所有可能的关键字。而实际地址集合的元素则受限于所分配的物理空间。
例如:标识符可定义为以字符为首的8位字符,则以标识符为关键字的集合大小为 C 26 1 × C 36 7 × 7 ! ≅ 1.09338 × 1 0 12 { C_{26}^1 \times C_{36}^7 \times 7! \cong 1.09338 \times {10^{12}} } C261×C367×7!≅1.09338×1012。如将其全部记录存储,则需要的物理存储空间大约为8.75TB。显然这个数据量已经超过常规计算机内存或磁盘的存储能力。
换个角度简单点说:设有4个关键字元素,都需要映射到实际地址集合中,地址编号从1到3。那么必然有两个元素会有同一个实际地址值,与具体所选择的哈希函数无关。这个压缩映射也是"抽屉原则"的体现。
总而言之,哈希函数是一个压缩映像函数,不可避免的要产生冲突。冲突只能尽可能地减少,而不能完全避免。
2.3评价哈希函数
同样的关键字集合和实际地址集合,可以有很多哈希函数可供选择。本节将讲述哈希函数的主要性质,以及如何评价哈希函数。
2.3.1分辨能力
定义1:分辨能力 D { D } D是指关键字集合经哈希函数的映射后,所得像集合中所有不重复元素的个数8。
像集合中至少要有一个元素,即 D ∈ N ∗ { D \in {N^*} } D∈N∗。例如:所有整数关键字元素都需要压缩映射到编号从0到9999的实际地址集合中。那么该哈希函数的分辨能力 D = 10000 { D = 10000 } D=10000;对于哈希函数 f ( k ) = C { f(k) = C } f(k)=C(其中为任意常数),该哈希函数的分辨能力 D = 1 { D = 1 } D=1;对于哈希函数 f ( k ) = k { f(k) = k } f(k)=k,该哈希函数的分辨能力 D = ∞ { D = \infty } D=∞;对于哈希函数 f ( k ) = k m o d n { f(k) = k\bmod n } f(k)=kmodn( n ∈ N ∗ { n \in {N^*} } n∈N∗),该哈希函数的分辨能力 D = n { D = n } D=n。
分辨能力的数值越大,说明哈希函数"越好"。当分辨能力 D = ∞ { D = \infty } D=∞的时候,哈希函数的分辨能力最强,同时像集合所使用的实际物理空间也将是无穷大。实际应用中,是不可能有足够的资源来预先分配出所有的存储空间。因此对于分辨能力 D = ∞ { D = \infty } D=∞的哈希函数是没有实际应用意义的。更多地是需要在分辨能力、存储空间和时间复杂度中寻找平衡点。
定理2:对于单调哈希函数,任何两个间隔不超过其分辨能力 D { D } D的关键字不可能具有完全相同的压缩映像数值。
在整数范畴内,关键字 k e y { key } key具有一定的排列规律,且这种排列是连续的。当哈希函数是单调函数时,那么从任意给定值 k { k } k起始至 ( k + D − 1 ) { (k + D{\rm{ - 1}}) } (k+D−1)这个范围内,这样 D { D } D个不同的关键字将拥有 D { D } D种不同的压缩映像数值(否则就与预设条件矛盾)。也就是说,对于任意两个给定值 k 1 { k_1 } k1和 k 2 { k_2 } k2,如果间隔不超过 D { D } D,就不可能获得同样的压缩映像数值。
2.3.2冲突概率
定义3:两个任意给定值 k 1 { k_1 } k1和 k 2 { k_2 } k2( k 1 ≠ k 2 { {k_{\rm{1}}} \ne {k_{\rm{2}}} } k1=k2),导致哈希函数出现 f ( k 1 ) = f ( k 2 ) { f({k_1}) = f({k_2}) } f(k1)=f(k2)的概率。这个概率被称作冲突概率 λ { \lambda } λ。
根据定义1,可知哈希函数分辨能力 D { D } D意味着压缩映像集合中有 D { D } D种不同元素。随机选择 N { N } N个不同的给定值 k { k } k,经过哈希函数映射后,得到 D { D } D种不同状态的计数统计结果为: C = { c i ∣ i ∈ [ 1 , D ] } { C = \left\{ { {c_i}|i \in \left[ {1,D} \right]} \right\} } C={ ci∣i∈[1,D]}。显然 N = ∑ i = 1 D c i { N = \sum\limits_{i = 1}^D { {c_i}} } N=i=1∑Dci。
某个压缩映像元素的冲突概率 λ c i { \lambda _{
{
{\rm{c}}_i}} } λci为:
λ c i = l i m N → ∞ ( c i N ⋅ c i − 1 N − 1 ) { {\lambda _{
{
{\rm{c}}_i}}} = \mathop {lim}\limits_{N \to \infty } \left( {\frac{
{
{c_i}}}{N} \cdot \frac{
{
{c_i} - 1}}{
{N - 1}}} \right) } λci=N→∞lim(Nci⋅N−1ci−1)
该哈希函数冲突概率 λ { \lambda } λ为:
λ = ∑ i = 1 D λ c i = lim N → ∞ ∑ i = 1 D ( c i N ⋅ c i − 1 N − 1 ) = lim N → ∞ ∑ i = 1 D c i 2 − N N ( N − 1 ) = lim N → ∞ ∑ i = 1 D c i 2 N ( N − 1 ) { \lambda = \sum\limits_{i = 1}^D {
{\lambda _{
{c_i}}}} = \mathop {\lim }\limits_{N \to \infty } \sum\limits_{i = 1}^D {\left( {\frac{
{
{c_i}}}{N} \cdot \frac{
{
{c_i} - 1}}{
{N - 1}}} \right)} = \mathop {\lim }\limits_{N \to \infty } \frac{
{\sum\limits_{i = 1}^D {c_i^2} - N}}{
{N(N - 1)}} = \mathop {\lim }\limits_{N \to \infty } \frac{
{\sum\limits_{i = 1}^D {c_i^2} }}{
{N(N - 1)}} } λ=i=1∑Dλci=N→∞limi=1∑D(Nci⋅N−1ci−1)=N→∞limN(N−1)i=1∑Dci2−N=N→∞limN(N−1)i=1∑Dci2
由此可以看出,对于任意哈希函数的冲突概率计算是比较复杂的。
推论4:单调哈希函数的冲突概率 λ = 1 D { \lambda = \frac{ {\rm{1}}}{D} } λ=D1。
对于单调哈希函数,每种压缩映像元素出现的概率都相等,均为 1 D { \frac{
{\rm{1}}}{D} } D1。所以对于任意两个给定值 k 1 { k_1 } k1和 k 2 { k_2 } k2。出现 f ( k 1 ) { f({k_1}) } f(k1)的概率是 1 D { \frac{
{\rm{1}}}{D} } D1,出现 f ( k 2 ) { f({k_2}) } f(k2)的概率也是 1 D { \frac{
{\rm{1}}}{D} } D1,一共有 D { D } D种状态。因此哈希函数的冲突概率(即 f ( k 1 ) = f ( k 2 ) { f({k_1}) = f({k_2}) } f(k1)=f(k2)):
0 ≤ λ = 1 D 2 ⋅ D = 1 D ≤ 1 { 0 \le \lambda = \frac{1}{
{
{D^{\rm{2}}}}} \cdot D = \frac{1}{D} \le 1 } 0≤λ=D21⋅D=