前言
在实际应用中,很多时候我们Map存储的元素是不需要讲究顺序,key也不需要具备可比较性的。接下来我们就来了解一下哈希表(Hash Table)。
1. 基本概念
- 哈希表也叫做散列表 (hash 有 “剁碎”的意思,即一种分列,散列的意义)
- 它是如何高效的处理数据呢?
例如有下面的数据:- put (“Jack”, 666);
- put(“Rose”, 777);
- put(“Kate”, 888);
这些数据由 Key 和 Value 组成,哈希表底层是数组,Key 通过哈希函数计算(O(1)级别的计算)后,得到数组的索引,然后在数组索引位置放入 Value。如:
- 哈希表是[空间换时间]的典型应用
- 哈希函数,也叫做散列函数
- 哈希表内部的数组元素,很多地方也叫 Bucket (桶), 整个数组 叫 Buckets 或者 Bucket Array
2. 哈希冲突 (Hash Collision)
- 哈希冲突也叫做哈希碰撞
- 2个不同的key, 经过哈希函数计算相同的结果
- key1 不等于 key2,但是 hash(key1)也可能等于hash (key2)
- 解决哈希冲突的常见方法
- 开放地址法 (Open Addressing)
按照一定规则向其他地址探测,直到遇到空桶
(线性探测,一个位置一个位置地往下探测;平方探测,按照数的平方跳过进行探测) - 再哈希法 (Re - Hashing)
设计多个哈希函数,把哈希冲突的值在计算一次 - 链地址法 (Separate Chaining)
比如通过链表将同一 index的元素串起来
2.1 JDK 1.8 的哈希冲突解决方案
链地址法
- 默认使用单向链表将元素串起来
- 在添加元素时,可能会由单向链表转为红黑树来存储元素。
- 比如当哈希表容量 >= 64 且单向链表的节点数量大于8时
- 当红黑树节点数量少到一定程度时,又会转为单向链表
- JDK1.8 中的哈希表是使用链表 + 红黑树解决哈希冲突
- 思考:为什么使用单向链表?
- 因为我们使用链地址法的时候每次都是从头节点开始遍历
- 单向链表比双向链表少一个指针,可以节省内存空间
3. 哈希函数
哈希表中哈希函数的实现步骤大概如下:
- 先生成 key的哈希值 (必须是整数)
- 再 让 key 的哈希值跟数组大小进行相关运算,生成一个索引值。
-为了提高效率,可以使用 & 位运算取代 % 运算 [前提:将数组的长度设计为 2 ^ n]
解析数组长度的设计,以及相关的 & 运算
这种与运算,得到的结果就是 0 ~ table.length - 1; (因为我们长度为 2 ^ n , 那么 table.length - 1 就只能是全为1的数,那么与我们的 hash_code (key) 进行与运算,只能得到比table.length - 1 小于或等于的数,这样我们就能找到满足数组下标的值) - 良好的哈希函数
让哈希值更加均匀分布 —> 减少哈希冲突次数 —> 提高哈希表的性能
3.1 如何生成 key 的哈希值
- key的常见种类可能有
整数,浮点数,字符串,自定义对象
不同种类的 key, 哈希值的生成方式不一样,但目标是一致的
尽量让每个key的哈希值是唯一的
尽量让key的所有信息参与运算
- 在 Java中,HashMap的key 必须实现 hashCode, equals 方法,也允许key为null
3.1.1. 整数
- 整数值当做哈希值
- 比如10的哈希值就是10
3.1.2. 浮点数
将存储的二进制格式转为整数值(也就是说浮点数在计算机里也有一个对应的二进制数,它也能转化成相应的int类型的hashCode值)
3.1.3. Long和Double的哈希值
》》》 和 ^ 的作用是?
- 高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));
}
- 存放在同一数组的链表列里表示的是他们算出来的索引值是一样的,但不能表示他们的hash值是一样的,因为算出来的hash值还需要与数组长度进行 & 运算;
- hash值相同的变量不能代表是同一个变量,因为他们是通过一个hash算法计算而来。(有可能他们类型不同,但是计算出来的hash值却是相同的)hash值相同,索引值就是相同的;但索引值相同,hash值不一定是相同的
- == 比较的是两个变量的地址,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