这是因为冲突的次数大大提高了,为了解决冲突,程序不得不遍历一段都存储了元素的数组空间来寻找空位置。
用白箱测试的方法统计,当规模为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进制数就完全能够达到目的了,加入了素数不仅是小