为什么需要哈希表?
在其他的数据结构中进行查找数据时,元素 关键码 与其 存储位置 之间 没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中查找的时间复杂度为树的高度,即O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。查找效率不够快,理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。哈希表提供了在O(1)的时间复杂度内查找一个值。
什么是哈希表?
哈希表也叫散列表,是通过哈希思想构建出来的表;类比顺序表,顺序表的物理结构是数组,元素按顺序从左到右依次存储在数组中,就构成了顺序表;散列表的物理结构也是数组,元素 可以 不按顺序 分散地存储在数组中,就构成了散列表。
散列表中的数据不按顺序存储,并不代表随意乱存,而是需要通过哈希函数建立指定元素和该元素的存储位置之间的映射关系,一个存储元素对应一个存储位置,查找的时候可以通过哈希函数在O(1)的时间复杂度内查找指定的元素。(O(1)不是一次,而是常数次)
可见哈希函数对于哈希表的构成十分重要。
确定映射关系的哈希函数
那什么是哈希函数呢?哈希函数就是用来构建 指定元素和该元素的存储位置之间的映射关系 的一个表达式。常见的哈希函数有 直接定址法 和 除留余数法。
直接定址法
直接定址法:分为直接映射 和 间接映射(例如26个英文字母间接映射数组的0~25号位置);根据关键码的值,直接得出对应的存储位置 公式如下:
- Hash(Key)= A*Key + B
使用直接定址法确定元素的存储位置如下图所示:
可以看出,数据是根据哈希函数(直接定址法)计算出对应的存储位置的,元素散列的分布在连续的数组空间上;查找的时候可以根据元素的值,直接确定元素的存储位置,查找效率非常快,但是代价是 有一定空间的浪费,所以哈希表也算是 空间换时间 策略的典型应用。
如果插入的数据为:4、9、10、3、7、10001、2002;元素之间的值的跨度比较大,不像上图中的数据这般集中,阁下如何对付?
如果还是采用直接定址法,必然造成大量空间的浪费;有人就要说了,哈希表不是空间换时间吗?直接定址不就好了,反正都要有空间的浪费的;咳咳!你要这么换,CPU是仰天大笑了,内存可就要抱头痛哭了;请记住:空间换时间策略要合理!
所以我们需要改进这种映射关系的计算方法,也就是改进哈希函数。接下来,除留余数法就要闪亮登场了。
除留余数法
除留余数法:通常是将关键码的值对哈希表的长度取余,得出对应的存储位置 公式如下:
- Hash(key) = key% len
使用除留余数法确定元素的存储位置:
可以看出 除留余数法 很好地解决了 直接定址法 对于数据之间的跨度较大而导致的大量空间浪费 的缺陷。但是,如果插入的数据为4、9、10、3、7、10001、2002、14;当插入数据14的时候,计算出的存储位置为4,与数据4的存储位置冲突了,这个时候又该怎么办呢?ps:我们称这种冲突为哈希冲突 —— 多个不同的值映射到相同的存储位置上了。
既然发生了冲突,那我们就要解决冲突,那如何解决哈希冲突呢?解决哈希冲突有两种方式:闭散列和开散列,这也代表了实现哈希表的两种方式;
解决哈希冲突的两种方式
闭散列解决哈希冲突
闭散列:闭散列就是数据散列地分布在给定的一段封闭的空间中,满足一定条件再扩容。如下图所示:
- 闭散列中又有两种方法:线性探测法 和 二次探测法
- 线性探测法:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
- 二次探测法:H_i = (H_0 + i^2 ) % m, 或者:H_i = (H_0 - i^2) % m。其中:i = 1,2,3…, H_0 是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。(不常用,了解方法即可)
闭散列之线性探测解决哈希冲突的具体做法:
对于插入操作:通过哈希函数计算出需要插入的位置,如果需要插入的位置已经有值了,就往后走找个空位置,把值放进去。如果找到表的结尾,需要往回绕。
对于查找操作:通过哈希函数计算出需要查找的位置,如果该位置上的元素不是要查找的值,就依次往后走,直到找到或者遇到空位置。如果找到表的结尾,需要往回绕。
对于删除操作:删除这里会有一个坑,如下图所示,比如:删除14,查找15会发生什么?删除14之后,5号位置就空了,查找的停止条件是找到该值,或者找到空就停止;但是查找15的时候,直接就找到空了,就不会继续往后走了;因此,会找不到15。可以通过状态标记解决该问题(具体实现见代码)
闭散列之线性探测解决哈希冲突示意图:
- 仔细分析一下可知,线性探测解决哈希冲突,确实可以解决燃眉之急,但是这种做法会导致哈希冲突变多,从而导致搜索存储位置的次数变多。 如上图中,14 通过线性探测的方式,确定最终的归宿为5号位置,那5号位置的 “亲儿子”15 来了咋办,又只能向后线性探测,继续去抢别人的位置,冲突越堆越多,查找特定位置的次数变多,终将会造成哈希表的效率变低。要解决这个问题,就需要控制查找特定位置的次数,因此,引入 负载因子和载荷因子的比值,该比值一般在0.7左右;也就是说哈希表中的数据个数与哈希表的空间大小的比值控制在0.7左右,当比值超过这个数,哈希表就需要扩容了。
开散列解决哈希冲突
开散列:开散列法 又 叫 链地址法(开链法),首先对 关键码 用 散列函数计算 散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。如下图所示:
开散列之线性探测解决哈希冲突的具体做法:
对于插入操作:通过哈希函数计算出需要插入的桶的下标,将该数据节点头插进对应的位置即可。
对于查找操作:通过哈希函数计算出需要查找的桶的下标,遍历该哈希桶上的值。
对于删除操作:通过哈希函数计算出需要删除的桶的下标,找到该值,删除即可。
开散列解决哈希冲突示意图: