哈希表详解(附实现代码)
2017-08-16 22:18
183 查看
Hash表也称散列表,也有直接译作哈希表,Hash表是一种特殊的数据结构,它同数组、链表以及二叉排序树等相比较有很明显的区别,它能够快速定位到想要查找的记录,而不是与表中存在的记录的关键字进行比较来进行查找。这个源于Hash表设计的特殊性,它采用了函数映射的思想将记录的存储位置与记录的关键字关联起来,从而能够很快速地进行查找。
那么可能会设计一个结构体包含姓名,手机号码这些信息,然后把4个联系人的信息存到一张链表中。当要查找”李四 15828662334“这条记录是否在这张链表中或者想要得到李四的手机号码时,可能会从链表的头结点开始遍历,依次将每个结点中的姓名同”李四“进行比较,直到查找成功或者失败为止,这种做法的时间复杂度为O(n)。即使采用二叉排序树进行存储,也最多为O(logn)。假设能够通过”李四“这个信息直接获取到该记录在表中的存储位置,就能省掉中间关键字比较的这个环节,复杂度直接降到O(1)。Hash表就能够达到这样的效果。
Hash表采用一个映射函数 f : key —> address 将关键字映射到该记录在表中的存储位置,从而在想要查找该记录时,可以直接根据关键字和映射关系计算出该记录在表中的存储位置,通常情况下,这种映射关系称作为Hash函数,而通过Hash函数和关键字计算出来的存储位置(注意这里的存储位置只是表中的存储位置,并不是实际的物理地址)称作为Hash地址。比如上述例子中,假如联系人信息采用Hash表存储,则当想要找到“李四”的信息时,直接根据“李四”和Hash函数计算出Hash地址即可。下面讨论一下Hash表设计中的几个关键问题。
假如对上述的联系人信息进行存储时,采用的Hash函数为:姓名的每个字的拼音开头大写字母的ASCII码之和。
假如只有这4个联系人信息需要进行存储,这个Hash函数设计的很糟糕。
首先,它浪费了大量的存储空间。因为假如采用char型数组存储联系人信息的话,每个人的信息需要12个字节来存储(每个汉字占两个字节,两个汉字占四个字节,手机号为11位,数值上为100多亿,2^64 = 1.844674407371 * 10^19,2^32 = 4 294 967 296,所以需要64位也就是8个字节来存储手机号,2 * 2 + 8 = 12个字节)。这样的话,至少需要开辟174*12字节的空间。然而空间利用率只有4/174,不到3%。
另外,根据Hash函数计算结果之后,address(张三)和address(李四)具有相同的地址,这种现象称作冲突,对于174个存储空间中只需要存储4条记录就发生了冲突,这样的Hash函数设计是很不合理的。所以在构造Hash函数时应尽量考虑关键字的分布特点来设计函数使得Hash地址随机均匀地分布在整个地址空间当中。
通常有以下几种构造Hash函数的方法:
1 直接定址法
取关键字或者关键字的某个线性函数为Hash地址,即address(key)=a*key+b;如知道学生的学号从2000开始,最大为4000,则可以将address(key)=key-2000作为Hash地址。
2 平方取中法
对关键字进行平方运算,然后取结果的中间几位作为Hash地址。假如有以下关键字序列{421,423,436},平方之后的结果为{177241,178929,190096},那么可以取中间的两位数{72,89,00}作为Hash地址。
3 折叠法
将关键字拆分成几部分,然后将这几部分组合在一起,以特定的方式进行转化形成Hash地址。假如知道图书的ISBN号为8903-241-23,可以将address(key)=89+03+24+12+3作为Hash地址。
4 除留取余法
如果知道Hash表的最大长度为m,可以取不大于m的最大质数p,然后对关键字进行取余运算,address(key)=key%p。
在这里p的选取非常关键,p选择的好的话,能够最大程度地减少冲突,p一般取不大于m的最大质数。
1 开放定址法
即当一个关键字和另一个关键字发生冲突时,使用某种探测技术在Hash表中形成一个探测序列,然后沿着这个探测序列依次查找下去,当碰到一个空的单元时,则插入其中。比较常用的探测方法有线性探测法,比如有一组关键字{12,13,25,23,38,34,6,84,91},Hash表长为14,Hash函数为address(key)=key%11,当插入12,13,25时可以直接插入,而当插入23时,地址1被占用了,因此沿着地址1依次往下探测(探测步长可以根据情况而定),直到探测到地址4,发现为空,则将23插入其中。
2 链地址法
采用数组和链表相结合的办法,将Hash地址相同的记录存储在一张线性表中,而每张表的表头的序号即为计算得到的Hash地址。如上述例子中,采用链地址法形成的Hash表存储表示为:
虽然能够采用一些办法去减少冲突,但是冲突是无法完全避免的。因此需要根据实际情况选取解决冲突的办法。
不论哈希表中有多少数据,查找、插入、删除(有时包括删除)只需要接近常量的时间即0(1)的时间级。实际上,这只需要几条机器指令。
哈希表运算得非常快,在计算机程序中,如果需要在一秒种内查找上千条记录通常使用哈希表(例如拼写检查器)哈希表的速度明显比树快,树的操作通常需要O(N)的时间级。哈希表不仅速度快,编程实现也相对容易。
如果不需要有序遍历数据,并且可以提前预测数据量的大小。那么哈希表在速度和易用性方面是无与伦比的。
缺点:
它是基于数组的,数组创建后难于扩展,某些哈希表被基本填满时,性能下降得非常严重,所以程序员必须要清楚表中将要存储多少数据,或者准备好定期地把数据转移到更大的哈希表中,这是个费时的过程。
运行结果:
零、Hash表的设计思想
对于一般的线性表,比如链表,如果要存储联系人信息:张三 13980593357 李四 15828662334 王五 13409821234 张帅 13890583472
那么可能会设计一个结构体包含姓名,手机号码这些信息,然后把4个联系人的信息存到一张链表中。当要查找”李四 15828662334“这条记录是否在这张链表中或者想要得到李四的手机号码时,可能会从链表的头结点开始遍历,依次将每个结点中的姓名同”李四“进行比较,直到查找成功或者失败为止,这种做法的时间复杂度为O(n)。即使采用二叉排序树进行存储,也最多为O(logn)。假设能够通过”李四“这个信息直接获取到该记录在表中的存储位置,就能省掉中间关键字比较的这个环节,复杂度直接降到O(1)。Hash表就能够达到这样的效果。
Hash表采用一个映射函数 f : key —> address 将关键字映射到该记录在表中的存储位置,从而在想要查找该记录时,可以直接根据关键字和映射关系计算出该记录在表中的存储位置,通常情况下,这种映射关系称作为Hash函数,而通过Hash函数和关键字计算出来的存储位置(注意这里的存储位置只是表中的存储位置,并不是实际的物理地址)称作为Hash地址。比如上述例子中,假如联系人信息采用Hash表存储,则当想要找到“李四”的信息时,直接根据“李四”和Hash函数计算出Hash地址即可。下面讨论一下Hash表设计中的几个关键问题。
一、Hash函数的设计
Hash函数设计的好坏直接影响到对Hash表的操作效率。下面举例说明:假如对上述的联系人信息进行存储时,采用的Hash函数为:姓名的每个字的拼音开头大写字母的ASCII码之和。
address(张三)=ASCII(Z)+ASCII(S)=90+83=173; address(李四)=ASCII(L)+ASCII(S)=76+83=159; address(王五)=ASCII(W)+ASCII(W)=87+87=174; address(张帅)=ASCII(Z)+ASCII(S)=90+83=173;
假如只有这4个联系人信息需要进行存储,这个Hash函数设计的很糟糕。
首先,它浪费了大量的存储空间。因为假如采用char型数组存储联系人信息的话,每个人的信息需要12个字节来存储(每个汉字占两个字节,两个汉字占四个字节,手机号为11位,数值上为100多亿,2^64 = 1.844674407371 * 10^19,2^32 = 4 294 967 296,所以需要64位也就是8个字节来存储手机号,2 * 2 + 8 = 12个字节)。这样的话,至少需要开辟174*12字节的空间。然而空间利用率只有4/174,不到3%。
另外,根据Hash函数计算结果之后,address(张三)和address(李四)具有相同的地址,这种现象称作冲突,对于174个存储空间中只需要存储4条记录就发生了冲突,这样的Hash函数设计是很不合理的。所以在构造Hash函数时应尽量考虑关键字的分布特点来设计函数使得Hash地址随机均匀地分布在整个地址空间当中。
通常有以下几种构造Hash函数的方法:
1 直接定址法
取关键字或者关键字的某个线性函数为Hash地址,即address(key)=a*key+b;如知道学生的学号从2000开始,最大为4000,则可以将address(key)=key-2000作为Hash地址。
2 平方取中法
对关键字进行平方运算,然后取结果的中间几位作为Hash地址。假如有以下关键字序列{421,423,436},平方之后的结果为{177241,178929,190096},那么可以取中间的两位数{72,89,00}作为Hash地址。
3 折叠法
将关键字拆分成几部分,然后将这几部分组合在一起,以特定的方式进行转化形成Hash地址。假如知道图书的ISBN号为8903-241-23,可以将address(key)=89+03+24+12+3作为Hash地址。
4 除留取余法
如果知道Hash表的最大长度为m,可以取不大于m的最大质数p,然后对关键字进行取余运算,address(key)=key%p。
在这里p的选取非常关键,p选择的好的话,能够最大程度地减少冲突,p一般取不大于m的最大质数。
二、Hash表大小的确定
Hash表大小的确定也非常关键,如果Hash表的空间远远大于最后实际存储的记录个数,则造成了很大的空间浪费,如果选取小了的话,则容易造成冲突。在实际情况中,一般需要根据最终记录存储个数和关键字的分布特点来确定Hash表的大小。还有一种情况时可能事先不知道最终需要存储的记录个数,则需要动态维护Hash表的容量,此时可能需要重新计算Hash地址。三、冲突的解决
在上述例子中,发生了冲突现象,因此需要办法来解决,否则记录无法进行正确的存储。通常情况下有2种解决办法:1 开放定址法
即当一个关键字和另一个关键字发生冲突时,使用某种探测技术在Hash表中形成一个探测序列,然后沿着这个探测序列依次查找下去,当碰到一个空的单元时,则插入其中。比较常用的探测方法有线性探测法,比如有一组关键字{12,13,25,23,38,34,6,84,91},Hash表长为14,Hash函数为address(key)=key%11,当插入12,13,25时可以直接插入,而当插入23时,地址1被占用了,因此沿着地址1依次往下探测(探测步长可以根据情况而定),直到探测到地址4,发现为空,则将23插入其中。
2 链地址法
采用数组和链表相结合的办法,将Hash地址相同的记录存储在一张线性表中,而每张表的表头的序号即为计算得到的Hash地址。如上述例子中,采用链地址法形成的Hash表存储表示为:
虽然能够采用一些办法去减少冲突,但是冲突是无法完全避免的。因此需要根据实际情况选取解决冲突的办法。
四、Hash表的平均查找长度
请参考 Hash表查找成功和查找不成功的平均查找长度五、优缺点
优点:不论哈希表中有多少数据,查找、插入、删除(有时包括删除)只需要接近常量的时间即0(1)的时间级。实际上,这只需要几条机器指令。
哈希表运算得非常快,在计算机程序中,如果需要在一秒种内查找上千条记录通常使用哈希表(例如拼写检查器)哈希表的速度明显比树快,树的操作通常需要O(N)的时间级。哈希表不仅速度快,编程实现也相对容易。
如果不需要有序遍历数据,并且可以提前预测数据量的大小。那么哈希表在速度和易用性方面是无与伦比的。
缺点:
它是基于数组的,数组创建后难于扩展,某些哈希表被基本填满时,性能下降得非常严重,所以程序员必须要清楚表中将要存储多少数据,或者准备好定期地把数据转移到更大的哈希表中,这是个费时的过程。
六、C语言实现
/*采用数组实现哈希表*/ #include<stdio.h> #define DataType int #define Len 10 typedef struct HashNode { DataType data; //存储值 int isNull; //标志该位置是否已被填充 }HashTable; HashTable hashTable[Len]; void initHashTable() //对hash表进行初始化 { int i; for(i = 0; i<Len; i++) { hashTable[i].isNull = 1; //初始状态为空 } } int getHashAddress(DataType key) //Hash函数 { return key * 3 % 7; } int insert(DataType key) { int address = getHashAddress(key); if(hashTable[address].isNull == 1) //没有发生冲突 { hashTable[address].data = key; hashTable[address].isNull = 0; } else //当发生冲突的时候 { while(hashTable[address].isNull == 0 && address<Len) { address++; //采用线性探测法,步长为1 } if(address == Len) //Hash表发生溢出 return -1; hashTable[address].data = key; hashTable[address].isNull = 0; } return 0; } int find(DataType key) { int address = getHashAddress(key); while( !(hashTable[address].isNull == 0 && hashTable[address].data == key && address<Len)) { address++; } if( address == Len) { address = -1; } return address; } int main(int argc, char *argv[]) { int key[]={7,8,30,11,18,9,14}; int i; initHashTable(); for(i = 0; i<7; i++) { insert(key[i]); } for(i = 0; i<7; i++) { int address; address = find(key[i]); printf("key:%d\t address:%d\n", key[i],address); } return 0; }
运行结果:
key:7 address:0 key:8 address:3 key:30 address:6 key:11 address:5 key:18 address:7 key:9 address:8 key:14 address:1
相关文章推荐
- 哈希表(散列表)详解及代码实现
- Spring Boot使用过滤器和拦截器分别实现REST接口简易安全认证示例代码详解
- Java实现仿淘宝滑动验证码研究代码详解
- Fresceo使用详解--用Java代码实现xml配置的效果
- java 算法之归并排序详解及实现代码
- 编程实现Office 2003 Research 功能的代码详解C#篇(3)
- 深度学习FPGA实现基础知识7(深度学习Matlab工具箱代码详解)
- javascript判断回文数详解及实现代码
- 哈希表及其常用算法(代码实现)
- 斐波那契堆(Fibonacci heap)原理详解(附java代码实现)
- 【Android】Listview返回顶部,快速返回顶部的功能实现,详解代码。
- php实现留言板功能(代码详解)
- 图文详解Heap Sort堆排序算法及JavaScript的代码实现
- C#内容分页简单实现代码及详解
- 详解字典树Trie结构及其Python代码实现
- TCP三次握手报文 实例详解&&syn flood C/C++ 完整代码实现
- Windows Forms 实现安全的多线程详解(附带程序代码示例) (摘自网络)
- 基于JavaScript+HTML5 实现打地鼠小游戏逻辑流程图文详解(附完整代码)
- JS模拟实现哈希表及应用详解
- GlusterFS之内存池(mem-pool)实现原理及代码详解