一、哈希表概念
问题:如何在一个无序的线性表中查询一个指定的数据元素
常规处理方法:由于该数据元素会随机出现在线性表中的任意位置,所以只能通过循环遍历该线性表中的每个元素,然后与目标元素进行匹配。这时候查询的时间复杂度为O(n)。对于O(n)的时间复杂度来说,在查找海量数据的线性表时候,会是一个非常消耗性能的操作。那么有没有一种数据结构,这种数据结构中的元素与它所在的线性表中的位置存在一个对应关系,这样的话我们就可以通过这个元素直接找到它所在的位置,从而可以直接找到对应匹配元素。这时候时间的复杂度就变成了O(1),最大的节省了程序的查找效率。这种对应关系的数据结构是存在的,就是哈希表
先来看一下哈希表的定义:
哈希表又叫散列表,是一种根据设定的映射函数f(key)将一组关键字映射到一个有限且连续的地址区间上,并以关键字在地址区间中的“像”作为元素在表中的存储位置的一种数据结构。这个映射过程称为哈希造表或者散列,这个映射函数f(key)即为哈希函数也叫散列函数,通过哈希函数得到的存储位置称为哈希地址或散列地址
简单来说,哈希表就是通过一个映射函数f(key)将一组数据散列存储在数组中的一种数据结构。在这哈希表中,每一个元素的key和它的存储位置都存在一个f(key)的映射关系,我们可以通过f(key)快速的查找到这个元素在表中的位置。
举个例子: 有一组数据:[19,24,7,11,35,21],我们用散列存储的方式将其存储在一个长度为9的数组中。
采用除留取余法
:将这组数据元素分别模上数组的长度(即f(key)=key % 9),结果表示元素存储在线性表中的位置
数据源 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
[19,24,7,11,35,21] | / | 19 | 11 | 21 | / | / | 24 | 7 | 35 |
此时,如果我们想从这个表中找到值为35的元素,只需要将35模9即可得到35在数组中的存储位置。可见哈希表对于查找元素的效率是非常高的。
二、哈希冲突
对于上述的数据,假如我们再数据源中继续插入元素16,这时候16%9=7。由于7的位置已经存在元素7了。所以将元素16存入7的位置就会导致冲突。即当n个元素通过哈希函数映射到线性表的相同位置时,就会产生哈希冲突。
数据源 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
[19,24,7,11,35,21,16] | / | 19 | 11 | 21 | / | / | 24 | 7 or 16 | 35 |
对于上述情况我们将其称之为哈希冲突。哈希冲突官方的定义:
对于不同的关键字,可能得到同一个哈希地址,即key1≠key2,而 f(key1)=f(key2),对于这种现象我们称之为哈希冲突,也叫哈希碰撞
1.如何减少哈希冲突?
尽管哈希冲突不可避免,也要尽可能的减少哈希冲突的出现。一个好的哈希函数是可以有效的减少哈希冲突的出现。那什么样的哈希函数才是一个好的哈希函数呢?通常来说,一个好的哈希函数对于关键字集合中的任意一个关键字,经过这个函数映射到地址集合中,任何一个集合的概率是相等的。
常用的构造哈希函数的方法有以下几种:
(1)除留取余法
取关键字被某个不大于哈希表长len的数p除后所得余数为哈希地址。即:f(key)=key % p, p≤len;
(2)直接定址法 直接定址法是指取关键字或关键字的某个线性函数值为哈希地址。即:f(key)=key 或者f(key)=a*key+b
(3)数字分析法
假设关键字是以为基的数(如以10为基的十进制数),并且哈希表中可能出现的关键字都是事先知道的,则可以选取关键字的若干位数组成哈希表。
当然,除了上边列举的几种方法,还有很多种选取哈希函数的方法,只要选取合适的哈希函数可以有效减少哈希冲突即可。
2.如何处理哈希冲突?
虽然我们可以通过选取好的哈希函数来减少哈希冲突,但是哈希冲突终究是避免不了的。那么,碰到哈希冲突应该怎么处理呢?接下来介绍几种处理哈希冲突的方法。
(1)开放定址法
开放定址法是指当发生地址冲突时,按照某种方法继续探测哈希表中的其他存储单元,直到找到空位置为止,放入该位置。 如上面列子,假如位置7发生可冲突,可以依次+1寻找后续的空位。或者二次探测再散列,探测地址的方式为原哈希地址加上d(d= ±1^2、±2^2、±3^2......±m^2),经过二次探测再散列后会得到求得16的哈希地址为0,使用(16+2)^2%9 = 0方式
(2)再哈希法
再哈希法即选取若干个不同的哈希函数,在产生哈希冲突的时候计算另一个哈希函数,直到不再发生冲突为止。
(3)建立公共溢出区
专门维护一个溢出表,当发生哈希冲突时,将值填入溢出表。
(4)链地址法
链地址法是指在碰到哈希冲突的时候,将冲突的元素以链表的形式进行存储。也就是凡是哈希地址为i的元素都插入到同一个链表中,元素插入的位置可以是表头(头插法),也可以是表尾(尾插法)。我们以仍然以[19,24,7,11,35,21,16]
这一组数据为例,用链地址法来进行哈希冲突的处理,得到如下图所示的哈希表:
数据源 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
[19,24,7,11,35,21,16] | / | 19 | 11 | 21 | / | / | 24 | 7 | 35 |
↓ | |||||||||
16 |
三、关于链地址法的弊端与优化
在几种常用处理哈希冲突的方法。其中比较常用的是链地址法,HashMap就是基于链地址法的哈希表结构。虽然链地址法是一种很好的处理哈希冲突的方法,但是在一些极端情况下链地址法也会出现问题。
举个例子,我们现在有这样一组数据:[31,13,22,4,40,49]。我们将这组数据仍然散列存储到长度为9的数组中,此时则得到了如下的结果:
数据源 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
[31,13,22,4,40,49] | / | / | / | / | 31->13->22->4->40->49 | / | / | / | / |
发现这时候所有的元素全部在4的位置构成了一个链表。当我们在这样的数据结构中去查找某个元素的话,时间复杂度又变回了O(n)。这显然不符合我们的预期。因此,当哈希表中的链表过长时就需要我们对其进行优化。我们知道,二叉查找树的查询效率是远远高于链表的。因此,当哈希表中的链表过长时我们就可以把这个链表变成一棵红黑树。上面的数据查询就可以得到优化。
红黑树是一个可以自平衡的二叉查找树。它的查询的时间复杂度为O(lgn)。通过这样的优化可以提高哈希表的查询效率。
四、哈希表的扩容与Rehash
在哈希表长度不变的情况下,随着哈希表中插入的元素越来越多,发生哈希冲突的概率会越来越大,相应的查找的效率就会越来越低。这意味着影响哈希表性能的因素除了哈希函数与处理冲突的方法之外,还与哈希表的装填因子大小有关。
我们将哈希表中元素数与哈希表长度的比值称为装填因子。装填因子 α= 哈希表中元素数/哈希表长度
很显然,α的值越小哈希冲突的概率越小,查找时的效率也就越高。而减小α的值就意味着降低了哈希表的使用率。显然这是一个矛盾的关系,不可能有完美解。为了兼顾彼此,装填因子的最大值一般选在0.65~0.9之间。比如HashMap中就将装填因子定为0.75。一旦HashMap的装填因子大于0.75的时候,为了减少哈希冲突,就需要对哈希表进行扩容操作。比如我们可以将哈希表的长度扩大到原来的2倍。
这里我们应该知道,扩容并不是在原数组基础上扩大容量,而是需要申请一个长度为原来2倍的新数组。因此,扩容之后就需要将原来的数据从旧数组中重新散列存放到扩容后的新数组。这个过程我们称之为Rehash。
由于新数组长度发生了变化。使用除留取余法哈希函数时,Rehash的操作将会重新散列扩容前已经存储的数据,这一操作涉及大量的元素移动,是一个非常消耗性能的操作。因此,在开发中我们应该尽量避免Rehash的出现。比如,可以预估元素的个数,事先指定哈希表的长度,这样可以有效减少Rehash。