开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第12天,点击查看活动详情
初识哈希表
写在前面
- 建议阅读时间:5 ~ 10 分钟
- 本文相对比较轻松,用TreeMap和一个很大的学生数组引入了哈希表,并且介绍了哈希表的基本结构、哈希冲突相关的问题!!!
文章摘要
- 引入哈希表
- 哈希冲突
- 有关哈希冲突的几个思考
一、引入哈希表
(1)TreeMap的分析
- 之前我们利用红黑树,完成了《TreeSet的实现》
- 不是要初识哈希表吗?怎么带我分析起
TreeMap了?哈哈哈,别着急,往后看看~ TreeMap的平均时间复杂度就不多分析了,因为它的底层是使用红黑树来实现的,操作都是O(logn)- 既然是红黑树实现的,那么:
- 说明
TreeMap中的元素必须要具备可比较性 - 而且红黑树中元素的分布也是有顺序的(值大的在右边,小的在左边)
- 说明
- 说完了
TreeMap的这两个特点,你可能感觉到它的不便之处了,在我们实际开发的需求中:- Map中存储的元素可能不需要讲究顺序,放入容器里面即可
- Map中的
Key也可能不需要具备可比较性,甚至Key本身就不具备可比较性
- 如果我们不需要考虑这两个点的话,用其他方式来实现
Map,可能会更好,速度会更快。也就是我们今天的主角:哈希表
(2)问题引入
- 抛出一个问题:统计某大学所有学生的信息,并且可以通过学号来查询某一学生(学号格式:20221818)
- 这个问题,可以使用映射
Map,很方便的实现:用学号作为Key,学生信息作为Value即可 - 用之前学习的
TreeMap来实现也还不错,虽然存储的信息是有序的、学号Key具备可比较性,这两个特点我们都没有使用到 - 如果我规定,对学生信息的添加、删除、查询的时间复杂度都要是
O(1)呢? - 那么
TreeMap就不够用了,增删改查的操作都是O(logn)级别的 - 谈到
O(1)级别的复杂度,还真的很难不想到数组的快速访问 - 没错,我们可以试着用数组来实现此需求,直接看看代码的实现怎么样:
public class School {
private Student[] students = new Student[100000000]; // 有一个足够大的学生数组
public void add(int number, Student student) { // 添加
students[number] = student;
}
public void remove(int number) { // 删除
students[number] = null;
}
public void set(int number, Student student) { // 修改
add(number, student);
}
public Student get(int number) { // 查询
return students[number];
}
}
- 可以发现,如果是上面的代码,根据数组索引去操作内部的学生数组,增删改查的操作都是
O(1)级别的 - 需求确实是解决了,可是这会有什么问题呢?来看一张图:
-
看这张图,很容易发现,如果是这样存放信息,会有很大的问题:
- 空间复杂度非常大:学生并没有那么多,但是要确保能容纳下任意一个八位的学号,所以初始容量定了
100000000 - 空间利用率极其低,非常浪费内存:该校学生的学号可能都是
2xxx开头的,也就是说,空间利用率不到10%
- 空间复杂度非常大:学生并没有那么多,但是要确保能容纳下任意一个八位的学号,所以初始容量定了
-
看完了存在的问题,不得不赞赏这样的设想,的确很好
-
还有,其实这个很大的学生数组
Student[],就是一个哈希表 -
通过这个简单的问题,我们至少可以从中得出,哈希表是一个典型的空间换时间的数据结构
(3)哈希表(Hash Table)
① 初识
- 上面的
学生哈希表有太多问题了,下面我们来看看,正儿八经的哈希表长什么样,有些地方也叫做散列表 - 比如说我们要用哈希表实现映射
Map,先插入几条数据
put("ciusyan", 666);
put("zhiyan", 888);
put("张三", 999);
- 看看这几条数据,在哈希表内部大概是怎么存储的:
-
可以看见,哈希表的底层其实是使用数组来实现的,但是和上面的超大容量数组不同,至于什么不同,我们往下慢慢看
-
映射中的元素是存储在哈希表维护的一个数组(也叫做
Buckets、Bucket Array)中的,而每一个数组元素,也叫做桶Bucket -
为什么叫哈希表呢?可以发现,在
Key和Value中间,会有一个哈希函数。有些地方也叫做散列函数。 -
先不管这个函数怎么实现的,先说说此函数的作用:根据key,算出该key在table数组中的索引,比如上图:
ciusyan这个key,通过哈希函数算出来的索引为 2zhiyan这个key,通过哈希函数算出来的索引为 12张三这个key,通过哈希函数算出来的索引为 10
-
既然哈希表的内部是用数组来实现的。想要访问数组元素,需要先根据哈希函数算出索引,再通过索引去访问数组元素
-
也就是说,哈希表中的添加、搜索、删除的操作都是类似的:
- 利用哈希函数生成
key对应的索引index,此操作为O(1)的时间复杂度 - 根据索引
index操作对应的数组元素,这就是数组的快速访问了,此操作的时间复杂度也为O(1)
- 利用哈希函数生成
-
虽然我们还不知道哈希函数是如何实现的,但是你可能和我一样,会有一个疑惑
② 哈希冲突(Hash Collision)
- 通过
hash(Key)算出来的索引index,不会重复吗?
- 如上图所示:键
ciusyan ≠ zhiyan,经过hash(ciusyan) = hash(zhiyan) = 10 - 2个不同的
Key,经过哈希函数计算出了相同的索引,这种现象叫做哈希冲突,也叫做哈希碰撞 - 既然出现了哈希冲突,该如何解决呢?
- 其实也很好理解,这里简单介绍几种常见的方法:
- 1、
开放定址法(Open Addressing):按照一定规则,向其他地址探测,直到遇到空桶 - 2、
再哈希法(Re-Hashing):设计多个哈希函数 - 3、
链地址法(Separate Chaining):比如通过链表将同一索引的元素串起来
- 1、
- 如图所示,我们简单的使用三种方式,解决了哈希冲突
- 下面我们来背背八股文,
JDK 1.8 在 HashMap中是如何解决哈希冲突的吧:JDK1.8中的哈希表是使用链表 + 红黑树来解决哈希冲突的- 默认使用单向链表将元素串起来
- 在添加元素时,可能会由单向链表转换为红黑树来存储元素
- 也就是当
哈希表容量 ≥ 64 且 单向链表的节点数量 > 8时
- 也就是当
- 在红黑树节点数量少到一定程度时,又会转换为单向链表
- 也就是当
单向链表的节点数量 ≤ 6时
- 也就是当
- 也就是形如下图这样的(假设下面每个元素计算出来的索引都是正确的):
-
图中
index = 1位置为什么是一棵红黑树?因为哈希表容量 = 64 ≥ 64 且 索引为1位置存储的节点数量 = 9 > 8,所以使用了红黑树来串连元素 -
而
index = 63位置,虽然哈希表的容量是 ≥ 64的,但是索引为63位置存储的节点数量 ≤ 8,所以还是使用链表来串连元素的 -
背完了八股文,我们来思考几个问题:
-
1、为什么要使用单向链表,之前学习的双向链表,性能不是更好吗?
- 添加元素在链表上时,需要保证每次都是从头开始遍历,将元素尾插在链表的末尾。【比如下图,想要修改
key = 张三的 value,我们需要遍历此链表的元素,查看是否已经存在key = 张三了,存在的话,直接覆盖原值,停止遍历。若遍历到尾部还不存在,那么将元素尾插在尾部。】
- 而且单向链表比双向链表少一个前驱节点的引用,更省空间
- 添加元素在链表上时,需要保证每次都是从头开始遍历,将元素尾插在链表的末尾。【比如下图,想要修改
-
2、为什么要用 64 作为哈希表容量、8 作为链表元素数量的临界值,作为链表和红黑树相互转换的条件?
- 首先明确:哈希冲突是迫不得已才会出现的情况。在最优的情况下,每个值应该存在单独的桶中,冲突后才会通过链地址法将元素串起来。如果单个索引位置出现了8个以上的冲突,说明哈希方法有待改进。应该使其节点频率服从泊松分布,这时链表长度到达8的概率极小,几乎是不可能事件
- 那为什么还要使用
红黑树 + 链表呢?因为Java中可以自定义对象的哈希方法。如果哈希方法定义得不是很好,红黑树更像是一种保底的做法。 - 其次如果在哈希表容量较小时出现了红黑树,反而会降低效率。因为红黑树需要进行旋转、变色等操作来保持平衡。况且树节点占用的空间比普通节点大,如果链表节点不够多就转换为红黑树,无疑会消耗大量的空间资源。
- 至于为什么
Java官方要取64作为在哈希表长度的临界值,而不用其它值呢?我认为原因主要有二- Java官方哈希表的容量必须满足
2的幂次方,因为它的内部为了优化效率较低的取模运算,也就是:hash % table.length = index = (table.length - 1) & hash(此关系之后会详细给出解释) 64 = 2 ^ 6,既满足 2 的幂次方,容量也不会太小,是一个很科学的数字
- Java官方哈希表的容量必须满足
-
写在后面
- 引用
本篇收获
- 初步了解了哈希表
- 哈希冲突相关的问题