哈希树(HashTree)

作者 罗堃
(此文为原创,若需要引用请注明出处)

2000年开始,作者开始研究基于TCP/IP的短信息传输技术。这种技术目前在国际上的标准被成为SMPPShort Message Peer to Peer Protocol)。SMPP协议是一种支持异步传输模式(Asynchronized Transmission Mode)的信息传输方式。这种异步方式主要体现在两个地方:传递信息和等待确认。在为电信运营商编写软件的过程当中,解决大容量(百万用户以上)要求下的快速查找与匹配成为实现这个系统的核心任务。

       作者在反复设计整个过程中曾经尝试过很多种方式和算法,但都未能取得满意的效果。最终不得不自己开始设计针对这种系统的特殊存储模式。并在这个过程中,找到了一种比较理想的数据存储结构——哈希树(HashTree)。

一、查找算法

       在各种数据结构(线性表、树等)中,记录在结构中的相对位置是随机的,和记录的关键字之间不存在确定的关系。因此在机构中查找记录的时需要进行一系列和关键字的比较。这一类的查找方法建立在“比较”的基础上。在顺序查找时,比较的结果为“等于”与“不等于”两种可能。在折半查找、二叉排序树查找和树查找时,比较的结果为“大于”、“等于”与“小于”三种可能。查找的效率依赖于查找过程中所进行的比较次数。

       为确定记录在查找表中的位置,需和给定值进行比较的关键字个数的期望值成为查找算法在查找成功时的平均查找长度(Average Search Length)。下面我们简单讨论一下在含有n个数据记录的结构中,各种查找算法的平均查找长度。

       在等概率的情况下,顺序查找的平均查找长度为:


       对于折半查找(表要求是顺序表),在n比较大(n > 50)的时候,可有以下近似结果:


       在随机情况下(二叉树查找可能退化为顺序查找),对于二叉树,其平均查找长度为:


在平衡二叉树上进行查找,其平均查找长度为:

,其中

       对于一颗m阶的B~树,从根节点到关键字所在节点的路径上涉及的节点数可以说是平均查找长度的上限:

       总的来说,这些查找算法的效率都将随着数据记录数的增长而下降。仅仅是有的比较快(时间复杂度为O(n)),有的比较慢(时间复杂度是O(logn))而已。这些查找算法的平均查找长度是在一种比较理想的情况下获得的。在实际应用当中,对数据结构中数据的频繁增加和删除将不断地改变着数据的结构。这些操作将可能导致某些数据结构退化为链表结构,那么其性能必然将下降。为了避免出现这种情况而采取的调整措施,又不可避免的增加了程序的复杂程度以及操作的额外时间。

二、哈希表

       理想的情况是希望不经过任何比较,一次存取便能得到所查的记录,那就必须在记的存储位置和它的关键字之间建立一个确定的对应关系f,使每个关键字和结构中一个唯一的存储位置相对应。因而在查找时,只要根据这个对应关系f找到给定值K的像f(K)。若结构中存在关键字和K相等的记录,则必定在f(K)的存储位置上,由此,不需要进行比较便可直接取得所查记录。在此,我们称这个对应关系为哈希(Hash)函数,按这个思想建立的表为哈希表。

       在哈希表中对于不同的关键字可能得到同一哈希地址,即K1不等于K2,而f(K1)等于f(K2)。这种现象称做冲突(Collision)。在一般情况下,冲突只能尽可能地减少,而不能完全避免。因为哈希函数是从关键字集合到地址集合的映像。通常关键字的集合比较大,它的元素包括所有可能的关键字,而地址集合的元素仅为哈希表中的地址值。

       假设标识符可定义为以字符为首的8位字符,则关键字(标识符)的集合大小为

    而在一个源程序中出现的数据对象是有限的,设有 10000 个元素。地址集合中的元素为 0 9999 。因此在一般情况下,哈希函数是一个压缩映像函数,这就不可避免的要产生冲突。

二、哈希树的理论基础

2.1质数分辨定理     


       这个定理可以简单的表述为:n个不同的质数可以“分辨”的连续整数的个数和他们的乘积相等。“分辨”就是指这些连续的整数不可能有完全相同的余数序列。这个为哈希树的分辨方式奠定了理论基础。

显然,这个定理的一个特殊情况就是2起的连续质数。我们可以记M(n)为前n个连续质数的乘积。连续10个质数就可以分辨大约M(10) = 6464693230 个数,已经超过计算机中常用整数(32bit)的表达范围。连续100个质数就可以分辨大约M(100) = 4.711930 乘以10的219次方。

而按照目前的CPU水平,100次取余的整数除法操作几乎不算什么难事。在实际应用中,整体的操作速度往往取决于节点将关键字装载内存的次数和时间。一般来说,装载的时间是由关键字的大小和硬件来决定的;在相同类型关键字和相同硬件条件下,实际的整体操作时间就主要取决于装载的次数。他们之间是一个成正比的关系。

2.2余数分辨定理

       在这里,我们对更为普遍的余数分辨方式做一个讨论。


这个定理2可以简单的表述为:n个不同的数可以“分辨”的连续整数的个数不超过他们的最小公倍数。超过这个范围就意味着冲突的概率会增加。定理1是定理2的一个特例。

2.3分辨算法评价标准

1.        状态和特征

分辨也即分辨不同的状态。分辨一般是先定义一组不同的状态,然后将这些状态记录下来形成特征。由这些特征所形成的空间是特征空间。特征空间的纬数是特征数列的长度。

2.        分辨能力

分辨能力D,也称分辨范围,就是指分辨算法可以分辨的最大连续空间大小。在这个范围内,分辨算法可以唯一确定被分辨数。即任何两个在分辨范围内的数,不可能具有完全相同的特征数。这些特征数会以某种形式被记录下来,或者以数据结构的形式体现出来。

对于两个具有相同分辨能力的分辨算法,我们认为他们是等价算法。

D趋于无穷大的时候,分辨算法的分辨能力最强。但是同时分辨算法所使用的特征空间也将是无穷大,我们不可能有足够的空间来存放这些相关特征记录。

3.        冲突概率

当被分辨数之间的距离超出分辨范围的时候,就有可能发生冲突,这种冲突的概率被定义为冲突概率λ


当被分辨的数是随机分布在整个数轴的时候,两个数之间的距离可能会超过分辨范围。这个时候分辨算法不一定会完全失效。冲突概率λ就体现为衡量某种分辨算法在处理散列时候的特性。冲突概率越小,那么处理散列的能力就越强,对非连续空间的特征分辨能力就越高;反之,那么处理散列的能力就越弱,对非连续空间的特征分辨能力就越低。

对于两个具有相同冲突概率的分辨算法,我们也认为他们是等价算法。

4.        分辨效率

根据分辨算法的特点,我们可以定义分辨效率γ


在由定理1和定理2可以得知:当用于余数分辨的整数数列是某一个确定整数,或者互不相等的质数数列的时候,或者是互相没有公约数的整数数列,分辨效率γ=100%;其他情况下,分辨效率都小于100%

5.        简化度

在分辨算法中,我们定义数列的简化度β为:

所有用于分辨的特征个数的和,代表了分辨所需要的特征总数。特征总数越少,那么简化度就越高。分辨算法的简化度越高,说明所用状态数越少,分辨过程将能更简单。这个评价标准有利于我们去除冗余特征的部分。


6.        综合评价指标

我们定义分辨算法综合评价指标Ω

       这个标准意味着:分辨范围越大,简化度越高,那么这个算法的综合性能就越好。例如:在余数分辨法中,如果我们选择数列{100,200, 300}作为分辨数列,那么采取这个数列的余数分辨算法各项指标如下:


       如果我们选择数列{2,3,5,7,11}作为分辨数列,那么采取这个数列的余数分辨算法各项指标如下:

       显然后者在综合评价方面要优于前者。

2.4线性分辨算法

       线性分辨算法是利用线性函数f(x)=ax+b作为分辨算法的分辨算法,或者称为余数分辨算法。这种算法简单易行。上面所有的讨论都是基于线性分辨算法的。

2.5非线性分辨算法

       非线性分辨算法是指在特征数的获取过程中采用非线性函数的方法。这也就是说,可以完全使用非线性函数,或者非线性函数和线性函数混合使用。但是只要是使用了非线性函数,那么就被称作非线性分辨。

在这些算法里面,可以分成两类:非线性函数特征法和分段特征提取法。

1.        非线性函数特征法

利用单值非线性函数(例如:开方函数,平方函数等等。)来提取特征的方法就称为非线性特征法。很明显这些函数的单值对应性,并没有改变分辨范围的大小。这些函数仅仅是将特征空间做了一个变形处理。这种变形可能是平移、缩放等等。但是分辨能力没有扩大,冲突概率也并没有得到减少,简化度也没有发生变化,分辨算法的整体评价标准也没有改变。

2.        分段特征提取法

分段特征提取法就是将被分辨的数分成若干部分,每个部分有若干状态。假设某个数可以被分成n段,每段所有可能的状态个数为Si(其中i为自然数)。那么我们可以得出以下结论:

Si为自然数,且Si不小于2。

       任何一个段的状态至少是有两个状态,否则这种分段是毫无意义的。或者说这段是没有任何特征的,对分辨算法来说是一种时间和空间的浪费。

       这种算法的分辨能力是:


       其冲突概率:


       这种算法的分辨效率γ=100%,简化度为:


       总体评价:


       这种算法可以做到在所有分辨算法中的简化度最高,综合评价Ω也可以很高。但这种分辨算法的综合评价Ω并不总是最高的。

       例如:当我们在分辨32位整数的时候,使用这种分段特征分辨法,那么这种分辨方法的各项指标如下:


       如果在余数分辨算法中,我们选择从229的连续10个质数作为分辨数列,那么这个分辨方法的各项指标如下:

       在分辨以2进制为基础的连续整数的时候,这种算法有着明显的优势。但是在分辨散列的时候,例如:以字符为基础的字符串的时候,其特征纬数和者特征数将会迅速增加。另外很明显,哈希树在查找的操作是O(10),分段算法是O(32),而且覆盖范围也明显有差异。过多的特征纬数和特征数意味者,对于特征空间的浪费、更多的初始化时间以及更多比较次数和比较时间。为了仍然能够使用这种分辨方法,普遍采用对字符串压缩编码转换成整数后,再使用该分辨算法。但是即使是采取转换手段,对于超长的字符串仍然不是十分容易处理。

三、哈希树的组织结构

使用不同的分辨算法都可以组织成哈希树。一般来说:每层哈希树的节点下的子节点数是和分辨数列一致的。哈希树的最大深度和特征空间的纬数是一致的。

为了研究的方便,我们选择质数分辨算法来建立一颗哈希树。选择从2开始的连续质数(这是一棵最简哈希树)来建立一个十层的哈希树(因为M(10)已经超过了计算机中常用整数的表达范围)。第一层节点为根节点,根节点下有2个节点;第二层的每个节点下有3个节点;依此类推,即每层节点的子节点数目为连续的质数。到了第十层,每个节点下有29个节点。


1 哈希树的组织结构

同一节点中的子节点,从左到右代表不同的余数结果。例如:第二层节点下有三个子节点。那么从左到右分别代表:除30,除31和除32

对质数的余数决定了处理的路径。例如:某个数来到第二层子节点,那么它要做取余操作,然后再决定从哪个子节点向下搜寻。如果这个数是12那么它需要从第一个子节点向下搜索;如果这个数是7那么它需要从第二个子节点向下搜索;如果这个数是32那么它需要从第三个子节点向下搜索。

如果所有的节点都存在,并容纳数据的话,那么可以容纳的数据项目总数将比M(10)要大一些:

        如果在建立当初就建立所有的节点,那么所消耗的计算时间和磁盘空间是巨大的。在实际使用当中,只需要初始化根节点就可以开始工作。子节点的建立是在有更多的数据进入到哈希树中的时候建立的。因此可以说哈希树和其他树一样是一个动态结构。

这些节点有以下基本元素:

1.        节点的关键字

我们用key来表示节点的关键字。在整个树中这个数值是唯一的。当节点占据标志位(occupied)为真的时候,关键字才认为是有效的,否则是无效的。

2.        节点是否被占据

我们用occupied来表示节点是否被占据。如果节点的关键字(key)有效,那么occupied应该设置位true,否则设置为false

3.        节点的子节点数组

我们用nodes[i]来表示节点的第i个子节点的地址。第10个质数为29,余数不可能大于32。这个数组的固定长度为可以设置为32,即i可以从0到31。当第i个子节点不存在时,nodes[i]=NULL,否则为子节点的地址。

4.        节点的数据对象

我们用value来表示节点的数据对象。

四、插入规则

       设需要插入的关键字和数据分别为keyvalue。用level表示插入操作进行到第几层,level0开始。Prime表为连续质数表。插入过程从根节点开始执行:

1.        如果当前节点没有被占据(occupied = false),则设置该节点的keyvalue,并设置占据标记为true,然后返回。

2.        如果当前节点被占据(occupied = true),则计算index = key mod Prime[level]

3.        如果nodes[index] = NULL,那么创建子节点,将level1,并重复第1步操作。

4.         如果nodes[index]为一个子节点那么,将level1,然后重复第1步操作。

用伪码来表示如下:

void insert (HashNode entry, int level, Key key, Value value)

{
       if (this.occupied = false)

       {

              this.key = key;

              this.value = value;

              this.occupied = true;

              return;

       }

 

       int index = key mod Prime[level];

       if( nodes[index] = NULL)

       {
              nodes[index] = new HashNode();

       }

   

       level = level + 1;

           insert(nodes[index], level, key, value);

}

五、查找操作

       设需要查找的关键字为key。用level表示插入进行到第几层,level0开始。Prime表为连续质数表。插入过程从根节点开始执行:

1.        如果当前节点没有被占据(occupied = false),则执行第5步操作。

2.        如果当前节点已经被占据(occupied = true),则读取该节点的关键字,将它和key进行比较。

3.        如果相等,则返回查找成功和该节点的value

4.        如果不等,则执行第5步操作。

5.        计算index = key mod Prime[level]

6.        如果nodes[index] = NULL,那么则返回查找失败。

7.        如果nodes[index]为一个子节点那么,将level1,然后重复第1步操作。

用伪码来表示如下:

Boolean search(HashNode entry, int level, Key key, Value value)

{

if(this.occupied = true)

{

if(this.key = key)

{

value = this.value;

return true;
}

}

       int index = key mod Prime[level];

       if( nodes[index] = NULL)

       {
              return false;

       }

   

       level = level + 1;

           return search(nodes[index], level, key, value);

}

六、删除操作

设需要删除的关键字为key。用level表示插入进行到第几层,level0开始。Prime表为连续质数表。插入过程从根节点开始执行:

1.        如果当前节点没有被占据,则执行第5步操作。

2.        如果当前节点被占据,则读取该节点的关键字,将它和key进行比较。

3.        如果相等,则设置该节点的占用标记为false,并返回删除操作成功。

4.        如果不等,则执行第5步操作。

5.        计算index = key mod Prime[level]

6.        如果nodes[index] = NULL,那么则返回删除失败。

7.        如果nodes[index]为一个子节点那么,将level1,然后重复第1步操作。

用伪码来表示如下:

Boolean remove(HashNode entry, int level, Key key)

{

if(this.occupied = true)

{

if(this.key = key)

{

this.occupied = false;

return true;
}

}

       int index = key mod Prime[level];

       if( nodes[index] = NULL)

       {
              return false;

       }

   

       level = level + 1;

           return remove(nodes[index], level, key);

}

七、哈希树的特点

7.1结构简单

       从哈希树的结构来说,非常的简单。每层节点的子节点个数为连续的质数。子节点可以随时创建。因此哈希树的结构是动态的,也不像某些哈希算法那样需要长时间的初始化过程。哈希树也没有必要为不存在的关键字提前分配空间。

       需要注意的是哈希树是一个单向增加的结构,即随着所需要存储的数据量增加而增大。即使数据量减少到原来的数量,但是哈希树的总节点数不会减少。这样做的目的是为了避免结构的调整带来的额外消耗。

        实际应用中,哈希树的增加和删除算法会增加有关存储空间的管理部分。主要就是为了合理释放和利用资源。保证消除哈希树的单向增加的缺点。后面谈到的不易退化的特点,对方便存储空间管理也有不少贡献。

7.2操作简单

       从上面所讲述的操作过程来说是相当简单的。程序上特别容易实现,比起B~树都更容易理解和实现。作者是通过实际中的应用,将数据和代码量减到了最低。这种做法不仅提高了速度,而且编写起来更容易些。

7.3查找迅速

       从算法过程我们可以看出,level最多能增加到10。因此最多只需要十次取余和比较操作,就可以知道这个对象是否存在。这个在算法逻辑上决定了哈希树的优越性。

一般的树状结构,往往随着层次和层次中节点数的增加而导致更多的比较操作。操作次数可以说无法准确确定上限。而哈希树的查找次数和元素个数没有关系。如果元素的连续关键字总个数在计算机的整数(32bit)所能表达的最大范围内,那么比较次数就最多不会超过10,通常低于这个数值。

7.4结构不变

       从删除算法中可以看出,哈希树在删除的时候,并不做任何结构调整。这个也是它的一个非常好的优点。常规树结构在增加元素和删除元素的时候都要做一定的结构调整,否则他们将可能退化为链表结构,而导致查找效率的降低。哈希树采取的是一种“见缝插针”的算法,从来不用担心退化的问题。也不必为优化结构而采取额外的操作,因为大大节约了操作时间。

7.5非排序性

       哈希树不支持排序,没有顺序特性。就好比你可以使用select * where id = 12345SQL选择语句,但是不可以使用select * where id > 12345SQL语句。如果在此基础上不做任何改进的话并试图通过遍历来实现排序,那么操作效率将远远低于其他类型的数据结构。

八、冲突处理

       从定理1中我们可以知道,这个定理只保证了M(10)个连续的整数是可以被“分辨”的。如果在使用中,有两个整数之间的距离超过M(10),那么就可能会出现冲突。

解决冲突的办法有两种:一种是主动解决这种冲突,另外一种是被动的。

所谓主动解决冲突,就是我们可以选择更大的质数序列来组织哈希树,只到这些质数的乘积可以覆盖所有可能的范围。或者选择更多的质数,只到他们的乘积可以覆盖所有可能的范围。

另外一个方面如果这种冲突发生的概率很低,那么可以让哈希树被动适应这种变化。冲突对哈希树来说并非不可以被接纳。无非是增加了哈希树的层数,或者说增加了所需要比较和取余的次数。但是为了容纳过多的冲突将会导致哈希树的严重退化,最终将退化一个链表结构。

这些能产生冲突的数值之间的距离必须满足:

Distance = S * M(10) 其中S为自然数。

       因此任何两个重复的数之间的距离必须为M(10),而非简单成倍关系。即knk不会导致哈希树的退化。

九、字符串关键字的处理

       字符串是由字符组成,字符都具有某种具体的编码方式。由这种编码方式最终都可以转换成字节数组的形式。因此我们可以简单的将字符串看作是256进制的数。例如:“abc”可以转换成字节数组0x616263,可以看作是十进制的6382179

       那么一个20个字符的字符串将是一个很大的数。我们不能简单地使用计算机的整数来做除法,而是使用程序模拟人工的除法方式来做除法并获得余数。一个简单的例子如下:

       int hash(String string, int prime)

       {

              byte[] bytes = string.getBytes();

              int residual = 0;

              for(int i = bytes.length;i > 0;i --)

              {

                     residual = residual * 256 + bytes[i – 1]

residual = residual mod prime

}

return residual;

       }

       虽然多做了几次基本的加、减、乘、除运算。但是因为都是整数计算,对于目前的CPU水平来说,几乎不算什么难事。而且还可以通过移位运算代替乘法运算,这样能更快些。而且除法的除数和被除数都不会太大,计算起来也不会有太多的CPU消耗。

在这种情况下,数与数之间的距离,很容易超过M(10)。如果两个字节数组之间的长度差超过5个字节,就很有可能出现冲突现象。但是在长期实际应用当中,这种情况并没有发生,这是为什么呢?

       这种现象可以解释如下:从冲突的角度来严格考虑,这两个字符串之间的距离必须满足最小冲突距离的关系,即至少要相差5个字节。然而,其中一个字符串的字节数组再加上这个距离后以后,很难完全保证整个字节数组能落在一个可读并且有意义的字符编码范围内。例如:ASCII码的范围是0~127。在加上一个数值后,可能会落到127255的范围内,这个部分是不可见的ASCII码。不可见的ASCII码,一般也不会作为关键字的。并且进入哈希树的字符串关键字都还具有一定的实际意义。所以这种冲突可以说很难发生。

       另外即使这种情况发生了,那么概率也是十分低的,哈希树本身在一定程度上是可以容纳这种冲突存在的。所以直接使用字符串作为关键字并不影响的运算结果。不过最好还是需要对字符串做更可靠的编码组织,以求完全防止这种冲突的发生。例如:使用MD5和选用更大的质数相结合的办法。这样就可以使得通过层次比较少的哈希树来获得对关键字区间的完整覆盖。这样就减少了比较操作的次数,并提高整体的工作效率。

十、哈希树的退化问题

       对于哈希树来说,可以退化成一个链表。但是这种严重退化是需要比较苛刻的条件。在前面所讲述的哈希树例子当中,能使得该哈希树严重退化的一个数列为:

{1,M(1),M(2),M(3),.......,M(10)}

       当哈希树在没有任何元素的情况下,上述数列将使得哈希树变成一个链表。但是任何干扰或者数列前后次序的变化都将打乱处理的顺序,而导致元素的均匀分布。要使得某种实际应用情况下的哈希树产生退化,需要一个十分特殊的数列。而这个数列出现的概率十分低,因此可以说在很大程度上哈希树是不会退化的。

十一、总结

       从上面的分析可以看出哈希树是一种可以自适应的树。通过给出足够多的不同质数,我们总可以将所有已经出现的关键字进行区别。而质数本身就是无穷无尽的。这种方式使得关键字空间和地址空间不再是压缩对应方式,而是完全可以等价的。

       哈希树可以广泛应用于那些需要对大容量数据进行快速匹配操作的地方。例如:数据库索引系统、短信息中的收条匹配、大量号码路由匹配、信息过滤匹配。程序员可以利用各种代码来实现哈希树结构(作者已经做过CJava的版本)。它可以为程序员提供一种使用起来更加方便,更加简单的快速数据存储方式。实际应用中,还需要做一些优化以及磁盘空间管理操作,否则HashTree会不停的长大。

       另外这种哈希树还可以做排序处理,稍作改进可以和B树相当。这里对这个部分将不做详细介绍。大多数数据库在增查改的时候,都比较快,出现删除的时候,会变得异常缓慢。这种HashTree结构特别适合于基于关键字查找的数据。而且同时增删改查都可以进行,不需要额外的平衡和防止退化的操作,效率十分理想。

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值