数据结构 | java中 哈希表及其冲突解决

Posted Jin - Wang

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构 | java中 哈希表及其冲突解决相关的知识,希望对你有一定的参考价值。

🎗️ 博客新人,希望大家一起加油进步
🎗️ 乾坤未定,你我皆黑马

目录

1、哈希表概念

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

当向该结构中:

  • 插入元素

根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放

  • 搜索元素

对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法, 哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称散列表)
例如:数据集合1,7,6,4,5,9;
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
比如:

引出冲突: 用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快, 问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?

2、冲突 - 概念

对于两个数据元素的关键字不同,经过一个哈希函数之后,两者的存储位置下标可能是相同的,即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。 把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”

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

3、冲突 - 避免 -哈希函数设计

引起哈希冲突的一个原因可能是:哈希函数设计不够合理。

哈希函数设计原则:

  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间

  • 哈希函数计算出来的地址能均匀分布在整个空间中

  • 哈希函数应该比较简单

常见哈希函数

1. 直接定制法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B ,优点:简单、均匀 缺点:需要事先知道关键字的分布情况,使用场景:适合查找比较小且连续的情况。
2. 除留余数法–(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。

还有其他不太常用的方法如:平方取中法,折叠法,随机数法,数学分析法

  • 注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

4、冲突 - 避免 -负载因子调节

散列表的载荷因子定义为: α =填入表中的元素个数 / 散列表的长度

α是散列表装满程度的标志因子。由于表长是定值,α与“填入表中的元素个数”成正比,所以,α越大,表明填入表中的元素越多,产生冲突的可能性就越大 ; 反之,α越小,标明填入表中的元素越少,产生冲突的可能性就越小。

实际上,散列表的平均查找长度是载荷因子α的函数,只是不同处理冲突的方法有不同的函数。
负载因子和冲突率的关系粗略演示:

在java的HashMap类中,HashMap的底层就是哈希表,在jdk8中,默认的负载因子为0.75。

已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小。

5、冲突 - 解决

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

5.1 闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。 那如何寻找下一个空位置呢?
1. 线性探测
比如上面的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,下标为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。

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

  • 插入
    通过哈希函数获取待插入元素在哈希表中的位置如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
  • 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素, 若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标 记的伪删除法来删除一个元素。
    另外还有二次探测的方法用于查找下次的空位置,在此我们不做过多的介绍。

5.2 开散列

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

从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。

开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。

  • 冲突严重时的解决办法

刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:

  1. 每个桶的背后是另一个哈希表
  2. 每个桶的背后是一棵搜索树

6、哈希表的模拟实现

哈希表的底层其实是一个数组,为了有效解决哈希冲突的问题,使用开散列的方式进行冲突的解决,也就是使用链表数组来进行元素的储存。

然后就是主要实现哈希表的插入和查找操作。

哈希函数,由于我们模拟的是整数类型数据的哈希表,所以直接对key取数组长度的模即可。即:hash(key)=key%len。

设置的负载因子为0.75,

  • 插入操作

思路:

1. 通过哈希函数计算出所插入到数组的下标。
2. 判断下标所对应的链表中是否包含该数据,如果有,则进行更新value值(因为哈希表中不会放重复的元素),若没有,则采用尾插或者头插的方式进行插入数据,本文以头插的方式进行。
3. 期间还有进行判断是否扩容。

注意事项: 扩容之后是一个新的数组,把原来数组数据转移到新数组中去,此时由于数组长度的改变,由哈希函数新计算出的下标也会发生改变。

  • 代码实现:
//模拟实现一个哈希  : 数组加链表
public class HashBuck 
    static class Node 
        public int val;
        public int key;
        public Node next;

        public Node(int key, int val) 
            this.val = val;
            this.key = key;
        
    
    public Node[] array;  //数组中存放的都是Node类型的节点
    public int usedSize;  //记录存放数据的多少

    public static final double LOAD_FACTOR = 0.75;  //负载因子

    public HashBuck() 
        array = new Node[10];  //仅用于自己实现,源码不是如此
    

    //模拟实现哈希表的放入数据 , 设置key对应的val
    public void put(int key,int val) 
        int index = key % array.length;  //相当于一个哈希函数
        Node node = new Node(key,val);
        //array[index] = node;
        Node cur = array[index];
        //判断放的位置是不是空的,若是空的,直接放,若不空,这个空格内加入链表结构
        if(array[index] == null) 
            array[index] = node;
            usedSize++;
         else 
            while(cur != null) 
                if(cur.key == key)    //这里说明原先保存的有这个key,更新它的次数即可
                                       //因为哈希表中不添加重复的值,只会更新它的val值
                    cur.val = val;
                    return;
                
                cur = cur.next;
            
            //走到这说明保存的没有这个key,需要进行插入,在此采用头插的方式
            cur = array[index];
            node.next = cur;
            cur = node;
            usedSize++;

            /**
             * 这里注意cur要再指回来给array[index],不然不会发生变化的,和下面 ============= 注释的原因是一个
             */
            array[index] = cur;
            /**
             *          注意扩容函数的位置放错了,应该放在 if的外面,每次添加完新的都要检查一下容量
             *   //判断是否需要扩容
             *             if(calculateLoadFactor() >= LOAD_FACTOR) 
             *                 //扩容
             *                 resize();
             *             
             */

        
        //判断是否需要扩容
        if(calculateLoadFactor() >= LOAD_FACTOR) 
            //扩容
            resize();
        
    

    private void resize() 
        Node[] arrayNew = new Node[2 * array.length];
        //再把原先的值放到新的数组里面
        for (int i = 0; i < array.length; i++) 
            //注意此时放的下标由于数组的扩容而会发生变化
            int index = i % arrayNew.length;
            /**
             *     ============================
             * 注意这种写法不对,
             *   for (int i = 0; i < array.length; i++) 
             *             //注意此时放的下标由于数组的扩容而会发生变化
             *             int index = i % arrayNew.length;
             *              arrayNew[index] = array[i];
             *          
             * 因为下标由于数组的扩容而会发生变化,所以会有可能由后面的i计算出的新下标跟前面的重复,而导致覆盖问题发生错误
             * 正确的应该是找到节点继续一个一个地头插
             *arrayNew[index] = array[i];
              **/
            Node cur = array[i];
            //Node curNew = arrayNew[index];
            while(cur != null) 
                Node curNext = cur.next;
                //把cur的节点一个一个地以头插的方式插入到新节点中去
                cur.next = arrayNew[index];
                arrayNew[index] = cur;
                cur = curNext;
                //arrayNew[index] = curNew;
            
            //arrayNew[index] = curNew;

            /**
             *    上面的代码也可以写成这种,这种格式更加直接,
             *    上面那种是赋值的,需要再给到原来的arrayNew[index] 节点处, 不然不会发生改变
             *          while(cur != null) 
             *                 Node curNext = cur.next;
             *                 //把cur的节点一个一个地以头插的方式插入到新节点中去
             *                 cur.next = arrayNew[index];
             *                 arrayNew[index] = cur;
             *                 cur = curNext;
             *          
             */
        
        array = arrayNew;
    

    //计算负载因子
    private double calculateLoadFactor() 
        return usedSize*1.0 / array.length;
    

  • 查找操作

思路:

1. 通过哈希函数计算出数组的下标,遍历数组找到该下标的链表
2. 遍历链表,判断链表中是否包含该元素,若包含返回其对应的value值

  • 代码实现
 public int get(int key) 
	//  for (int i = 0; i < array.length; i++) 
	//   
        int index = key % array.length;
        Node cur = array[index];
        while(cur != null) 
            if(cur.key == key) 
                return cur.val;
             else    //这里其实用不到else,因为这两个不可能同时出现,因为上面有return,走上一步就不可能走下面了,直接返回了
                cur = cur.next;
            
        
        return -1;
    

7、哈希表和 java 类集的关系

1. HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set
2. java 中使用的是哈希桶方式解决冲突的
3. java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
4. java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode 和 equals
方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。

🎗️🎗️🎗️ 好啦,到这里我们的 哈希表及其冲突解决的分享就结束了,如果感觉做的还不错的可以点个赞,关注一下,你的支持就是我继续下去的动力,蟹蟹大家了,我们下期再见,拜拜~ ☆*: .。. o(≧▽≦)o .。.:*☆

Java集合哈希冲突及解决哈希冲突的4种方式

Java集合(九)哈希冲突及解决哈希冲突的4种方式

一、哈希冲突

(一)、产生的原因

哈希是通过对数据进行再压缩,提高效率的一种解决方法。但由于通过哈希函数产生的哈希值是有限的,而数据可能比较多,导致经过哈希函数处理后仍然有不同的数据对应相同的哈希值。这时候就产生了哈希冲突。

(二)、因素

  • 装填因子(装填因子=数据总数 / 哈希表长);
  • 哈希函数;
  • 处理冲突的方法。

(三)、解决哈希冲突的4中方式

开放地址法;再哈希法;链地址法(拉链法);公共溢出区法。

二、开放地址法

开放地址法处理冲突的基本原则就是出现冲突后按照一定算法查找一个空位置存放。公式:


其中:Hi为计算出的地址,h(key)为哈希方法,di增量序列1,2,3,...,k(k<= m - 1),m为哈希表的长度。 

假设问题:关键码集合为:{38,25,74,63,52,48,55},m = 7,采用除留余数法h(key) = key mod 7,并存储在哈希表中。

(一)、线性探测:依次向后查找

从上图可以看出,38和52存放3号地址冲突,25和74存放4地址冲突,根据集合,可以知道,38先存放在了3,25先存放了4,所以将74和52进行线上探测,根据公式,线上探测74时,取d = 1,探测52时,取d = 5,最终结果如下表:

优点:只要哈希表未被填满,保证能找到一个空地址单元存放有冲突的元素。

缺点:能使第i个哈希地址的同义词存入第i+1个地址,这样本应存入第i+1个哈希地址的元素变成了第i+2个哈希地址的同义词,产生“聚集”现象,降低查找效率。

(二)、二次探测:依次向前后查找,增量为1、2、3的二次方

以上面(一)线上探测74为例,根据公式,取d = 1²,最终结果如下表:

(三)、伪随机探测:随机产生一个增量位移

还是以74为例,根据公式,取d = 29时,最终结果如下表:

(四)、建立哈希表的步骤

1、取数据元素的关键码key,计算其哈希函数值(地址)。若该地址对应的存储 空间还没有被占用,则将该元素存入;否则执行2解决冲突。

2、根据选择的冲突处理方法,计算关键码key的下一个存储地址。若下一个存储地址仍被占用,则继续执行2,直到找到能用的存储地址为止。

三、再哈希法

再哈希法,又叫双哈希法,有多个不同的Hash函数,出现冲突后采用其他的哈希函数计算,直到不再冲突为止。虽然不易发生聚集,但是增加了计算时间。公式:

其中RHi为不同的哈希函数。比如乘余取整法:RH(k)=[b ×(a × k mod 1)] ,还是以上面74为例:设b = 10,a = 0.6180339,根据公式有:RH(74)=[10 ×(0.6180339 × 74 mod 1)]  = 7,最终结果如下表:

四、拉链法(链地址法)

将具有相同哈希地址的记录链成一个单链表,m个哈希地址就设m个单链表,,然后用一个数组将m个单链表的表头指针存储起来,形成一个动态的结构。

优点:

1、拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;

  2、由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;

  3、开放定址法为减少冲突,要求装填因子α较小,故当结点规模较大时会浪费很多空间。而拉链法中可取α≥1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;

  4、在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。

缺点:

1、指针占用较大空间时,会造成空间浪费,若空间用于增大散列表规模进而提高开放地址法的效率。

假设:关键字集合{47,7,29,11,16,92,22,8,3,50,37,89},m = 11,哈希算法为H(k) = k mod 11,则建表如下图:

(一)、建立哈希表的步骤

1、取数据元素的关键码key,计算其哈希函数值(地址)。若该地址对应的链表为空,则将该元素插入此链表;否则执行2解决冲突。

2、根据选择的冲突处理方法,计算关键码key的下一个存储地址。若该地址对应的链表不为空,则利用链表的前插法或后插法将该元素插入此链表。

(二)、特点

1、非同义词不会冲突,无“聚集”现象;

2、链表上的结点空间动态申请,适用于表长不确定的情况。

五、公共溢出区法

创建哈希表时,将所有产生冲突的的同义词集中放在一个溢出表中。假设哈希函数的值域是[1,m-1],则设哈希表HashTable[0...m-1]为基本表,每个分量存放一个记录,另外设溢出表OverTable[0,v]为溢出表,所有关键字和基本表中关键字为同义词的记录,不管它们由哈希函数得到的哈希地址是什么,一旦发生冲突,都填入溢出表。


例子:关键码集合{26,36,41,38,44,15,68,12,6,51,25},m = 12,哈希函数:H(k)= k mod 12,则哈希表如下:

上图蓝色部分,元素的哈希地址冲突了,此时创建一个溢出表:

以上是关于数据结构 | java中 哈希表及其冲突解决的主要内容,如果未能解决你的问题,请参考以下文章

Java 数据结构HashMap和HashSet

iOS中的哈希表

初识哈希表数据结构

Java散列表以拉链法解决冲突问题(以电话簿为例)

hash哈希冲突常用解决方法

Java 数据结构与算法-哈希表