数据结构与算法之哈希表
前提条件
- 熟悉C语言与指针
- 熟悉数据结构与算法
基本概念
- 在查找过程中,最理想的情况是可以不进行记录的比较,就直接得到待查记录。若记录的关键字与存储位置存在某种一一映射关系,那么就可以通过待查记录的关键字,计算其存储位置,直接找到该记录。
- 把关键字集合K映像到一个有限的连续的地址集(区间)D的映射关系H可表示为 H ( k e y ) : K → D , k e y ∈ K H(key): K→D , key\in K H(key):K→D,key∈K
- H称为哈希函数或散列函数。按哈希函数构建的表称为哈希表。D的大小m称为哈希表的地址区间长度。
- 例如,假设要建立一个地址区间长度为11的哈希表,哈希函数为 H ( k e y ) : ( 3 ∗ k e y ) % 11 H(key):(3*key)\%11 H(key):(3∗key)%11,输入关键字序列{11,8,53,35,34},构造哈希表为
0 1 2 3 4 5 6 7 8 9 10 11 8 34 53 35
- 注: H ( 11 ) : ( 3 ∗ 11 ) % 11 = 0 H(11):(3*11)\%11=0 H(11):(3∗11)%11=0
- 若要查找记录,则只需计算记录的关键字的哈希函数值,就可以直接找到该记录。
- 例如,要查找关键字为11的记录,计算得到哈希函数值0,就在0号单元得到该记录。
- 若在哈希表中插入关键字30,其哈希函数值为2,但2号单元已被8占据。由此可见,不同关键字的哈希函数值可能相同,即; H ( k e y 1 ) = H ( k e y 2 ) , k e y 1 ≠ k e y 2 H(key1)= H(key2),key1≠key2 H(key1)=H(key2),key1=key2
- 这种现象称之为冲突。哈希函数值相同的关键字称为同义词。
- 在一般情况下,哈希函数是一种压缩映射,即关键字集大于哈希地址集,这就不可避免产生冲突,因此在构造哈希表时不仅要设计一个好的哈希函数,而且要设定一个处理冲突的方法。
- 根据设定的哈希函数和处理冲突的方法将一组关键字映像到一个连续的有限地址集上,并以关键字在地址集中的“像”作为记录在表中的存储位置,这种表称为哈希表,这一映像过程称为哈希造表或散列,所得存储位置称哈希地址或散列地址。
构造方法
- 若对于关键字集合中的任一个关键字,经哈希函数映像到地址集合中任何一个地址的概率是相等的,则称此类哈希函数为均匀的哈希函数。哈希函数越均匀,发生冲突的概率越低。
- 哈希函数的构造方法有很多,设计时应注意两个原则:
- 计算过程尽量简单;
- 哈希函数尽量均匀。
直接定址法
- 最简单的直接定址法直接用关键字key作为哈希地址,即: H ( k e y ) = k e y H(key)=key H(key)=key。
- 例如一个儿童人口统计表,记录了从0岁到12岁的儿童人口数目,以年龄作为关键字,哈希函数取关键字自身。
- 要查找11岁的儿童人数时,直接读出第11项即可。
- 如下表所示。
- 一般情况下,直接定址法可通过对关键字缩放和平移,以获得合适的地址区间,即以下线性函数
- H ( k e y ) = a × k e y + b H(key) = a × key + b H(key)=a×key+b
- 其中,a为缩放系数,b为平移系数。
- 例如某公司有在职员工各年龄人数统计表,年龄从18周岁到65周岁,就可以采用将关键字年龄减去18来作为数据的存储单元,即a=1,b=-18。员工各年龄人数统计的哈希函数示例见下表。
除留余数法
- 对于地址区间长度为m的哈希表,除留余数法取某个不大于m的数p为模,将哈希函数定义为
- H ( k e y ) = k e y % p ( p ≤ m ) H(key) = key \% p (p≤m) H(key)=key%p(p≤m)。
- 除留余数法简单常用,不仅可以对关键字直接取模,也可以在折叠、平方取中等运算后再取模。值得注意的是模p的选择十分重要。理论研究表明,模p取不大于m且最接近m的素数,或不包含小于20的质因子的合数时,可使哈希地址尽可能均匀地分布在地址空间。
- 为保证求得的哈希函数值在地址区间长度范围以内,除留余数法常与其它方法配合使用。
- 下表为模p建议值示例。
数字分析法
- 数字分析法取关键字中某些取值分布较均匀的数位作为哈希地址。
- 该方法适合于关键字位数较多,且能预测关键字各位分布均匀度的情况。
- 例如构造一个数据元素个数n=60,哈希地址空间长度m=100的哈希表。
- 假设关键字均为8位的十进制数,这60个关键字中的部分如下图所示。
- 对关键字分析发现,关键字的第1、2、3、6位取值比较集中,不宜作为哈希地址,
- 第4、5、7、8位取值较均匀,可选取其中的两位作为哈希地址
- 设选取最后两位作为哈希地址,则8个关键字的哈希地址分别为:02,75,28,34,15,38,62,20。
折叠法
- 折叠法将关键字分割成位数相同的若干部分(最后一部分的位数可以较少),并取各部分的叠加和(舍去进位)作为哈希函数。
- 折叠法适用于关键字位数较多且每一位数字分布大致均匀的情况。
- 分割后的叠加可分为移位叠加和Z形叠加两种方法
- 移位叠加是将分割后每一部分的最低位对齐,然后相加;
- Z形叠加是从一端向另一端沿分割界来回折叠,然后对齐相加。
- 例如,
- 设当哈希表的地址区间长度为10000时,关键字key =401108105302169891,允许的地址空间为四位十进制数。
- 用移位叠加得到的哈希地址是2308,而用Z形叠加所得到的哈希地址是5115。
平方取中法
- 平方取中法先取关键字的平方,然后根据哈希表地址区间长度m的大小,选取平方数的中间若干位作为哈希地址。
- 通过取平方扩大关键字之间的差别,而平方值的中间若干位和这个数的每一位都相关,使得不同关键字的哈希函数值分布较为均匀,不易产生冲突。
- 设哈希表地址区间长度为1000,可取关键字平方值的中间三位。
- 实现了一个取关键字平方值的万千百三位的哈希函数
int hash_3(int key)
{
long temp;
temp = key*key/100;
if(temp>=1000)//关键字平方值大于5位
temp = temp%1000; //temp -= temp/1000*1000;
return temp;
}
- 一般情况下,应该根据关键字的取值范围,来确定取关键字平方值的中间哪几位做为哈希地址。
处理冲突的方法
- 构造哈希表时,根据关键字集合的特点选择合适的哈希函数,使哈希地址尽量均匀地分布在哈希表的地址区间内,以避免或减少冲突。
- 但哈希函数的构造,与关键字的长度、哈希表的大小、关键字的实际取值状况等许多因素有关,而且有些因素事前不能确定。所以,避免冲突不易做到。
- 关键在于如何处理冲突。
开放定址法
-
开放定址法是在哈希表的地址空间内解决冲突。插入时一旦发生冲突,可使用某种探测技术在哈希表中计算得到另一个地址,若不冲突,则插入,否则求下一个地址,直到探测到空闲地址为止,则插入新结点到该地址单元。在探测过程中,求得的一系列地址称为探测地址序列。
-
查找的探测过程与插入相同。沿着探测地址序列逐个查找,若找到给定的关键字,则查找成功;若探测到一个空闲地址,则表明表中无待查的关键字,查找失败。
-
采用开放定址法的哈希表在删除记录时应做特殊处理。
-
从哈希表中删除一个记录时,不是简单地将其单元置为空闲,而是打上删除标记,否则将导致查找时找不到在探测地址序列中位于该被删记录之后的那些记录,因为空闲单元是查找失败的条件。
-
下面介绍两种常用的开放定址法:线性探测法和二次探测法。
线性探测法
- 线性探测法(Linear Probing)的基本思想是:假定哈希函数为H(key),哈希表的地址区间长度为m,并将哈希表看成是一个循环空间,则线性探测法的探测地址序列可表示为:
- H i = ( H ( k e y ) + i ) % m ( 1 ≤ i ≤ m − 1 ) H_i=(H(key)+i)\%m \ (1≤i≤m-1) Hi=(H(key)+i)%m (1≤i≤m−1)
- 其中, H i H_i Hi表示出现冲突时,第i次探测的地址空间。
- 例如,
- 假设有8个关键字22,41,53,46,30,13,12,67,哈希表的地址区间长度为11,哈希函数为 H ( k e y ) = ( 3 ∗ k e y ) % 11 H(key) = (3*key)\%11 H(key)=(3∗key)%11,采用线性探测法处理冲突。
0 1 2 3 4 5 6 7 8 9 10 22 41 30 12 53 46 13 67
- 注:
- H ( 22 ) = 0 H(22)=0 H(22)=0
- H ( 41 ) = 2 H(41)=2 H(41)=2
- H ( 53 ) = 5 H(53)=5 H(53)=5
- H ( 46 ) = 6 H(46)=6 H(46)=6
- H ( 30 ) = 2 H(30)=2 H(30)=2与 H ( 41 ) = 2 H(41)=2 H(41)=2冲突,然后往后找空闲地址 H 1 ( 30 ) = 2 + 1 = 3 H_1(30)=2+1=3 H1(30)=2+1=3,地址3没有空闲,故30插入到地址3的空间中。
- 其余地,同理。
- 优点:思路清晰,算法简单
- 缺点:很容易产生堆聚现象。
- 所谓堆聚现象,就是存入哈希表的记录在表中连成一片。
- 按照线性探测法处理冲突,如果生成哈希地址的连续序列越长,则当新的记录加入该表时,与这个序列发生冲突的可能性越大。
二次探测法
- 二次探测法生成的探测地址序列不是连续的,而是跳跃式的,为后续待插入的记录留下空间从而减少堆聚。
- 二次探测法的探测地址序列可表示为
- H = ( H ( k e y ) + d i ) % m ( 1 ≤ i ≤ m − 1 ) H=(H(key)+d_i)\ \%m\ (1≤i≤m-1) H=(H(key)+di) %m (1≤i≤m−1)
- 其中, d , = 1 2 , − 1 2 , 2 2 , − 2 2 , . . . , k 2 , − k 2 ( k ≤ m / 2 ) d,= 1^2,-1^2,2^2,-2^2,...,k^2,-k^2(k≤m/2) d,=12,−12,22,−22,...,k2,−k2(k≤m/2),即
- H = H ( k e y ) H= H(key) H=H(key),
- H 1 = ( H + 1 2 ) % m H_1 =(H+1^2) \% m H1=(H+12)%m,
- H 2 = ( H − 1 2 ) % m H_2=(H-1^2) \% m H2=(H−12)%m,
- H 3 = ( H + 2 2 ) % m H_3=(H+2^2) \% m H3=(H+22)%m,
- H 4 = ( H − 2 2 ) % m H_4=(H-2^2) \% m H4=(H−22)%m
- …
- 假设有8个关键字22,41,53,46,30,13,12,67,哈希表的地址区间长度为11,哈希函数为 H ( k e y ) = ( 3 ∗ k e y ) % 11 H(key)= (3*key)\%11 H(key)=(3∗key)%11,采用二次探测法处理冲突。
0 1 2 3 4 5 6 7 8 9 10 22 41 30 12 53 46 13 67
- 注:
- H ( 22 ) = 0 H(22)=0 H(22)=0
- H ( 41 ) = 2 H(41)=2 H(41)=2
- H ( 53 ) = 5 H(53)=5 H(53)=5
- H ( 46 ) = 6 H(46)=6 H(46)=6
- H ( 30 ) = 2 H(30)=2 H(30)=2与 H ( 41 ) = 2 H(41)=2 H(41)=2冲突,然后 H 1 ( 30 ) = ( 2 + 1 2 ) % 11 = 3 H_1(30)=(2+1^2) \% 11=3 H1(30)=(2+12)%11=3,故30插入到地址3的空间中。
- 其余地,同理。
链地址法
- 链地址法将关键字为同义词的记录链接在同一个单链表中。
- 设哈希表地址区间长度为m,则可将哈希表定义为一个由m个头指针组成的指针数组T[0…m-1]。
- 凡是哈希地址为i的记录,均插入到以T[i]为头指针的单链表中,称该单链表为i同义词链表。
- T中各分量的初值置为空指针。
- 假设有8个关键字22,41,53,46,30,13,12,67,哈希表的地址区间长度为11,哈希函数为
H
(
k
e
y
)
=
(
3
∗
k
e
y
)
%
11
H(key)=(3*key) \%11
H(key)=(3∗key)%11,采用链地址法处理冲突建立哈希表.
链地址法哈希表的实现
链地址哈希表的类型定义
typedef struct Node {
RcdType r;
struct Node *next;
}Node;
typedef struct {
Node **rcd;//哈希表的记录record
int size;//哈希表容量
int count;//当前表中含有的记录个数
int (*hash)(KeyType key, int);//函数指针变量,用于选取的哈希函数
}HashTable;
链地址哈希表的接口(函数的声明)
Status lnitHash(HashTable &H, int size, int(*hash)(KeyType,int));//初始化哈希表
Status DestroyHash(HashTable &H);//销毁哈希表
node* SearchHash(HashTable H, KeyType key);//查找
Status InsertHash(HashTable &H, RcdType e);//插入
Status deleteHash(HashTable &H, KeyType key, RcdType &e);//删除
链地址哈希表的初始化
Status InitHash(HashTable &H, int size, int (*hash)(KeyType,int))
{//初始化哈希表
int i;
H.rcd = H.rcd=(Node*)malloc(sizeof(Node)*size)//分配长度为size的存储空间,元素类型为指针Node*
if(NULL==(H.rcd))
return OVERFLOW;
for(i=0; i<size; i++) H.rcd[i]= NULL;
H.size = size;
H.hash = hash;
H.count = 0;
return OK;
}
链地址哈希表的查找
int hash(int key, int hashSize)
{//哈希函数,hashSize为地址空间长度
return 3*key%hashSize;
}
Node* SearchHash(HashTable &H, int key)
{//在哈希表H中查找关键字为key的记录
int p = H.hash(key, H.size);
Node* np;
for(np=H.rcd[p]; np!=NULL; np=np->next)
if(np->r.key==key)//查找成功,返回该结点指针
return np;
return NULL;//否则返回空指针
}
链地址哈希表的插入
Status InsertHash(HashTable &H,RcdType e)
{//插入记录e
int p;
Node *np;
if((np=SearchHash(H, e.key))==NULL)
{//查找不成功时插入到表头
p = H.hash(e.key,H.size);
np = (Node*)malloc(sizeof(Node));
if(np==NULL)
return OVERFLOW;
np->r = e;
np->next = H.rcd[p]; //插入到表头
H.rcd[p]= np;
H.count++;
return OK;
}
else
return ERROR;
}
哈希表的查找性能
- 对同一组关键字,设定相同的哈希函数,若采用不同的处理冲突方法,则导致实际的哈希表不同,它们的平均查找长度也不同。
- 假设有8个关键字22,41,53,46,30,13,12,67,哈希函数为 H ( k e y ) = ( 3 ∗ k e y ) % 11 H(key) = (3*key) \%11 H(key)=(3∗key)%11,地址区间长度为11,我们分别来计算采用线性探测法、二次探测法和链地址法处理冲突的平均查找长度。
平均查找长度
线性探测法
- 关键字序列为(22,41,53,46,30,13,12,67),哈希函数为 H ( k e y ) = ( 3 ∗ k e y ) % 11 H(key)= (3*key) \%11 H(key)=(3∗key)%11,利用线性探测法处理冲突,若记录的查找概率相同的情况下,其查找成功的平均查找长度为:
-
A
S
L
(
8
)
=
(
1
+
1
+
1
+
1
+
2
+
2
+
2
+
6
)
/
8
=
2
ASL(8)=(1+1+1+1+2+2+2+6)/8=2
ASL(8)=(1+1+1+1+2+2+2+6)/8=2
二次探测法
- 关键字序列为(22,41,53,46,30,13,12,67),哈希函数为 H ( k e y ) = ( 3 ∗ k e y ) % 11 H(key)= (3*key) \%11 H(key)=(3∗key)%11,利用二次探测法处理冲突,若记录的查找概率相同的情况下,其查找成功的平均查找长度为:
-
A
S
L
(
8
)
=
(
1
+
1
+
1
+
1
+
2
+
2
+
2
+
5
)
/
8
=
1.875
ASL(8)= (1+1+1+1+2+2+2+5)/8=1.875
ASL(8)=(1+1+1+1+2+2+2+5)/8=1.875
链地址法
- 关键字序列为(22,41,53,46,30,13,12,67),哈希函数为 H ( k e y ) = ( 3 ∗ k e y ) % 11 H(key)= (3*key) \%11 H(key)=(3∗key)%11,利用链地址法处理冲突,若记录的查找概率相同的情况下,其查找成功的平均查找长度为:
-
A
S
L
(
8
)
=
(
1
+
2
+
1
+
2
+
1
+
1
+
2
+
1
)
/
8
=
1.375
ASL(8)= (1+2+1+2+1+1+2+1)/8=1.375
ASL(8)=(1+2+1+2+1+1+2+1)/8=1.375
装填因子
- 在构造哈希表时,哈希表中插入的记录数占地址区间长度的比率称为装填因子 α \alpha α:
- α = 表 中 填 入 的 记 录 数 地 址 区 间 的 长 度 \alpha=\frac{表中填入的记录数}{地址区间的长度} α=地址区间的长度表中填入的记录数
- 处理冲突方法相同的哈希表,其平均查找长度依赖于 α α α,直观上来看, α α α越大,发生冲突的可能性就越大。可以证明:
- 采用线性探测法的哈希表查找成功时的平均查找长度为
- S n l ≈ 1 2 ( 1 + 1 1 − α ) S_{nl}\approx\frac{1}{2}(1+\frac{1}{1-α}) Snl≈21(1+1−α1)
- 采用二次探测法的哈希表查找成功时的平均查找长度为
- S n r ≈ − 1 α l n ( 1 − α ) S_{nr}\approx-\frac{1}{α}ln(1-α) Snr≈−α1ln(1−α)
- 采用链地址法的哈希表查找成功时的平均查找长度为
- S n c ≈ 1 + α 2 S_{nc}\approx1+\frac{α}{2} Snc≈1+2α
- 由于哈希表中查找不成功时所需比较次数与给定值有关,则可类似地定义哈希表中查找不成功时的平均查找长度为:查找不成功时需和给定值进行比较的关键字个数的期望值。
- 同样可以证明,不同处理冲突的方法构成的哈希表查找不成功时的平均查找长度分别为:
- 线性探测法 U n l ≈ 1 2 ( 1 + 1 ( 1 − α ) 2 ) U_{nl}\approx\frac{1}{2}(1+\frac{1}{(1-α)^2}) Unl≈21(1+(1−α)21)
- 二次探测法 U n r ≈ 1 1 − α U_{nr}\approx\frac{1}{1-α} Unr≈1−α1
- 链地址法 U n c ≈ α + e − α U_{nc}\approxα+e^{-α} Unc≈α+e−α
装填因子与平均查找长度
- 这三种处理冲突的方法中,装载因子与平均查找长度在查找成功和查找不成功的关系
- 在装载因子较小时(小于0.6时),这三种处理冲突的方法,性能相差不大;
- 但当装载因子大于0.6后,线性探测法处理冲突的性能急剧下降;
- 用链地址法处理冲突,随着装载因子的变化,其性能变化不大。
链地址法与开放定址法
- 链地址法处理冲突简单,且无堆聚现象,即非同义词决不会发生冲突,因此平均查找长度较短,查找效率高;
- 链地址法中链表的结点是动态申请的,更适合事前无法确定元素个数的情况;
- 在用链地址法构造的哈希表中,删除操作易于实现,只需删去链表上相应结点即可。而对开放地址法构造的哈希表,不能简单地将被删记录的存储单元置为空闲单元,而是打删除标记,否则将截断探测路径,影响路径上该单元之后的记录的查找;
- 开放定址法为了减少冲突要求装填因子较小,故哈希表需设置较大的地址区间长度,耗费较多空间。而链地址法中装填因子可以大于1,但需要另设指针空间。
完美哈希函数
- 对于预先知道且规模不大的关键字集,有时可以构造完美哈希函数。
- 完美哈希函数是指没有冲突的哈希函数,即若K的大小为n,D的大小为m,m>=n,且哈希函数H不出现同义词。若m等于n,则称为最小完美哈希函数(Minmal Perfect Hash Function,MPHF)。
- 其中,K为关键字集合,D的大小m称为哈希表的地址区间长度。
参考文献
[1] 严蔚敏,吴伟民. 数据结构(C语言版). 北京: 清华大学出版社,2020
[2] 严蔚敏,李冬梅,吴伟民. 数据结构(C语言版)(第二版). 北京: 人民邮电出版社,2021
[3] 吴伟民,李小妹,刘添添,黄剑锋,苏庆,林志毅,李杨.数据结构. 北京:高等教育出版社,2017
[4] 王道论坛. 2022数据结构考研复习指导. 北京:电子工业出版社,2021