大家好,又见面了,我是你们的朋友全栈君。
一、散列的概念
散列同顺序、链接和索引一样,是又一种数据存储方法。散列存储的方法是:以数据集合中的每个元素的关键字k为自变量,通过一种函数h(k)计算出函数值,把这个值用做一块连续存储空间(即数组或文件空间)中的元素存储位置(即下标),将该元素存储到这个下标位置上。散列存储中使用的函数h(k)被称为散列函数或哈希函数,它实现关键字到存储位置(地址)的映射(或称转换),h(k)被称为散列地址或哈希地址;使用的数组或文件空间是对数据集合进行散列存储的地址空间,所以被称为散列表或哈希表。在散列表上进行查找时,首先根据给定的关键字k,用与散列存储时使用的同一散列函数h(k)计算出散列地址,然后按此地址从散列表中取出对应的元素。
例10-1 假定一个数据集合为:
s ={ 18,75,60,43,54,90,46}
其中每个整数可以是元素本身,也可以仅是元素的关键字,用以代表整个元素。为了散列存储该集合,假定选取的散列函数为: h(k)=k%m
即用元素的关键字k整除以散列表的长度m,取余数作为存储元素的散列地址,它取值为0~m-1之间一个整数,这里的看和m都是正整数,并且m要大于等于待散列存储的数据集合的长度n。结合例10-1 ,n=7,所以假定取m=3,则得到的每个元素的散列地址为:
h(18)=18%13=5
h(60)=60%13=8
h(54)=54%13=2
h(46)=46%13=7
h(75)=75%13=13
h(43)=43%13=4
h(90)=90%13=12
根据散列地址把元素存储到长度为m的散列表中,假定该散列表用数组a表示,则得到的数组a中的内容为:
从散列表中查找元素同插入元素一样简单,例如,从a中查找关键字为60的元素时,只要利用上面的函数h(k)计算出k=60时的散列地址8,从下标为8的元素位置中取出元素值即可。
例10-1 中讨论的散列存储是一种理想的情况,即插入时根据元素的关键字求出的散列地址,其对应的存储元素位置都是空闲的,也就是说,每个元素都能够直接存储到它的散列地址所对应的元素位置上,不会出现该元素位置已被其他元素占用的情况。在实际应用中,这种理想情况是很少见的,通常可能出现的情况是:一个待插入元素的散列地址位置已被占用,使得该元素无法直接存入到此下标位置上,把这种现象叫做冲突。
在散列存储中,冲突是很难避免的,除非关键字的变化区间小于等于散列地址的变化区间,而这种情况当关键字取值不连续时又是非常浪费存储空间的。一般情况是关键字的取值区间大大大于散列地址的变化区间。例如,在例10-1 中,关键字为两位正整数,其取值区间为0~99,而散列地址的取值区间为0~12,远比关键字的取值区间小。这样,当不同的关键字通过同一散列函数计算散列地址时,就可能出现具有相同散列地址的情况,若该地址中已经存入了一个元素,则具有相同散列地址的其他元素就无法直接存入进去,从而引起冲突,通常把这种具有不同关键字而具有相同散列地址的元素称为“同义词”,由同义词引起的存储冲突称为“同义词冲突”。因此,如何尽量避免冲突和冲突发生后如何解决冲突(即为发生冲突的待插入元素找到一个空闲位置,使之存储起来)就成了散列存储的两个关键问题。
在散列存储中,虽然冲突很难避免,但发生冲突的可能性缺有大有小,这主要与三个因素有关。第一是与装填因子a有关。所谓装填因子,是指散列表中以存入的元素数n与散列长度m的比值。为了既兼顾减少存储冲突的发生,又兼顾提高存储空间的利用率这两个方面,通常使a值控制在0.6~0.9范围为宜。第二与所采用的散列函数有关。第三与解决冲突的方法有关。
在散列存储中每个散列地址对应的存储位置被称为一个桶,一个桶可以为存储一个元素的位置,也可以为存储多个元素的位置。当一个桶能够用来存储多个元素时,则只有被全部存满后才发生冲突。
二、散列函数
构造散列函数的目标是使散列函数尽可能均匀地分布在散列地址的空间上,同时使计算尽可能简单,以节省时间。根据关键字的结构和分布不同,可构造出与之适应的各不相同的散列函数,下面介绍较常用的几种,其中又以介绍除留余数发为主。在下面的讨论中,假定关键字均为整型数,若不是则要设法把它转换为整型数后再进行运算。
1、直接定址法
直接定址法是以关键字k本身或关键字加上某个数值常量C作为散列地址的方法。对应的散列函数h(k)为:
h(k)=k+c
若C为0,则散列地址就是关键字本身。
这种方法计算最简单,并且没有冲突发生,若有冲突发生,则表明是关键字重复错误。它适用于关键字的分布基本连续的情况,若关键字分布不连续,空号较多,将造成存储空间的较大浪费。
2、除留余数法
除留余数法使用关键字k除以散列表长度m所得余数作为散列地址的方法。对应的散列函数h(k)=k%m
这种方法在上面的例10-1 中已经使用过。除留余数法计算较简单,适用范围广,是一种最常使用的方法。这种方法的关键是选好作为除数的散列表长度m,使得每一个关键字通过该函数转换后映射到散列空间上任一地址的概率都相等,从而尽可能减少发生冲突的可能性。例如,取m为奇数比取m为偶数要好,因为当m为偶数时,它总是把关键字为偶数的元素散列到偶数单元中,把关键字为奇数的元素散列到奇数单元中,即把一个元素散列到一半的存储空间中;当m为奇数时就不会出现这种问题,它能够把一个元素散列到整个存储空间中。结合处理冲突时对m的要求,最好取散列表的长度m为一个素数(即除1和本身之外,不能被任何数整除的数)。当然,要确保m的值大于等于待散列的数据表的长度n,根据装填因子a最好在0.6~0.9之间,所以m应取1.1n至1.7n之间的一个素数。
另外当关键字k为一个字符串时,需要设法转换为一个整数,然后再用整数除以m得到余数,即散列地址。下面的hash(k,m)函数就能够求出关键字k为字符串时的散列地址。在这里,把字符串k转换为整数的过程是:首先求出k的长度,即所含的字符个数,接着把每个字符的编码累加到整型变量h上,并在每次累加之前把h的值左移3个二进制位,即扩大8倍。
public static int hash(String k,int m)
{
//把字符串k转换为0~m-1之间的一个整数值,然后再计算出对应记录的散列地址
int c=k.length(); //求出字符串k的长度
int h=0; //给累加变量h赋初值0
for(int i=0;i<c;i++)
{
h<<=3; //h的值左移3位
h+=k.charAt(i); //把第i个字符的ASCII编码值累加到h上
}
return h%m; //返回这个整数整除以m的余数
}
例如,假定一个记录的关键字k为“ab1”,则调用上述函数时最后计算得到的h值为:
h=97*2^6+98*2^3+49=7041
若m为127,则返回的散列地址为56.
3、数字分析法
数字分析法是取关键字中某些取值较分散的数字位作为散列地址的方法。它适合于所有关键字以知,并对关键字中每一位的取值分布情况作出了分析。
4、平方取中法
平方取中法是取关键字平方的中间几位作为散列地址的方法,具体取多少位视实际要求而定。一个数平方后的中间几位和原数据中的每一位都有关。从而可知,有平方取中法得到的散列地址同关键字的每一位都有关,使得散列地址具有较好的分散性。平方取中法适用于关键字中的每一位取值都不够分散或者较分散的位数小于散列地址所需的位数的情况。
5、折叠法
折叠法是首先将关键字分割成位数相同的几段(最后一段的位数若不足应补0),段的位数取决于散列地址的位数,由实际需要而定,然后将它们的叠加和(舍去最高位进位)作为散列地址的方法。折叠法适用于关键字的位数较多,而所需的散列地址的位数较少,同时关键字中每一位的取值又较集中的情况。
三、处理冲突的方法
处理冲突的方法可分为开放定址法和链接法两类。
1、开放定址法
开放定址法就是从发生冲突的那个存储单元开始,按照一定的次序,从散列表中查找出一个空闲的存储单元,把发生冲突的代插入元素存入到该单元中的这样一类处理冲突的方法。在开放地址法中,散列表中的一个空闲单元(假定是下标为d的元素位置)不仅向散列地址为d的同义词元素开放,即允许他们使用,而且向发生冲突的其他元素开放,因它们的散列地址不为d,所以被称为非同义词元素。总之,在开放定址法中,空闲单元既向同义词元素开放,也向发生冲突的非同义词元素开放,此方法的名称也由此而来。在使用开放定址法处理冲突的散列表中,下标为d的单元到低存储的是同义词中的一个元素,还是其他元素,就看谁先占用它。
在采用开放定址法进行散列存储的散列表中,查找一个元素的过程是:首先根据给定的关键字k,利用与插入时使用的同一散列函数h(k)计算出散列地址(假定为下标d),然后,用k同d单元的关键字进行比较,若相等则查找成功,否则按照插入时处理冲突的相同次序,依次用k同查找路径上的每个元素的关键字进行比较,直到查找成功或查找到一个空单元(表明失败)为止。
在开放定址法中,从发生冲突的散列地址为d的单元起进行查找有多种方法,每一种都对应着一定的查找次序,所经过的单元构成了一条查找路径或称探查序列。在查找的多种方法中,主要有线性探查法,平方探查法和双散列函数探查法等。
(1)线性探查法
线性探查法是用开放定址法处理冲突的一种最简单的探查方法,它从发生冲突的d单元起,依次探查下一个单元,当达到下标为m-1的表尾单元时,下一个探查的单元是下标为0的表首单元,即把散列表看做首尾相接的循环表,当遇到一个空闲单元或探查完所有单元后为止。这种方法的探查路径为d、d+1、d+2、……,或表示为(d+i)%m
(0<=i<=m-1)。若使用递推公式表示,则为:
d0=h(k)
di=(d(i-1)+1)%m (1<=i<=m-1)
当然,这里的i在最坏的情况下才能取值到m-1,一般只需取前几个值就可能找到一个空闲单元。找到一个空闲单元后,把发生冲突的待插入元素存入到该单元中。
利用线性探查法处理冲突容易造成元素的“堆积”或称“聚集”现象。这是因为当连续n个单元被占用后,再散列到这些单元上的元素和直接散列到后面一个空闲单元上的元素都要占用这个空闲单元,致使该空闲单元很容易被占用,造成更大的堆积,从而大大地增加查找下一个空闲单元的路径长度。
在线性探查中,造成堆积现象的根本原因是探查序列过分集中在发生冲突的单元的后面,没有在整个散列空间上分散开,下面介绍的双散列函数探查法和平方探查法可以在一定程度上克服堆积现象的发生。
(2)平方探查法
平方探查法的探查路径为d、d+1^2、d+2^2、。。。。。。,或表示为(d+i^2)%m(0<=i<=m-1)。若使用递推公式表示,则为:
d0=h(k)
di=(d(i-1)+2i-1)%m (1<=i<=m-1)
平方探查法是一种较好的处理冲突的方法,它能够较好地避免堆积想象。它的缺点是不能探查到散列表上的所有单元,但至少能探查到一半单元(证明从略)。例如,当d0=5,m=17时,只能探查到单元地址依次为5、6、9、14、4、13、7、3、1的单元,而不能探查到剩余的单元。不过在实际应用中,能探查到一半单元也就可以了,若探查到一半单元仍找不到一个空闲单元,表明此散列表太满,应该重新建立。
(3)双散列函数探查法
这种方法使用两个散列函数h1和h2,其中,h1和前面的h(k)一样,以关键字为自变量,产生一个0至m-1之间的数作为散列地址;h2也以关键字为自变量,产生一个1至m-1之间的,并和m互素的数(即m不能被该数整除)作为探查序列的地址增量(即步长)。双散列函数的探查序列为: d0=h1(k)
di=(d(下标i-1)+h2(k))%m (1<=i<=m-1)
由以上可知,对于线性探查法,探查序列的步长值是固定值1;对于平方探查法,探查序列的步长值是探查次数i的两倍减1;对于双散列函数探查法,其探查序列的步长值是同一关键字的另一散列函数的值。
2、链接法
链接法就是把发生冲突的同义词元素用单链表链接起来的一种处理方法。在这种方法中,散列表中的每个单元(元素)不是存储待散列的元素,而是存储相应单链表的表头指针。由于每个同义词元素都被存储在同一个单链表中,即一个散列地址通过单链表可以链接存储多个元素,所以在采用链接法处理冲突的散列存储中,其填充因子a既可以小于等于1,也可以大于1。当向链接法的散列表中插入一个关键字为k的元素时,首先根据关键字k计算出散列地址d,接着把由该元素生成的结点插入到下标为d的单链表的表头(可以插入到单链表中的任何位置,但插入表头最为方便)。查找过程也与插入类似,首先计算出散列地址d,然后从下标为d的单链表中顺序查找关键字为k的元素,若查找成功则返回该元素的引用或值,若查找失败则返回空值。
例10-3 假定一个集合B为B={ 18,75,60,43,54,90,46,31,58,73,15,34}
为进行散列存储,假定采用的散列函数为: h(k)=k%13
当发生冲突时,假定采用链接法处理,则得到的散列表如图10-5所示。
用链接法处理冲突虽然比开放定址法多用一些存储空间用来存储链接指针,但它可以减少在插入和查找过程中同关键字平均比较的次数(即平均查找长度)。这是因为在链接法中待比较的结点都是同义词结点,而在开放定址法中待比较的查找路径长的结点不仅包含同义词结点,而且包含非同义词结点,往往非同义词结点比同义词结点还要多。
对于一个具体的散列表来说,求出在插入或查找过程中的平均查找长度很容易,在随机插入或在查找每个元素概率相等的情况下,它等于所有元素的查找长度(即比较次数)之和除以所有元素的个数。
3、散列存储的性能分析
在散列存储中,插入和查找的速度是相当快的,它优于前面介绍过的任一种存储方法,特别是当数据量很大时更是如此。
采用散列存储结构也有其固有的缺点:
(1)根据关键字计算散列地址需要花费一定的计算时间,若关键字不是整数,则首先要把它转换为整数,为此也要花费一定的转化时间。
(2)占用的存储空间较多,因为采用开放定址法解决冲突的散列表总是取a值下于1,采用链接法处理冲突的散列表同数据的链接存储相比多占用一个具有m个位置的引用数组空间。
(3)在散列表中只能按关键字查找元素,而无法按非关键字查找元素。
(4)数据中元素之间的原有逻辑关系无法在散列表中体现出来,所以散列表只适合存储集合数据,不适合存储带有逻辑结构的线性表、树和图等数据结构。
四、散列表的运算
对散列表的运算主要有插入、删除和查找运算,还有返回散列表中当前包含的元素个数,返回散列表的容量(即散列地址空间中地址单元的个数,即相应的数组长度),判断散列表是否为空,清除散列表中的所有元素使之成为一个空表,输出散列表中的所有元素等运算。
在向散列表插入一个元素时,首先根据该元素的关键字,通过散列函数求出散列地址,然后按散列地址和探查路径把关键字和元素值同时写入到相应的存储单元中,若散列表插入一个新元素后,使得散列表中保存的元素个数增1,结束运算后返回true表示插入了新元素,否则为修改了原有元素的值,返回false表示修改了元素的值。
从散列表中删除一个元素时,是根据所给定的关键字求出散列地址,然后按照探查路径查找到对应的关键字和元素后删除,并且使得散列表中的元素个数减1,最后返回真表示删除成功;若散列表中不存在相应的元素,则返回假表示删除失败。
从散列表中查找一个元素时,首先根据所给定的关键字求出散列地址,然后按照探查路径对应的元素,如找到则返回它表示查找成功,否则若找到了一个空值单元表示查找失败,应返回空值。
进行散列表的运算,首先要定义散列表的抽象数据类型和在java语言中的接口类,然后再采用相应的处理冲突的方法定义存储类实现接口中给出的所有方法。散列表的存储类有两种:一种是采用开放定址法处理冲突的数组类;另一种是采用链接法处理冲突的链接类。
1、散列表的抽象数据类型和接口类
public interface HashTable {
//散列表的接口类
//向散列表中插入一个关键字为theKey的元素obj,若成功返回真否则返回假
boolean insert(Object thekey,Object obj);
//从散列表中查找并返回与给定关键字theKey对应的元素,若查找失败返回空
Object search(Object thekey);
//从散列表中删除关键字为thekey的元素,若删除成功返回真否则返回假
boolean delete(Object thekey);
//返回散列表中已存在的元素个数
int size();
//返回散列表的容量,即散列表的空间大小m的值
int capacity();
//判断散列表是否为空,若为空则返回真否则返回假
boolean isEmpty();
//清除散列表中的所有元素,使之变为一个空表
void clear();
//输出散列表中保存的所有关键字和对应元素
void output();
}
2、采用开放定址法处理冲突的数组存储结构
在开放定址法中,假定选用线性探查法处理冲突,并假定进行散列存储的元素的关键字为int类型的整数,若不是则应设法转换成整型数后再使用。在该存储类中,定义的数据成员对应包含表示散列表容量的整型对象m、表示散列表中当前元素个数的整型对象n、保存m个关键字的数组对象key、保存m个元素值的数组对象ht、表示元素被删除的特定关键字对应tag。因为一个关键字和对应的元素被删除后,若把被删除关键字的存储单元置为空,则割断了以后查找元素的路径,无法查找到在解决冲突时被存储到此位置后面的元素,显然是不行的,所以必须在被删除的关键字的存储单元存储删除标记,此删除标记由数据成员tag提供,当关键字为整型时,通常使用-1作为此删除标记。该数组存储类要实现散列表接口中定义的每一方法,另外,还要定义自己的构造方法,实现对数据成员的初始化。
假定采用开放地址法中的线性探查法处理冲突的数组存储类的类名用标识符SeqHashTable表示,则该类的具体定义如下。
3、采用链接法处理冲突的链接存储类
此存储类与上面介绍的数组存储类相似,其区别是:它不需要保存关键字删除标记的数据成员tag,因为同义词结点被链接到同一个散列地址上,删除元素后不需要保留结点;另一个区别是它只需要一个保存表头指针的引用数组,不需要分别定义的关键字数组和元素数组。引用数组中的元素类型应为链接表中的结点类型,假定结点类型用HashNode表,则定义为:
//定义采用链接法处理冲突的散列表中的结点类型
public class HashNode {
Object key; //保存元素的关键字
Object element; //保存元素的值
HashNode next; //链接下一个结点的链接域
//构造方法
public HashNode(Object thekey,Object obj)
{
key=thekey;
element=obj;
next=null;
}
}
假定采用链接法处理冲突的链接存储类名用LinkHashTable表示,则该类的具体定义如下。
4、对散列表的插入、删除和查找算法
(1)向散列表中插入元素的算法
向散列表中插入一个关键字为thekey的新元素obj,若当前散列表中不存在该元素,则插入后表示散列表元素个数的对象n增1,并返回真表示插入成功;若元素已存在,则修改原来的元素值,返回假表示元素被修改。在插入算法开始时,要按照查找路径查找元素是否存在,若存在则退出查找,否则继续查找,直到查找路径结束为止;然后再进行修改或插入元素的操作。在数组存储类中,元素的插入位置可以是空闲位置,也可以是带有删除标记的非空闲位置;在链接存储类中,插入位置是对应单链表的表头位置。另外,对于采用线性探查法的数组存储类,若从所求得的散列地址为起点开始,顺序探查一周(即所有m个地址位置)后仍没有遇到插入位置,则表明散列表已满,应调用一个专门的重建散列表的算法,扩大散列空间,并将原有元素重新散列到新散列表中。本算法为了简单期间,采用简单的终止程序运行和处理方法。
对于采用线性探查法处理冲突的数组存储类,对应的插入算法描述如下:
//向散列表中插入一个关键字为theKey的元素obj,若成功返回真否则返回假
public boolean insert(Object thekey, Object obj) {
int d=h(thekey); //求关键字为thekey的散列函数的值
int temp=d; //用temp暂存所求得的散列地址d
while(key[d]!=null&&key[d].equals(tag)!=true)
{
//用线性探查法处理冲突,寻找插入位置
if(key[d].equals(thekey)==true)
{
break; //元素已存在则退出循环
}
d=(d+1)%m; //探查下一个单元
if(d==temp) //查找一周后仍无位置,应重组散列表或退出运行
{
System.out.println("散列表无空间,退出运行!");
System.exit(1);
}
}
if(key[d]==null||key[d].equals(tag)==true)
{
key[d]=thekey;
ht[d]=obj;
n++;
return true;
}
else //用新元素obj修改已存在的元素值并返回假
{
ht[d]=obj;
return false;
}
}
对于采用链接法处理冲突的链接存储类,对应的插入算法描述如下:
//向散列表中插入一个关键字为theKey的元素obj,若成功返回真否则返回假
public boolean insert(Object thekey, Object obj) {
int d=h(thekey); //求关键字为thekey的散列函数的值
HashNode p=ht[d]; //将散列地址单元中保存的表头指针赋给p
while(p!=null) //从单链表中顺序查找关键字为thekey的结点
{
if(p.key.equals(thekey)==true)
{
break; //找到已有结点则退出循环
}
else
{
p=p.next;
}
}
if(p!=null) //用新元素obj修改已有结点的元素值并返回假
{
p.element=obj;
return false;
}
else
{
p=new HashNode(thekey,obj);
p.next=ht[d];
ht[d]=p;
n++;
return true;
}
}
(2)从散列表中查找元素的算法
从散列表中查找关键字为thekey的过程就是一个按照查找路径进行顺序查找的过程,若找到则返回对应的元素值,否则返回空值表示查找失败。
对于采用线性探查法处理冲突的数组存储类为thekey的元素
//从散列表中查找并返回与给定关键字theKey对应的元素,若查找失败返回空
public Object search(Object thekey) {
int d=h(thekey); //求关键字为thekey的散列地址
int temp=d; //用temp暂存所求得的散列地址d
while(key[d]!=null) //当散列地址中的关键字不为空时则循环
{
if(key[d].equals(thekey))
{
return ht[d]; //查找成功返回元素值
}
else
{
d=(d+1)%m; //继续向后探查
}
if(d==temp)
{
return null; //查找一周后失败返回空值
}
}
return null; //查找失败返回空值
}
对于采用链接法处理冲突的链接存储类,对应的查找算法描述如下:
//从散列表中查找并返回与给定关键字theKey对应的元素,若查找失败返回空
public Object search(Object thekey) {
int d=h(thekey); //求关键字为thekey的散列地址
HashNode p=ht[d]; //将地址单元中保存的表头指针赋给p
while(p!=null) //在单链表中顺序查找对应的结点
{
if(p.key.equals(thekey))
{
return p.element; //成功返回
}
}
return null; //查找失败返回空值
}
(3)从散列表中删除元素的算法
从散列表中删除 关键字为thekey的元素也是一个按照探查路径进行查找的过程,在数组存储类中,不能简单地把被删除的关键字的值置为null,若这样就切断了原来的探查路径,所以只能赋给一个删除标记,从而不影响原来的探查路径。
对于采用线性探查法处理冲突的数组存储类,对应的删除算法描述如下:
//从散列表中删除关键字为thekey的元素,若删除成功返回真否则返回假
public boolean delete(Object thekey) {
int d=h(thekey); //求关键字为thekey的散列地址
int temp=d; //用temp暂存所求得的散列地址d
while(key[d]!=null) //当散列地址中的关键字不为空时则循环
{
if(key[d].equals(thekey)) //找到被删除的元素
{
key[d]=tag; //设置删除标记
ht[d]=null; //元素值变为空
n--; //元素个数减1
return true; //返回真表示删除成功
}
else
{
d=(d+1)%m; //继续向后探查
}
if(d==temp)
{
return false; //扫描一周后删除失败返回假
}
}
return false; //删除失败返回假
}
对应从采用链接法处理冲突的链接存储类,对应的删除算法描述如下:
//从散列表中删除关键字为thekey的元素,若删除成功返回真否则返回假
public boolean delete(Object thekey) {
int d=h(thekey); //求关键字为thekey的散列地址
HashNode p=ht[d],q=null; //p指向表头结点,q指向前驱结点,初值为空
while(p!=null) //循环查找被删除的结点
{
if(p.key.equals(thekey))
{
break;
}
else
{
q=p;
p=p.next;
}
}
if(p==null)
{
return false; //没有被删除的元素返回假
}
else if(q==null)
{
ht[d]=p.next; //删除的是表头结点
}
else
{
q.next=p.next; //删除的是非表头结点
}
n--; //元素个数减1
return false; //返回真表示删除成功
}
发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/146497.html原文链接:https://javaforall.cn