文章目录
1. 概念
1) Hash表
Hash表也叫哈希表、散列表,(我的理解)它是一种特殊的数组。数组是支持随机访问的一种数据结构,我们可以根据下表直接访问内容,时间复杂度是O(1)。但是在进行查找操作的时候,我们不知道目标元素在什么位置,下标是多少,所以只能借助二分查找、顺序查找等方法,时间效率大打折扣。
2) Hash函数
通过Hash函数将元素的内容转化成数组的下标,我们就可以通过下标进行随机访问了。这里的部分内容可能是用户的id、学生的姓名等等,我们称作Key。通过Hash函数计算得到的值,我们称为Hash值。其实Hash值不一定就是数组的下标,因为其大小可能大于数组大小,我们通常再将Hash值取模,得到数组下标。
2. Hash冲突
1) 必然发生Hash冲突
明白了Hash表和Hash函数的基本概念,那么如何设计Hash函数,得到Hash值呢?
-
我们要保证Hash值是一个非负整数。很好理解,这是由于数组下标的特性决定的。
-
当两个key相同的时候,我们要保证他们的Hash值是相同的。否则两次相同的查找,查找的结果不同,就乱套了。
-
我们要尽量保证当两个key不同的时候,他们的Hash值也不同。
前两点很好理解,最后一点看起来很必要(否则很多元素都会对应数组中同一个下标,就会出现Hash冲突的现象),但是实现起来几乎不可能。
大学离散数学中有一个原理叫鸽巢原理。就是说,如果有 10 个鸽巢,有 11 只鸽子,那肯定有 1 个鸽巢中的鸽子数量多于 1 个,换句话说就是,肯定有 2 只鸽子在 1 个鸽巢内。
所以,我们一方面要尽可能的设计一个完美的Hash函数来尽量避免Hash冲突,另一方面,我们要在Hash冲突时选择合适的方法来解决。
2) Hash函数的要求
一个好的Hash函数首先不能太过于复杂,否则消耗太多的计算时间会间接影响Hash表的性能。其次Hash函数要尽可能不出现Hash冲突的现象,即使出现Hash冲突,也要尽量平均分配到数组每一个单元空间。
3) 解决Hash冲突
(1) 开放寻址法
开放寻址法核心思想是,出现冲突后,寻找数组中下一个空位置存放。寻找下一个空位置的方法有多种。
1. 线性探测
线性探测很直白,出现冲突就取 **(下标 + 1)% 数组长度 ** 作为下一个空位置,如果还冲突就继续+1。
在删除操作的时候有一点需要注意。在查找操作时如果发现当前下标对应元素不是目标元素,则会寻找 **(下标 + 1)% 数组长度 ** 下标,如果还不是目标元素,则再寻找+1个位置,直到+1位置为空,则判定没有目标元素。如果删除的时候我们真的将元素删除了,在插入时由于冲突保存在被删除元素后面的元素就找不到了。我们采取的措施就是使用逻辑删除,而不是物理删除,直白的说就是将元素标记为删除,保证线性探测的连续性。
2. 二次探测
二次探测在出现冲突时,取 **(下标 + 1^2)% 数组长度 ** 作为下一个空位置,如果还冲突就取 **(下标 + 2^2)% 数组长度 ** 作为下一个空位置,以此类推。
3. 双重散列(哈希)
双重散列则是准备一组散列函数 hash1(key)、hash2(key)、hash3(key)等等,先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。
不管使用哪种开放寻址法,散列表中存储的元素个数最多就是数组的大小,如果数组存储空间满了,就只能通过扩容来解决。换个思路,如果数组几近满了,这时操作Hash表的效率就会大大降低,为了保证Hash表的操作效率,我们要在合适的时候进行扩容。这里我们引入一个概念,装载因子,用来表示Hash表数组的使用情况。
// 散列表的装载因子 = 填入表中的元素个数 / 散列表的长度。
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能就会下降。
(2) 链表法
链表法的核心思想是,使用Hash表的数组保存指针,将每个数组单元看作一个桶或者槽,发生冲突的元素全部丢到桶中。对于桶中的元素,可以使用链表来组织,也可以使用树来组织。
3. Hash表扩容
随着Hash表中元素越来越多,也就是装载因子越来越大,哈希冲突发生的概率也越来越大。当装载因子超过某个阈值时,就需要进行扩容。装载因子阈值如果过大,会影响Hash表效率,如果过小,会浪费内存空间。
扩容的大小通常为原大小的二倍,这样可以使用位运算提升效率if ((e.hash & oldCap) == 0),当if成立说明当前节点应该放在原桶中,否则,说明当前节点应该放在 原桶下标+oldCap 对应的桶中。在Java的HashMap类的resize方法中有实现。传送门:[集合类] 源码解析10(HashMap类)
如果在某次添加数据触发了扩容,而数组扩容和数据搬移的时间都分配给这次添加操作,显然会影响效率。通过均摊的思想,redis就是将数据搬移工作均摊到每个添加、删除、查找、更新操作上,从而避免了集中数据搬移带来的庞大工作对某一次操作的影响。