哈希表(hash table
,也叫散列表),是根据键(key
)直接访问访问在内存储存位置的数据结构。
哈希表本质是一个数组,数组中的每一个元素成为一个箱子,箱子中存放的是键值对。根据下标index
从数组中取value
。关键是如何获取index
,这就需要一个固定的函数(哈希函数)
,将key
转换成index
。不论哈希函数设计的如何完美,都可能出现不同的key
经过hash
处理后得到相同的hash
值,这时候就需要处理哈希冲突。
哈希表特点
- 优点 :哈希表可以提供快速的操作
- 缺点 :哈希表通常是基于数组的,数组创建后难于扩展。 也没有一种简便的方法可以以任何一种顺序〔例如从小到大)遍历表中的数据项。
综上,如果不需要有序遍历数据,井且可以提前预测数据量的大小。那么哈希表在速度和易用性方面是无与伦比的。
哈希查找步骤
- 1、使用哈希函数将被查找的键映射(转换)为数组的索引,理想情况下(hash函数设计合理)不同的键映射的数组下标也不同,所有的查找时间复杂度为O(1)。但是实际情况下不是这样的,所以哈希查找的第二步就是处理哈希冲突。
- 2、处理哈希碰撞冲突。处理方法有很多,比如拉链法、线性探测法。
哈希表存储过程
- 1、使用hash函数根据key得到哈希值h
- 2、如果箱子的个数为n,那么值应该存放在底(h%n)个箱子中。h%n的值范围为[0, n-1]。
- 3、如果该箱子非空(已经存放了一个值)即不同的key得到了相同的h产生了哈希冲突,此时需要使用拉链法或者开放定址线性探测法解决冲突。
哈希函数
哈希查找第一步就是使用哈希函数将键映射成索引。这种映射函数就是哈希函数。如果我们有一个保存0-M数组,那么我们就需要一个能够将任意键转换为该数组范围内的索引(0~M-1)的哈希函数。哈希函数需要易于计算并且能够均匀分布所有键。比如举个简单的例子,使用手机号码后三位就比前三位作为key更好,因为前三位手机号码的重复率很高。再比如使用身份证号码出生年月位数要比使用前几位数要更好。 在实际中,我们的键并不都是数字,有可能是字符串,还有可能是几个值的组合等,所以我们需要实现自己的哈希函数。
- 1、直接寻址法
- 2、数字分析法
- 3、平方取中法
- 4、折叠法
- 5、随机数法
- 6、除留余数法
优秀的哈希函数需要满足一下特点
- 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法)
- 对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同
- 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小
- 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值
哈希表大小
在设计用除法来散射的哈希表时,我们都会用数值模哈希表大小,得到的余数来作为ID存入哈希表对应格子中。所有文章都表明要用一个较大的素数来作为哈希表的大小,也就是要模一个较大的素数。但为什么就是要用素数呢?简单分析一下可以看出玄机
先看看如果用一个合数8作为哈希表大小,0-30在哈希表中的散射情况
再来看看用质数7作为哈希表大小,0-30在哈希表中的散射情况:
我们都知道,合数8除了1和自身以外,还有2跟4这两个因数。观察表1的单独一列可以发现,这些在同一列的数,他们实际上就是上一个数+8,而查看2、4、6这三行我们发现,因为2 4 6 能被2(或4)整除,而在同一列上的数在+8以后一样满足可以被2(或4)整除的这一特性。例如4这一列,4、12、20、28,这些哈希映射在同一个格子里的数都是前一个数+8,然后他们都能被2和4整除,这样就导致他们之间有很强烈的关系,很容易发生哈希冲突。
再来看看表2,同样情况,同一列中的数都是由上一个数+7得到的,但因为7是一个素数,它除了1跟本身之外没有其他因数,所以在同一列的数里就找不到我们刚刚所说的那种特性。
而我们都知道,哈希表设计目的就是希望尽量的随机散射,不希望这些在同一列上的元素(也就是会冲突的元素)之间具有关系,所以我们都采用素数作为哈希表的大小,从而避免模数相同的数之间具备公共因数
负载因子
负载因子是哈希表的一个重要属性,用来衡量哈希表的空/满程度,一定程度也可以提现查询的效率。负载因子越大,意味着哈希表越满,越容易导致冲突,性能也就越低。所以当负载因子大于某个常数(一般是0.75)时,哈希表将自动扩容。哈希表扩容时,一般会创建两倍于原来的数组长度。因此即使key的哈希值没有变化,对数组个数取余的结果会随着数组个数的扩容发生变化,因此键值对的位置都有可能发生变化,这个过程也成为重哈希(rehash
)。
哈希表扩容 在数组比较多的时候,需要重新哈希并移动数据,性能影响较大。
哈希表扩容 虽然能够使负载因子降低,但并不总是能有效提高哈希表的查询性能。比如哈希函数设计的不合理,导致所有的key计算出的哈希值都相同,那么即使扩容他们的位置还是在同一条链表上,变成了线性表,性能极低,查询的时候时间复杂度就变成了O(n)
简单来说就是 数组 + 链表 。将键通过hash函数映射为大小为M的数组的下标索引,数组的每一个元素指向一个链表,链表中的每一个结点存储着hash出来的索引值为结点下标的键值对。
Java 8
解决哈希冲突采用的就是拉链法。在处理哈希函数设计不合理导致链表很长时(链表长度超过8切换为红黑树,小于6重新退化为链表
)。将链表切换为红黑树能够保证插入和查找的效率,缺点是当哈希表比较大时,哈希表扩容会导致瞬时效率降低。
Redis
解决哈希冲突采用的也是拉链法。通过增量式扩容解决了Java 8中的瞬时扩容导致的瞬时效率降低的缺点,同时拉链法的实现方式(新插入的键值对放在链表头部)带来了两个好处:
- 一、头插法可以节省插入耗时。如果插到尾部,则需要时间复杂度为O(n)的操作找到链表尾部,或者需要额外的内存地址来保存尾部链表的位置
- 二、头插法可以节省查找耗时。对于一个数据系统来说,最新插入的数据往往可能频繁的被查询
使用两个大小为N的数组(一个存放keys,另一个存放values)。使用数组中的空位解决碰撞,当碰撞发生时(即一个键的hash值对应数组的下标被两外一个键占用)直接将下标索引加一(index += 1),这样会出现三种结果:
- 1、未命中(数组下标中的值为空,没有占用)。keys[index] = key,values[index] = value。
- 2、命中(数组下标中的值不为空,占用)。keys[index] == key,values[index] == value。
- 3、命中(数组下标中的值不为空,占用)。keys[index] != key,继续index += 1,直到遇到结果1或2停止。
缺点
- 1、容易产生堆积问题
- 2、不适于大规模的数据存储
- 3、散列函数的设计对冲突会有很大的影响
- 4、插入时可能会出现多次冲突的现象,删除的元素是多个冲突元素中的一个,需要对后面的元素作处理,实现较复杂
- 5、结点规模很大时会浪费很多空间
Hash表的平均查找长度包括查找成功时的平均查找长度和查找失败时的平均查找长度。
查找成功时的平均查找长度=表中每个元素查找成功时的比较次数之和/表中元素个数;
查找不成功时的平均查找长度相当于在表中查找元素不成功时的平均比较次数,可以理解为向表中插入某个元素,该元素在每个位置都有可能,然后计算出在每个位置能够插入时需要比较的次数,再除以表长即为查找不成功时的平均查找长度。
给定一组数据{32,14,23,01,42,20,45,27,55,24,10,53},假设散列表的长度为13(最接近n的质数),散列函数为H(k) = k%13。分别画出用 线性探测法 和 拉链法 解决冲突时构造的哈希表,并求出在等概率下情况,这两种方法查找成功和查找不成功的平均查找长度
拉链法
查找成功时的平均查找长度
ASL = (1*6+2*4+3*1+4*1)/12 = 7/4
查找不成功时的平均查找长度
ASL = (4+2+2+1+2+1)/13
线性探测法
查找成功时查找次数=插入元素时的比较次数,查找成功的平均查找长度:
ASL = (1+2+1+4+3+1+1+1+3+9+1+1+3)/12=2.5
查找不成功时的查找次数=第n个位置不成功时的比较次数为,第n个位置到第1个没有数据位置的距离:如第0个位置取值为1,第1个位置取值为2.查找不成功时的平均查找长度:
ASL = (1+2+3+4+5+6+7+8+9+10+11+12)/ 13 = 91/13