哈希表是什么?哈希冲突又是什么?又如何解决哈希冲突?

729 阅读7分钟

1. 哈希表

1.1 什么是哈希表?

  哈希表(Hash Table)是一种数据结构,也被称为散列表或哈希映射。哈希表使用一个哈希函数将键映射到一个确定的索引或桶(Bucket)中,从而可以在常量时间内(平均情况下)访问存储在哈希表中的数据。

  哈希表可以不经过任何比较,一次直接从表中得到要搜索的元素。

  比如数据{1,6,5,7,4,9, 13}

第一步:设置哈希函数,这里设置为:hash(key) = key % capacity (capacity为存储元素底层空间总的大小)

未命名绘图.drawio.png

第二步:映射。

  • 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

未命名绘图-第 2 页.drawio.png

  该方式即为哈希方法,哈希方法中使用的转换函数称为哈希函数,构造出来的结构称为哈希表。

1.2 哈希冲突

  我们已经有了下面的哈希表,现在我还想存一些数据{14,11,19}

未命名绘图-第 2 页.drawio.png

  根据哈希函数:

  • 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) 平方取中法

  平方取中法是一种常用的哈希函数设计方法,其基本思想是对于给定的键值,将其平方后取中间一段位作为哈希值。具体步骤如下:

  1. 将键值进行平方运算,得到一个较大的整数。
  2. 从平方后的结果中取出中间的若干位作为哈希值。取出的位数需要根据哈希表的大小进行确定,通常取中间的几位,以便均匀地分布在哈希表中。
  3. 将取出的位数作为哈希值返回。

平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。

(4) 折叠法

  折叠法是一种常用的哈希函数设计方法,其基本思想是将给定的键值按照固定的位数进行分割,然后将这些分割的部分相加,得到哈希值。具体步骤如下:

  1. 将键值按照固定的位数进行分割,通常每个部分的位数相等。
  2. 将分割的部分相加,得到哈希值。
  3. 将哈希值进行模运算,得到在哈希表中的位置。

例如,对于键值为123456的情况,如果要将其映射到一个大小为1000的哈希表中,可以按照如下步骤进行哈希函数计算:

  1. 将键值按照每两位进行分割,得到12、34和56三个部分。
  2. 将分割的部分相加,得到哈希值12+34+56=102。
  3. 将哈希值除以1000取余数,得到102 % 1000 = 102,将102作为键值123456对应的哈希值。

(5) 随机数法

  选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。

(6)数学分析法

  设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。

1.3.2 避免-负载因子调节(重要)

  负载因子是哈希表中一个很重要的概念,它表示哈希表中已存储的键值对数量与哈希表大小之间的比例关系。通常用公式 α = n / m 来表示,其中n表示哈希表中已存储的键值对数量,m表示哈希表的大小。

  负载因子可以用来衡量哈希表的空间利用率,它越大,表示哈希表中已存储的键值对数量越多,哈希表的空间利用率越高。但是,当负载因子过大时,哈希冲突的概率也会增加,可能导致哈希表的性能下降。

image.png

(ps: 网上用得比较多的图,不知道具体来源)

  这里的负载因子调节表示:当负载因子达到一定阈值时(一般为0.7~0.8),可以将哈希表大小扩大一倍,以减少哈希冲突的概率。 Java的系统库中的哈希表(HashMap、HashSet)就是 0.75 的负载因子。

image.png

1.4 哈希冲突的解决

  前面的方法都是尽量的避免哈希冲突,然而还没有真正地解决哈希冲突,解决哈希冲突有如下常用方法。

1.4.1 解决-闭散列

  闭散列(也称为开放寻址)是一种解决哈希碰撞的方法,它在哈希表中存储键值对时,当发生哈希碰撞时,会尝试寻找下一个可用的空闲位置,直到找到一个空闲位置为止。这个过程可以通过线性探测、二次探测等算法来实现。

(1)线性探测

  1. 哈希函数计算: 对于要插入或查找的键,首先需要通过哈希函数将其转换为一个哈希值,并将哈希值映射到哈希表的一个槽位中。

  2. 槽位被占用: 如果要插入的槽位已经被占用,则线性探测会检查下一个槽位,直到找到一个空槽位或扫描整个哈希表。

  3. 槽位为空: 如果找到了一个空槽位,则可以将键值对存储在该位置。

  4. 槽位存储的键与要插入的键相等: 如果在探测的过程中找到了一个与要插入的键相等的键,则可以更新该键的值。

  5. 查找键的过程: 对于查找操作,同样需要通过哈希函数计算出要查找的键的哈希值,并将其映射到哈希表的一个槽位中。如果该槽位存储的不是要查找的键,则线性探测会检查下一个槽位,直到找到一个存储了该键的槽位或扫描整个哈希表。

演示文稿1.gif

(2)二次探测

  二次探测与线性探测类似,它也是在哈希表中寻找下一个可用的槽位来存储键值对。不同之处在于,二次探测使用一个二次探测序列来计算下一个槽位的位置,而不是像线性探测一样使用固定的步长来计算下一个槽位的位置。

  线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:Hi=(H0+i2)H_i = (H_0 + i^2) % m 或者 Hi=(H0i2)H_i = (H_0 - i^2) % m。(其中i=1,2,3,4……表示冲突的次数,m为表的大小,H0H_0 表示是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置)

假如我有以下的哈希表:

未命名绘图-第 2.1 页.drawio.png

现在添加元素:44 --> hash(44) = 44 % 10 = 4;

发生哈希冲突:H1=(4+12) H_1 = (4 + 1^2) % 10 = 5

未命名绘图-第 2.3 页.drawio.png

如果槽位 HiH_i 已经被占用,则继续使用二次探测序列计算下一个槽位。

补充:删除键值对

  在闭散列哈希表中,一般采用标记删除的方式。标记删除的过程是将待删除元素所在的槽位标记为已删除状态,但并不从哈希表中删除该元素,这样可以避免因为删除元素而导致探测序列中断的情况。在后续的查找操作中,如果遇到已经标记为删除的槽位,则可以继续使用探测序列查找下一个元素。

1.4.2 解决-开散列

  在开散列中,每个槽位都是一个指向链表的头指针,该链表存储哈希值相同的键值对。 当哈希表需要插入一个新的键值对时,首先计算该键值对的哈希值,然后将其插入到对应槽位的链表中。

未命名绘图-第 3 页.drawio.png

  在开散列中查找一个键值对的过程与插入类似。首先计算该键值对的哈希值,然后访问对应槽位的链表,查找是否存在相同键值的键值对。如果存在,则返回该键值对的值;否则,返回查找失败。

  在某些哈希表的实现中,当某个桶中的链表长度超过一定阈值时,会将该链表转化为平衡二叉树,以提高哈希表的性能。

  这个阈值通常被称为树化阈值。例如,在Java中,HashMap类会根据树化阈值自动将链表转化为红黑树。(HashMap的阈值为8,下面是它的源码)

image.png


  在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是O(1)O(1)