Jsoi春季函授A层次讲义一.docx
《Jsoi春季函授A层次讲义一.docx》由会员分享,可在线阅读,更多相关《Jsoi春季函授A层次讲义一.docx(27页珍藏版)》请在冰豆网上搜索。
![Jsoi春季函授A层次讲义一.docx](https://file1.bdocx.com/fileroot1/2023-2/7/64b2b4b8-5357-4875-93b9-af14b8094d5f/64b2b4b8-5357-4875-93b9-af14b8094d5f1.gif)
Jsoi春季函授A层次讲义一
哈希表及其应用
常州市第一中学林厚从
hc.lin@
一、引入
现在要存储和使用下面的线性表:
A=(1,75,324,43,1353,90,46)。
那么,很简单,定义一个一维数组A[1..n],此处n=7,将表中元素按先后顺序存储在A[i]中,但这样给查找带来了开销,尤其是n很大时,我们需要用O(n)的时间去查找某个元素(当然也可采用二分查找提高效率);反之,为了用O
(1)的时间实现查找,可以分析这个线性表的元素类型和范围,开一个一维数组A[1..1353],使得A[key]=key,即线性表的key这个元素存储在A[key]中,这样一来,查找的效率便为O
(1)了,但显然造成了空间上的很大浪费,尤其是数据范围分布很广时。
为了使空间开销减少,我们可以对第二种方法加以优化,设计一个函数h(key)=keymod13,然后把key存在A[h(hey)]中,这样一来定义一个一维数组A[0..12]就已足够,这种方法就是我们要学习的哈希表(散列表)。
哈希表是一种高效的数据结构。
它的最大优点就是把数据存储和查找所消耗的时间大大降低,几乎可以看成是常数时间;而代价仅仅是消耗比较多的内存。
然而在当前可利用内存越来越多、程序运行时间控制的越来越短的情况下,用空间换时间的做法还是值得的。
另外,哈希表编码实现起来比较容易也是它的优点之一。
二、基本原理
哈希表的基本原理是:
使用一个下标范围比较大的数组A来存储元素,设计一个函数h,对于要存储的线性表的每个元素node,取一个关键字key,算出一个函数值h(key),把h(key)作为数组下标,用A[h(key)]这个数组单元来存储node。
也可以简单的理解为,按照关键字为每一个元素“分类”,然后将这个元素存储在相应“类”所对应的地方(这一过程称为“直接定址”)。
但是,不能够保证每个元素的关键字与函数值是一一对应的,因此极有可能出现对于不同的元素,却计算出了相同的函数值,这样就产生了“冲突”,换句话说,就是把不同的元素分在了相同的“类”之中。
例如,假设一个结点的关键码值为key,把它存入哈希表的过程是:
根据确定的函数h计算出h(key)的值,如果以该值为地址的存储空间还没有被占用,那么就把结点存入该单元;如果此值所指单元里已存了别的结点(即发生了冲突),那么就再用另一个函数I进行映象算出I(h(key)),再看用这个值作为地址的单元是否已被占用了,若已被占用,则再用I映象,……,直到找到一个空位置将结点存入为止。
当然这只是解决“冲突”的一种简单方法,如何避免、减少和处理“冲突”是使用哈希表的一个难题。
在哈希表中查找的过程与建立哈希表的过程相似,首先计算h(key)的值,以该值为地址到基本区域中去查找。
如果该地址对应的空间未被占用,则说明查找失败,否则用该结点的关键码值与要找的key比较,如果相等则检索成功,否则要继续用函数I计算I(h(key))的值,……。
如此反复到某步或者求出的某地址空间未被占用(查找失败)或者比较相等(查找成功)为止。
三、基本概念和简单实现
图1用哈希函数h将关键字映射到哈希表T中的示意图
图1形象地表示了哈希表处理的各个要素,具体概念如下:
1、两个集合:
U是所有可能出现的关键字集合;K是实际存储的关键字集合。
2、函数h将U映射到表T[0..m-1]的下标上,可以表示成h:
U→{0,1,2,...,m-1},通常称h为“哈希函数(HashFunction)”,其作用是压缩待处理的下标范围,使待处理的|U|个值减少到m个值,从而降低空间开销(注:
|U|表示U中关键字的个数,下同)。
3、将结点按其关键字的散列地址存储到哈希表(散列表)中的过程称为“散列(Hashing)”。
方法称为“散列法”。
4、h(Ki)(Ki∈U)是关键字为Ki的结点的“存储地址”,亦称散列值、散列地址、哈希地址。
5、用散列法存储的线性表称为“哈希表(HashTable)”,又称散列表。
图中T即为哈希表。
在散列表里可以对结点进行快速检索(查找)。
6、对于关键字为key的结点,按照哈希函数h计算出地址h(key),若发现此地址已被别的结点占用,也就是说有两个不同的关键码值key1和key2对应到同一个地址,即h(key1)=h(key2),这个现象叫做“冲突(碰撞)”。
碰撞的两个(或多个)关键码称为“同义词”(相对于函数h而言)。
如图1中的关键字k2和k5,h(k2)=h(k5),即发生了“冲突”,所以k2和k5称为“同义词”。
假如先存了k2,则对于k5,我们可以存储在h(k2)+1中,当然h(k2)+1要为空,否则可以逐个往后找一个空位存放。
这是另外一种简单的解决冲突的方法。
发生了碰撞就要想办法解决,必须想办法找到另外一个新地址,这当然要降低处理效率,因此我们希望尽量减少碰撞的发生。
这就需要分析关键码集合的特性,找适当的哈希函数h使得计算出的地址尽可能“均匀分布”在地址空间中。
同时,为了提高关键码到地址转换的速度,也希望哈希函数“尽量简单”。
然而对于各种取值的关键码而言,一个好的哈希函数通常只能减少碰撞发生的次数,无法保证绝对不产生碰撞。
因此散列除去要选择适当的哈希函数以外,还要研究发生碰撞时如何解决,即用什么方法存储同义词。
7、负载因子
我们把h(key)的值域所对应到的地址空间称为“基本区域”,发生碰撞时,同义词可以存放在基本区域还没有被占用的单元里,也可以放到基本区域以外另开辟的区域中(称为“溢出区”)。
下面引入散列的一个重要参数“负载因子或装填因子(LoadFactor)”,它定义为:
а=
负载因子的大小对于碰撞的发生频率影响很大。
直观上容易想象,а越大,散列表装得越满,则再要载入新的结点时碰上已有结点的可能性越大,冲突的机会也越大。
特别当а>1时碰撞是不可避免的。
一般总是取а<1,即分配给散列表的基本区域大于所有结点所需要的空间。
当然分配的基本区域太大了也是浪费。
例如,某校学生干部的登记表,每个学生干部是一个结点,用学号做关键码,每个学号用7位数字表示,如果分配给这个散列表的基本区域为107个存储单元,那么散列函数就可以是个恒等变换,学号为7801050的学生结点就存入相对地址为7801050的单元,这样一次碰撞也不会发生,但学校仅几百个学生干部,实际仅需要几百个单元的空间,如果占用了107个存储单元,显然太浪费了,所以这是不可取的。
负载因子的大小要取得适当,使得既不过多地增加碰撞,有较快的检索速度,也不浪费存储空间。
下面结合引例说明一下上面的思想和方法。
【例1】用散列存储线性表:
A=(18,75,60,43,54,90,46)。
分析:
假定选取的散列函数为:
h(K)=Kmodm,K为元素的关键字,m为散列表的长度,用余数作为存储该元素的散列地址。
这里假定K和m均为正整数,并且m要大于等于线性表的长度n。
此例n=7,故假定取m=13,则得到的每个元素的散列地址为:
h(18)=18mod13=5h(75)=75mod13=10h(60)=60mod13=8
h(43)=43mod13=4h(54)=54mod13=2h(90)=90mod13=12
h(46)=46mod13=7
根据散列地址按顺序把元素存储到散列表H(0:
m-1)中,存储映象为:
0123456789101112
H
54
43
18
46
60
75
90
当然这是一个比较理想的情况。
假如再往表中插入第8个元素30,h(30)=30mod13=4,我们会发现H[4]已经存了43,此时就发生了冲突。
我们可以从H[4]往后按顺序找一个空位置存放30,即可以把它插入到H[6]中。
四、哈希函数的构造方法
选择适当的哈希函数是实现散列的重中之重,构造哈希函数有两个标准:
简单和均匀。
简单是指哈希函数的计算要简单快速;均匀是指对于关键字集合中的任一关键字,哈希函数能以等概率将其映射到表空间的任何一个位置上。
也就是说,哈希函数能将子集K随机均匀地分布在表的地址集{0,1,...,m-1}上,以使冲突最小化。
为简单起见,假定关键码是定义在自然数集合上,常见的哈希函数构造方法有:
1、直接定址法
以关键字Key本身或关键字加上某个数值常量C作为散列地址的方法。
散列函数为:
h(Key)=Key+C,若C为0,则散列地址就是关键字本身。
2、除余法
选择一个适当的正整数m,用m去除关键码,取其余数作为地址,即:
h(Key)=Keymodm,这个方法应用的最多,其关键是m的选取,一般选m为小于某个区域长度n的最大素数(如例1中取m=13),为什么呢?
就是为了尽力避免冲突。
假设取m=1000,则哈希函数分类的标准实际上就变成了按照关键字末三位数分类,这样最多1000类,冲突会很多。
一般地说,如果m的约数越多,那么冲突的几率就越大。
简单的证明:
假设m是一个有较多约数的数,同时在数据中存在q满足gcd(m,q)=d>1,即有m=a*d,q=b*d,则有以下等式:
qmodm=q–m*[qdivm]=q–m*[bdiva]。
其中,[bdiva]的取值范围是不会超过[0,b]的正整数。
也就是说,[bdiva]的值只有b+1种可能,而m是一个预先确定的数。
因此上式的值就只有b+1种可能了。
这样,虽然mod运算之后的余数仍然在[0,m-1]内,但是它的取值仅限于等式可能取到的那些值。
也就是说余数的分布变得不均匀了。
容易看出,m的约数越多,发生这种余数分布不均匀的情况就越频繁,冲突的几率越高。
而素数的约数是最少的,因此我们选用大素数。
记住“素数是我们的得力助手”。
3、数字分析法
常有这样的情况:
关键码的位数比存储区域的地址的位数多,在这种情况下可以对关键码的各位进行分析,丢掉分布不均匀的位留下分布均匀的位作为地址。
本方法适用于所有关键字已知,并对关键字中每一位的取值分布情况作出了分析。
【例2】对下列关键码集合(表中左边一列)进行关键码到地址的转换,要求用三位地址。
Key
H(Key)
000319426
326
000718309
709
000629443
643
000758615
715
000919697
997
000310329
329
分析:
关键码是9位的,地址是3位的,需要经过数字分析丢掉6位。
丢掉哪6位呢?
显然前3位是没有任何区分度,第5位1太多、第6位基本都是8和9、第7位都是3、4、5,这几位的区分度都不好,而相对来说,第4、8、9位分布比较均匀,所以留下这3位作为地址(表中右边一列)。
4、平方取中法
将关键码的值平方,然后取中间的几位作为散列地址。
具体取多少位视实际要求而定,取哪几位常常结合数字分析法。
【例3】将一组关键字(0100,0110,1010,1001,0111)平方后得(0010000,0012100,1020100,1002001,0012321),若取表长为1000,则可取中间的三位数作为散列地址集:
(100,121,201,020,123)。
5、折叠法
如果关键码的位数比地址码的位数多,而且各位分布较均匀,不适于用数字分析法丢掉某些数位,那么可以考虑用折叠法。
折叠法是将关键码从某些地方断开,分关键码为几个部分,其中有一部分的长度等于地址码的长度,然后将其余部分加到它的上面,如果最高位有进位,则把进位丢掉。
一般是先将关键字分割成位数相同的几段(最后一段的位数可少一些),段的位数取决于散列地址的位数,由实际需要而定,然后将它们的对应位叠加和(舍去最高位进位)作为散列地址。
【例4】如关键码Key=58422241,要求转换为3位的地址码。
分析:
分如下3段:
584|222|41,则相加:
584
222
41
847
h(Key)=847
6、基数转换法
将关键码值看成在另一个基数制上的表示,然后把它转换成原来基数制的数,再用数字分析法取其中的几位作为地址。
一般取大于原来基数的数作转换的基数,并且两个基数要是互质的。
如:
key=(236075)10是以10为基数的十进制数,现在将它看成是以13为基数的十三进制数(236075)13,然后将它转换成十进制数。
(236075)13=2*135+3*134+6*133+7*13+5
=(841547)10
再进行数字分析,比如选择第2,3,4,5位,于是h(236075)=4154
五、哈希表支持的运算
哈希表支持的运算主要有:
初始化(makenull)、哈希函数值的运算(h(x))、插入元素(insert)、查找元素(member)。
设插入元素的关键字为x,A为哈希表,则各种运算过程如下:
1、初始化比较容易,例如:
constempty=maxlongint;{用非常大的整数代表这个位置没有存储元素}
p=9997;{根据需要设定的表的大小}
proceduremakenull;
vari:
integer;
begin
fori:
=0top-1doA[i]:
=empty;
End;
2、哈希函数值的运算,根据函数的不同而变化,例如除余法的一个例子:
functionh(x:
longint):
Integer;
begin
h:
=xmodp;
end;
3、我们注意到,插入和查找首先都需要对这个元素定位,因此加入一个定位的函数locate:
functionlocate(x:
longint):
integer;
varorig,i:
integer;
begin
orig:
=h(x);
i:
=0;
while(i
x)and(A[(orig+i)modP]<>empty)doinc(i);
{当这个循环停下来时,要么找到一个空的存储单元,
要么找到这个元素存储的单元,要么表已经满了}
locate:
=(orig+i)modP;
end;
4、插入元素:
procedureinsert(x:
longint);
varposi:
integer;
begin
posi:
=locate(x);{定位函数的返回值}
ifA[posi]=emptythenA[posi]:
=x
elseerror;{error即为发生了错误,当然这是可以避免的}
end;
5、查找元素是否已经在表中:
proceduremember(x:
longint):
boolean;
varpos:
integer;
begin
pos:
=locate(x);
ifA[pos]=xthenmember:
=true
elsemember:
=false;
end;
六、处理冲突的方法
1、完全避免冲突的条件
最理想的解决冲突的方法是完全避免冲突。
要做到这一点必须满足两个条件:
一是|U|≤m;二是选择合适的散列函数。
这仅适用于|U|较小,且关键字均事先已知的情况,此时经过精心设计散列函数h有可能完全避免冲突。
但是,一味的追求低冲突率也不好。
理论上是可以设计出一个几乎完美、几乎没有冲突的哈希函数。
然而,这样做显然不值得,因为这样的函数设计很浪费时间而且编码一定很复杂,与其花费这么大的精力去设计函数,还不如用一个虽然冲突多一些但是编码简单的哈希函数。
所以,设计一个好的哈希函数是很关键的。
而“好”的标准,就是较低的冲突率和易于实现。
2、冲突不可能完全避免
通常情况下,哈希函数h是一个压缩映像。
虽然|K|≤m,但|U|>m,故无论怎样设计h,也不可能完全避免冲突。
因此,只能在设计h时尽可能使冲突最少。
同时还需要确定解决冲突的方法,使发生冲突的同义词能够存储到表中。
处理冲突的方法基本上有两类:
一类方法是“拉链法(Chaining)”,当发生冲突时就拉出一条链,建立一个链接方式的子表。
若n个关键码值映象到基本区域的m个存储单元上,最多可以建立m个子表,每个关键码的同义词存放在以这m个单元为首结点链接的子表里。
另一类叫“开地址法(OpenAddressing)”,也称为“开放定址法”。
当冲突发生时,用某种方法在基本区内形成一个探查序列,沿着这个探查序列一个单元一个单元地查找,直到找到这个关键码或碰到一个开放的地址(没有存储关键码的空单元)为止,下面分别讲解。
3、拉链法
用拉链法法处理冲突时要求散列表的每个结点增加一个link字段,用于链接同义词子表。
同义词子表建立在什么地方?
一种办法是在基本存储区外开辟一个溢出区存储同义词表,这种方法叫做建立“分离的同义词表”的方法。
这时基本存储区里的结点即存放关键码值,同时又是一个链接的同义词子表的表头。
如果某个关键码值没有同义词,link字段为空;如果某个关键码值有同义词,则它的link字段指向溢出区的同义词子表。
同义词的检索也是顺着这些链进行的。
这种方法的存储如图2所示。
图2用分离的同义词子表解决冲突
【例5】已知一组关键字为(26,36,41,38,44,15,68,12,06,51),请用拉链法解决冲突,构造这组关键字的散列表。
分析:
这里关键字个数n=10,为了减少冲突(保证装填因子а不妨取m=13,此时α≈0.77,散列表为T[0..12],散列函数为:
h(k)=kmod13。
具体构造过程参见动画模拟软件llf.swf(注:
请安装flash或播放软件)。
注意:
当把h(k)=i的关键字插入第i个单链表时,既可插入在链表的头上,也可以插在链表的尾上。
若采用将新关键字插入链尾的方式,依次把给定的这组关键字插入表中,则所得到的散列表如下图3所示,具体程序留给大家完成。
另一个办法是不另外建立溢出区,而是把同义词子表就存入基本存储区中目前还没有被占用的单元里,例如:
可以在基本存储区里从后往前找空单元,找到空单元就将同义词存进去,并将它链接进同义词子表。
这种方法叫建立“结合的同义词子表”。
下面给出该方法的算法。
此算法在散列表里检索一个给定的关键码值,设此关键码值进入算法前已在变量K中,若在散列表中找到这个关键码值则检索成功;若找不到则将这个关键码插入散列表。
算法用散列表函数h(x)计算表里的相对地址,有关的类型和变量说明如下:
TYPEnode=RECORD
key:
integer;
link:
integer;
END
VAR
table:
ARRAY[0..m-1]OFnode;
r,k,i:
integer;
r是个辅助变量,用来帮助在插入时找到可利用单元。
r的初始值为m。
在算法进行过程中,始终有下列情况存在:
散列表中从table[r]到table[m-1]的所有单元都已被占用了。
设未被占用的单元起始内容全为0,而关键码值不能为0。
【算法1】 散列表的检索和插入(用结合的同义词子表解决冲突)
begin
i:
=h(k);{计算散列地址}
iftable[i].key=0{检索并插入}
thenbegintable[i].key:
=k;table[i].link:
=-1;write(‘inseted’,i);end
elsebegin
while((table[i].key<>k)and(table[i].link<>-1))do
I:
=table[i].link;
Iftable[i].key=kthenwrite(‘retreval’,i,table[i])
Elsebegin
While((r>=0)and(table[r].key<>0))do r:
=r-1;
Ifr<0thenwrite(‘overflow’)
Elsebegin
table[i].link:
=r;
table[r].key:
=k;
table[r].link:
=-1;
write(‘inserted’,r);
end;
end;
end;
end;
4、开地址法
用开地址法解决冲突的做法是:
当冲突发生时一定要产生一个探查序列,检索沿这个探查序列去查找。
最简单的产生探查序列的方法是进行线性探查,就是当冲突发生时到顺序的下一个基本存储区单元进行探查。
将散列表T[0..m-1]看成是一个循环向量,若初始探查的地址为d(即h(Key)=d),则最长的探查序列为:
d+l,d+2,...,m-1,0,1,...,d-1
即:
探查时从地址d开始,首先探查T[d],然后依次探查T[d+1],...,直到T[m-1],此后又循环到T[0],T[1],...,直到探查到T[d-1]为止。
探查过程终止于三种情况:
(1)若当前探查的单元为空,则表示查找失败(若是插入则将key写入其中);
(2)若当前探查的单元中含有key,则查找成功,但对于插入意味着失败;
(3)若探查到T[d-1]时仍未发现空单元也未找到key,则无论是查找还是插入均意味着失败(此时表满)。
利用开地址法的一般形式,线性探查法的探查序列为:
I(i)=(h(Key)+i)modm(0<i≤m-1=
下面具体介绍:
(1)利用线性探查法构造散列表
【例6】已知一组关键字和选定的散列函数和例5相同,用除余法构造散列函数,用线性探查法解决冲突,构造这组关键字的散列表。
分析:
和例1类似,散列函数为:
h(K)=Kmod13,散列表为T[0..12]。
由除余法的散列函数计算出的上述关键字序列的散列地址为(0,10,2,12,5,2,3,12,6,12)。
前5个关键字插入时,其相应的地址均为开放地址,故将它们直接插入T[0],T[10],T[2],T[12]和T[5]中。
当插入第6个关键字15时,其散列地址2(即h(15)=15mod13=2)已被关键字41(15和41互为同义词)占用。
故探查I=(2+1)mod13=3,此地址开放,所以将15放入T[3]中。
当插入第7个关键字68时,其散列地址3已被非同义词15先占用,故将其插入到T[4]中。
当插入第8个关键字12时,散列地址12已被同义词38占用,故探查I1=(12+1)mod13=0,而T[0]亦被26占用,再探查I2=(12+2)mod13=1,此地址开放,可将12插入其中。
类似地,第9个关键字06直接插入T[6]中;而最后一个关键字51插人时,因探查的地址12,0,1,……,6均非空,故51插入T[7]中。
用开地址法(线性探查法)解决冲突,构造散列表的具体过程参见动画模拟软件kfdzh.swf。
下面给出用线性探查法解决冲突的算法,算法用散列函数h(x)计算散列地址。
算法有关类型和变量说明如下:
TYPEnode=RECORD
key:
integer
END;
VAR
table:
ARRAY[0…N-1]OFnode;
k,i,j:
integer;
j是个辅助变量,用来记录表里已有多少个表目,散列表建立时,设基本存储区已清为0,且j=0,要检索或插入的关键码,在进入算法前已放在变量K之中,算法中的m是一个常量,等于基本存储区的长度。
【算法2】散列表的检索和插入(用线性探索法解决冲突)
begin
I:
=h(k);{计算散列地址}
while((table[I].key<>k)and(table