【数据结构】 | 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 .。.:*☆