有许多涉及散列表的数据结构可用做索引。我们假定读者知道用作主存数据结构的散列表。在这种结构中有一个散列函数,它以查找键(我们可称之为散列键)为参数并计算出一个介于0到B-1的整数,其中B是桶的数目。桶数组,即一个序号从0~B-1的数组中包含B个链表的头,每一个对应于数组中的一个桶。如果记录的查找键为K,那么通过将该记录链接到桶号为h(K)的桶列表中来存储它,其中h是散列函数。

1、辅存散列表

有的散列表包含大量记录,记录如此之多,以至于它们主要存放在辅助存储器上,这样的散列表在一些细小而重要的方面与主存中的散列表存在区别。首先,桶数组由存储块组成而不是由链表头的指针组成。通过散列函数h散列到某个桶中的记录被放到该桶的存储块中。如果桶溢出,即它容纳不下所有属于它的记录,那么可以给该桶加一个溢出块链以存放更多的记录。

我们将假定,只要给一个i,桶i的第一个存储块的位置就可以找到。例如,主存中可以有一个指向存储块的指针数组,数组项以桶号为序号。另一种可能是把每个桶的第一个存储块存放到磁盘上某固定的、连续的位置,这样我们就可以根据整数i计算出桶i的位置。

例4.28 图4-30所示为一个散列表。为了使我们的图例易于处理,假定每个存储块只能存放两个记录,且B=4,即散列函数h的返回值介于0~3之间。我们列出了一些位于散列表中的记录。在图4-30中,键值为字母a~f。我们假定h(d)=0,h(c)=h(e)=1,h(b)=2且hh(a)=h(f)=3。因此,这六个记录在块中的分布如图所示。

094100910.jpg

注意,图4-30中所示每个存储块的右端都有一个小凸块,这个小凸块表示存储块块头中附加的信息。我们将用它来链接溢出块,并且从4.4.5节开始,我们将用它来保留存储块的其他重要信息。

=================================散列函数的选择========================================

散列函数对键的“散列”应使得到的整数类似键的一个随机函数、因此。桶常常能分到相同数量的记录,正如我们将在4.4.4节中讨论的那样,这能改进访问一个记录的平均时间。另外,散列函数应该容易计算,因为我们要多次计算它。

(1)当键为整数时,散列函数的一种常见选择是计算K/B的余数,其中K是键值,B是桶的数目。通常,B选为一个素数,尽管正如我们将从4.4.5节开始讨论的那样,将B选为2的幂也有其理由。

(2)当键为字符串时,我们可以把每个字符看作一个整数来处理,把它们累加起来,并将总和除以B,然后取其余数。

========================================================================================


2、散列表的插入

当一个查找键为K的新记录需要被插入时,我们计算h(K)。如果桶号为h(K)的桶还有空间,我们就把该记录存放到此桶的存储块中或在其存储块没有空间时存储到块链上的某个溢出块中。如果桶的所有存储块都没有空间,我们就增加一个新的溢出块到该桶的链上,并把新记录存入该块。

例4.29 假若我们给图4-30的散列表增加一个键值为g的记录,并且h(g)=1。那么,我们必须把记录加到桶号为1的桶中,也就是从上面数起的第二个桶。可是,该桶的块中已经有两个记录。因此,我们增加一个新块,并把它链到桶1的第一块上。键值为g的记录插入到这一块中,如图4-31所示。


094115279.jpg


3、散列表的删除

删除查找键值为K的记录方式相同。我们找到桶号为h(K)的桶且从中搜索查找键为K的记录,继而将找到的记录删除。如果我们可以将记录在块中移动,那么删除记录后,我们可选择合并同一链上的存储块。

例4.30 图4-32所示为从图4-31的散列表中删除键值为c的记录后的结果。由前面可知,h(c)=1,因而我们到桶号为1的桶(即第二个桶)中去查看它的所有块,以找出键值为c的一条记录(或所有记录,当查找键不是主键时)。我们在桶1的链表的第一个存储块中找到了该记录。既然现在有可用空间,我们可以把键值为g的记录从链表的第二个存储块移到第一个存储块,并删除第二个存储块。

我们也做了删除键值为a的记录。对于这一键值,我们找到桶3,删除该记录,并把剩下的记录移到块的前部以使之紧凑。

094130671.jpg


4、散列表索引的效率

理想情况是存储器中有足够的桶,使绝大多数桶都只由单个块组成。如果这样,那么一般的查询只需一次磁盘I/O,且文件的插入和删除也只需两次磁盘I/O。这样的结果比直接用稀疏索引、稠密索引或B树好得多(尽管散列表不能像B树那样支持范围查询;参见B树索引第4节)。

但是,如果文件不断增长,那么最终就会出现多数桶的链表中都有许多块的情况。如果这样,我们就需要在块的长链表中查找,每个块至少需要一次磁盘I/O。因此,我们就必须设法减少每个桶的块数。

到目前为止,我们学过的散列表都称为静态散列表,因为桶的数目B从不改变。但是,散列表中还有几种动态散列表,它们允许B改变,使B近似于记录总数除以块中能容纳的记录数所得到的商;也就是说,每个桶大约有一个存储块。我们将讨论两种这样的方法:

1)可扩展散列;

2)线性散列。

第一种方法在认为B太小时即将其加倍,而第二种方法每当文件的统计数字表明B需要增加时即给B加1。


5、可扩展散列表

我们的第一种动态散列方法称为可扩展散列表。它在简单的静态散列表结构上主要增加了:

1)为桶引入了一个间接层,即用一个指向块的指针数组来表示桶,而不是用数据块本身组成的数组来表示桶。

2)指针数组能增长,它的长度总是2的幂,因而数组每增长一次,桶的数目就翻倍。

3)不过,并非每个桶都有一个数据块;如果某些桶中的所有记录都可以放在一个块中,那么,这些桶可能共享一个块。

4)散列函数h为每个键计算出一个K位二进制序列,该K值足够大,比如32。但是,无论何时桶的数目都使用从序列第一位开始的若干位,此位数小于K,比如说是i位。也就是说,当i是使用的位数时,桶数组将有2i个项。

例4.31 图4-33所示为一个小的可扩展散列表。为简单起见,我们假定K=4,即散列函数h只产生四位二进制序列。当前使用的只有其中一位,正如桶数组上方的框中i=1所标明的那样。因此,桶数组只有两个项,一个对应0,另一个对应1。

094148334.jpg

桶数组项指向两个块。第一块存放当前所有查找键被散列成以0开头的二进制序列的记录;第二个块存放所有查找键被散列成以1开头的二进制序列的记录。为方便起见,我们显示的记录键是散列函数将这些键转换成的二进制位序列。因此,第一块有一个键被散列为0001的记录;而第二个块存放着键分别散列为1001和1100的记录。

我们应该注意到,图4-33中每个存储块的“小凸块”中都出现了数字1。这个数字其实出现在每个存储块的块头中,表明由散列函数得到的位序列中有多少位用于确定记录在该块中的成员资格。在例4.31的情况下,只用一个二进制位来确定所有的块和记录,但正如我们将要看到的那样,随着散列表的增长,不同块中需要考虑的位数可能不同。也就是说,桶数组的大小由我们当前正在使用的最大二进制位数来决定,但有些块可能使用较少的位数。

6、可扩展散列表的插入

可扩展散列表的插入开始时类似静态散列表的插入。为了插入键值为K的记录,我们计算出h(K),取出这一二进制位序列的前i位,并找到桶数组中序号为这个i位的项。注意,因为i作为散列数据结构的一部分保存,我们能确定i。

根据数据组中该项的指针找到某个存储块B。如果B中还有存放新记录的空间,我们就把新记录存入,而插入也就完成了。如果B中没有空间,那么视数字i的不同有两种可能,数字i表明散列值中有多少位于确定存储块B的成员资格(回忆一下,j的值可在图中每个存储块的“小凸块“中找到)。

1)如果j<i,那么不必对桶数组做什么变化。我们:

(a)将块B分裂成两个存储块。

(b)根据记录散列值的第(j+1)位,将B中的记录分配到这两个存储块中,该位为0的记录保留在B中,而该位为1的记录则放入到新块中。

(c)把(j+1)存入这两个存储块的小凸块中,以标明用于确定成员资格的二进制位数。

(d)调整桶数组中的指针,使原来指向块B的项指向块B或新块,这由项的第(j+1)位决定。

注意,分裂块B可能解决不了问题,因为有可能块B中所有记录将分配到由B分裂成的两个存储块的其中一个中去。如果这样,我们需要对仍然太满的块用下一个更大的j值重复上述过程。


2)如果j=i,那么我们必须先将i加1。我们使桶数组长度翻了一倍,因此数组中现在有2i+1。假定w是以前的桶数组中作为某项序号的i位二进制位序列。在新桶数组中,序号为w0和w1(即分别用0和1扩展w所得到的数)的项都指向原w项指向的块。也就是说,这两个新项共享同一个存储块,而存储块本身没有变化。该块的成员资格仍然按原先的位数确定。最后,我们继续像第一种情况中那样分裂B。由于i现在大于j,所以满足第一种情况。

例4.32 假如在图4-33的表中插入一个键值散列为1010序列的记录。因为第一位是1,所以该记录属于第二个块。然而,该块已满,因此需要分裂。这时我们发现j=i=1,因此首先需要将桶数组加位,如图4-34所示。图中我们已将i设为2。

注意,以0开头的两个项都指向存放键值散列序列以0开头的记录的那个存储块,且该存储块的“小凸块”中数字仍然为1,这表明该块的成员资格只由位序列的第一位确定。但是,位序列以1开头的记录存储块需要分裂,因此我们把这一块中的记录分到以10开头和11开头的两个存储块中。在这两个存储块中的小凸中有一个2,表示成员资格用位序列的前两位来确定。幸好,分裂是成功的;既然两个新块都至少有一个记录,我们就不用进行递归分裂。

094207353.jpg


现在,假定我们插入键值分别为0000和0111的记录。这两个记录都属于图4-34中第一个存储块,于是该块溢出。因为该块中只用一位来确定其成员资格,而i=2,所以我们就不用调整桶数组。我们只需分裂该块,让0000和0001留在该块,而将0111存放到新块中,桶数组中由01项改为指向新块。这一次我们又很幸运,所有记录没有全分配到一个块中,所以我们不必递归地分裂。

假若现在要插入一个键值为1000的记录。对应10的块溢出。由于它已经使用两位来确定其成员资格,这时需要再次分裂桶数组,并且把i设为3。图4-35给出了这时的数据结构。注意,图中对应10的块被分裂成100的块和101的块,而其他块仍只使用两位来确定成员资格。


094223901.jpg

7、线性散列表

可扩展散列表有一些重要的好处。最大的好处在于,当查找一个记录时,我们总是只需要查找一个数据块。我们还需要查找到一个桶数组的项,但如果桶数组小到可以存放在主存中,那么访问桶数组就不需要进行磁盘I/O。然而,可扩展散列表也有一些缺点:

1)当桶数组需要翻倍时,要做大量的工作(当i很大时)。这些工作会阻碍对数据文件的访问,或是使某些插入看来花费很长的时间。

2)当桶数翻倍后,它在主存中可能就装不下了,或者把其他的一些我们需要保存在主存的数据挤出去。其结果是,一个运行良好的系统可能突然之间每个操作所需的磁盘I/O开始大增,并且出现明显的性能下降。

3)如果每块的记录数很少,那么很有可能某一块的分裂比在逻辑上讲需要分裂的时间提前许多。例如,如果像我们使用的例子一样,块可存放两个记录,即使记录的总数远小于220,这也有可能出现三个记录的前20位二进制位序列。在这种情况下,我们将不得不使用i=20和一百万个桶数组项,尽管存有记录的块数远小于一百万。

另一种策略称为线性散列,其中桶的增长较为缓慢。在线性散列中我们发现的新要点为:

(1)桶数n的选择总是使存储块的平均记录数保持与存储块所能容纳的记录总数成一个固定的比例,如80%。

(2)由于存储块并不总是可以分裂,所以允许有溢出块,尽管每个桶的平均溢出块数远小于1。

(3)用来做桶数组项序号的二进制位数是[log2n],其中n是当前的桶数。这些位总是从散列函数得到的位序列的右(低位)端开始取。

(4)假定散列函数值的i位正在用来给桶数组项编号,且有一个键值为K的记录想要插入到编号为a1a2ai的桶中;即a1a2ai是h(K)的后i位。那么,把a1a2ai当作二进制整数,设它为m。如果m<n,那么编号为m的桶存在并把记录存入该桶中。如果n≤m<2i,那么桶m还不存在,因此我们把记录存入桶m-2i-1,也就是当我们把a1(它肯定是1)改为0时对应的桶。


例题4.33 图4-36所示为一个n=2的线性散列表。

094241937.jpg

我们目前只用散列值的一位来确定记录所属的桶。按照例4.31建立的模式,我们假定散列函数产生四位,并且用将散列函数作用到记录的查找键上所产生的值来表示记录。

我们在图4-36中看到两个桶,每个桶包含一个存储块,桶的编号为0和1。所有散列值以0结尾的记录存入第一个桶,而所有散列值以1结尾的记录存入第二个桶。

参数i(当前被使用的散列函数值的位数)、n(当前的桶数)和r(当前散列表中的记录总数)也是这一结构的一部分。比率r/n将受到限制,使一般的桶都只需要约一个磁盘存储块。在选择桶数n时,我们采用的策略是使数据文件中记录的个数不超过1.7n,即r≤1.7n。也就是说,由于每个存储块存放两个记录,桶的平均充满程度不会超过存储块容量的85%。


8、线性散列表的插入

当插入一个新记录时,我们通过在4.4.7节提出的算法来确定它所属的桶。也就是说,我们计算h(K),其中K是记录的键,并确定h(K)序列后面用做桶号的正确位数。我们把记录或者放入该桶,或者(在桶号大于等于n时)放入把第一个二进制由1改为0后确定的桶中。如果桶中没有空间,那么我们创建一个溢出块,并把它链到那个桶上,并且记录就存入该溢出块中。

每次插入,我们都用当前的记录总数r的值跟阈值r/n相比,若比率太大,就增加下一个桶到线性散列表中。注意,新增加的桶和发生插入的桶之间没有任何联系!如果新加入的桶号的二进制表示为1a2a3ai,那么我们就分裂桶号为0a2a3ai的桶中的记录,根据记录的后i位值分别存入这两个桶。注意,这些记录的散列值都以a2a3ai结尾,并且只有从右数起的第i位不同。

最后一个重要的细节是当n超过2i时的情况。这时,i递增1。从技术上来讲,所有桶的桶号都要在它们的位序前面增添一个0,但由于这些位序列被解释成整数,因而就不需要做任何物理上的变化,还是保持原样。


例4.34 我们继续例4.33,考虑插入键值散列为0101的记录时的情况。因为位序列以1结尾,记录属于图4-36中的第二个桶。桶中有空间,因而不用创建溢出块。但是,由于那里现在有四个记录在两个桶中,超过了1.7这一比率,因此我们必须把n提高到3。因为[log23]=2,我们应该开始考虑把桶0和1改成桶00和01,但不需要对数据结构做任何改变。我们增加下一个桶到散列表中,该桶编号为10,接着,分裂桶00,桶00的序号只有第一位与新加的桶不同。在分裂桶时,键值散列为0000的记录保留在00桶,因为它以00结尾;而键值散列为1010的记录存入桶10,因为它以10结尾,所产生的散列表如图所示4-37。

094256966.jpg


下面,假定我们增加一个键值散列为0001的记录。记录最后两位为01,且01桶目前存在,我们把记录存入该桶。不巧的是,该桶的块已经装满,所以我们增加一个溢出块。这三个记录被分配在这个桶的两个块中;我们选择按散列键的数值顺序来保存它们,但这个顺序并不重要。由于该散列表中记录与桶的比率为5/3,小于1.7,故我们不需创建新桶。所产生的散列表如图4-38所示。

094325775.jpg


最后,考虑插入键值散列为0111的记录。该记录最后两位为11,但桶11还不存在,因此我们把记录改为存入桶01,该桶号只是在第一位上与桶11不同,不是1而是0。新记录存入到该桶中的溢出块中。

但是,该散列表的记录与桶的比率已超过1.7,因此,我们必须创建一个编号为11的新桶,该桶碰巧是新记录所需的桶。我们分裂桶01中的四个记录,散列值为0001和0101的记录保留在桶01,而散列值为0111和1111的记录存入新桶。因为桶01现在只有两个记录,我们可以删除其溢出块。现在,散列表如图4-39所示。

094336385.jpg


注意,当下次插入记录到图4-39中时,我们将会使记录与桶的比率超过1.7。那时,我们将把n提到5,并且i变成3。记录例4.35线性散列表的查询依照我们所描述的选择插入记录所属桶的过程。如果我们希望查找的记录不在该桶中,那么别的地方也不会有所需记录。举例来说,考虑图4-37中的情形,其中i=2且n=3。

首先,假定我们想查找键值散列为1010的记录。由于i=2,我们查看最后两位10,把它们解释为二进制整数,即m=2。因为m<n,则编号为10的桶存在,因而我们到该桶中查找。注意,不能因为我们找到一个键值散列为1010的记录就以为找到了所需的记录,我们需要检查记录的整个键来确定是否所需记录。


接着,我们考虑查找键值散列为1011的记录。现在,我们必须查看编号为11的桶。由于该桶号作为二进制值整数m=3,并且m≥n,所以桶11不存在。我们通过把第一位1改为0后重新定位到桶01中。可是,桶01中不存在键值散列为1011的记录,因而我们所需查找的记录肯定不在散列表中。


oracle视频教程请关注:http://u.youku.com/user_video/id_UMzAzMjkxMjE2.html