面试复盘:哈希冲突的常见解决方案?

Posted javacn123

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试复盘:哈希冲突的常见解决方案?相关的知识,希望对你有一定的参考价值。

Java 面试中不可能不问 HashMap,问到 HashMap 就会问到哈希冲突的解决方案,相信很多人也遇到过了,所以这里就详细的总结复盘一下。

哈希冲突是指在哈希表中,两个或多个元素被映射到了同一个位置的情况。

String str1 = "3C";
String str2 = "2b";
int hashCode1 = str1.hashCode();
int hashCode2 = str2.hashCode();
System.out.println("字符串: " + str1 + ", hashCode: " + hashCode1);
System.out.println("字符串: " + str2 + ", hashCode: " + hashCode2);

程序的运行结果如下:

不同的字符串,却拥有了相同的 hashCode 这就是哈希冲突。因为元素的位置是根据 hashCode 的值进行定位的,此时它们的 hashCode 相同,但一个位置只能存储一个值,这就是哈希冲突。

解决哈希冲突

在 Java 中,解决哈希冲突的常用方法有以下三种:链地址法、开放地址法和再哈希法。

  1. 链地址法(Separate Chaining):将哈希表中的每个桶都设置为一个链表,当发生哈希冲突时,将新的元素插入到链表的末尾。这种方法的优点是简单易懂,适用于元素数量较少的情况。缺点是当链表过长时,查询效率会降低。

  2. 开放地址法(Open Addressing):当发生哈希冲突时,通过一定的探测方法(如线性探测、二次探测、双重哈希等)在哈希表中寻找下一个可用的位置。这种方法的优点是不需要额外的存储空间,适用于元素数量较多的情况。缺点是容易产生聚集现象,即某些桶中的元素过多,而其他桶中的元素很少。

  3. 再哈希法(Rehashing):当发生哈希冲突时,使用另一个哈希函数计算出一个新的哈希值,然后将元素插入到对应的桶中。这种方法的优点是简单易懂,适用于元素数量较少的情况。缺点是需要额外的哈希函数,且当哈希函数不够随机时,容易产生聚集现象。

链地址法 VS 开放地址法

链地址法和开放地址法个人觉得以下几点不同:

  1. 存储结构不同:链地址法规定了存储的结构为链表(每个桶为一个链表),每次将值存储到链表的末尾;而开放地址法未规定存储的结构,所以它可以是链表也可以是树结构等。

  2. 查找方式不同:链地址法查找时,先通过哈希函数计算出哈希值,然后在哈希表中查找对应的链表,再遍历链表查找对应的值。而开放地址法查找时,先通过哈希函数计算出哈希值,然后在哈希表中查找对应的值,如果查找到的值不是要查找的值,就继续查找下一个值,直到查找到为止。

  3. 插入方法不同:链地址法插入时,先通过哈希函数计算出哈希值,然后在哈希表中查找对应的链表,再将值插入到链表的末尾。而开放地址法插入时,是通过一定的探测方法,如线性探测、二次探测、双重哈希等,在哈希表中寻找下一个可用的位置。所以链地址法插入方法实现非常简单,而开放地址法插入方法实现相对复杂。

线性探测 VS 二次探测

线性探测是发生哈希冲突时,线性探测会在哈希表中寻找下一个可用的位置,具体来说,它会检查哈希表中下一个位置是否为空,如果为空,则将元素插入该位置;如果不为空,则继续检查下一个位置,直到找到一个空闲的位置为止。

二次探测是发生哈希冲突时,二次探测会使用一个二次探测序列来寻找下一个可用的位置,具体来说,它会计算出一个二次探测序列,然后依次检查哈希表中的每个位置,直到找到一个空闲的位置为止。二次探测的优点是相对于线性探测来说,它更加均匀地分布元素,缺点是当哈希表的大小改变时,需要重新计算二次探测序列。

具体来说,二次探测序列是一个二次函数,它的形式如下:

f(i) = i^2

其中,i 表示探测的步数,f(i) 表示探测的位置。

例如,当发生哈希冲突时,如果哈希表中的第 k 个位置已经被占用,那么二次探测会依次检查第 k+1^2、第 k-1^2、第 k+2^2、第 k-2^2、第 k+3^2、第 k-3^2……等位置,直到找到一个空闲的位置为止。

二次探测的优点是相对于线性探测来说,它更加均匀地分布元素,但缺点是容易产生二次探测聚集现象,即某些桶中的元素过多,而其他桶中的元素很少。

HashMap 如何解决哈希冲突?

在 Java 中,HashMap 使用的是开放地址法解决哈希冲突的,因为在 JDK 1.8 之后(包含 JDK 1.8),HashMap 使用的数组 + 链表或红黑树的结构来存储数据了,所以显然不能使用链地址法来解决哈希冲突。

本文已收录至《Java面试突击》,专注 Java 面试 100 年,查看更多:www.javacn.site

HashTable - 哈希表 - 细节狂魔

文章目录

哈希表 / 散列表 的概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度(log2 N),搜索的效率取决于搜索过程中元素比较的次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系【怎么放的,怎么取出来】,那么在查找时通过该函数可以很快找到该元素【O(1)】。

当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功.

该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称散列表)


实践中理解哈希表的运行原理


由上面的实践,我们得知了 哈希表 存在 哈希冲突的概念,下面我们讲讲 哈希冲突的概念 和 解决办法。

冲突 - 概念

对于 两个数据元素 的关键字 K1 和 K2(互不相等),但有:哈希函数:Hash(k1) == Hash(K2) ,即:不同的关键字通过相同的哈希函数计算出了相同的哈希地址,这种现象成为哈希冲突或 哈希碰撞。
把具有不同关键码,但却具有相同的哈希地址的数据元素称为 “同义词”。


冲突 - 避免

首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是 小于 实际要存储的关键字的数量。这就导致一个问题,冲突的发生是必然的,我们能做的就是尽量的降低哈希冲突发生的概率。


冲突 - 避免 - 哈希函数的设计

引起哈希冲突的一个原因可能是:哈希函数的设计不够合理。
哈希函数的设计原则:
1、哈希函数的定义域必须包括需要存储的全部关键码,而如果哈希表允许有m个地址时,其值必须在 0到 m -1之间。【比如:数组容量为10,下标为 0~9】

2、哈希函数计算出来的地址能均匀分布在整个空间。
简单来说:就是优化,或者重新设计一个更好的哈希函数。来降低哈希冲突发生率。
此时,认清楚一个事实:哈希冲突是无法避免的!我们只是说 让哈希冲突发生的概率,保持在一个“健康”的度【负载因子】。

3、 哈希函数的设计不能太复杂,尽量简单。


常见的哈希函数

1、直接定制法 - (常用)

取关键字的某个线性函数为哈希(散列)地址:类似 数学中直线方程:Hash(Key) = A * (Key) + B。
优点:简单、均匀
缺点:需要事先知道关键字的分布情况

使用场景:适合查找比较小且连续的情况


相关面试题 - LeetCode - 387. 字符串中的第一个唯一字符


题目分析 与 解题思路


代码如下
class Solution 
    public int firstUniqChar(String s) 
        int[] array = new int[26];
        for(int i = 0;i < s.length();i++)
            array[s.charAt(i) - 'a']++;
        
        for(int i = 0;i < s.length();i++)
            if(array[s.charAt(i) - 'a'] == 1)
                return i;
            
        
        return -1;
    


拓展: HashMap 思维

不了解的HashMap 或者 HashSet,可以参考这篇文章Map && Set,带你进入Java最常用到的两个接口
利用 HashMap【Key = 字符,Value - 出现次数】 去 统计字符串每个字符的出现次数,之后也是去遍历字符串,通过 Key 去获取 Value 值,如果等于1,返回遍历的字符串的变量/索引。


代码如下
class Solution 
    public int firstUniqChar(String s) 
        Map<Character,Integer> map =  new HashMap<>();
        for(int i = 0;i < s.length();i++)
            char ch = s.charAt(i);
            map.put(ch,map.getOrDefault(ch,0)+1);
            // if(map.containsKey(ch))
            //     int val = map.get(ch);
            //     map.put(ch,val+1);
            // else
            //     map.put(ch,1);
            // 
        
        for(int i = 0; i < s.length();i++)
            char ch = s.charAt(i);
            if(map.get(ch) == 1)
                return i;
            
        
        return -1;
    


2、除留余数法 - (常用)

设哈希(散列)表中允许的地址数为m, 取一个不大于m,但最接近或等于 m 的 质数****p作为除数。【质数又称素数,有无限个。一个大于1的自然数,除了1和它本身外,不能被其他自然数整除,换句话说就是该数除了1和它本身以外不再有其他的因数】
按照哈希函数 Hash(key) = key % p(p <= m),将关键码转换成哈希地址。
缺点:假设 m = 10,我们取一个7,这也就意味着 7后面的三个空间将会被浪费。
所以,还是要少用。


3、平方取中法 - (了解)

假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址,平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。


4、折叠法 - (了解)

折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按 哈希(散列)表的长度,取后几位作为哈希(散列)地址。
折叠法适合事先不需要知道关键字的分布,适合关键字比较多的情况。


5、 随机数法 - (了解)

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


数学分析法 - (了解)

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

假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。

数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况.
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。


冲突-避免-负载因子调节(重点掌握)

哈希(散列)表的 载荷/负载 因子定义为:α = 填入表中的元素个数 / 散列表的长度
α 是 哈希(散列)表装满程度的标志因子。由于表长是定值,α 与 “填入表中的元素个数” 成 正比,所以,α 越大,表明的填入表中的元素越多,产生冲突的可能性就越大;反之,α越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,哈希(散列)表的平均查找长度是载荷因子α的函数,只是处理冲突的方法是不同的【不同的函数】。

对于开放定址法,负载因子是特别重要因素,应严格限制在 0.7 ~ 0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing) 按照指数曲线上升。因此,一些采用开放定址法的hash库,如java的系统库限制了荷载因子为0.75,超过此值将重新 resize 哈希(散列)表。【resize - 扩容,重新哈希】


负载因子和冲突率的关系粗略演示

所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。
已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小


冲突 - 解决

解决哈希冲突两种常见的方法是:闭散列开散列


冲突 - 解决 - 闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个”空位置中去。
那么,问题来了:如何寻找下一个空位置呢?


1、 线性探测

比如下述场景中,需要插入元素44,先通过哈希函数计算哈希地址,下标为 4,因此 44 理论上应该插在4下标位置,但是该位置已经放了值为4的元素,即发生哈希冲突。


线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。


插入操作

通过哈希函数获取待插入元素在哈希表中的位置,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。

采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。


为了能够均匀的分布冲突元素,二次探测由此而生。

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

研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。


冲突-解决-开散列/哈希桶(重点掌握)

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

此时,哈希表底层数组的每个下标的空间就像一个个有着自己“独特编号”的的“桶”,去装 符合自己的“条件”的数据。
也就是说:此时的数组,是一个节点数组。
【提示:在jdk1.8版本,用的是尾插。这个知道就行】

有的朋友可能会有疑问: 如果输入的数据越多[全是冲突元素],链表的越长。在寻找元素的时候,时间复杂度不还是有可能达到O(N) 吗?
答案:不会达到O(N)的,因为有负载因子的存在(存储的元素达到一定的数目,就会进行扩容),链表的长度不会很长,控制在常数范围内。
另外,当链表越来越长,下面不会是一个链表了!
从jdk1.8开始:当链表的长度超过8,且数组长度大于等于64,这个链表才会变成红黑树。
而 红黑树又是一种对于查找来说:特别高效的一种树。再加上还有负载因子的介入[链表没机会达到O(n),就会去扩容了],所以请放心,它的时间复杂度是不会达到O(N)的。

开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。
将数据根据函数,分别存入不同的“桶”里,要什么数据,再去根据函数,找到对应的“桶”进行相应的操作。
也是我们 HashMap 底层的处理方式。


哈希表的性能分析

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


冲突严重时的解决办法

刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
1、 每个桶的背后是另一个哈希表
2、每个桶的背后是一棵搜索树【红黑树】


模拟实现简易的HashMap - 简单数据类型。

只实现重要的功能:put 和 get

准备工作 - 程序框架



put 功能

第一步:找 key 的 存储位置


第二步 :插入节点。

在 jdk1.8版本之后,采用的是尾插法,但是具体是尾插还是头插不重要,不影响操作。
这里我采用的是头插法。
但是在插入节点之前,我们需要判断一下。
1、插入节点的位置,可能已经有其他元素了。

2、如果我们插入的数据,在它的存储位置中已经存在,根据HashMap的特性:将我们插入的数据中 value值 赋予 已经存在链表中节点的value(更新)。


代码如下


resize函数 - 扩容函数

注意!这并不是普通的扩容!
如果你想这将数组容量由10变成20,那你就想的太简单了!
来看下面的图

所以在扩容之后,需要重新哈希每个下标的链表节点。确保扩容之后,能通过哈希函数获得下标中的链表找到我们想要的值。
另外重新哈希的时候,还需要注意一点,来看下面


代码如下
  private void resize()
      Node[] newArray = new Node[this.array.length*2];
      for (int i = 0; i < this.array.length; i++) 
          Node cur = this.array[i];
          while(cur!=null)
              // 获取 在 新数组中的 存储位置。
              int index = cur.key % newArray.length;
              // 头插到新数组中
              Node curNext = cur.next;// 记录下一个节点的位置
              cur.next = newArray[index];
              newArray[index] =  cur;
              cur = curNext;// 接着下一个节点。
          
          // while循环执行完之后,for循环i++,继续下一个下标的哈希
      
      // 最后更新引用 array 的指向
      this.array = newArray;
  

get功能

怎么放的,怎么取。通过自定义的哈希函数: int index = key % 数组长度


总程序

/*
* 实现一个 统计 整数数据的出现次数
* key - 整形数据
* value - key 元素 的 出现次数
* */

public class HashTable 
    // 节点类 - 静态内部类
  static class Node
      public int key;
      public int val;
      public Node next;
      public Node(int key,int val)
          this.val = val;
          this.key = key;
      
  

  public Node[] array;// 底层数组
  public int usedSize;// 元素个数
  public  static final double DEFAULT_LOAD_FACTOR = 0.75;// 负载因子
  public HashTable()
      // 数组初始容量为 10
      this.array = new Node[10];
  

/*
* 根据 key 获取 val 值
* */
  public int get(int key)
      // 1、 找到 key 所在的 位置。
      int index = key % this.array.length;
      // 2、遍历数组,寻找 key
      Node cur = this.array[index];
      while(cur!=null)
          if(cur.key == key)
              return cur.val;
          
          cur = cur.next;
      
      // 返回-1,表示没有找到符号条件的节点
      return -1;
  

  /*
  * 将 key ,value 值 存入
  * 此时的代码是有缺陷的,key 不能为负数
  * */
  public void put(int key,int val)
      // 1、 找到 key 所在的 位置。
      int index = key % this.array.length;
      // 2、遍历这个下标的链表
      Node cur = array[index];// 获取头节点地址
      while(cur!=null)
//  判断是否有相同的key,有,则更新value值
          if(cur.key == key)
              cur.val = val;
              return;
          
          cur = cur.next;
      
      // 没有相同的key,就直接头插节点
      Node node = new Node(key,val);
      node.next = array[index];
      array[index] = node;
      this.usedSize++;
      // 检查当前的负载因子
      if(loadFactor() > DEFAULT_LOAD_FACTOR)
          resize();// 扩容
      

  
  private double loadFactor()
      return 1.0 * this.usedSize / this.array.length ;//乘以1.0为了结果是小数。
  
  private void resize()
      Node[] newArray = new Node[this.array.length*2];
      for (int i = 0; i < this.array.length; i++) 
          Node cur = this.array[i];
          while(cur!=null)
              // 获取 在 新数组中的 存储位置。
              int index = cur.key % newArray.length;
              // 头插到新数组中
              Node curNext = cur.next;// 记录下一个节点的位置
              cur.next = newArray[index];
              newArray[index] =  cur;
              cur = curNext;// 接着下一个节点。
          
          // while循环执行完之后,for循环i++,继续下一个下标的哈希
      
      // 最后更新 array 的 引用大的指向
      this.array = newArray;
  

    public static void main(String[] args) 
        HashTable hashTable = new HashTable();
        hashTable.put(1,1);
        hashTable.put(3,3);
        hashTable.put(12,12);
        hashTable.put(2,2);// 冲突元素
        hashTable.put(11,11);// 冲突元素
        // 5个元素,此时的负载因子为0.5
        System.out.println(hashTable.get(11));

    

效果图


模拟实现简易的HashMap - 引用数据类型。

在实现这个代码之前,我需要先给你们介绍一个函数:hashCode
hashCode:将一个引用类型的数据 转换一个整数、


有了这个,我们再去写 》》 程序框架

这我们用的泛型来写。为了突出引用类型的数据


put 方法

和 上面的 put方法一模一样,但是需要注意的是 哈希函数 int index = key % 数组长度。这个 key 不能直接与 整数数据运算。
需要通过hashCode方法先转换一下,再去进行运算获取存储位置。

    public void put(K key,V val)
//        int index = key % array.length;// 错误写法,引用类型无法与整形数据进行运算
        // 需要借助hashCode方法,将引用类型数据转换成整形数据
        int hash = key.hashCode();
        int index = hash % array.length;
        // 2、遍历这个下标的链表
        Node<K,V> cur = array[index];// 获取头节点地址
        while(cur!=null)
//      注意的 key 是引用类型的数据,所以需要用 equals 比较
    // 而这个在我们重写 hashCode方法的时候,就已经捆绑重写了
            if(cur.key.equals(key))
                cur.val = val;
                return;
            
  //简单来说:hashCode 用来确定 key 存储位置, equals 用来比较 key 是否相等。
            cur = cur.next;
        
        // 没有相同的key,就直接头插节点
      Node<K,V> node = new Node<>(key,val);
        node.next = array[index];
        array[index] = node;
        this.usedSize++;
        //  扩容就不写,跟前面的代码一模一样,稍微改一下就行了。
    

代码注释说到:hashCode 用来确定 key 存储位置

以上是关于面试复盘:哈希冲突的常见解决方案?的主要内容,如果未能解决你的问题,请参考以下文章

HashTable - 哈希表 - 细节狂魔

京东tp-link软件工程师面试复盘

真的太重要了,面试出现的概率达到了 99%!!!对于哈希表的知识(建议收藏)

2.24专项测试复盘

Android面试之事件分发(机制冲突)

数据结构:哈希表原理以及面试中的常见考点