1. 哈希表
1.1 什么是哈希表?
哈希表(Hash Table)是一种数据结构,也被称为散列表或哈希映射。哈希表使用一个哈希函数将键映射到一个确定的索引或桶(Bucket)中,从而可以在常量时间内(平均情况下)访问存储在哈希表中的数据。
哈希表可以不经过任何比较,一次直接从表中得到要搜索的元素。
比如数据{1,6,5,7,4,9, 13}
第一步:设置哈希函数,这里设置为:hash(key) = key % capacity (capacity为存储元素底层空间总的大小)
第二步:映射。
- hash(1) = 1 % 10 = 1
- hash(6) = 6 % 10 = 6
- hash(5) = 5 % 10 = 5
- hash(7) = 7 % 10 = 7
- hash(4) = 4 % 10 = 4
- hash(9) = 9 % 10 = 9
- hash(13) = 13 % 10 = 3
该方式即为哈希方法,哈希方法中使用的转换函数称为哈希函数,构造出来的结构称为哈希表。
1.2 哈希冲突
我们已经有了下面的哈希表,现在我还想存一些数据{14,11,19}
根据哈希函数:
- hash(14) = 14 % 10 = 4
- hash(11) = 11 % 10 = 1
- hash(19) = 19 % 10 = 9
但是 4、1、9下标的位置都已经被占用了,无法存放,这就是哈希冲突。
哈希冲突:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
1.3 哈希冲突的避免
1.3.1 避免-哈希函数设计
如果哈希冲突率比较高,有可能是哈希函数设计不够合理,应设计更好的函数以减少哈希冲突,下面是哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间。
- 哈希函数计算出来的地址能均匀分布在整个空间中。
- 哈希函数应该比较简单。
常见的设计哈希函数的方法:
(1) 直接定制法(常见)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀。
缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况。
(2) 除留余数法(常见)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key % p(p<=m),将关键码转换成哈希地址。
(3) 平方取中法
平方取中法是一种常用的哈希函数设计方法,其基本思想是对于给定的键值,将其平方后取中间一段位作为哈希值。具体步骤如下:
- 将键值进行平方运算,得到一个较大的整数。
- 从平方后的结果中取出中间的若干位作为哈希值。取出的位数需要根据哈希表的大小进行确定,通常取中间的几位,以便均匀地分布在哈希表中。
- 将取出的位数作为哈希值返回。
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。
(4) 折叠法
折叠法是一种常用的哈希函数设计方法,其基本思想是将给定的键值按照固定的位数进行分割,然后将这些分割的部分相加,得到哈希值。具体步骤如下:
- 将键值按照固定的位数进行分割,通常每个部分的位数相等。
- 将分割的部分相加,得到哈希值。
- 将哈希值进行模运算,得到在哈希表中的位置。
例如,对于键值为123456的情况,如果要将其映射到一个大小为1000的哈希表中,可以按照如下步骤进行哈希函数计算:
- 将键值按照每两位进行分割,得到12、34和56三个部分。
- 将分割的部分相加,得到哈希值12+34+56=102。
- 将哈希值除以1000取余数,得到102 % 1000 = 102,将102作为键值123456对应的哈希值。
(5) 随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。
(6)数学分析法
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
1.3.2 避免-负载因子调节(重要)
负载因子是哈希表中一个很重要的概念,它表示哈希表中已存储的键值对数量与哈希表大小之间的比例关系。通常用公式 α = n / m 来表示,其中n表示哈希表中已存储的键值对数量,m表示哈希表的大小。
负载因子可以用来衡量哈希表的空间利用率,它越大,表示哈希表中已存储的键值对数量越多,哈希表的空间利用率越高。但是,当负载因子过大时,哈希冲突的概率也会增加,可能导致哈希表的性能下降。
(ps: 网上用得比较多的图,不知道具体来源)
这里的负载因子调节表示:当负载因子达到一定阈值时(一般为0.7~0.8),可以将哈希表大小扩大一倍,以减少哈希冲突的概率。 Java的系统库中的哈希表(HashMap、HashSet)就是 0.75 的负载因子。
1.4 哈希冲突的解决
前面的方法都是尽量的避免哈希冲突,然而还没有真正地解决哈希冲突,解决哈希冲突有如下常用方法。
1.4.1 解决-闭散列
闭散列(也称为开放寻址)是一种解决哈希碰撞的方法,它在哈希表中存储键值对时,当发生哈希碰撞时,会尝试寻找下一个可用的空闲位置,直到找到一个空闲位置为止。这个过程可以通过线性探测、二次探测等算法来实现。
(1)线性探测
-
哈希函数计算: 对于要插入或查找的键,首先需要通过哈希函数将其转换为一个哈希值,并将哈希值映射到哈希表的一个槽位中。
-
槽位被占用: 如果要插入的槽位已经被占用,则线性探测会检查下一个槽位,直到找到一个空槽位或扫描整个哈希表。
-
槽位为空: 如果找到了一个空槽位,则可以将键值对存储在该位置。
-
槽位存储的键与要插入的键相等: 如果在探测的过程中找到了一个与要插入的键相等的键,则可以更新该键的值。
-
查找键的过程: 对于查找操作,同样需要通过哈希函数计算出要查找的键的哈希值,并将其映射到哈希表的一个槽位中。如果该槽位存储的不是要查找的键,则线性探测会检查下一个槽位,直到找到一个存储了该键的槽位或扫描整个哈希表。
(2)二次探测
二次探测与线性探测类似,它也是在哈希表中寻找下一个可用的槽位来存储键值对。不同之处在于,二次探测使用一个二次探测序列来计算下一个槽位的位置,而不是像线性探测一样使用固定的步长来计算下一个槽位的位置。
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: % m 或者 % m。(其中i=1,2,3,4……表示冲突的次数,m为表的大小, 表示是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置)
假如我有以下的哈希表:
现在添加元素:44 --> hash(44) = 44 % 10 = 4;
发生哈希冲突: % 10 = 5
如果槽位 已经被占用,则继续使用二次探测序列计算下一个槽位。
补充:删除键值对
在闭散列哈希表中,一般采用标记删除的方式。标记删除的过程是将待删除元素所在的槽位标记为已删除状态,但并不从哈希表中删除该元素,这样可以避免因为删除元素而导致探测序列中断的情况。在后续的查找操作中,如果遇到已经标记为删除的槽位,则可以继续使用探测序列查找下一个元素。
1.4.2 解决-开散列
在开散列中,每个槽位都是一个指向链表的头指针,该链表存储哈希值相同的键值对。 当哈希表需要插入一个新的键值对时,首先计算该键值对的哈希值,然后将其插入到对应槽位的链表中。
在开散列中查找一个键值对的过程与插入类似。首先计算该键值对的哈希值,然后访问对应槽位的链表,查找是否存在相同键值的键值对。如果存在,则返回该键值对的值;否则,返回查找失败。
在某些哈希表的实现中,当某个桶中的链表长度超过一定阈值时,会将该链表转化为平衡二叉树,以提高哈希表的性能。
这个阈值通常被称为树化阈值。例如,在Java中,HashMap类会根据树化阈值自动将链表转化为红黑树。(HashMap的阈值为8,下面是它的源码)
在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是。