📚 目录


前言:
  哈希表是 Java 集合底层核心数据结构,HashMap、HashSet 的实现都离不开哈希桶。

1. Java哈希前置知识

  在动手手写哈希桶之前,我们先理清三个核心概念,后文put、get、remove所有代码逻辑都基于这三点实现。

1.1 哈希定义

  哈希(Hash)本质是哈希函数:接收任意类型的key,通过固定算法运算转换成整数,再对数组长度取模,映射成数组的下标。
  它的核心优势是理想查询效率O(1):仅通过哈希计算就能直接定位存储下标,无需逐个遍历比较,直接建立key与存储位置的映射关系。
  公式简化下标 = hash(key) % table.length(存储容量大小)




1.2 哈希冲突

在这里插入图片描述
  简单来说: 不同 key 经过哈希计算后,得到同一个数组下标,该现象就是哈希冲突或者哈希碰撞
  我们把具有不同关键码而具有相同哈希地址的数据元素称为“同义词
  冲突避免:
  首先: 哈希冲突是无法避免的,我们只能尽可能降低它发生的概率;
  冲突原因 : 哈希函数设计的不够合理;
  常见的哈希函数:

方法名称 核心原理 适用场景 优缺点 掌握程度
直接定制法 H a s h ( K e y ) = A ∗ K e y + B Hash(Key) = A*Key + B Hash(Key)=AKey+B,线性函数映射 关键字连续、数量少;字符串首字符哈希场景 优:计算简单均匀
缺:需提前知晓关键字分布
常用,重点掌握
除留余数法 H a s h ( k e y ) = k e y % p Hash(key) = key \% p Hash(key)=key%p p p p 取≤表长的质数 通用所有场景,手写Hash桶/HashMap底层使用 优:实现极简、适配所有数据
缺: p p p 非质数会加剧冲突
核心重点(必学)
平方取中法 关键字平方后截取中间数位为哈希地址 不清楚关键字分布、数字位数较短 优:无需预判数据分布
缺:大数平方计算有性能损耗
了解即可
折叠法 关键字分段求和,取后几位作为哈希地址 关键字数字位数极多(长编号) 优:长数据哈希分布均衡
缺:分段求和逻辑复杂
了解即可
随机数法 通过随机函数映射关键字为哈希地址 关键字长度长短不一、差距大 优:冲突概率低
缺:哈希结果不可复现,工程不实用
了解即可
数学分析法 筛选字符分布均匀的数位作为哈希地址 大批量格式统一的关键字,可提前统计 优:冲突概率最低
缺:前期统计工作量巨大
了解即可




1.3 负载因子

   负载因子 = 哈希桶内总元素数量 s i z e 底层数组 t a b l e 长度 负载因子 = \frac{哈希桶内总元素数量size}{底层数组table长度} 负载因子=底层数组table长度哈希桶内总元素数量size
  核心作用
  负载因子用来衡量数组的填充程度,直接决定哈希冲突的密集程度:
  1. 数值越大:数组空位越少,每条链表挂载的节点变多,查询、遍历效率大幅下降;
  2. 数值越小:数组大量空间闲置,内存资源浪费严重。
在这里插入图片描述

  扩容触发规则(和JDK HashMap保持一致)
  我们设定阈值默认0.75,执行put插入元素后,若 负载因子 ≥ 0.75,会触发扩容逻辑:
  1. 底层数组长度翻倍;
  2. 遍历原有全部链表节点,重新哈希计算下标,迁移到新数组;
  3. 打散过长链表,缩短遍历长度,恢复O(1)查询效率。
  为什么阈值选0.75?
  0.75是时间效率与内存占用的平衡点:阈值过高冲突暴增,阈值过低频繁扩容浪费内存。




2. 手动实现Hash桶

  接下来我们使用Java代码来模式实现简易版的Hash桶;
  采用和 JDK HashMap 一致的数组 + 单向链表(链地址法) 结构,核心实现put插入、get查询、remove删除三大方法。




2.1 底层数组结构

在这里插入图片描述

  哈希桶最外层是一个数组,数组中每一个位置存储一条单向链表的头节点;不同 key 哈希取模后落到同一个数组下标时,全部挂载到对应链表上。
  size:哈希桶中实际存储的键值对总数量;
  LOAD_FACTOR负载因子大小 = 0.75f;

public class HashBuck<K,V> {
	//负载因子
  public static final float LOAD_FACTOR = 0.75f;
  //大小
  public int size;
}




2.2 链表节点封装

  数组每一格存放的链表节点需要保存key、value,以及下一个节点的引用next,我们静态内部类封装 Node 节点:

  • key:存储映射唯一标识,用于对比判断重复元素;
  • value:key 对应的存储数据;
  • next:指向下一个冲突节点,形成单向链表。
public class HashBuck<K,V> {

    //自己定义的hash桶
    static class Node<K,V> {
        public K key;
        public V value;
        public Node<K,V> next;

        public Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }
	
	//负载因子
    public static final float LOAD_FACTOR = 0.75f;
    //开始定义自己的hash数组
    public Node<K,V>[] array;
    //大小
    public int size;
    //默认大小为10
    public HashBuck() {
        array = (Node<K,V>[])new Node[10];
    }
  }




2.3 put插入逻辑

在这里插入图片描述
  如果我们只用我们要插入的元素的值进行计算:
  举个例子,我们存入自定义对象 Student s = new Student(18,“张三”);,直接调用 map.put(s,“学生信息”);。
  此时如果我们没有重写 Student 类的 hashCode() 方法,会出现严重问题:
  我们新建两个属性完全一致的学生对象:

Student s1 = new Student(18,"张三");
Student s2 = new Student(18,"张三");

  逻辑上这两个对象是同一个 key,但因为是两块不同内存, 经过取模计算数组下标后,s1、s2 会落到数组两个完全不相同的格子里,本该判定为重复 key、覆盖 value,结果变成两条独立链表,哈希桶里同时存两份一模一样的 key,违背 HashMap “键唯一” 的核心规则.
弊端:
1. 内容相同的对象会生成不同哈希值,落到数组不同下标,出现重复 key;
2. 使用属性一致的新对象 get 查询,无法匹配到已存储数据;
3. 违背 Java 哈希契约,搭配重写后的equals直接失效;
4. 哈希分布不均,链表堆积,哈希桶查询性能暴跌。

  所以我们必须在自定义实体类中同时重写hashCode()与equals(),基于对象业务属性生成哈希值,保证属性相同的对象哈希值完全一致,才能让 Hash 桶 put、get 逻辑正常运行。
  我们只保证这些就行了吗? - > 答案是否定的;
   负载因子 = 哈希桶内总元素数量 s i z e 底层数组 t a b l e 长度 负载因子 = \frac{哈希桶内总元素数量size}{底层数组table长度} 负载因子=底层数组table长度哈希桶内总元素数量size
  我们知道负载因子的计算公式知道 : Hash桶内元素越多,我们后续添加数据的过程中,出现哈希冲突的概率就越高;
  我们不能保证Hash桶内的元素个数
  但是我们能保证底层数组的长度,当我们自己计算负载因子的值大于等于0.75的时候,我们能做到的是让数组进行扩容;
在这里插入图片描述
  假如 : 我们数组放着元素,当数组中个数/长度满足负载因子的时候,我们就需要对数组进行重新扩容;
  此时 : 每一个位置上的元素可能都不会在原来数组中对应下标的位置,我们需要重新拿出数组中的所有元素进行重新哈希;

  1. 哈希寻址:通过 key 的哈希值对数组长度取模,算出目标数组下标,锁定插入位置;
  2. 链表操作:
  • 若数组该下标无链表,直接将新键值对节点存入数组;
  • 若已存在链表,则遍历整条链表:
  • 存在相同 key:覆盖原有 value,返回旧值;
  • 无相同 key:将新节点挂载到链表尾部,解决哈希冲突。
 	//添加
    public void push(K key,V value) {
        if (key == null) {
            throw new IllegalArgumentException("key cannot be null");
        }
        //拿到对应哈希值
        int hash = key.hashCode();
        //计算插入下标
        int index = hash % array.length;
        //拿到下标位置的链表
        Node<K,V> cur = array[index];
        //判断当前链表中是否存在Key
        while (cur!=null) {
            if(cur.key.equals(key)) {
                cur.value = value;
                return;
            }
            cur = cur.next;
        }
        //进行头插
        Node<K,V> node = new Node<>(key,value);
        node.next = array[index];
        array[index] = node;
        size++;

        //扩容 ; 满足条件时进行重新哈希
        if((float) size/array.length >= LOAD_FACTOR) {
            resize();
        }
    }

    //扩容需要重新哈希
    private void resize() {
    	//进行二倍扩容
        Node<K,V>[] newArray = (Node<K,V>[])new Node[array.length<<1];
        //重新哈希
        for (int i = 0; i < array.length; i++) {
            Node<K,V> cur = array[i];
            while (cur!=null) {
                Node<K,V> curN = cur.next;
                //重新计算哈希值
                int hash = cur.key.hashCode();
                int index = hash % newArray.length;
                cur.next = newArray[index];
                newArray[index] = cur;
                cur = curN;
            }
        }
        array = newArray;
    }




2.4 get 逻辑

在这里插入图片描述
  相较于put方法,get方法是比较简单的,我们既不需要重新哈希也不需要对数组进行任何改变;
  原理: 就是获取我们想要key中在数组中下标位置的链表,对链表进行遍历即可.

  1. 哈希寻址定位数组下标:对目标 key 做 hash 运算,和 put 插入时完全一致,算出它存放在数组哪一格;
  2. 遍历对应链表匹配 key:取出该下标下的整条单向链表,从头节点逐个比对 key;
  • 匹配到完全相同 key:直接返回对应的 value;
  • 整条链表遍历完都无匹配 key:返回null代表元素不存在。
      相较于put插入方法,get逻辑极简:不会修改数组、不会新增节点、无需处理扩容重哈希,只做查询只读操作。底层原理就是:先定位数组槽位,再线性遍历槽内链表完成匹配。
 //查找
    public V get(K key) {
        if (key == null) {
            return null;
        }
        int hash = key.hashCode();
        int index = hash % array.length;
        Node<K,V> node = array[index];
        while (node!=null) {
            if(node.key.equals(key)) {
                return node.value;
            }
            node = node.next;
        }
        //找不到我们则返回null
        return null;
    }




2.5 remove 移除逻辑

在这里插入图片描述
场景 1:要删除的节点是链表头节点(图右侧示例)
  数组槽位直接指向当前节点的下一个节点。
场景 2:要删除的是链表中间 / 尾部节点(图左侧示例,删除 16)
  单向链表无法反向查找前驱,所以遍历链表时必须用 prev 记录上一个节点;
  找到目标节点后,让前驱节点的 next 直接跳过待删节点、连接到它的下一个节点,断开待删节点的链路完成删除。
  整体流程:哈希寻址 → 遍历链表并记录前驱节点 → 区分头 / 中间节点执行断开逻辑 → 元素总数 size 减一 → 返回被删除节点的 value;key 不存在则返回null。

    //删除
    public V remove(K key) {
        if(key == null) {
            return null;
        }
        int hash = key.hashCode();
        int index = hash%array.length;
        Node<K,V> cur = array[index];
        Node<K,V> prev = null;
        while (cur!=null) {
            if(cur.key.equals(key)) {
                if(prev == null) {
                    array[index] = cur.next;
                }else {
                    prev.next = cur.next;
                }
                size--;
                return cur.value;
            }
            prev = cur;
            cur = cur.next;
        }
        return null;
    }
面试核心考点汇总
  1. 哈希冲突解决:链地址法(数组+单向链表);
  2. 负载因子0.75的设计平衡:时间查询效率 & 内存占用;
  3. 自定义对象作为key,必须重写hashCode()和equals()底层原因;
  4. put插入流程:哈希寻址、冲突链表挂载、扩容重哈希逻辑;
  5. remove删除两种场景:链表头节点 / 中间节点的断开方式;
  6. 扩容时旧链表全部重哈希迁移,为什么不能直接复制下标。




更多推荐