在现实中,经常遇到按给定的值进行查询的事例。为此,必须在开发相应软件时考虑在记录的存放位置和用以标识它的数据项 (称为关键码) 之间的对应关系,从而选择适当的数据结构,很方便地根据记录的关键码检索到对应记录的信息。
1 词典(Dictionary)的抽象数据类型
在计算机科学中把词典当作一种抽象数据类型。
在讨论词典抽象数据类型时,把词典定义为:
<名字-属性>对的集合。根据问题的不同,可以为名字和属性赋予不同的含义。
例如,在图书馆检索目录中,名字是书名,属性是索书号及作者等信息;在计算机活动文件表中,名字是文件名,属性是文件地址、大小等信息;而在编译程序建立的变量表中,名字是变量标识符,属性是变量的属性、存放地址等信息。
词典的抽象数据类型:
const int DefaultSize = 26;
template<class Name, Attribute>class HashTable {
public:
Dictionary ( int size = DefaultSize );
int IsIn ( Name name );
Attribute *Find ( Name name );
void Insert ( Name name, Attribute attr );
void Remove ( Name name );
}
在词典的所有操作中,最基本的只有3种:
Find (搜索)
Insert (插入)
Remove (删除)
在选择词典的表示时,必须确保这几个操作的实现。
通常,用文件 (表格) 来表示实际的对象集合,用文件记录 (表格的表项) 来表示单个对象。这样,词典中的<名字-属性>对将被存于记录 (表项) 中,通过表项的关键码 (即<名字-属性>对的名字) 来标识该表项。
表项的存放位置及其关键码之间的对应关系可以用一个二元组表示:
( 关键码key,表项位置指针addr )
这个二元组构成搜索某一指定表项的索引项。考虑到搜索效率,可以用顺序表的方式组织词典,也可以用二叉搜索树或多路搜索树的方式组织词典,本节讨论另一种搜索效率很高的组织词典的方法,即散列表结构。
2 静态散列方法
散列方法在表项的存储位置与它的关键码之间建立一个确定的对应函数关系Hash( ),使每个关键码与结构中一个唯一存储位置相对应:
Address = Hash ( Rec.key )
在搜索时,首先对表项的关键码进行函数计算,把函数值当做表项的存储位置,在结构中按此位置取表项比较。若关键码相等,则搜索成功。在存放表项时,依相同函数计算存储位置,并按此位置存放。这种方法就是散列方法。在散列方法中使用的转换函数叫做散列函数。而按此种想法构造出来的表或结构就叫做散列表。
使用散列方法进行搜索不必进行多次关键码的比较,搜索速度比较快,可以直接到达或逼近具有此关键码的表项的实际存放地址。
散列函数是一个压缩映象函数。关键码集合比散列表地址集合大得多。因此有可能经过散列函数的计算,把不同的关键码映射到同一个散列地址上,这就产生了冲突 (Collision)。
示例:有一组表项,其关键码分别是
12361, 07251, 03309, 30976
采用的散列函数是 hash(x) = x % 73 + 13420
其中,“%”是除法取余操作。则有:hash(12361) = hash(07250) = hash(03309) = hash(30976) = 13444。就是说,对不同的关键码,通过散列函数的计算,得到了同一散列地址。我们称这些产生冲突的散列地址相同的不同关键码为同义词。
由于关键码集合比地址集合大得多,冲突很难避免。所以对于散列方法,需要讨论以下两个问题:
a. 对于给定的一个关键码集合,选择一个计算简单且地址分布比较均匀的散列函数,避免或尽量减少冲突;
b. 拟订解决冲突的方案。
3 散列函数
构造散列函数时的几点要求:
散列函数的定义域必须包括需要存储的全部关 键码,如果散列表允许有 m 个地址时, 其值域 必须在 0 到 m-1 之间。
散列函数计算出来的地址应能均匀分布在整个 地址空间中:若 key 是从关键码集合中随机抽 取的一个关键码,散列函数应能以同等概率取 0 到 m-1 中的每一个值。
散列函数应是简单的,能在较短的时间内计算 出结果。
1.直接定址法:
此类函数取关键码的某个线性函数值作为散列地址:
Hash ( key ) = a * key + b { a, b为常数 }
这类散列函数是一对一的映射,一般不会产生冲突。但是,它要求散列地址空间的大小与关键码集合的大小相同。
示例:有一组关键码如下: { 942148, 941269, 940527, 941630, 941805, 941558, 942047, 940001 }。散列函数为 Hash (key) = key - 940000
则有
Hash (942148) = 2148 Hash (941269) = 1269
Hash (940527) = 527 Hash (941630) = 1630
Hash (941805) = 1805 Hash (941558) = 1558
Hash (942047) = 2047 Hash (940001) = 1
可以按计算出的地址存放记录。
2.数字分析法
设有 n 个 d 位数,每一位可能有 r 种不同的符号。这 r 种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布均匀些;在某些位上分布不均匀,只有某几种符号经常出现。可根据散列表的大小,选取其中各种符号分布均匀的若干位作为散列地址。
计算各位数字中符号分布的均匀度 λk 的公式:
其中,
表示第 i 个符号在第 k 位上出现的次数,n/r 表示各种符号在 n 个数中均匀出现的期望值。计算出的 λk 值越小,表明在该位 (第k 位)各种符号分布得越均匀。
若散列表地址范围有 3 位数字, 取各关键码的④⑤⑥位做为记录的散列地址。也可以把第①,②,③和第⑤位相加,舍去进位位,变成一位数,与第④,⑥位合起来作为散列地址。还有其它方法。
9 4 2 1 4 8 ①位, λ 1 = 510.60
9 4 1 2 6 9 ②位, λ 2 = 510.60
9 4 0 5 2 7 ③位, λ 3 = 110.60
9 4 1 6 3 0 ④位, λ 4 = 110.60
9 4 1 8 0 5 ⑤位, λ 5 = 110.60
9 4 1 5 5 8 ⑥位, λ 6 = 110.60
9 4 2 0 4 7
9 4 0 0 0 1
① ② ③ ④ ⑤ ⑥
数字分析法仅适用于事先明确知道表中所有关键码每一位数值的分布情况,它完全依赖于关键码集合。如果换一个关键码集合,选择哪几位要重新决定。
3.除留余数法
设散列表中允许的地址数为 m, 取一个不大于 m,但最接近于或等于 m 的质数 p, 或选取一 个不小于 20 的质因数的合数作为除数,利用以下公式把关键码转换成散列地址。散列函数为:
hash ( key ) = key % p (p ≤ m)
其中, “%”是整数除法取余的运算,要求这时的质数 p 不是接近2的幂。
示例:有一个关键码 key = 962148,散列表大小 m = 25,即 HT[25]。取质数 p= 23。
散列函数 hash ( key ) = key % p。
则散列地址为hash ( 962148 ) = 962148 % 23 = 12。
可以按计算出的地址存放记录。需要注意的是,使用上面的散列函数计算出来的地址范围是 0到 22,因此,从23到24这几个散列地址实际上在一开始是不可能用散列函数计算出来的,只可能在处理溢出时达到这些地址。
4.乘余取整法
使用此方法时,先让关键码 key 乘上一个常数 A (0 < A < 1),提取乘积的小数部分。然后,再用整数 n 乘以这个值,对结果向下取整,把它做为散列的地址。散列函数为:
hash ( key ) = [n * ( A * key % 1 ) ]
其中, “A * key % 1”表示取 A * key 小数部分: A * key % 1 = A * key - [ A * key ]
示例:设关键码 key = 123456,n = 10000,且
取 A = = 0.6180339,
则 hash ( key ) = [10000*(0.6180339*key % 1)]
因此有 hash(123456)
= [10000*(0.6180339*123456 % 1)]
= [10000 * (76300.0041151… % 1)]
= [10000 * 0041151…] = 41
此方法的优点是对 n 的选择不很关键。
5.平方取中法
此方法在词典处理中使用十分广泛。它先计算构成关键码的标识符的内码的平方,然后按照散列表的大小取中间的若干位作为散列地址。
设标识符可以用一个计算机字长的内码表示。因为内码平方数的中间几位一般是由标识符所有字符决定,所以对不同的标识符计算出的散列地址大多不相同,即使其中有些字符相同。
在平方取中法中,一般取散列地址为 2 的某次幂。例如,若散列地址总数取为 m =
,则对内码的平方数取中间的 r 位。如果 r = 3,所取得的散列地址参看图的最右一列。
标识符的八进制内码表示及其平方值
标识符 内码 内码的平方 散列地址
A 01 01 001
A1 0134 20420 042
A9 0144 23420 342
B 02 4 004
DMAX 04150130 21526443617100 443
DMAX1 0415013034 5264473522151420 352
AMAX 01150130 135423617100 236
AMAX1 0115013034 3454246522151420 652
6.折叠法
此方法把关键码自左到右分成位数相等的几部分,每一部分的位数应与散列表地址位数相同,只有最后一部分的位数可以短一些。
把这些部分的数据叠加起来,就可以得到具有该关键码的记录的散列地址。
有两种叠加方法:
移位法 — 把各部分的最后一位对齐相加;
分界法 — 各部分不折断,沿各部分的分界来回折叠,然后对齐相加,将相加的结果当做散列地址。
示例:设给定的关键码为 key = 23938587841,若存储空间限定 3 位, 则划分结果为每段 3 位. 上述关键码可划分为 4段: 239 385 878 41
把超出地址位数的最高位删去, 仅保留最低的3位,做为可用的散列地址。
一般当关键码的位数很多,而且关键码每一位上数字的分布大致比较均匀时,可用这种方法得到散列地址。
以上介绍了几种常用的散列函数。在实际工作中应根据关键码的特点,选用适当的方法。有人曾用“轮盘赌”的统计分析方法对它们进行了模拟分析,结论是平方取中法最接近于“随机化”。
在应用平方取中法时,若关键码不是整数而是字符串时,可以把每个字符串转换成整数。
转换的方法:
把字符串从右向左,按一个固定长度 (例如 4 ) 进行分段,必要时可在最左端添一些空格。
把每一个字符看成为一个数字,把字符串的每一段看作为一个整数。如, ASCII码采用 7 位字符代码,因此每一个字符可以看成一个128进制的数字。字符串 abcd 看成整数 a*(128)3 + b*(128)2 + c*(128) + d。
把字符串的每一段都转换成一个整数后,再把各段转换成的整数加起来。
如果这个整数之和太大,再选择一个适当的常数C (大于任一段字符串转换成的整数)来除这个和并取其余数,就得到这个字符串所对应的整数了。
下面给出每一段计算和分段相加计算的程序:
int Hash ( const char *Key, int TableSize ) {
//按基数128将关键码转换为整数
int HashVal = 0;
for ( int i = 0; i < Key.Length ( ); i++ )
HashVal = ( HashVal*128+Key[i]) % TableSize;
return HashVal;
}
int Hash ( const char *Key, int TableSize ){
//将关键码各位相加求模 (除余法)
int HashVal = 0;
while ( *Key )
HashVal += *Key++;
return HashVal % TableSize;
}
4 处理溢出的闭散列方法
解决冲突的方法又称为溢出处理技术。因为任一种散列函数也不能避免产生冲突,因此选择好的解决冲突溢出的方法十分重要。
为了减少冲突,对散列表加以改造。若设散列表 HT 有 m 个地址, 将其改为 m 个桶。其桶号与散列地址一一对应, 第 i (0 ≤ i < m) 个桶的桶号即为第 i 个散列地址。
每个桶可存放 s 个表项, 这些表项的关键码互为同义词。如果对两个不同表项的关键码用散列函数计算得到同一个散列地址,就产生了冲突,它们可以放在同一个桶内的不同位置。只有当桶内所有 s 个表项位置都放满表项后再加入表项才会产生溢出。
通常桶的大小 s 取的比较小, 因此在桶内大多采用顺序搜索。
闭散列就是处理溢出的一种常用的方法, 也叫做开地址法。
所有的桶都直接放在散列表数组中。因此每个桶只有一个表项 (s = 1)。
若设散列表中各桶的编址为 0 到 m-1,当要加入一个表项 R2时, 用它的关键码 R2.key,通过散列函数 hash ( R2.key ) 的计算,得到它的存放桶号 j,但是在存放时发现这个桶已经被另一个表项 R1 占据了。这时不但发生了冲突,还必须处理溢出。为此,必须把 R2 存放到表中“下一个”空桶中。如果表未被装满,则在允许的范围内必定还有空桶。
(1) 线性探查法 (Linear Probing)
假设给出一组表项,它们的关键码为 Burke, Ekers, Broad, Blum, Attlee, Alton, Hecht, Ederly。采用的散列函数是:取其第一个字母在字母表中的位置。
Hash (x) = ord (x) - ord (‘A’)
//ord()是求字符内码的函数
这样,可得
Hash (Burke) = 1 Hash (Ekers) = 4
Hash (Broad) = 1 Hash (Blum) = 1
Hash (Attlee) = 0 Hash (Hecht) = 7
Hash (Alton) = 0 Hash (Ederly) = 4
又设散列表为HT[26],m = 26。采用线性探查法处理溢出,则上述关键码在散列表中散列位置如图所示。括号内的数字表示找到空桶时的比较次数。
0 1 2 3 4 5 6 7 8 9
Attlee | Burke | Broad | Blum | Ekers | Alton | Ederly | Hecht |
|
|
(1) (1) (2) (3) (1) (6) (3) (1)
需要搜索或加入一个表项时,使用散列函数计算桶号: H0 = hash ( key )
一旦发生冲突,在表中顺次向后寻找“下一个”空桶 Hi 的公式为:
Hi = ( Hi-1 +1 ) % m, i =1, 2, …, m-1
即用以下的线性探查序列在表中寻找“下一个”空桶的桶号:
H0 +1, H0 +2, …, m-1, 0, 1, 2, …, H0-1
亦可写成
Hi = ( H0 + i ) % m, i =1, 2, …, m-1
当发生冲突时, 探查下一个桶。当循环 m-1次后就会回到开始探查时的位置, 说明待查关键码不在表内, 而且表已满, 不能再插入新关键码。
用平均搜索长度ASL (Averagy Search Length)衡量散列方法的搜索性能。
根据搜索成功与否,它又有搜索成功的平均搜索长度ASLsucc和搜索不成功的平均搜索长度ASLunsucc之分。
搜索成功的平均搜索长度 ASLsucc 是指搜索到表中已有表项的平均探查次数。它是找到表中各个已有表项的探查次数的平均值。
搜索不成功的平均搜索长度 ASLunsucc 是指在表中搜索不到待查的表项,但找到插入位置的平均探查次数。它是表中所有可能散列到的位置上要插入新元素时为找到空桶的探查次数的平均值。
在使用线性探查法对示例进行搜索时,搜索成功的平均搜索长度为:
搜索不成功的平均搜索长度为:
下面给出用线性探查法在散列表 ht 中搜索给定值 x 的算法。如果查到某一个 j,使得 ht[j].info == Active 同时 ht[j].Element == x,则搜索成功;否则搜索失败。造成失败的原因可能是表已满,或者是原来有此表项但已被删去,或者是无此表项且找到空桶。
class HashTable {
//用线性探查法处理溢出时散列表类的定义
public:
enum KindOfEntry { Active, Empty, Deleted };
HashTable ( ) : buckets ( DefaultSize ) { AllocateHt ( ); CurrentSize = 0; }
~HashTable ( ) { delete [ ] ht; }
const HashTable & operator = ( const HashTable & ht2 );
int Find ( const Type & x );
int Insert ( const Type & x );
int Remove ( const Type & x );
int IsIn ( const Type & x ) { return ( i = Find (x) ) >= 0 ? 1 : 0; }
void MakeEmpty ( );
private:
struct HashEntry {
Type Element;
KindOfEntry info;
int operator== ( HashEntry &, HashEntry & );
int operator!= ( HashEntry &, HashEntry & );
HashEntry ( ) : info (Empty ) { }
HashEntry (const Type & E, KindOfEntry i = Empty ) : Element (E), info (i) { }
};
enum { DefualtSize = 11 }
HashEntry *ht;
int CurrentSize, TableSize;
void AllocateHt ( ){ ht = new HashEntry[TableSize ]; }
int FindPos ( const Type & x ) const;
}
template <class Type> int HashTable<Type>:: Find ( const Type & x ) {
//线性探查法的搜索算法,函数返回找到位置。
// 若返回负数可能是空位,若为-TableSize则失败。
int i = FindPos ( x ), j = i;
while ( ht[j].info != Empty && ht[j].Element != x ){
j = ( j + 1 ) % TableSize;
if ( j == i )
return -TableSize;
}
if ( ht[j].info == Active )
return j;
else
-j;
}
在利用散列表进行各种处理之前,必须首先将散列表中原有的内容清掉。只需将表中所有表项的info域置为Empty即可。
散列表存放的表项不应有重复的关键码。在插入新表项时,如果发现表中已经有关键码相同的表项,则不再插入。
在闭散列情形下不能真正删除表中已有表项。删除表项会影响其它表项的搜索。若把关键码为Broad的表项真正删除,把它所在位置的info域置为Empty,以后在搜索关键码为Blum和Alton的表项时就查不下去,从而会错误地判断表中没有关键码为Blum和Alton的表项。
若想删除一个表项,只能给它做一个删除标记deleted,进行逻辑删除,不能把它真正删去。
逻辑删除的副作用是:在执行多次删除后,表面上看起来散列表很满,实际上有许多位置没有利用。
template <class Type> void HashTabe<Type>::MakeEmpty ( ) {
//置表中所有表项为空
for ( int i = 0; i < TableSize; i++)
ht[i].info = Empty;
CurrentSize = 0;
}
template <class Type> const HashTable <Type> & HashTable<Type>::
operator= ( const HashTable<Type> &ht2 ) {
//重载函数:从散列表ht2复制到当前散列表
if ( this != &ht2 ) {
delete [ ] ht;
TableSize = ht2.TableSize;
AllocateHt ( );
for ( int i = 0; i < TableSize; i++ )
ht[i] = ht2.ht[i];
CurrentSize = ht2.CurrentSize;
}
return *this;
}
template <class Type> int HashTable<Type>:: Insert (const Type & x ) {
//将新表项 x 插入到当前的散列表中
if ( ( int i = Find (x) ) >= 0 )
return 0; //不插入
else if ( i != -TableSize && ht[-i].info != Active ) { //在 -i 处插入表项x
ht[-i].Element = x;
ht[-i].info = Active;
CurrentSize++;
return 1;
}
else
return 0;
}
template <class Type> int HashTable<Type>:: Remove ( const Type & x ) {
//在当前散列表中删除表项x
if ( ( int i = Find (x) ) >= 0 ) { //找到,删除
ht[i].info = deleted; //做删除标记
CurrentSize--;
return 1;
}
else
return 0;
}
线性探查方法容易产生“堆积”,不同探查序列的关键码占据可用的空桶,为寻找某一关键码需要经历不同的探查序列,导致搜索时间增加。
算法分析:
设散列表的装填因子为 а = n / (s*m),其中 n 是表中已有的表项个数,s 是每个桶中最多可容纳表项个数,m 是表中的桶数。
可用 а表明散列表的装满程度。а越大,表中表项数越多,表装得越满,发生冲突可能性越大。
通过对线性探查法的分析可知,为搜索一个关键码所需进行的探查次数的期望值 P 大约是 (2-а)/(2-2а)。虽然平均探查次数较小,但在最坏情况下的探查次数会相当大。
(2) 二次探查法 (quadratic probing)
为改善“堆积”问题,减少为完成搜索所需的平均探查次数,可使用二次探查法。
通过某一个散列函数对表项的关键码 x 进行计算,得到桶号,它是一个非负整数。
H0 = hash(x)
二次探查法在表中寻找“下一个”空桶的公式为:
式中的 m 是表的大小,它应是一个值为 4k+3 的质数,其中k是一个整数。这样的质数如 3, 7, 11, 19, 23, 31, 43, 59, 127, 251, 503, 1019, …。
下面给出求下一个大于参数表中所给正整数 N 的质数的算法。
int NextPrime ( int N ) {
if ( N % 2 == 0 )
N++; //偶数不是质数
for ( ; !IsPrime (N); N+=2 ); //不是质数,继续求
return N;
}
int IsPrime ( int N ) { //判断N是否质数
for ( int i = 3; i*i <= N; i += 2 )
if ( N % i == 0 )
return 0; //不是,返回0
return 1; //是,返回1
}
探查序列形如 H0, H0+1, H0-1, H0+4, H0-4, …。
在做
的运算时,当
时,运算结果也是负数。实际算式可改为
示例:给出一组关键码 { Burke, Ekers, Broad, Blum, Attlee, Alton, Hecht, Ederly }。
散列函数为:Hash (x)=ord (x)-ord ('A')
用它计算可得
Hash (Burke) = 1 Hash (Ekers) = 4
Hash (Broad) = 1 Hash (Blum) = 1
Hash (Attlee) = 0 Hash (Hecht) = 7
Hash (Alton) = 0 Hash (Ederly) = 4
若设表的长度为TableSize = 23,则利用二次探查法所得到的散列结果如图所示。
利用二次探查法处理溢出 :
使用二次探查法处理溢出时的搜索成功的平均搜索长度为:
搜索不成功的平均搜索长度为:
适用于二次探查散列表类的定义
template <class Type> class HashTable {
public:
enum KindOfEntry { Active, Empty, Deleted };
const int &Find ( const Type & x );
int IsEmpty ( ) { return !CurrentSize ? 1 : 0; }
int IsFull ( ) { return CurrentSize == TableSize ? 1 : 0; }
int WasFound ( ) const { return LastFindOK; }
……
private:
struct HashEntry {
Type Element;
KindOfEntry info;
HashEntry ( ) : info (Empty ) { }
HashEntry ( const Type &E, KindOfEntry i = Empty ) : Element (E), info (i) { }
};
enum { DefualtSize = 11; }
HashEntry *ht;
int TableSize;
int CurrentSize;
int LastFindOK;
void AllocateHt ( );
int FindPos ( const Type & x );
};
设散列表桶数为 m,待查表项关键码为 x,第一次通过散列函数计算出来的桶号为 H0=hash(x)。当发生冲突时,第 i-1 次和第 i 次计算出来的“下 一个”桶号分别为:
相减,可以得到:
从而
只要知道上一次的桶号
和
,当 i 增加 1 时可以从
和
简单地导出
和
,不需要每次计算 i 的平方。
在溢出处理算法 Find 中,首先求出 H0 作为当前桶号 CurrentPos,当发生冲突时求“下一个”桶号,i = 1。
此时用一个标志 odd 控制是加 i2 还是减 i2 。
若 odd == 0 加
,并置 odd = 1;
若 odd == 1 减
,并置 odd = 0。
下次 i 进一后,又可由 odd 控制先加后减。
处理溢出的算法:
template <class Type> int HashTable<Type>:: Find ( const Type & x ) {
int i = 0, odd = 0 ;
int CurrentPos = HashPos ( x ); //桶号
while ( ht[CurrentPos].info != Empty && ht[CurrentPos].Element != x ) { //冲突
if ( !odd ) { // odd == 0 加 i 2
CurrentPos += 2*++i-1;
odd = 1;
while ( CurrentPos >= TableSize )
CurrentPos -= TableSize;
}
else { // odd == 1 减 i 2
CurrentPos -= 2*i-1;
odd = 0;
while ( CurrentPos < 0 )
CurrentPos += TableSize;
}
}
LastFindOK = ht[CurrentPos].info == Active;
return CurrentPos;
}
可以证明,当表的长度TableSize为质数且表的装填因子 а 不超过 0.5 时,新的表项 x 一定能够插入,而且任何一个位置不会被探查两次。因此,只要表中至少有一半空的,就不会有表满问题。
在搜索时可以不考虑表装满的情况;但在插入时必须确保表的装填因子?不超过0.5。如果超出,必须将表长度扩充一倍,进行表的分裂。
在删除一个表项时,为确保搜索链不致中断,也只能做表项的逻辑删除,即将被删表项的标记info改为Deleted。
二次散列的插入操作:
template <class Type> int HashTable<Type>
Insert ( const Type & x ) {
int CurrentPos = Find (x);
if (LastFindOK )
return 0;
ht[CurrentPos] = HashEntry ( x, Active );
if ( ++CurrentSize < TableSize/2)
return 1;
HashEntry *Oldht = ht;
int OldTableSize = TableSize;
CurrentSize = 0;
TableSize = NextPrime ( 2 * OldTableSize );
Allocateht ( );
for ( i = 0; i < OldTableSize; i++)
if ( Oldht[i].info == Active )
Insert ( OldArray[i].Element );
delete [ ] OldArray;
return 1;
}
template <class Type> int HashTable<Type>
IsIn ( const Type & x ) {
int CurrentPos = Find ( x );
return LastFindOK.;
}
template <class Type> int HashTable<Type>
Remove ( const Type & x ) {
int CurrentPos = Find (x);
if (!LastFindOK )
return 0;
ht[CurrentPos].info = Deleted;
return 1;
}
二次散列的删除和判存在操作:
(3) 双散列法
使用双散列方法时,需要两个散列函数。
第一个散列函数 Hash( ) 按表项的关键码 key 计算表项所在的桶号 H0 = Hash(key)。
一旦冲突,利用第二个散列函数 ReHash( ) 计算该表项到达“下一个”桶的移位量。它的取值与 key 的值有关,要求它的取值应当是小于地址空间大小 TableSize,且与 TableSize 互质的正整数。
若设表的长度为 m = TableSize,则在表中寻找“下一个”桶的公式为:
j = H0 = Hash(key), p = ReHash(key);
j = ( j + p ) % m;
p是小于m且与m互质的整数
利用双散列法,按一定的距离,跳跃式地寻找“下一个”桶,减少了“堆积”的机会。
双散列法的探查序列也可写成:Hi = (H0 + i * ReHash(key) ) % m,
i =1, 2, …, m-1
最多经过m-1次探查,它会遍历表中所有位置,回到H0 位置。
示例:给出一组表项关键码{ 22, 41, 53, 46, 30, 13, 01, 67 }。散列函数为:Hash(x)=(3x) % 11。
散列表为 HT[0..10],m = 11。
因此,再散列函数为 ReHash(x) = (7x) % 10 +1。
Hi = ( Hi-1 + (7x) % 10 +1 ) % 11, i = 1, 2, …
H0(22) = 0 H0(41) = 2 H0(53) = 5
H0(46) = 6 H0(30) = 2 冲突 H1 = (2+1) = 3
H0(13) = 6 冲突 H1 = (6+2) = 8
H0(01) = 3 冲突 H1 = (3+8) = 0 H2 = (0+8) = 8
H3 = (8+8) = 5 H4 = (5+8) = 2 H5 = (2+8) = 10
H0(67) = 3 冲突 H1 = (3+10) = 2 H2 = (2+10) = 1
搜索成功的平均搜索长度
搜索不成功的平均搜索长度
a. 每一散列位置的移位量有10种:1, 2,....., 10。先算出各种移位量的情况下找到下一个空位的比较次数,求出平均值;
b. 再累加各个位置的平均比较次数,求出总平均值。
Rehash( )的取法很多。例如,当m是质数时,可定义
ReHash(key) = key % (m-2) +1
ReHash(key) = ┌key / m┐ % (m-2)+1
当 m 是 2 的方幂时,ReHash(key) 可取从 0 到m-1 中的任意一个奇数。
用开放定址法建立散列表
5 处理溢出的开散列方法 — 链地址法
开散列方法首先对关键码集合用某一个散列函数计算它们的存放位置。
若设散列表地址空间的所有位置是从0到m-1,则关键码集合中的所有关键码被划分为m个子集,具有相同地址的关键码归于同一子集。我们称同一子集中的关键码互为同义词。每一个子集称为一个桶。
通常各个桶中的表项通过一个单链表链接起来,称之为同义词子表。所有桶号相同的表项都链接在同一个同义词子表中,各链表的表头结点组成一个向量。
向量的元素个数与桶数一致。桶号为 i 的同义词子表的表头结点是向量中的第 i 个元素。
示例:给出一组表项关键码{ Burke, Ekers, Broad, Blum, Attlee, Alton, Hecht, Ederly }。散列函数为:Hash (x)=ord (x)-ord ('A')。
用它计算可得:
Hash (Burke) = 1 Hash (Ekers) = 4
Hash (Broad) = 1 Hash (Blum) = 1
Hash (Attlee) = 0 Hash (Hecht) = 7
Hash (Alton) = 0 Hash (Ederly) = 4
散列表为 HT[0..25],m = 26。
采用开散列法处理溢出,则上述关键码在散列表中的散列位置如图所示。
通常,每个桶中的同义词子表都很短,设有n 个关键码通过某一个散列函数,存放到散列表中的 m 个桶中。那么每一个桶中的同义词子表的平均长度为 n / m。这样,以搜索平均长度为 n / m 的同义词子表代替了搜索长度为 n 的顺序表,搜索速度快得多。
利用链地址法处理溢出时的类定义
template <class Type> class ListNode { //链表结点
friend HashTable;
private:
Type key; //关键码
ListNode *link; //链指针
};
typedef ListNode<Type> *ListPtr;
class HashTable { //散列表的类定义
public:
HashTable( int size = defaultsize ) { buckets = size; ht = new ListPtr[buckets]; }
private:
int buckets; //桶数
ListPtr<Type> *ht; //散列表数组的头指针
};
循链搜索的算法:
template <class Type> int *HashTable<Type>:: Find ( const Type & x,) {
int j = HashFunc ( x, buckets );
ListPtr<Type> *p = ht[j];
while ( p != NULL )
if ( p→key == x )
return & p→key;
else
p = p→link;
return 0;
}
用链地址法建立散列表
其它如插入、删除操作可参照单链表的插入、删除等算法来实现。
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上,由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装填因子а ≤0.5,而表项所占空间又比指针大得多,所以使用链地址法反而比开地址法节省存储空间。
6 散列表分析
散列表是一种直接计算记录存放地址的方法,它在关键码与存储位置之间直接建立了映象。
当选择的散列函数能够得到均匀的地址分布时,在搜索过程中可以不做多次探查。
由于很难避免冲突,就增加了搜索时间。冲突的出现,与散列函数的选取 (地址分布是否均匀),处理溢出的方法 (是否产生堆积) 有关。
在实际应用中,在使用关键码进行散列时,如在用作关键码的许多标识符具有相同的前缀或后缀,或者是相同字符的不同排列的场合,不同的散列函数往往导致散列表具有不同的搜索性能。
下图给出一些实验结果,列出在采用不同的散列函数和不同的处理溢出的方法时,搜索关键码所需的对桶访问的平均次数。实验数据为 { 33575, 24050, 4909, 3072, 2241, 930, 762, 500 }。
搜索关键码时所需对桶的平均访问次数
从图中可以看出,链地址法优于开地址法;
在散列函数中,用除留余数法作散列函数优于其它类型的散列函数,最差的是折叠法。
当装填因子 а 较高时,选择的散列函数不同,散列表的搜索性能差别很大。在一般情况下多选用除留余数法,其中的除数在实用上应选择不含有20以下的质因数的质数。
对散列表技术进行的实验评估表明,它具有很好的平均性能,优于一些传统的技术,如平衡树。
但散列表在最坏情况下性能很不好。如果对一 个有 n 个关键码的散列表执行一次搜索或插入操作,最坏情况下需要 O(n) 的时间。
Knuth对不同的溢出处理方法进行了概率分析。
若设а是散列表的装填因子:
用地址分布均匀的散列函数Hash( )计算桶号。
Sn 是搜索一个随机选择的关键码 xi (1≤ i≤ n) 所需的关键码比较次数的期望值
Un 是在长度为 m 的散列表中 n 个桶已装入表项的情况下,装入第 n+1 项所需执行的关键码比较次数期望值。
前者称为在 а = n / m 时的搜索成功的平均搜索长度,后者称为在а = n / m时的搜索不成功的平均搜索长度。
用不同的方法溢出处理冲突时散列表的平均搜索长度如图所示。
散列表的装填因子 а 表明了表中的装满程度。越大,说明表越满,再插入新元素时发生冲突的可能性就越大。
散列表的搜索性能,即平均搜索长度依赖于散列表的装填因子,不直接依赖于 n 或 m。
不论表的长度有多大,我们总能选择一个合适的装填因子,以把平均搜索长度限制在一定范围内。