浅谈竞赛中哈希表的应用.docx

上传人:b****6 文档编号:9013221 上传时间:2023-02-02 格式:DOCX 页数:32 大小:46.93KB
下载 相关 举报
浅谈竞赛中哈希表的应用.docx_第1页
第1页 / 共32页
浅谈竞赛中哈希表的应用.docx_第2页
第2页 / 共32页
浅谈竞赛中哈希表的应用.docx_第3页
第3页 / 共32页
浅谈竞赛中哈希表的应用.docx_第4页
第4页 / 共32页
浅谈竞赛中哈希表的应用.docx_第5页
第5页 / 共32页
点击查看更多>>
下载资源
资源描述

浅谈竞赛中哈希表的应用.docx

《浅谈竞赛中哈希表的应用.docx》由会员分享,可在线阅读,更多相关《浅谈竞赛中哈希表的应用.docx(32页珍藏版)》请在冰豆网上搜索。

浅谈竞赛中哈希表的应用.docx

浅谈竞赛中哈希表的应用

浅谈竞赛中哈希表的应用

哈尔滨市第三中学刘翀

[关键词]应用哈希表数据结构

[摘要]

哈希表是一种高效的数据结构。

本文分五个部分:

首先提出了哈希表的优点,其次介绍了它的基础操作,接着从简单的例子中作了效率对比,指出其适用范围以及特点,然后通过例子说明了如何在题目中运用哈希表以及需要注意的问题,最后总结全文。

[正文]

1.引言

哈希表(HashTable)的应用近两年才在NOI中出现,作为一种高效的数据结构,它正在竞赛中发挥着越来越重要的作用。

哈希表最大的优点,就是把数据的存储和查找消耗的时间大大降低,几乎可以看成是常数时间;而代价仅仅是消耗比较多的内存。

然而在当前可利用内存越来越多的情况下,用空间换时间的做法是值得的。

另外,编码比较容易也是它的特点之一。

哈希表又叫做散列表,分为“开散列”和“闭散列”。

考虑到竞赛时多数人通常避免使用动态存储结构,本文中的“哈希表”仅指“闭散列”,关于其他方面读者可参阅其他书籍。

2.基础操作

2.1基本原理

我们使用一个下标范围比较大的数组来存储元素。

可以设计一个函数(哈希函数,也叫做散列函数),使得每个元素的关键字都与一个函数值(即数组下标)相对应,于是用这个数组单元来存储这个元素;也可以简单的理解为,按照关键字为每一个元素“分类”,然后将这个元素存储在相应“类”所对应的地方。

但是,不能够保证每个元素的关键字与函数值是一一对应的,因此极有可能出现对于不同的元素,却计算出了相同的函数值,这样就产生了“冲突”,换句话说,就是把不同的元素分在了相同的“类”之中。

后面我们将看到一种解决“冲突”的简便做法。

总的来说,“直接定址”与“解决冲突”是哈希表的两大特点。

2.2函数构造

构造函数的常用方法(下面为了叙述简洁,设h(k)表示关键字为k的元素所对应的函数值):

a)除余法:

选择一个适当的正整数p,令h(k)=kmodp

这里,p如果选取的是比较大的素数,效果比较好。

而且此法非常容易实现,因此是最常用的方法。

b)数字选择法:

如果关键字的位数比较多,超过长整型范围而无法直接运算,可以选择其中数字分布比较均匀的若干位,所组成的新的值作为关键字或者直接作为函数值。

2.3冲突处理

线性重新散列技术易于实现且可以较好的达到目的。

令数组元素个数为S,则当h(k)已经存储了元素的时候,依次探查(h(k)+i)modS,i=1,2,3……,直到找到空的存储单元为止(或者从头到尾扫描一圈仍未发现空单元,这就是哈希表已经满了,发生了错误。

当然这是可以通过扩大数组范围避免的)。

2.4支持运算

哈希表支持的运算主要有:

初始化(makenull)、哈希函数值的运算(h(x))、插入元素(insert)、查找元素(member)。

设插入的元素的关键字为x,A为存储的数组。

初始化比较容易,例如

constempty=maxlongint;//用非常大的整数代表这个位置没有存储元素

p=9997;//表的大小

proceduremakenull;

vari:

integer;

begin

fori:

=0top-1do

A[i]:

=empty;

End;

哈希函数值的运算根据函数的不同而变化,例如除余法的一个例子:

functionh(x:

longint):

Integer;

begin

h:

=xmodp;

end;

我们注意到,插入和查找首先都需要对这个元素定位,即如果这个元素若存在,它应该存储在什么位置,因此加入一个定位的函数locate

functionlocate(x:

longint):

integer;

varorig,i:

integer;

begin

orig:

=h(x);

i:

=0;

while(ix)and(A[(orig+i)modS]<>empty)do

inc(i);

//当这个循环停下来时,要么找到一个空的存储单元,要么找到这个元

//素存储的单元,要么表已经满了

locate:

=(orig+i)modS;

end;

插入元素

procedureinsert(x:

longint);

varposi:

integer;

begin

posi:

=locate(x);//定位函数的返回值

ifA[posi]=emptythenA[posi]:

=x

elseerror;//error即为发生了错误,当然这是可以避免的

end;

查找元素是否已经在表中

proceduremember(x:

longint):

boolean;

varposi:

integer;

begin

posi:

=locate(x);

ifA[posi]=xthenmember:

=true

elsemember:

=false;

end;

这些就是建立在哈希表上的常用基本运算。

下文提到的所有程序都能在附录中找到。

3.效率对比

3.1简单的例子与实验

下面是一个比较简单的例子:

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

集合(Subset)

问题描述:

给定两个集合A、B,集合内的任一元素x满足1≤x≤109,并且每个集合的元素个数不大于104个。

我们希望求出A、B之间的关系。

只需确定在B中但是不在A中的元素的个数即可。

 

这个题目是根据OIBHNOIP2002模拟赛#1的第一题改编的。

分析:

我们先不管A与B的具体关系如何,注意到这个问题的本质就是对于给定的集合A,确定B中的元素是否在A中。

所以,我们使用哈希表来处理。

至于哈希函数,只要按照除余法就行了,由于故意扩大了原题的数据规模,H(x)=xmod15889;

当然本题可以利用别的方法解决,所以选取了速度最快的快速排序+二分查找,让这两种方法作效率对比。

我们假定|A|=|B|,对于随机生成的数据,计算程序重复运行50次所用时间。

对比表格如下:

哈希表(sec)

快速排序+二分查找(sec)

复杂度

O(N)(只有忽略了冲突才是这个结果。

当然实际情况会比这个大,但是重复的几率与哈希函数有关,不容易估计)

O(NlogN+N)=O(NlogN)

测试数据规模

——

——

500

0.957

0.578

1000

1.101

0.825

2500

1.476

1.565

5000

2.145

2.820

7500

2.905

4.203

10000

3.740

5.579

13500

7.775

7.753

15000

27.550

8.673

 

对于数据的说明:

在Celeron566下用TP测试,为了使时间的差距明显,让程序重复运了行50次。

同时哈希表中的P=15889,下标范围0..15888。

由于快速排序不稳定,因此使用了随机数据。

3.2对试验结果的分析:

注意到两个程序的用时并不像我们期望的那样,总是哈希表快。

设哈希表的大小为P.

首先,当规模比较小的时候(大约为a<10%*P,这个数据仅仅是通过若干数据估记出来的,没有严格证明,下同),第二种方法比哈希表快。

这是由于,虽然每次计算哈希函数用O

(1)的时间,但是这个系数比较大。

例如这道题的H(x)=xmod15589,通过与做同样次数的加法相比较,测试发现系数>12,因为mod运算本身与快速排序的比较大小和交换元素运算相比,比较费时间。

所以规模小的时候,O(N)(忽略冲突)的算法反而不如O(NlogN)。

这一点在更复杂的哈希函数上会体现的更明显,因为更复杂的函数系数会更大。

其次,当规模稍大(大约为15%*P

这是因为冲突的次数较少。

再次,当规模再大(大约为90%*P

这是因为冲突的次数大大提高了,为了解决冲突,程序不得不遍历一段都存储了元素的数组空间来寻找空位置。

用白箱测试的方法统计,当规模为13500的时候,为了找空位置,线性重新散列平均做了150000次运算;而当规模为15000的时候,平均竟然高达2000000次运算,某些数据甚至能达到4265833次。

显然浪费这么多次运算来解决冲突是不合算的,解决这个问题可以扩大表的规模,或者使用“开散列”(尽管它是动态数据结构)。

然而需要指出的是,冲突是不可避免的。

初步结论:

当数据规模接近哈希表上界或者下界的时候,哈希表完全不能够体现高效的特点,甚至还不如一般算法。

但是如果规模在中央,它高效的特点可以充分体现。

我们可以从图像直观的观察到这一点。

 

 

 

试验表明当元素充满哈希表的90%的时候,效率就已经开始明显下降。

这就给了我们提示:

如果确定使用哈希表,应该尽量使数组开大(由于竞赛中可利用内存越来越多,大数组通常不是问题,当然也有少数情况例外),但对最太大的数组进行操作也比较费时间,需要找到一个平衡点。

通常使它的容量至少是题目最大需求的120%,效果比较好(这个仅仅是经验,没有严格证明)。

4.应用举例

4.1应用的简单原则

什么时候适合应用哈希表呢?

如果发现解决这个问题时经常要询问:

“某个元素是否在已知集合中?

”,也就是需要高效的数据存储和查找,则使用哈希表是最好不过的了!

那么,在应用哈希表的过程中,值得注意的是什么呢?

哈希函数的设计很重要。

一个不好的哈希函数,就是指造成很多冲突的情况,从前面的例子已经可以看出来,解决冲突会浪费掉大量时间,因此我们的目标就是尽力避免冲突。

前面提到,在使用“除余法”的时候,h(k)=kmodp,p最好是一个大素数。

这就是为了尽力避免冲突。

为什么呢?

假设p=1000,则哈希函数分类的标准实际上就变成了按照末三位数分类,这样最多1000类,冲突会很多。

一般地说,如果p的约数越多,那么冲突的几率就越大。

简单的证明:

假设p是一个有较多约数的数,同时在数据中存在q满足gcd(p,q)=d>1,即有p=a*d,q=b*d,则有qmodp=q–p*[qdivp]=q–p*[bdiva].①其中[bdiva]的取值范围是不会超过[0,b]的正整数。

也就是说,[bdiva]的值只有b+1种可能,而p是一个预先确定的数。

因此①式的值就只有b+1种可能了。

这样,虽然mod运算之后的余数仍然在[0,p-1]内,但是它的取值仅限于①可能取到的那些值。

也就是说余数的分布变得不均匀了。

容易看出,p的约数越多,发生这种余数分布不均匀的情况就越频繁,冲突的几率越高。

而素数的约数是最少的,因此我们选用大素数。

记住“素数是我们的得力助手”。

另一方面,一味的追求低冲突率也不好。

理论上,是可以设计出一个几乎完美,几乎没有冲突的函数的。

然而,这样做显然不值得,因为这样的函数设计很浪费时间而且编码一定很复杂,与其花费这么大的精力去设计函数,还不如用一个虽然冲突多一些但是编码简单的函数。

因此,函数还需要易于编码,即易于实现。

综上所述,设计一个好的哈希函数是很关键的。

而“好”的标准,就是较低的冲突率和易于实现。

另外,使用哈希表并不是记住了前面的基本操作就能以不变应万变的。

有的时候,需要按照题目的要求对哈希表的结构作一些改进。

往往一些简单的改进就可以带来巨大的方便。

这些只是一般原则,真正遇到试题的时候实际情况千变万化,需要具体问题具体分析才行。

下面,我们看几个例子,看看这些原则是如何体现的。

4.2有关字符串的例子

我们经常会遇到处理字符串的问题,下面我们来看这个例子:

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

找名字

问题描述:

给定一个全部由字符串组成的字典,字符串全部由大写字母构成。

其中为每个字符串编写密码,编写的方式是对于n位字符串,给定一个n位数,大写字母与数字的对应方式按照电话键盘的方式:

2:

A,B,C5:

J,K,L8:

T,U,V

3:

D,E,F6:

M,N,O9:

W,X,Y

4:

G,H,I7:

P,R,S

题目给出一个1——12位的数,找出在字典中出现且密码是这个数的所有字符串。

字典中字符串的个数不超过8000。

这个是USACOTrainingGate1.2.4的一道题。

分析:

看懂题目之后,对于给定的编码,只需要一个回溯的过程,所有可能的原字符串都可以被列举出来,剩下的就是检查这个字符串是否在给定的字典中了。

所以这个问题需要的还是“某个元素是否在已知集合中?

”由于给出的“姓名”都是字符串,因此我们可以利用字符的ASCII码。

那么,如何设计这个哈希函数呢?

注意到题目给出的字典中,最多能有5000个不同元素,而一个字符的ASCII码只能有26种不同的取值,因此至少需要用在3个位置上的字符(26^3>5000,但是26^2<5000),于是我们就选取3个位置上的字符。

由于给定的字符串的长度从1——12都有可能,为了容易实现,选取最开始的1个字符,和最末尾的2个字符。

让这3个字符组成27进制的3位数,则这个数的值就是这个字符串的编码。

这样哈希函数就设计出来了!

不过,由于可能出现只有1位的字符串,在写函数代码的时候需要特殊考虑;大素数选取13883。

这个函数是这样的:

functionhash(s:

string):

integer;

vari,tmp:

longint;

begin

tmp:

=0;{用来记录27进制数的值}

iflength(s)>1thenbegin

tmp:

=tmp*27+ord(s[1])-64;

fori:

=1downto0do

tmp:

=tmp*27+ord(s[length(s)-i])-64;{取第一位和后两位}

end

elsefori:

=1to3do

tmp:

=tmp*27+ord(s[1])-64;{当长度为1的时候特殊处理}

hash:

=tmpmod13883;

end;

值得指出的是,本题给出的字符串大都没有什么规律,用哈希表可以做到近似“平均”,但是对于大多数情况,字符串是有规律的(例如英文单词),这个时候用哈希表反而不好(例如英语中有很多以con开头的单词),通常用检索树解决这样的查找问题。

 

4.3在广度优先搜索中应用的例子

在广度优先搜索中,一个通用而且有效的剪枝就是在拓展节点之前先判重。

而判重的本质也是数据的存储与查找,因此哈希表大有用武之地。

来看下面的例子:

转花盆

题意描述:

    给定两个正6边形的花坛,要求求出从第一个变化到第二个的最小操作次数以及操作方式。

一次操作是:

选定不在边上的一盆花,将这盆花周围的6盆花按照顺时针或者逆时针的顺序依次移动一个单位。

限定一个花坛里摆放的不同种类的花不超过3种,对于任意两种花,数量多的花的盆数至少是数量少的花的2倍

这是SGOI-8的一道题

分析:

首先确定本题可以用广度优先搜索处理,然后来看问题的规模。

正6边形共有19个格子可以用来放花,而且根据最后一句限定条件,至多只能存在C(2,19)*C(5,17)=1058148种状态,用搜索完全可行。

然而操作的时候,可以预料产生的重复节点是相当多的,需要迅速判重才能在限定时间内出解,因此想到了哈希表。

那么这个哈希函数如何设计呢?

注意到19个格子组成6边形是有顺序的,而且每一个格子只有3种可能情况,那么用3进制19位数最大3^20-1=3486784400用Cardinal完全可以承受。

于是我们将每一个状态与一个整数对应起来,使用除余法就可以了。

4.4小结

从这两个例子可以发现,对于字符串的查找,哈希表虽然不是最好的方法,但是每个字符都有“天生”的ASCII码,在设计哈希函数的时候可以直接利用。

而其他方法,例如利用检索树的查找,编写代码不如哈希表简洁。

至于广度优先搜索中的判重更是直接利用了哈希表的特点。

另外,我们看到这两个题目都是设计好哈希函数之后,直接利用前面的基本操作就可以了,因此重点应该是在哈希函数的设计上(尽管这两个例子的设计都很简单),需要注意题目本身可以利用的条件,以及估计值域的范围。

下面我们看两个需要在哈希表基础上作一些变化的例子。

4.5需要微小变化的例子

下面,我们来分析一道NOI的试题:

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

方程的解数

问题描述

已知一个n元高次方程:

其中:

x1,x2,…,xn是未知数,k1,k2,…,kn是系数,p1,p2,…pn是指数。

且方程中的所有数均为整数。

假设未知数1≤xi≤M,i=1,,,n,求这个方程的整数解的个数。

约束条件

1n6;1M150;

方程的整数解的个数小于231。

本题中,指数Pi(i=1,2,……,n)均为正整数。

这个是NOI2001的第二试中的《方程的解数》。

分析:

初看此题,题目要求出给定的方程解的个数,这个方程在最坏的情况下可以有6个未知数,而且次数由输入决定。

这样就不能利用数学方法直接求出解的个数,而且注意到解的范围最多150个数,因此恐怕只能使用枚举法了。

最简单的思路是穷举所有未知数的取值,这样时间复杂度是O(M^6),无法承受。

因此我们需要寻找更好的方法,自然想到能否缩小枚举的范围呢?

但是发现这样也有很大的困难。

我们再次注意到M的范围,若想不超时,似乎算法的复杂度上限应该是O(M^3)左右,这是因为150^3<10000000。

这就启示我们能否仅仅通过枚举3个未知数的值来找到答案呢?

如果这样,前一半式子的值S可以确定,这时只要枚举后3个数的值,检查他们的和是否等于-S即可。

这样只相当于在O(M^3)前面加了一个系数,当然还需要预先算出1到150的各个幂次的值。

想到了这里,问题就是如何迅速的找到某个S是否曾经出现过,以及出现过了多少次,于是又变成了“某个元素是否在给定集合中”这个问题。

所以,我们还是使用哈希表解决这个问题。

至于哈希函数不是问题,还是把S的值作为关键字使用除余法即可。

然而有一点需要注意,这个例子我们不仅需要纪录某个S是否出现,出现的次数也很重要,所以可以用一个2维数组,仅仅是加了一个存储出现次数的域而已。

Var

e:

array[0..max-1,1..2]oflongint;{e[x,1]记录哈希函数值为x的S值,e[x,2]记录这个S值出现了几次}

因此insert过程也需要一些变化:

procedureins(x:

longint);

varposi:

longint;

begin

posi:

=locate(x);

e[posi,1]:

=x;

inc(e[posi,2]);{仅仅这一条语句,就可以记录下来S出现了几次}

end;

4.6最后一个例子

下面我们来仔细分析下面这个问题:

 

迷宫的墙

题意描述:

神话中byte山边有一个井之迷宫。

迷宫的入口在山顶。

迷宫中有许多房间,每个的颜色是以下之一:

红、绿、蓝。

两个相同颜色的房间看起来相似而不可区分。

每个房间里有三口井标以1,2,3。

从一个房间到另一间只有一种方式:

从上面房间的井里跳到(不一定竖直地)井底的房间。

可以从入口房间到达任何其他房间。

迷宫中的所有通路走向坐落在最底部的龙宫。

所有的迷宫之旅对应了一系列在相继访问的房间里选择的井的标号。

这一列数称为一个旅行计划。

一个走过好几次迷宫的英雄bytezar画好了图,然而有的房间重复出现了多次。

输入:

第一行有一个整数n,2<=n<=6000,房间数(包括龙宫)。

房间从1到n标号,较大编号的房间再较低处(入口房间编号1,龙宫编号n)。

接下的n-1行描述迷宫的房间(除了龙宫)和井。

每行有一个字母,一个空格,和三个由空格分隔的整数。

字母代表了房间的颜色(C——红,Z——绿,N——蓝),第i(i=1,2,3)个数是第i个井通往的房间号。

输出:

迷宫最少的房间数目

 

这是IOI2003中国国家集训队难题讨论活动的0020题。

分析:

题目的意思是给出这个迷宫的地图,去掉重复出现的房间,找出这个迷宫的最少房间数目。

于是关键就是确定什么样的房间是重复的。

通过对样例的分析,可以看出这样的房间是重复的:

如果两个房间i和j(1<=i,j<=n-1),他们的颜色相同,而且第k(k=1,2,3)堵墙通向的房间或者相同、或者重复。

因为这样从i和j可到达的房间是完全相同的。

所以,我们只需要记录下每个房间的情况和已经被确定相同的房间,然后挨个比较即可。

于是又需要用到高效的数据存储与查找,自然想到哈希表。

然而,这里面需要对哈希表作更大的改进:

首先每个房间只能是3种颜色之一,因此针对每种颜色分别建立哈希表,可以使哈希函数的自变量减少一个;其次还需要纪录每个不重复的房间每堵墙都通向哪个房间,还有哪些房间是重复的。

具体这样实现:

var

e:

array[0..2,0..p-1,1..4]oflongint;{0..2代表共有3种颜色,0..p-1是哈希函数的值域,而1..4中的1..3表示三堵墙连接到那个房间,4表示这个单元存储的是哪个节点}

r:

array[1..maxn]oflongint;{r[i]表示与i相同的节点。

如果有多个节点都是相同的,择取其中最大的(这一点不需要特殊的操作,只要在处理节点的时候注意就行了)}

至于哈希函数,最开始我是随意的写了一个(因为越是随意的,就越是随机的!

),定位函数是这样的:

functionlocate(vara,b,c,d:

longint):

longint;

vart:

longint;

i:

integer;

begin

t:

=r[b]*10037+r[c]*5953+r[d]*2999;{用3堵墙的值任意乘大素数相加再取余数,使得结果分布比较随机,也就比较均匀}

t:

=tmodp;

i:

=0;

while(e[a,(t+i)modp,1]<>0)and(e[a,(t+i)modp,1]<>r[b])do

if(e[a,(t+i)modp,2]<>r[c])or(e[a,(t+i)modp,3]<>r[d])theninc(i);{线性重新散列}

locate:

=(t+i)modp;

end;

但是后来发现完全没有必要这样做,这样的哈希函数在计算t的时候浪费了很多时间(不过数据规模不是很大,所以这点不十分明显),而且素数起到的作用也不应当是这样的。

其实让r[b],r[c],r[d]组成n进制数就完全能够达到目的了,加入了素数不仅是小

展开阅读全文
相关资源
猜你喜欢
相关搜索

当前位置:首页 > 高等教育 > 农学

copyright@ 2008-2022 冰豆网网站版权所有

经营许可证编号:鄂ICP备2022015515号-1