HashMap 与 HashSet

        在 Java 集合框架中,HashMap 和 HashSet 是日常开发中出镜率极高的两个类。它们之所以受欢迎,核心在于高效的动态查找能力—— 相比传统的遍历(O (N))或二分查找(依赖有序序列),这两个集合能以接近 O (1) 的时间复杂度完成插入、删除和查找操作。。

一、哈希表

        要理解 HashMap 和 HashSet,必须先搞懂它们的底层实现 ——哈希表(Hash Table)。哈希表的本质是一种 “键 - 地址映射” 结构,通过一个「哈希函数」将关键码(Key)直接映射到存储地址,从而实现 “一次定位” 的高效查找。

1.1 哈希表的核心逻辑

        举个简单例子:我们要存储一组数字 {1,4,5,6,9,44},选择哈希函数为 hash(key) = key % 10(除留余数法,最常用的哈希函数之一),哈希表底层数组长度为 10。

  • 计算每个 key 的哈希地址:hash(1)=1hash(4)=4hash(44)=4...
  • 直接将 key 存到对应的数组索引位置:1 存到索引 1,4 存到索引 4,44 也该存到索引 4—— 这就引出了 “哈希冲突”。

1.2 哈希冲突:不可避免,但可优化

什么是哈希冲突?

        不同的 Key 通过哈希函数计算出相同的地址(比如 4 和 44 都映射到索引 4),这种现象就是哈希冲突。冲突无法完全避免(因为数组容量有限,Key 无限),但我们可以通过两种方式降低冲突影响:

1. 冲突避免:从源头减少冲突
  • 合理设计哈希函数:优先选择「除留余数法」(取一个接近数组长度的质数作为除数),保证 Key 分布均匀。
  • 控制负载因子:负载因子(负载因子 = 元素个数 / 数组长度)是哈希表的 “松紧度” 指标。负载因子越大,数组越满,冲突率越高;反之则空间浪费越多。Java 中 HashMap 的默认负载因子是 0.75—— 这是团队权衡空间与性能的结果:当元素个数超过 数组长度 * 0.75 时,就会触发「扩容」(数组长度翻倍),从而降低负载因子,减少冲突。
2. 冲突解决:哈希桶(数组 + 链表 / 红黑树)

        Java 采用「哈希桶(开散列)」解决冲突:哈希表底层是数组,每个数组元素(称为 “桶”)对应一个链表;当发生冲突时,将冲突的 Key 以链表节点的形式挂在同一个桶下。

        如果某个桶的链表过长(JDK 1.8 后阈值为 8),且数组长度 >= 64,链表会自动转为红黑树—— 因为链表查询是 O (N),而红黑树是 O (logN),能大幅提升极端场景下的性能。

public class HashBucket {
    //哈希桶节点(存储Key-Value)
    private static class Node {
        private int key;
        private int value;
        Node next; //链表指针(解决冲突)

        public Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

    private Node[] array; //底层数组(哈希桶)
    private int size; //当前存储的元素个数
    private static final double LOAD_FACTOR = 0.75; //负载因子阈值(文档1-160)
    private static final int DEFAULT_CAPACITY = 8; //默认初始容量

    //构造方法:初始化哈希桶
    public HashBucket() {
        array = new Node[DEFAULT_CAPACITY];
        size = 0;
    }

    /**
     * 插入/更新Key-Value
     * @param key 待插入key
     * @param value 待插入value
     * @return 若key已存在,返回旧value;否则返回-1
     */
    public int put(int key, int value) {
        //1. 计算哈希地址(除留余数法,文档1-139)
        int index = key % array.length;

        //2. 检查桶中是否存在该key(存在则更新value)
        Node cur = array[index];
        while (cur != null) {
            if (cur.key == key) {
                int oldValue = cur.value;
                cur.value = value; //更新value
                return oldValue;
            }
            cur = cur.next;
        }

        //3. 不存在该key,头插法插入新节点(解决冲突)
        Node newNode = new Node(key, value);
        newNode.next = array[index];
        array[index] = newNode;
        size++;

        //4. 检查负载因子,超过阈值则扩容
        if (loadFactor() >= LOAD_FACTOR) {
            resize();
        }
        return -1;
    }

    /**
     * 根据key获取value
     * @param key 目标key
     * @return 找到返回value,否则返回-1
     */
    public int get(int key) {
        //1. 计算哈希地址
        int index = key % array.length;

        //2. 遍历桶中的链表查找key
        Node cur = array[index];
        while (cur != null) {
            if (cur.key == key) {
                return cur.value;
            }
            cur = cur.next;
        }
        return -1; //未找到
    }

    /**
     * 计算当前负载因子
     */
    private double loadFactor() {
        return size * 1.0 / array.length;
    }

    /**
     * 扩容:数组长度翻倍,重新哈希迁移节点
     */
    private void resize() {
        //1. 创建新数组(容量翻倍)
        Node[] newArray = new Node[array.length * 2];

        //2. 遍历旧数组,迁移每个桶的节点到新数组
        for (int i = 0; i < array.length; i++) {
            Node cur = array[i];
            while (cur != null) {
                Node next = cur.next; //保存下一个节点(防止链表断裂)

                //重新计算当前节点在新数组中的索引
                int newIndex = cur.key % newArray.length;

                //头插法插入新数组
                cur.next = newArray[newIndex];
                newArray[newIndex] = cur;

                cur = next; //处理下一个节点
            }
        }

        //3. 替换数组引用
        array = newArray;
    }

    //测试方法
    public static void main(String[] args) {
        HashBucket hashBucket = new HashBucket();
        //插入数据(包含冲突场景:4和44哈希地址相同)
        hashBucket.put(4, 40);
        hashBucket.put(44, 440);
        hashBucket.put(5, 50);
        hashBucket.put(14, 140);

        //测试获取
        System.out.println("get(4):" + hashBucket.get(4)); //40
        System.out.println("get(44):" + hashBucket.get(44)); //440
        System.out.println("get(14):" + hashBucket.get(14)); //140
        System.out.println("get(20):" + hashBucket.get(20)); //-1(不存在)

        //测试更新
        hashBucket.put(4, 400);
        System.out.println("更新后get(4):" + hashBucket.get(4)); //400
    }
}

二、HashMap:键值对的高效存储方案

        HashMap 是 Map 接口的实现类,专门用于存储「Key-Value 键值对」,是开发中存储关联数据的首选。

2.1 HashMap 的核心特性

  1. Key 唯一性:同一个 HashMap 中,Key 不能重复(若插入相同 Key,会覆盖旧 Value 并返回旧值)。
  2. Value 可重复:不同 Key 可以对应相同 Value。
  3. null 允许:Key 和 Value 都可以为 null(注意:Key 最多只能有一个 null,Value 可以有多个)。
  4. 无序性:存储顺序不保证与插入顺序一致(底层哈希表是无序的)。
  5. 线程不安全:多线程环境下操作可能出现并发问题(需用 ConcurrentHashMap 替代)。

2.2 底层实现:数组 + 链表 / 红黑树

HashMap 的底层结构可以概括为 “数组承载桶,桶下挂链表 / 红黑树”:

  • 数组:称为「哈希桶数组」,每个元素是链表 / 红黑树的头节点。
  • 链表:解决哈希冲突,存储相同哈希地址的 Key-Value 对。
  • 红黑树:优化长链表的查询性能(当链表长度 > 8 且数组长度 >= 64 时转换)。

2.3 关键流程:put 与 get 是如何工作的?

1. put 方法:插入键值对

当我们调用 map.put(key, value) 时,底层会经历以下步骤:

  1. 计算 Key 的哈希值:先调用 Key 的 hashCode() 方法得到哈希值,再通过一次 “扰动计算”(减少哈希碰撞)得到最终哈希值。
  2. 确定数组索引:用最终哈希值对「当前数组长度」取模,得到 Key 对应的桶索引。
  3. 处理桶内元素
    • 若桶为空:直接新建节点插入桶中。
    • 若桶不为空:遍历链表 / 红黑树,检查是否存在相同 Key(通过 equals() 比较):
      • 存在相同 Key:更新 Value,返回旧 Value。
      • 不存在相同 Key:新建节点插入链表头部(JDK 1.8 后)或红黑树中。
  4. 检查扩容:插入后若元素个数超过 数组长度 * 0.75,触发扩容(数组长度翻倍),并重新计算所有节点的索引,迁移数据。
2. get 方法:查询键值对

调用 map.get(key) 时,流程更简单:

  1. 同 put 步骤 1-2:计算 Key 的哈希值,确定桶索引。
  2. 遍历桶内的链表 / 红黑树,通过 equals() 找到相同 Key 的节点,返回其 Value。
  3. 若未找到,返回 null。

2.4 常用方法与实战场景

核心方法
方法功能描述
V put(K key, V value)插入 / 更新键值对,返回旧 Value(若 Key 不存在则返回 null)
V get(Object key)根据 Key 获取 Value,不存在则返回 null
V remove(Object key)根据 Key 删除键值对,返回被删除的 Value(不存在则返回 null)
Set<K> keySet()返回所有 Key 的集合(用于遍历 Key)
Set<Map.Entry<K,V>> entrySet()返回所有键值对的集合(推荐用于遍历 Key-Value,效率比 keySet 高)
boolean containsKey(Object key)判断是否包含指定 Key
实战场景
  • 存储用户信息:Key 为用户 ID,Value 为 User 对象。
  • 统计单词频次:Key 为单词,Value 为出现次数(插入时判断 Key 是否存在,存在则 Value+1)。
  • 缓存临时数据:比如接口请求结果,Key 为请求参数,Value 为响应数据。

2.5 坑点提醒:自定义类作为 Key 的注意事项

        如果用自定义类(比如 User)作为 HashMap 的 Key,必须同时覆写 hashCode() 和 equals() 方法,否则会出现逻辑错误。

原因:

  • hashCode() 决定 Key 映射到哪个桶,equals() 决定桶内是否为相同 Key。
  • 若不覆写:默认使用 Object 类的方法 ——hashCode() 返回对象的内存地址,equals() 比较内存地址,导致即使内容相同的两个对象也会被视为不同 Key。

正确原则:

  • 若 a.equals(b) 为 true,则 a.hashCode() 必须等于 b.hashCode()
  • 若 a.hashCode() 相等,a.equals(b) 不一定为 true(哈希冲突)。

2.6 HashMap使用示例

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class HashMapDemo {
    public static void main(String[] args) {
        //1. 初始化HashMap(存储<String, Integer>:姓名-年龄)
        Map<String, Integer> hashMap = new HashMap<>();

        //2. 插入键值对(文档1-80)
        hashMap.put("张三", 20);
        hashMap.put("李四", 22);
        hashMap.put("王五", 21);
        hashMap.put("张三", 23); //重复Key,覆盖value

        System.out.println("HashMap内容:" + hashMap); //{张三=23, 李四=22, 王五=21}

        //3. 常用方法
        //获取value
        System.out.println("张三的年龄:" + hashMap.get("张三")); //23
        System.out.println("赵六的年龄(默认值):" + hashMap.getOrDefault("赵六", 0)); //0(不存在)

        //判断是否包含Key/Value
        System.out.println("是否包含Key李四:" + hashMap.containsKey("李四")); //true
        System.out.println("是否包含Value21:" + hashMap.containsValue(21)); //true

        //遍历方式1:遍历所有Key(keySet)
        System.out.print("遍历Key:");
        Set<String> keySet = hashMap.keySet();
        for (String key : keySet) {
            System.out.print(key + " "); //张三 李四 王五(无序)
        }
        System.out.println();

        //遍历方式2:遍历所有Value(values)
        System.out.print("遍历Value:");
        Collection<Integer> values = hashMap.values();
        for (Integer value : values) {
            System.out.print(value + " "); // 23 22 21
        }
        System.out.println();

        //遍历方式3:遍历键值对(entrySet,推荐高效)
        System.out.println("遍历键值对:");
        Set<Map.Entry<String, Integer>> entrySet = hashMap.entrySet();
        for (Map.Entry<String, Integer> entry : entrySet) {
            System.out.println(entry.getKey() + "->" + entry.getValue());
        }

        //删除Key
        hashMap.remove("王五");
        System.out.println("删除王五后:" + hashMap); //{张三=23, 李四=22}
    }
}

三、HashSet:一键去重的 “利器”

        HashSet 是 Set 接口的实现类,专门用于存储 “不重复的单个元素”,核心功能是去重

3.1 HashSet 的核心特性

  1. 元素唯一性:集合中不会有重复元素(add 重复元素会返回 false)。
  2. 只存 Key:相比 HashMap,HashSet 只存储单个元素(本质是 HashMap 的 Key)。
  3. null 允许:最多只能有一个 null 元素。
  4. 无序性:存储顺序与插入顺序无关。
  5. 线程不安全:与 HashMap 一致,多线程需谨慎。

3.2 底层秘密:基于 HashMap 实现

        很多人不知道,HashSet 其实是 “借壳” HashMap 实现的 —— 它的底层就是一个 HashMap!当我们调用 hashSet.add(element) 时,底层实际执行的是:hashMap.put(element, PRESENT)其中 PRESENT 是一个静态的空 Object 对象(private static final Object PRESENT = new Object()),仅作为占位符使用(因为 HashMap 需要键值对,而 HashSet 只需要 Key)。

        这就解释了为什么 HashSet 能去重:因为 HashMap 的 Key 是唯一的,HashSet 的元素本质就是 HashMap 的 Key。

3.3 常用方法与去重原理

核心方法
方法功能描述
boolean add(E e)添加元素,成功返回 true,重复返回 false
boolean contains(Object o)判断集合是否包含元素 o,存在返回 true
boolean remove(Object o)删除元素 o,成功返回 true,不存在返回 false
int size()返回集合中元素的个数
void clear()清空集合所有元素
去重原理

HashSet 的去重逻辑完全依赖 HashMap 的 Key 唯一性:

  1. 调用元素的 hashCode() 计算哈希值,确定 HashMap 的桶索引。
  2. 遍历桶内元素,调用 equals() 比较是否存在相同元素。
  3. 若存在,add 方法返回 false,不插入;若不存在,插入元素(作为 HashMap 的 Key),返回 true。

因此,自定义类作为 HashSet 元素时,同样需要覆写 hashCode() 和 equals() 方法,否则无法正确去重。

3.4 实战场景

  • 列表去重:比如将 List<String> phones 转为 HashSet,快速去除重复手机号。
  • 判断元素存在:比如检查某个用户 ID 是否在 “黑名单” 集合中。
  • 统计唯一值:比如统计日志中出现的唯一 IP 地址。

3.5 自定义类作为 HashMap/HashSet Key(需覆写 hashCode () 和 equals ())

import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;

//自定义类:User(作为Key)
class User {
    private String id;
    private String name;

    public User(String id, String name) {
        this.id = id;
        this.name = name;
    }

    //自定义Key必须覆写equals()和hashCode()
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id) && Objects.equals(name, user.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, name);
    }

    @Override
    public String toString() {
        return "User{id='" + id + "', name='" + name + "'}";
    }
}

//测试自定义Key
public class CustomKeyDemo {
    public static void main(String[] args) {
        //1. 自定义Key在HashMap中的使用
        HashMap<User, String> userMap = new HashMap<>();
        User user1 = new User("001", "张三");
        User user2 = new User("001", "张三"); //与user1内容相同,应视为同一Key

        userMap.put(user1, "北京");
        userMap.put(user2, "上海"); //覆盖user1的value

        System.out.println("HashMap中User对应的地址:" + userMap.get(user1)); // 上海(证明user1和user2是同一Key)

        //2. 自定义Key在HashSet中的使用
        HashSet<User> userSet = new HashSet<>();
        userSet.add(user1);
        userSet.add(user2); //重复Key,无法插入

        System.out.println("HashSet大小:" + userSet.size()); //1(去重成功)
        System.out.println("HashSet内容:" + userSet); //[User{id='001', name='张三'}]
    }
}

四、横向对比:理清这些 “Set/Map” 的区别

        实际开发中,我们常需要在 HashMap/HashSet 和 TreeMap/TreeSet 之间做选择,这里用表格清晰对比:

4.1 HashMap vs HashSet

对比维度HashMapHashSet
存储内容键值对(K-V)单个元素(K)
底层依赖直接基于哈希表基于 HashMap(Value 占位)
核心功能关联数据存储元素去重
关键方法put、getadd、contains
null 允许度Key 和 Value 均可为 null仅元素(K)可为 null

4.2 HashMap/HashSet vs TreeMap/TreeSet

对比维度HashMap/HashSetTreeMap/TreeSet
底层结构数组 + 链表 / 红黑树红黑树
有序性无序(插入顺序不保证)有序(Key 自然排序 / 定制排序)
时间复杂度插入 / 查找 / 删除 O (1)插入 / 查找 / 删除 O (logN)
Key 要求需覆写 hashCode + equals需实现 Comparable 或提供 Comparator
null 允许度允许(HashMap Key 可 null)不允许(TreeMap Key 不可 null)
适用场景追求高效读写,无需有序需要按 Key 排序的场景

总结

  1. 底层基石是哈希表:HashMap 和 HashSet 都基于哈希表实现,高效性的核心是 “哈希函数映射 + 冲突解决”。
  2. HashMap 是键值对存储:Key 唯一,Value 可重复,需注意自定义 Key 覆写 hashCode 和 equals。
  3. HashSet 是去重工具:底层依赖 HashMap,元素即 HashMap 的 Key,去重逻辑与 HashMap Key 唯一性一致。
  4. 选择依据看需求:需有序用 Tree 系列,需高效用 Hash 系列;存键值对用 Map,存单个元素用 Set。

掌握这些核心逻辑后,再遇到 HashMap 或 HashSet 的问题(比如为什么会有重复元素、为什么查询慢),就能从底层原理出发快速定位原因,真正做到 “知其然,更知其所以然”。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值