哈希表是什么?

200 阅读9分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第12天,点击查看活动详情

初识哈希表

写在前面

  • 建议阅读时间:5 ~ 10 分钟
  • 本文相对比较轻松,用TreeMap和一个很大的学生数组引入了哈希表,并且介绍了哈希表的基本结构、哈希冲突相关的问题!!!

文章摘要

  1. 引入哈希表
  2. 哈希冲突
  3. 有关哈希冲突的几个思考

一、引入哈希表

(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)级别的
  • 需求确实是解决了,可是这会有什么问题呢?来看一张图:

image-20221202183307507

  • 看这张图,很容易发现,如果是这样存放信息,会有很大的问题:

    • 空间复杂度非常大:学生并没有那么多,但是要确保能容纳下任意一个八位的学号,所以初始容量定了100000000
    • 空间利用率极其低,非常浪费内存:该校学生的学号可能都是2xxx开头的,也就是说,空间利用率不到10%
  • 看完了存在的问题,不得不赞赏这样的设想,的确很好

  • 还有,其实这个很大的学生数组Student[],就是一个哈希表

  • 通过这个简单的问题,我们至少可以从中得出,哈希表是一个典型的空间换时间的数据结构

(3)哈希表(Hash Table)

① 初识

  • 上面的学生哈希表有太多问题了,下面我们来看看,正儿八经的哈希表长什么样,有些地方也叫做散列表
  • 比如说我们要用哈希表实现映射Map,先插入几条数据
put("ciusyan", 666);
put("zhiyan", 888);
put("张三", 999);
  • 看看这几条数据,在哈希表内部大概是怎么存储的:

image-20221210170835691

  • 可以看见,哈希表的底层其实是使用数组来实现的,但是和上面的超大容量数组不同,至于什么不同,我们往下慢慢看

  • 映射中的元素是存储在哈希表维护的一个数组(也叫做Buckets、Bucket Array)中的,而每一个数组元素,也叫做桶Bucket

  • 为什么叫哈希表呢?可以发现,在KeyValue中间,会有一个哈希函数。有些地方也叫做散列函数。

  • 先不管这个函数怎么实现的,先说说此函数的作用:根据key,算出该key在table数组中的索引,比如上图:

    • ciusyan这个key,通过哈希函数算出来的索引为 2
    • zhiyan这个key,通过哈希函数算出来的索引为 12
    • 张三这个key,通过哈希函数算出来的索引为 10
  • 既然哈希表的内部是用数组来实现的。想要访问数组元素,需要先根据哈希函数算出索引,再通过索引去访问数组元素

  • 也就是说,哈希表中的添加、搜索、删除的操作都是类似的:

    1. 利用哈希函数生成key对应的索引 index,此操作为O(1)的时间复杂度
    2. 根据索引index操作对应的数组元素,这就是数组的快速访问了,此操作的时间复杂度也为O(1)
  • 虽然我们还不知道哈希函数是如何实现的,但是你可能和我一样,会有一个疑惑

② 哈希冲突(Hash Collision)

  • 通过hash(Key)算出来的索引index,不会重复吗?

image-20221220142550613

  • 如上图所示:键ciusyan ≠ zhiyan,经过hash(ciusyan) = hash(zhiyan) = 10
  • 2个不同的 Key,经过哈希函数计算出了相同的索引,这种现象叫做哈希冲突,也叫做哈希碰撞
  • 既然出现了哈希冲突,该如何解决呢?
  • 其实也很好理解,这里简单介绍几种常见的方法:
    • 1、开放定址法(Open Addressing):按照一定规则,向其他地址探测,直到遇到空桶
    • 2、再哈希法(Re-Hashing):设计多个哈希函数
    • 3、链地址法(Separate Chaining):比如通过链表将同一索引的元素串起来

image-20221220150318707

  • 如图所示,我们简单的使用三种方式,解决了哈希冲突
  • 下面我们来背背八股文,JDK 1.8 在 HashMap中是如何解决哈希冲突的吧:
    • JDK1.8中的哈希表是使用链表 + 红黑树来解决哈希冲突的
    • 默认使用单向链表将元素串起来
    • 在添加元素时,可能会由单向链表转换为红黑树来存储元素
      • 也就是当哈希表容量 ≥ 64 且 单向链表的节点数量 > 8
    • 红黑树节点数量少到一定程度时,又会转换为单向链表
      • 也就是当单向链表的节点数量 ≤ 6
  • 也就是形如下图这样的(假设下面每个元素计算出来的索引都是正确的):

image-20221220155923298

  • 图中index = 1位置为什么是一棵红黑树?因为哈希表容量 = 64 ≥ 64 且 索引为1位置存储的节点数量 = 9 > 8,所以使用了红黑树来串连元素

  • index = 63位置,虽然哈希表的容量是 ≥ 64的,但是索引为63位置存储的节点数量 ≤ 8,所以还是使用链表来串连元素的

  • 背完了八股文,我们来思考几个问题:

    • 1、为什么要使用单向链表,之前学习的双向链表,性能不是更好吗?

      • 添加元素在链表上时,需要保证每次都是从头开始遍历,将元素尾插在链表的末尾。【比如下图,想要修改key = 张三的 value ,我们需要遍历此链表的元素,查看是否已经存在key = 张三了,存在的话,直接覆盖原值,停止遍历。若遍历到尾部还不存在,那么将元素尾插在尾部。】

      image-20221220163418315

      • 而且单向链表比双向链表少一个前驱节点的引用,更省空间
    • 2、为什么要用 64 作为哈希表容量、8 作为链表元素数量的临界值,作为链表和红黑树相互转换的条件?

      • 首先明确:哈希冲突是迫不得已才会出现的情况。在最优的情况下,每个值应该存在单独的桶中,冲突后才会通过链地址法将元素串起来。如果单个索引位置出现了8个以上的冲突,说明哈希方法有待改进。应该使其节点频率服从泊松分布,这时链表长度到达8的概率极小,几乎是不可能事件
      • 那为什么还要使用红黑树 + 链表呢?因为Java中可以自定义对象的哈希方法。如果哈希方法定义得不是很好,红黑树更像是一种保底的做法
      • 其次如果在哈希表容量较小时出现了红黑树,反而会降低效率。因为红黑树需要进行旋转、变色等操作来保持平衡。况且树节点占用的空间比普通节点大,如果链表节点不够多就转换为红黑树,无疑会消耗大量的空间资源。
      • 至于为什么Java官方要取64作为在哈希表长度的临界值,而不用其它值呢?我认为原因主要有二
        • Java官方哈希表的容量必须满足2的幂次方,因为它的内部为了优化效率较低的取模运算,也就是:hash % table.length = index = (table.length - 1) & hash(此关系之后会详细给出解释)
        • 64 = 2 ^ 6,既满足 2 的幂次方,容量也不会太小,是一个很科学的数字

写在后面

  • 引用

链表转红黑树的原因?为什么阈值为8?

HashMap夺命14问,你能坚持到第几问?

本篇收获

  • 初步了解了哈希表
  • 哈希冲突相关的问题