哈希

1.哈希表雏形

问题1:给定若干数字[0-99],求随意给定一个数字判断是否在集合中存在?
       解决方法是创建长度为 100 的boolean数组。
       把给定的若干数字在指定下标位置上进行标记,置为true,则就可以直接通过访问下表的方式判定是否存在。

问题2:数字变大了怎么办?
       eg:数字区间[十万,十万零九十九],问题同上。
       如果按照上面的思路解决,定义长度是 十万零九十九 的数组,这样太浪费空间。
       解决方法是进行映射转换(key-10w),依旧是长度为100的boolean[],把换算后的下标置为true。

问题3:数字区间[0,10w],依旧是求随意给定一个数字判断是否在集合中存在?
       隐含条件:给定数集合只有10 个元素。
       解决方法是对 key 进行映射(key % 数组长度)。
       如果发现两个 key 不同的元素,计算得到的 hash 值相同,此时被称为“hash 冲突“

       哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。

       hash 函数的目标就是为了把 key 映射成下标,希望通过映射过程能尽量避免hash 冲突。

2.哈希冲突

例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

解释散列:散列就是哈希,哈希就是散列。哈希表就是散列表,哈希函数就是散列函数。

2.1冲突解决:闭散列

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

线性探测

  • 核心思想:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
    在这里插入图片描述
  • 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。

二次探测

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

  • eg:再插入44
    在这里插入图片描述

一旦涉及哈希冲突,此时 hash 表的基础操作时间复杂度就不是严格的 O(1)了。随着冲突越严重,效率越低。
       正因为如此,在选择 hash表长度时,一般要选一个较大的值(如果集合中有100个元素,最好选1000个元素的数组)。如果数组比较大,确实冲突率低了,但是浪费空间变多了。
       另外,如果把数组长度选成一个素数,那么冲突概率也会低一点(数组长度不选1000,选1001)。

2.2冲突解决:开散列/哈希桶(常用)

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
在这里插入图片描述
开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。

标准库中的 HashMap 就是通过开散列的方式来处理 hash 冲突的。如果发现链表太长(jdk版本不同阈值不一样),就会把链表结构转化成红黑树。

2.3负载因子

负载因子(Load Factor) = N / L,即 LF = 哈希表中实际元素个数 / 数组容量 ,通过负载因子可以衡量元素冲突率。

如果是闭散列:负载因子一定是 > 1 (不可能大于1);如果是开散列:负载因子可以大于1。

根据负载因子的值来决定是否要对 hash 表进行扩容。一旦采用闭散列的 hash 库,如 Java 的系统库限制了负载因子为 0.75 ,超过此值将 resize 散列表
在这里插入图片描述
目标:降低哈希冲突,即降低 LF 即可。不能降低 N ,故只能通过提升 L 来降低 LF,进而降低冲突率。

扩容操作:申请一个更大的数组作为新的 hash 表,把原来的元素拷贝过去(非常耗时)。

哈希表扩容的目的是什么?
答:扩容目的其实是为了降低冲突率。

2.4哈希冲突可以避免吗?

哈希冲突是理论上客观存在的,避免不了的,我们能做的应该是尽量的降低冲突率

1.选择合适的数组长度
2.合理设计哈希函数,降低冲突率
3.解决哈希冲突两种常见的方法是:闭散列和开散列

比较好的哈希函数是什么?
答: 输入的 key ,理论上符合高斯分布;经过哈希函数计算后,得到的哈希值(下标)最好是符合均匀分布。


一个 key 可以对应的唯一下标吗?
答:不可以。 假设数据 N,数组长度 L,而为了提升空间利用率,N 往往是远大于 L 的,根据鸽笼原理:必然有一个下标处会出现多个 key。

2.5冲突严重时的解决办法

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

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

3.实现

3.1程序设计(简单版本)

哈希表的插入、查找、删除时间复杂度近似O(1)。具体数值取决于 hash 冲突的严重程度。

// 通过开散列的方式来处理 hash 冲突
public class MyHashMap {
    static class Node {
        public int key;
        public int value;
        public Node next;

        public Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

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

    private Node[] array = new Node[101];
    //hash 表的本体,数组的每个元素又是一个链表的头结点.

    private int size = 0; // 表示当前 hash 表中的元素个数

    private int hashFunc(int key) {
        // 实际的 hashFunc 可能会比较复杂的.
        // 此处采取的是 除留余数法
        return key % array.length;
    }

    // 如果 key 已经存在, 就修改当前的 value 值.
    // 如果 key 不存在, 就插入新的键值对.
    public void put(int key, int value) {
        // 1. 需要把 key 映射成数组下标
        int index = hashFunc(key);
        // 2. 根据下标找到对应的 链表
        Node list = array[index];
        // 3. 当前 key 在链表中是否存在.
        for (Node cur = list; cur != null; cur = cur.next) {
            if (cur.key == key) {
                // key 已经存在, 直接修改 value 即可
                cur.value = value;
                return;
            }
        }
        // 4. 如果刚才循环结束, 没有找到 相同 key 的节点, 直接插入到指定链表的头部.
        //    此处实现尾插也完全 ok~~
        Node newNode = new Node(key, value);
        newNode.next = list;
        array[index] = newNode;
        size++;

        if (size / array.length > LOAD_FACTOR) {
            resize();
        }
    }

    // 扩容
    private void resize() {
        Node[] newArray = new Node[array.length * 2];
        // 把原来hash表中的所有元素搬运到新的 数组 上.
        for (int i = 0; i < array.length; i++) {
            for (Node cur = array[i]; cur != null; cur = cur.next) {
                int index = cur.key % newArray.length;
                Node newNode = new Node(cur.key, cur.value);
                newNode.next = newArray[index]; // 新节点插入(头插)到指定位置
                newArray[index] = newNode;
            }
        }
        // 让新的数组代替原来数组.
        array = newArray;
    }

    // 根据 key 查找指定元素. 如果找到返回对应 value. 如果没找到, 返回 null
    public Integer get(int key) {
        // 1. 先计算出 key 对应的下标
        int index = hashFunc(key);
        // 2. 根据下标找到对应的链表
        Node list = array[index];
        // 3. 在链表中查找指定元素
        for (Node cur = list; cur != null; cur = cur.next) {
            if (cur.key == key) {
                return cur.value;
            }
        }

        // 没找到
        return null;
    }
}

3.2实现哈希的注意事项

1> 覆写hashCode和equals方法
2> 引用相等 hashCode肯定想等;hashCode相等,引用不一定相等

if(p.equals(q){
      p.hashCode == q.hashCode;
}

3.3比较两种重要的搜索数据结构

搜索树(平衡)1.中序遍历序性;2.时间复杂度O(log(n)) ;3.平衡调节复杂
哈希表1.底层本质是数组;2. 时间复杂度近似O(1);3.借助了数组下标随机访问能力,实现了高效的增删改查

3.4哈希和 java 类集的关系?

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

3.4hash源码

在这里插入图片描述

4.标准库中的哈希相关问题

4.1能否使用任何类作为 Map 的 key?

答:可以使用任何类作为 Map 的 key。

然而在使用之前,需要考虑以下几点:

  • 如果类重写了 equals() 方法,也应该重写 hashCode() 方法。类的所有实例需要遵循与 equals() 和 hashCode() 相关的规则。如果一个类没有使用 equals(),不应该在 hashCode() 中使用它。
  • 用户自定义 Key 类最佳实践是使之为不可变的,这样 hashCode() 值可以被缓存起来,拥有更好的性能。不可变的类也可以确保 hashCode() 和 equals() 在未来不会改变,这样就会解决与可变相关的问题了。

4.2为什么HashMap中String、Integer这样的包装类适合作为K?

答:String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率。

  • 都是final类型,即不可变性,保证key的不可更改性,不会存在获取hash值不同的情况。
  • 内部已重写了equals()、hashCode()等方法,遵守了HashMap内部的规范(不清楚可以去上面看看putValue的过程),不容易出现Hash值计算错误的情况。

4.3如果使用Object作为HashMap的Key,应该怎么办呢?

答:重写hashCode()和equals()方法。

  • 重写hashCode()是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞。
  • 重写equals()方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性。

4.4HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?

答:hashCode()方法返回的是int整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~2 ^ 30,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;

那怎么解决呢?

HashMap自己实现了自己的hash()方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率也使得数据分布更平均;
在保证数组长度为2的幂次方的时候,使用hash()运算之后的值与运算(&)(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,三来解决了“哈希值与数组大小范围不匹配”的问题;

4.5HashMap 的长度为什么是2的幂次方?

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。

这个算法应该如何设计呢?

我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。

那为什么是两次扰动呢?

答:这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值