《恋上数据结构第1季》哈希表介绍以及从源码分析哈希值计算

简介: 《恋上数据结构第1季》哈希表介绍以及从源码分析哈希值计算
数据结构与算法笔记目录《恋上数据结构》 笔记目录

想加深 Java 基础推荐看这个Java 强化笔记目录

我的《恋上数据结构》源码(第1季 + 第2季):https://github.com/szluyu99/Data_Structure_Note

引出哈希表

设计一个写字楼通讯录,存放所有公司的通讯信息

  • 座机号码作为 key(假设座机号码最长是8位)

公司详情(名称、地址等)作为 value

  • 添加、删除、搜索的时间复杂度要求是$o(1)$

我们用这种思路,将座机号码作为索引值数组数组索引,公司详情作为数据元素值

由于数组添加、删除、搜索元素的时间复杂度都是$o(1)$,可以达到要求。
在这里插入图片描述

private Company[] companies = new Company[100000000];
public void add(int phone, Company company){
    companies[phone] = company;
}
public void remove(int phone){
    companies[phone] = null;
}
public Company get(int phone){
    return companies[phone];
}

但是这种做法有缺点。

  • 空间复杂度非常大
    要保证必须存储的下元素,必须开辟一个非常大的空间。
  • 空间使用率极其低,非常浪费内存空间

就拿电话号码来说,开辟了100000000大小的空间,但是1~99999999之间有很多数字是索引值是用不到的。

其实 数组companies 就是一个哈希表,典型的【空间换时间】。

哈希表(Hash Table)

哈希表也叫做散列表(hash有“剁碎”的意思)

哈希表是如何实现高效处理数据的?

put("Jack",666);
put("Rose",777);
put("Kate",888);

1.利用哈希函数生成 key 对应的 index【O(1)】
2.根据 index 操作定位数组元素【O(1)】
在这里插入图片描述

  • 哈希表添加、搜索、删除的流程都是类似的
  • 哈希表是【空间换时间】的典型应用
  • 哈希函数,也叫做 散列函数
  • 哈希表内部的数组元素,很多地方也叫 Bucket(桶),整个数组叫 Buckets 或者 Bucket Array

哈希冲突(Hash Collision)

哈希冲突 也叫做 哈希碰撞

  • 2个不同的 key,经过哈希函数计算出相同的结果
  • key1 $\neq$ key2, hash(key1) = hash(key2)

如下图,"Rose" 不等于 "Kate",但是经过哈希函数算出来的索引确实相同的,此时就产生了冲突。
在这里插入图片描述
解决哈希冲突的常见方法

  • 开放定址法(Open Addressing)
    按照一定规则向其他地址探测,直到遇到空桶
  • 再哈希法(Re-Hashing)
    设计多个哈希函数
  • 链地址法(Separate Chaining)
    比如通过链表将同一 index 的元素串起来

JDK1.8的哈希冲突解决方案

  • 默认使用单向链表将元素串起来
  • 在添加元素时,可能会由单向链表转为红黑树来存储元素
    比如当哈希表容量 ≥ 64 且 单向链表的节点数量大于 8 时
  • 红黑树节点数量少到一定程度时,又会转为单向链表
  • JDK1.8 中的哈希表是使用 链表+红黑树 解决哈希冲突

在这里插入图片描述
思考:这里为什么使用单链表而不是双向链表?

  • 每次都是从头节点开始遍历
  • 单向链表比双向链表少一个指针,可以节省内存空间

哈希函数

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

  1. 先生成 key 的哈希值(必须是整数
  2. 再让 key 的哈希值数组的大小 进行相关运算,生成一个 索引值
public int hash(Object key) {
    return hash_code(key) % table.length;
}

为了提高效率,可以使用 & 位运算 取代 % 运算
【前提:将数组的长度设计为 2 的幂(2^n^)】

public int hash(Object key) {
    return hash_code(key) & (table.length - 1);
}

将数组的长度设计为 2 的幂(2^n^)是因为 2^n^-1 的二进制必然为全 1

1        2^1 - 1 = 1
11        2^2 - 1 = 3
111        2^3 - 1 = 7
1111    2^4 - 1 = 15
11111    2^5 - 1 = 31    

哈希值的二进制一个全为1的二进制 &(相与),结果必然小于等于哈希值,如下图。
在这里插入图片描述
一个良好的哈希函数,可以让哈希值更加均匀分布 → 减少哈希冲突次数 → 提升哈希表的性能;

如何生成 key 的哈希值

key 的常见种类可能有

  • 整数、浮点数、字符串、自定义对象
  • 不同种类的 key ,哈希值的生成方式不一样,但目标是一致的

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

在Java中,HashMap 的 key 必须实现 hashCode、equals 方法,也允许 key 为 null

Integer 的哈希值计算

  • 整数值当做哈希值
  • 比如 10 的哈希值就是 10

Java 中 Integer.hashCode() 源码:
在这里插入图片描述

让我们亲自打印一下:
在这里插入图片描述

Float 的哈希值计算

  • 将存储的二进制格式转为整数值
public static int hashCode(flaot value){
    return floatToInBits(value);
}

Java 中 Float.hashCode() 源码:
在这里插入图片描述
验证一下,float型数据的哈希值就是将对应的二进制数转为整数,只是浮点数的二进制跟整数的相比,在计算机中的存储方式更复杂,也更难计算。
在这里插入图片描述

Long 的哈希值计算

Long.HashCode()的计算有点奇怪,Java源码如下:
在这里插入图片描述
那么, ^>>> 的作用是什么呢?

  • 高32bit低32bit 混合计算出 32bit 的哈希值
  • 充分利用所有信息计算出哈希值

>>>无符号右移,代码中的 value >>> 32 即将 value 的值无符号右移 32 位。
^ 即位运算中的异或,它的作用是:两数异或,不同为1,相同为0
在这里插入图片描述
由这张图可以知道,将 64bit 的二进制数字,无符号右移 32bit 后,左边32位必然全为0(第二行), 右边则为为左32位的数字 (value ^ (value >>> 32))相当于用自己的右32位去异或自己的左32位,最后(int)强转回了 32bit ,相当于把左边的32位全部去掉。这样可以充分利用了自身所有信息来计算哈希值

Double 的哈希值计算

惊奇的发现,Double.HashCode()的计算和 Long 差不多呢,就不多说了。
在这里插入图片描述
验证一下,果然是个奇奇怪怪的数字。
在这里插入图片描述

String 的哈希值计算

思考一下:整数 5489 是如何计算出来的?
$5 * 10^3 + 4 * 10^2 + 8 * 10^1 + 9*10^0$

字符串是由若干个字符组成的

  • 比如字符串 jack,由 j、a、c、k 四个字符组成(字符的本质就是一个整数
  • 因此,jack 的哈希值可以表示为 $j * n^3 + a*n^2 + c*n^2+k*n^0$
    等价于$[(j*n + a) * n + c ] * n + k$ (这样写效率比上面更高)

new String().hashCode() 源码如下:在这里插入图片描述

由上图可知,在JDK中,乘数n为31,为什么使用31?(了解一下)

  • JVM 会将 31 * i 优化成 (i << 5) - i
  • $31 * i =(2^5-1) * i = i * 2^5 - i = (i << 5) - i$
  • 31 不仅仅是符合$2^n-1$,它是个奇素数(既是奇数,又是素数,也就是质数)
  • 素数和其他数相乘的结果比其他方式更容易产成唯一性,减少哈希冲突
  • 最终选择 31 是经过观测分布结果后的选择

因此在Java中,下面两种写法完全等价。
在这里插入图片描述

哈希值计算总结

Java 中内置了计算哈希值的函数。
在这里插入图片描述

自定义对象的哈希值

我们写一个简单的Person类。

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

不重写hashCode

可以看到不重写hashCode也不重写equals的情况下,虽然自定义对象的属性值都是相等的,但是计算出来的哈希值不一样。(不重写hashCode的情况下,默认的哈希值的计算与内存地址有关系,即内存地址相同才有相同的哈希值
在这里插入图片描述

重写hashCode

在 Person.java 内部重写了hashCode方法,使用了 Person 类所有的信息来计算哈希值

public class Person {
    private int age;
    private float height;
    private String name;
    
    public Person(int age, float height, String name) {
        super();
        this.age = age;
        this.height = height;
        this.name = name;
    }
    
    @Override
    public int hashCode() {
        int hashCode = Integer.hashCode(age);// *31是因为JVM内部优化
        hashCode = hashCode * 31 + Float.hashCode(height);
        hashCode = hashCode * 31 + (name!=null ? name.hashCode() : 0);
        return hashCode;
    }

}

可见,重写hashCode的情况下,只要自定义对象的属性值都是相等的,计算出来的哈希值也是相等的。
在这里插入图片描述

重写hashCode方法和equals方法

hashCode方法的作用上面已经说过了,这里主要看一下 equals 方法。

equals 用以判断2个key 是否为同一个key。

  • 自反性
    对于任何非 null 的 x
    x.equals(x) 必须返回 true
  • 对称性
    对于任何非 null 的 x、y
    如果y.equals(x)返回 true
    x.equals(y)必须返回 true

    • 传递性

    对于任何非 null 的 x、y、z
    如果x.equals(y)y.equals(z)返回 true
    那么x.equals(z)必须返回 true

    • 一致性

    对于任何非 null 的 x、y
    只要 equals 的比较操作在对象中所用的信息没有被修改
    x.equals(y)的结果就不会变

不同类型的数据有可能计算出相同的哈希值,例如 String 类型的数据与Double类型的数据的哈希值可能相同,此时会产生冲突,Java中解决冲突的方法为单链表与红黑树
当两个 key 不相等,但是哈希值相等时,我们需要用 equals 方法来判断两个 key 是否为同一个key,此时就需要重写 equals

public class Person {
    private int age;
    private float height;
    private String name;
    
    public Person(int age, float height, String name) {
        super();
        this.age = age;
        this.height = height;
        this.name = name;
    }
    
    @Override
    /**
     * 比较两个对象是否相等
     */
    public boolean equals(Object obj) {
        if(this == obj) return true;
        if(obj == null || obj.getClass() != getClass()) return false;
        
        Person person = (Person)obj;
        return person.age == age 
                && person.height == height
                && person.name==null ? name==null : person.name.equals(name);
                // 传入name若为空,则当前对象name也必须为空才为 true
                // 传入name若不为空,则调用equals方法比较即可
    }
    
    @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;
    }

}

验证一下重写了equalshashCode的作用:

  • 由于重写 hashCode,p1、p2 哈希值必然相等,则放入 map 会去比较 key
  • 由于重写 equals,p1、p2 为同一 key,则 p1 会覆盖 p2
public static void main(String[] args) {
    Person p1 = new Person(18, 1.75f, "jerry");
    Person p2 = new Person(18, 1.75f, "jerry");
    // 由于重写 hashCode(),p1、p2哈希值必然相等
    
    Map<Object, Object> map = new HashMap<>();
    map.put(p1, "abc");
    map.put("test", "bcd");
    // 由于 p1 与 p2 哈希值相等
    // 会比较 p1 与 p2 是否为同一key
    // 由于重写 equals(), p1、p1为同一key
    map.put(p2, "ccc"); // 同一 key 则覆盖,map里的元素数量不增加

    System.out.println(map.size()); // 2
}
2
  • 只重写 hashCode
    由于重写 hashCode,p1、p2 哈希值必然相等,则放入 map 会去比较 key
    但是 equals 默认比较地址,p1、p2地址不同,不为同一 key,因此 map.size() 为3
  • 只重写 equals

由于没有重写 hashCode,p1、p2 哈希值大概率不相等(有极小可能相等)
一般情况下,p1、p2哈希值不相等,map.size()值应当为3
若是真遇到了哈希值相等的情况,由于重写了 equalsmap.size()值为2

结论就是,重写 hashCodeequals 是最稳妥的做法。

易错点总结

  • 哈希值相等,根据哈希函数求的索引必然相等
  • 哈希值不相等,根据哈希函数求的索引有可能相等

原因是hash_code(key) & (table.length - 1)取模运算可能会遇到相等的情况
可以理解为 2 % 3 = 2,5 % 3 = 2,2 和 3 不相等,%3 的结果却相等

  • HashMap 的 key 必须实现 hashCode、equals 方法,也允许 key 为null
相关文章
|
20天前
|
算法 Java 数据库
数据结构与算法学习十五:哈希表
这篇文章详细介绍了哈希表的概念、应用实例、实现思路,并提供了使用Java实现的哈希表代码。
41 0
数据结构与算法学习十五:哈希表
|
2月前
|
存储 Java Serverless
【数据结构】哈希表&二叉搜索树详解
本文详细介绍了二叉搜索树和哈希表这两种数据结构。二叉搜索树是一种特殊二叉树,具有左子树节点值小于根节点、右子树节点值大于根节点的特点,并且不允许键值重复。文章给出了插入、删除和搜索等方法的具体实现。哈希表则通过哈希函数将键名映射为数组下标,实现快速查找,其插入、删除和查找操作时间复杂度理想情况下为O(1)。文中还讨论了哈希函数的设计原则、哈希冲突的解决方法及哈希表的实现细节。
36 8
【数据结构】哈希表&二叉搜索树详解
|
24天前
|
存储 缓存 Java
【数据结构】哈希表
【数据结构】哈希表
21 1
|
25天前
|
算法 Java C语言
【数据结构】后缀(逆波兰)表达式的计算以及中缀转后缀的方法
【数据结构】后缀(逆波兰)表达式的计算以及中缀转后缀的方法
26 0
|
3月前
|
存储 Java
数据结构中的哈希表(java实现)利用哈希表实现学生信息的存储
这篇文章通过Java代码示例展示了如何实现哈希表,包括定义结点类、链表类、数组存储多条链表,并使用简单的散列函数处理冲突,以及如何利用哈希表存储和查询学生信息。
数据结构中的哈希表(java实现)利用哈希表实现学生信息的存储
|
4月前
|
消息中间件 存储 NoSQL
Redis数据结构—跳跃表 skiplist 实现源码分析
Redis 是一个内存中的数据结构服务器,使用跳跃表(skiplist)来实现有序集合。跳跃表是一种概率型数据结构,支持平均 O(logN) 查找复杂度,它通过多层链表加速查找,同时保持有序性。节点高度随机生成,最大为 32 层,以平衡查找速度和空间效率。跳跃表在 Redis 中用于插入、删除和按范围查询元素,其内部节点包含对象、分值、后退指针和多个前向指针。Redis 源码中的 `t_zset.c` 文件包含了跳跃表的具体实现细节。
|
5月前
|
算法 C++
【数据结构与算法】:关于时间复杂度与空间复杂度的计算(C/C++篇)——含Leetcode刷题-2
【数据结构与算法】:关于时间复杂度与空间复杂度的计算(C/C++篇)——含Leetcode刷题
|
5月前
|
算法 C++
【数据结构与算法】:关于时间复杂度与空间复杂度的计算(C/C++篇)——含Leetcode刷题-1
【数据结构与算法】:关于时间复杂度与空间复杂度的计算(C/C++篇)——含Leetcode刷题
|
5月前
|
存储 算法 NoSQL
数据结构和算法——哈希查找冲突处理方法(开放地址法-线性探测、平方探测、双散列探测、再散列,分离链接法)
数据结构和算法——哈希查找冲突处理方法(开放地址法-线性探测、平方探测、双散列探测、再散列,分离链接法)
115 1
|
5月前
|
存储 算法 测试技术
数据结构学习记录——树习题-Complete Binary Search Tree(题目描述、输入输出示例、数据结构的选择、核心算法、计算左子树的规模)
数据结构学习记录——树习题-Complete Binary Search Tree(题目描述、输入输出示例、数据结构的选择、核心算法、计算左子树的规模)
75 1