数据结构-----哈希表(Hash Table)

前言

在实际应用中,很多时候我们Map存储的元素是不需要讲究顺序,key也不需要具备可比较性的。接下来我们就来了解一下哈希表(Hash Table)。

1. 基本概念
  • 哈希表也叫做散列表 (hash 有 “剁碎”的意思,即一种分列,散列的意义)
  • 它是如何高效的处理数据呢?
    例如有下面的数据:
    • put (“Jack”, 666);
    • put(“Rose”, 777);
    • put(“Kate”, 888);

这些数据由 Key 和 Value 组成,哈希表底层是数组,Key 通过哈希函数计算(O(1)级别的计算)后,得到数组的索引,然后在数组索引位置放入 Value。如:数据结构-----哈希表(Hash Table)_第1张图片

  • 哈希表是[空间换时间]的典型应用
  • 哈希函数,也叫做散列函数
  • 哈希表内部的数组元素,很多地方也叫 Bucket (桶), 整个数组 叫 Buckets 或者 Bucket Array
2. 哈希冲突 (Hash Collision)
  • 哈希冲突也叫做哈希碰撞
    • 2个不同的key, 经过哈希函数计算相同的结果
    • key1 不等于 key2,但是 hash(key1)也可能等于hash (key2)
  • 解决哈希冲突的常见方法
  1. 开放地址法 (Open Addressing)
    按照一定规则向其他地址探测,直到遇到空桶
    (线性探测,一个位置一个位置地往下探测;平方探测,按照数的平方跳过进行探测)
  2. 再哈希法 (Re - Hashing)
    设计多个哈希函数,把哈希冲突的值在计算一次
  3. 链地址法 (Separate Chaining)
    比如通过链表将同一 index的元素串起来
    数据结构-----哈希表(Hash Table)_第2张图片
2.1 JDK 1.8 的哈希冲突解决方案

链地址法
数据结构-----哈希表(Hash Table)_第3张图片

  • 默认使用单向链表将元素串起来
  • 在添加元素时,可能会由单向链表转为红黑树来存储元素。
    • 比如当哈希表容量 >= 64 且单向链表的节点数量大于8时
  • 当红黑树节点数量少到一定程度时,又会转为单向链表
  • JDK1.8 中的哈希表是使用链表 + 红黑树解决哈希冲突
  • 思考:为什么使用单向链表?
    • 因为我们使用链地址法的时候每次都是从头节点开始遍历
    • 单向链表比双向链表少一个指针,可以节省内存空间
3. 哈希函数

哈希表中哈希函数的实现步骤大概如下:

  • 先生成 key的哈希值 (必须是整数)
  • 再 让 key 的哈希值跟数组大小进行相关运算,生成一个索引值。
    数据结构-----哈希表(Hash Table)_第4张图片
    -为了提高效率,可以使用 & 位运算取代 % 运算 [前提:将数组的长度设计为 2 ^ n]
    数据结构-----哈希表(Hash Table)_第5张图片
    解析数组长度的设计,以及相关的 & 运算
    这种与运算,得到的结果就是 0 ~ table.length - 1; (因为我们长度为 2 ^ n , 那么 table.length - 1 就只能是全为1的数,那么与我们的 hash_code (key) 进行与运算,只能得到比table.length - 1 小于或等于的数,这样我们就能找到满足数组下标的值)数据结构-----哈希表(Hash Table)_第6张图片
  • 良好的哈希函数
    让哈希值更加均匀分布 —> 减少哈希冲突次数 —> 提高哈希表的性能
3.1 如何生成 key 的哈希值
  • key的常见种类可能有
    整数,浮点数,字符串,自定义对象

不同种类的 key, 哈希值的生成方式不一样,但目标是一致的

尽量让每个key的哈希值是唯一的
尽量让key的所有信息参与运算

  • 在 Java中,HashMap的key 必须实现 hashCode, equals 方法,也允许key为null
3.1.1. 整数
  • 整数值当做哈希值
  • 比如10的哈希值就是10
    数据结构-----哈希表(Hash Table)_第7张图片
3.1.2. 浮点数

将存储的二进制格式转为整数值(也就是说浮点数在计算机里也有一个对应的二进制数,它也能转化成相应的int类型的hashCode值)
在这里插入图片描述

3.1.3. Long和Double的哈希值

数据结构-----哈希表(Hash Table)_第8张图片
》》》 和 ^ 的作用是?

  • 高32 bit 和 低 32 bit 混合计算出 32 bit 的哈希值
  • 充分利用信息计算出哈希值
  • 如果采用与运算,算出来的就是后32位的值,如果采用或运算,算出来的就是前32位的值,运算结果十分容易造成哈希冲突
    在这里插入图片描述
3.1.3. 字符串
  • 整数 5489 是如何计算出来的?
    在这里插入图片描述
  • 字符串是由若干个字符组成的
    • 比如字符串 jack, 由 j, a, c, k 四个字符组成 (字符的本质就是一个整数)
    • 因此, jack的哈希值可以表示为
      在这里插入图片描述
    • 在JDK中,乘数 n 为 31 ,为什么使用 31 ?
      31 是一个奇素数, JVM 会将 31 * i 优化成 (i < < 5) - i

关于31的探讨
在这里插入图片描述

  • 31不仅仅是符合 2 ^ n - 1, 它是个奇素数(即是奇数,又是素数,也就是质数)
    • 素数和其他数相乘结果比其他方式更容易产生唯一性
    • 最终选择31是经过观测分布结果后的选择
3.1.4. 自定义对象

自定义对象本身是继承自Object方法的,它本身就实现了hashCode的方法,但是它是以地址值作为哈希值的,所以即使是两个对象的属性值是一致的,但是该对象的hashCode的值也是不一致的。
在这里插入图片描述
所以在实际开发中,我们一般需要重写hashCode的方法以达到我们的需求。

public class Person {

    private int age;
    private float height;
    private String name;

    public Person(int age, float height, String name) {
        this.age = age;
        this.height = height;
        this.name = name;
    }


    //计算出每个属性的hash值,并把该对象看成一个字符串
    @Override
    public int hashCode() {
        int hashCode = Integer.hashCode(age);
        hashCode = hashCode * 31 + Float.hashCode(height);
        hashCode = hashCode * 31 + (name != null ? name.hashCode() : 0);

        return hashCode;
    }
}

比如我们把对象的属性看成字符串的组成,通过各个值运算后相加得到最终的hash值。这样上面两个对象对应的hash值就是相同的了。

  • 思考几个问题
    • 哈希值太大,整型溢出怎么办?
      不用作任何处理,因为我们只需表明他们的定位相同即可。
    • 不重写 hashCode方法有什么后果?
      当我们对象的属性值相同时,却不能认为是同一个对象。

除了重写hashCode() 方法外,还需要重写 equal() 方法 (HashMap的key 必须实现 hashCode, equals 方法)

主要作用:hashCode() 主要是为了定位索引值,equals() 主要是为了解决hash冲突时的值覆盖

 @Override
    public boolean equals(Object obj){
        //内存地址
        if(this == obj) return true;
        if(obj == null || obj.getClass() != getClass()) return false;

        //if(obj == null || !(obj instanceof Person)) return false;

        //比较成员变量
        Person person = (Person) obj;
        return person.age == age
                && person.height == height
                && (person.name == null ? name == null : person.name.equals(name));
    }
  1. 存放在同一数组的链表列里表示的是他们算出来的索引值是一样的,但不能表示他们的hash值是一样的,因为算出来的hash值还需要与数组长度进行 & 运算;
  2. hash值相同的变量不能代表是同一个变量,因为他们是通过一个hash算法计算而来。(有可能他们类型不同,但是计算出来的hash值却是相同的)hash值相同,索引值就是相同的;但索引值相同,hash值不一定是相同的
  3. == 比较的是两个变量的地址,equals 比较的是两个变量的内容是否相等
 public static void main(String[] args) {
        Person p1 = new Person(10, 1.67f, "jack");
        Person p2 = new Person(10, 1.67f, "jack");
        System.out.println(p1.hashCode());
        System.out.println(p2.hashCode());

        Map<Object, Object> map = new HashMap<>();
        map.put(p1, "abc");

        map.put("test","ccc");
        map.put(p2, "bcd");

        System.out.println(map.size());
    }

假如我们没有重写hashCode和 equals 方法

那么我们得到 map的容量为3,因为Object类型的hashCode方法比较的是地址,所以是两个不同的Person 对象,添加后map的容量为3

假如我们重写equals方法,没有重写hashCode方法

那么我们map容量的值为2或3,因为两个Person的hash值虽然不一样,但他们定位的索引值可能一样,如果一样的情况,我们又实现了equals的方法,那么p2会覆盖p1,则容量为2;如果是不一样的情况,那么两个person的索引值自然不同,也就不存在覆盖现象,那么mpa的容量就为3.

假如我们重写hashCode方法,没有重写equals方法

那么我们map容量的值为3, 因为重写了hashCode后,两个Person对象的hash值是相同的,定位的索引值也是相同的,但是我们解决哈希冲突时调用的equals()方法默认是通过地址比较的,由于地址不同,所以不会覆盖,那么map的容量为3.

两个方法之间的联系

自定义对象作为 key, 最好同时重写 hashcode, equals方法 (hashcode是用来确定索引的位置的,equals是来解决hash冲突时的覆盖问题)

  • equals: 用以判断2个key是否为同一个key
  • hashCode: 必须保证 equals为true的 2个key的哈希值一样
  • 反过来 hashcode相等的key, 不一定equals为true

你可能感兴趣的:(算法)