当前位置: 首页 > news >正文

深入理解 Java Map 与 Set

文章目录

  • 前言
  • 1. 搜索树
    • 1.1 什么是搜索树
    • 1.2 查找
    • 1.3 插入
    • 1.4 删除
        • 情况一:`cur` 没有子节点(即为叶子节点)
        • 情况二:`cur` 只有一个子节点(只有左子树或右子树)
        • 情况三:`cur` 有两个子节点(左右子树都存在)
    • 1.5 基础代码框架
    • 1.6 二叉搜索树的性能分析
  • 2. Map和Set
    • 2.1 概念
    • 2.2 模型
  • 3. Map 接口的核心用法
    • 3.1 什么是 Map
    • 3.2 Map 的常用方法
      • 代码演示
    • 3.3 深入 `Map.Entry<K, V>`
    • 3.4 `TreeMap` vs `HashMap`
  • 4. Set 接口的核心用法
    • 4.1 Set 的常用方法
    • 4.2 `TreeSet` vs `HashSet`
    • 4.3 TreeSet 使用案例
  • 5. 深入哈希表 (Hash Table)
    • 5.1 哈希表的核心思想
    • 5.2 哈希冲突 (Hash Collision)
    • 5.3 如何降低哈希冲突
      • 5.3.1 设计优秀的哈希函数
      • 5.3.2 调节负载因子 (Load Factor)
    • 5.4 如何解决哈希冲突
      • 5.4.1 闭散列 (Closed Hashing)
      • 5.4.2 开散列 (Open Hashing)
    • 5.5 代码实现
      • 基本类型版本
      • 泛型版本
    • 5.6 性能分析
    • 5.7 和 Java 集合框架的关系

前言

这篇文章是基于个人学习体会整理而来,主要分享 MapSet 并深入 HashMapHashSet 底层所依赖的关键数据结构——哈希表,去理解它的工作原理,完成一个简单的实现。如果内容有不足之处,非常欢迎大家一起指正和交流!

1. 搜索树

在正式开始学习 MapSet 之前,有必要先了解一种非常基础且重要的数据结构——搜索树。许多高效的集合类,比如 TreeMapTreeSet,它们的底层实现就完全离不开搜索树。

1.1 什么是搜索树

提到搜索树,最常接触到的就是二叉搜索树(Binary Search Tree),它也经常被称为二叉排序树(Binary Sort Tree)。

那么,什么样的二叉树才能算作一棵二叉搜索树呢?它可以是一棵空树,如果不是,就必须满足以下几条性质:

  • 左子树不“大”:若左子树不为空,则左子树上所有节点的值均小于其根节点的值。
  • 右子树不“小”:若右子树不为空,则右子树上所有节点的值均大于其根节点的值。
  • “子孙”也守规矩:它的左、右子树也分别为二叉搜索树。

这三条规则确保了树中的数据总是有序的,为高效查找打下了坚实的基础。

在这里插入图片描述

上图就是一棵典型的二叉搜索树,根节点是 8。可以验证一下它是否满足上述所有规则。

1.2 查找

理解了二叉搜索树的定义后,其查找操作的思路就变得非常直观了。整个过程好比在一个有序的字典里找单词,每一次比较都能排除掉一半的可能性。

若要在树中寻找一个特定的 key,查找过程可以这样描述:

  • 从根节点开始: 查找总是从树的顶端——根节点——出发。
  • 比较节点值:
    • 如果当前节点的 key 正好等于要找的 key,说明已经找到了目标。
    • 如果要找的 key 小于当前节点的 key,根据二叉搜索树的性质,只需要在当前节点的左子树中继续查找。
    • 如果要找的 key 大于当前节点的 key,同理,就转向右子树继续查找。
  • 持续进行: 这个过程会一直持续下去,直到找到匹配的节点,或者遇到一个空节点(null),后者意味着整个树里都没有要找的 key

这个查找逻辑充分利用了二叉搜索树的有序性,使得每一次比较都能有效地缩小查找范围。

参考实现代码:

       /*** 在二叉搜索树中查找指定的键。* 查找操作的时间复杂度分析:*          - 最好情况:O(logN),当树为一棵完全二叉树时。*          - 最坏情况:O(N),当树退化为单支树时。* @param key 要查找的键* @return 找到的节点,未找到则返回 null*/public TreeNode search(int key) {TreeNode cur = root; // 从根节点开始查找while (cur != null) { // 循环直到找到节点或遍历到空链接if (cur.val == key) {return cur; // 找到了,返回当前节点} else if (cur.val > key) {cur = cur.left; // 当前节点值太大,去左子树找} else {cur = cur.right; // 当前节点值太小,去右子树找}}return null; // 遍历完整棵树都没找到}

1.3 插入

向二叉搜索树中插入一个新节点,其过程和查找非常相似。首先要做的,就是找到这个新节点应该被安放的“空位”,以确保插入后,整棵树的性质不被破坏。

插入操作的步骤可以分解为:

  1. 空树处理: 如果树是空的(即 rootnull),那么新节点就直接成为根节点。
  2. 寻找插入位置: 如果树不为空,从根节点开始,像执行查找操作一样,根据新节点的 key 与当前节点的 key 的大小关系,决定是向左走还是向右走。
  3. 找到“父”节点: 这个过程会一直持续,直到找到了一个 null 链接。这个 null 链接所连接的“父”节点,就是新节点将要被挂载的地方。
  4. 完成插入: 将新节点链接到“父”节点的左边或右边,具体取决于新节点的 key 是小于还是大于“父”节点的 key

值得注意的是,在很多二叉搜索树的实现中,通常不允许插入键值重复的节点。如果在寻找插入位置的过程中,发现树中已存在一个相同 key 的节点,插入操作就会直接终止。

我们可以用下面这个流程图来更清晰地展示整个插入过程:

插入操作
树是否为空?
开始
创建新节点, 设为根节点
从根节点开始, 寻找插入位置
当前节点是否为空?
在父节点的正确位置插入新节点
新key < 当前key?
向左子树移动
新key > 当前key?
向右子树移动
key已存在, 放弃插入
结束

参考实现代码:

        private TreeNode root;/*** 向二叉搜索树中插入一个新键。* @param key 要插入的键*/public void insert(int key) {// 1. 如果是空树,新节点直接成为根节点if (root == null) {root = new TreeNode(key);return;}// 2. 寻找插入位置,同时记录父节点TreeNode cur = root;TreeNode parent = null; // 用于记录cur的父节点while (cur != null) {if (cur.val < key) {parent = cur;cur = cur.right; // 向右走} else if (cur.val > key) {parent = cur;cur = cur.left; // 向左走} else {// 树中已存在相同的值,不允许插入,直接返回return;}}// 3. 循环结束,cur为null,parent为待插入位置的父节点TreeNode node = new TreeNode(key);if (parent.val > key) {// 如果新键小于父节点,链接到左边parent.left = node;} else {// 如果新键大于父节点,链接到右边parent.right = node;}}

1.4 删除

相比查找和插入,删除操作是二叉搜索树中最复杂的一个环节。因为它需要处理多种不同的情况,同时还要保证删除节点后,树的结构依然满足二叉搜索树的性质。

删除操作通常根据待删除节点 cur 的子节点数量分为三种情况来讨论:

情况一:cur 没有子节点(即为叶子节点)

这是最简单的情况。直接将其父节点 parent 指向 cur 的链接断开(设置为 null)即可。

情况二:cur 只有一个子节点(只有左子树或右子树)

处理起来也比较直观。将 cur 的父节点 parent 直接链接到 cur 的那个唯一的子节点上,就相当于“跳过”了 cur 节点,从而完成了删除。

情况三:cur 有两个子节点(左右子树都存在)

这是最复杂的情况。不能简单地删除 cur,因为这会留下两个“孤儿”子树,破坏整体结构。

这里的核心思想是替换法

  1. cur 的子树中,找到一个合适的节点来“顶替” cur 的位置。这个“顶替者”必须满足:比 cur 左子树的所有节点都大,同时比 cur 右子树的所有其他节点都小。
  2. 满足这个条件的节点有两个选择:
    • cur 左子树中的最大值节点(即左子树中最右边的节点)。
    • cur 右子树中的最小值节点(即右子树中最左边的节点)。
  3. 通常选择后者,即在 cur右子树中寻找值最小的节点(我们称之为 target)。
  4. target 的值赋给 cur
  5. 现在问题就转化成了删除 target 节点。因为 target 是其所在子树中最小的节点,所以它一定没有左子节点,最多只有一个右子节点。这样,删除 target 的问题就退化成了更简单的情况一情况二

参考实现代码:

       /*** 从二叉搜索树中删除指定的键。* @param key 要删除的键*/public void remove(int key) {TreeNode cur = root;TreeNode parent = null;// 1. 首先,找到要删除的节点(cur)及其父节点(parent)while (cur != null) {if (cur.val < key) {parent = cur;cur = cur.right;} else if (cur.val > key) {parent = cur;cur = cur.left;} else {// 找到了!调用辅助方法执行删除removeNode(parent, cur);return;}}}/*** 真正执行删除操作的辅助方法。* @param parent 要删除节点(cur)的父节点* @param cur 要删除的节点*/private void removeNode(TreeNode parent, TreeNode cur) {// 情况一:待删除节点没有左孩子 (包含叶子节点和只有右孩子两种子情况)if (cur.left == null) {if (cur == root) { // 如果删除的是根节点root = cur.right;} else if (cur == parent.left) { // 如果cur是其父节点的左孩子parent.left = cur.right;} else { // 如果cur是其父节点的右孩子parent.right = cur.right;}// 情况二:待删除节点没有右孩子} else if (cur.right == null) {if (cur == root) { // 如果删除的是根节点root = cur.left;} else if (cur == parent.left) { // 如果cur是其父节点的左孩子parent.left = cur.left;} else { // 如果cur是其父节点的右孩子parent.right = cur.left;}// 情况三:待删除节点左右孩子都存在} else {// 采用“替换法”:在右子树中找到最小的节点(target)来替换curTreeNode targetParent = cur;TreeNode target = cur.right;while (target.left != null) {targetParent = target;target = target.left;}// 将target的值赋给cur,相当于替换了curcur.val = target.val;// 问题转化为删除target节点(target最多只有一个右孩子)if (target == targetParent.left) {targetParent.left = target.right;} else {targetParent.right = target.right;}}}

1.5 基础代码框架

具体的 search, insert, remove 方法上文已经介绍了,这里简要搭建起二叉搜索树的基本骨架。这主要包括两个部分:树本身 BinarySearchTree 类,以及构成树的基石——节点 TreeNode 类。

TreeNode 通常会作为一个内部类,因为它和树紧密相关。每个节点需要包含三个核心信息:

  1. 存储的数据值(val)。
  2. 指向左子节点的引用(left)。
  3. 指向右子节点的引用(right)。

下面是一个基础的实现框架,所有操作都将在这个框架内展开:

public class BinarySearchTree {// 树的节点定义static class TreeNode {public int val;       // 节点存储的值private TreeNode left;  // 指向左子树private TreeNode right; // 指向右子树// 构造方法private TreeNode(int val) {this.val = val;}}// 树的根节点private TreeNode root = null;// 此处将添加 search, insert, remove 等方法...
}

1.6 二叉搜索树的性能分析

之所以选择二叉搜索树,就是看中了它高效的查找性能。理论上,插入、删除和查找操作的效率都取决于树的高度。

对于一个包含 N 个节点的树:

  • 最优情况: 当树的形态接近于一棵完全二叉树时,它的高度大约是 log₂N。在这种结构下,每次操作都能排除大约一半的节点,因此时间复杂度为 O(logN)。这是最理想的状态。

  • 最坏情况: 然而,二叉搜索树的形态与节点的插入顺序密切相关。如果插入的序列本身就是有序的(例如,依次插入 1, 2, 3, 4, 5),那么树就会退化成一个“单链表”,或者说是一棵“单支树”。

    在这里插入图片描述

    在这种极端情况下,树的高度变成了 N。此时再进行查找,就跟遍历一个普通链表没什么区别了,时间复杂度会飙升到 O(N)。这显然违背了使用二叉搜索树的初衷。

这就引出了一个关键问题:有没有办法避免这种最坏情况的发生,让树无论在何种插入顺序下都能维持一个相对平衡的“好身材”,从而保证 O(logN) 的高效性能呢?

答案是肯定的。为了解决这个问题,计算机科学家们设计出了更高级的自平衡二叉搜索树,比如 AVL树红黑树TreeMapTreeSet 的底层就是红黑树)。它们通过在插入和删除后进行一些巧妙的“旋转”操作,来时刻保持树的平衡,避免退化。

不过,AVL 树和红黑树的实现相对复杂,在这里先埋下一个伏笔,等对基础的二叉搜索树有了扎实的理解后,再去探索它们会更有收获。

2. Map和Set

2.1 概念

Map和Set是一类专门用于搜索的容器或数据结构,其搜索效率与其具体的实现类密切相关。回顾一下常见的搜索方式:

  1. 直接遍历:时间复杂度为O(N),当元素较多时效率低下。
  2. 二分查找:时间复杂度为O(logN),但前提是序列必须有序。

这两种方法更适合静态数据的查找,即数据集合不经常发生插入和删除操作的场景。例如:

  • 根据姓名查询固定的考试成绩。
  • 在通讯录中根据姓名查询联系方式。
  • 检查一个单词是否在某个固定的词典中。

然而,当需要频繁地进行插入和删除,即动态查找时,上述两种方式就不太适用了。因此,MapSet 作为专为动态查找设计的集合容器,就显得尤为重要。

2.2 模型

一般把用于搜索的数据称为关键字(Key),与关键字对应的值称为(Value),它们共同构成了一个Key-Value键值对。基于此,搜索模型可以分为两种:

  1. 纯Key模型: 只关心Key本身是否存在。
    • 快速查找一个单词是否在词典中。
    • 快速查找某个名字是否在通讯录中。
  2. Key-Value模型: 关心与Key关联的Value。
    • 统计文件中每个单词出现的次数:<单词, 出现次数>。
    • 身份证系统:<身份证号, 公民信息>。

在Java中,Map 存储的就是Key-Value键值对,而 Set 则只存储Key,并保证其唯一性。

3. Map 接口的核心用法

了解了底层的搜索树之后,让我们回到 Java 集合框架,正式开始探索 Map 这个强大的接口。

3.1 什么是 Map

首先需要明确,Map 是一个接口,它和 Collection 接口处于同一层级,二者之间没有继承关系。

在这里插入图片描述

Map 的核心思想是存储键值对(Key-Value Pair)。可以把它想象成一本字典,每个“单词”(Key)都对应着一个“释义”(Value)。在 Map 中,Key 是唯一的,不允许重复,但 Value 可以重复。

因为 Map 是接口,所以不能直接 new Map(),而是需要实例化它的实现类,最常用的就是 HashMapTreeMap

3.2 Map 的常用方法

Map 接口定义了一系列非常实用的方法,下面是一些最核心的用法:

方法签名方法说明
V put(K key, V value)将一个键值对放入 Map 中。如果 key 已存在,则会用新的 value 覆盖旧的,并返回旧的 value
V get(Object key)根据 key 获取对应的 value。如果 key 不存在,则返回 null
V getOrDefault(Object key, V defaultValue)get 方法的安全版。如果 key 不存在,它会返回一个指定的默认值。
V remove(Object key)根据 key 删除对应的键值对,并返回被删除的 value
boolean containsKey(Object key)检查 Map 中是否包含指定的 key
boolean containsValue(Object value)检查 Map 中是否包含指定的 value
int size()返回 Map 中键值对的数量。
boolean isEmpty()判断 Map 是否为空。
Set<K> keySet()获取 Map 中所有 key 组成的 Set 集合。
Collection<V> values()获取 Map 中所有 value 组成的 Collection
Set<Map.Entry<K, V>> entrySet()获取 Map 中所有键值对 (Entry) 组成的 Set 集合。这是遍历 Map 的最高效方式。

代码演示

通过一个简单的例子来看看这些方法如何使用。这里选用 TreeMap,它能保证 key 是有序的。

import java.util.Map;
import java.util.Set;
import java.util.TreeMap;public class MapExample {public static void main(String[] args) {// 1. 创建一个 TreeMap 实例Map<String, String> map = new TreeMap<>();// 2. 使用 put 添加键值对map.put("作者", "adam");map.put("主题", "Map和Set");map.put("类型", "笔记");System.out.println("初始Map: " + map);// 3. put 一个已存在的 key,会覆盖旧的 valueString oldValue = map.put("类型", "博客");System.out.println("更新后的Map: " + map);System.out.println("被覆盖的旧值: " + oldValue);// 4. 使用 get 获取 valueString author = map.get("作者");System.out.println("作者是: " + author);// 5. 使用 getOrDefault 获取一个不存在的 keyString status = map.getOrDefault("状态", "更新中");System.out.println("文章状态: " + status);// 6. 检查 key/value 是否存在System.out.println("是否包含 key '主题': " + map.containsKey("主题"));System.out.println("是否包含 value '博客': " + map.containsValue("博客"));// 7. 高效遍历 Map (推荐方式)System.out.println("\n--- 使用 entrySet 遍历 ---");Set<Map.Entry<String, String>> entries = map.entrySet();for (Map.Entry<String, String> entry : entries) {System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());}}
}

3.3 深入 Map.Entry<K, V>

在遍历 Map 时,我们接触到了 Map.Entry。这是 Map 接口内部定义的一个静态内部接口,它代表了 Map 中的一个独立的“条目”,也就是一个键值对。

entrySet() 之所以是最高效的遍历方式,是因为它一次性就把每个键值对(Entry 对象)都拿到了,避免了像 keySet() 那样需要先拿 key 再通过 get() 去找 value 的额外开销。

Map.Entry 接口提供了几个有用的方法:

方法签名说明
K getKey()获取这个条目的 key
V getValue()获取这个条目的 value
V setValue(V value)修改这个条目的 value,并返回旧的 value

请注意: Map.Entry 只允许修改 value,而不允许修改 key这是因为 keyMap 的结构中起着定位作用(比如在哈希表中计算位置,或在树中进行比较),一旦修改,会破坏 Map 的内部结构。如果确实需要修改 key,正确的做法是先 remove 旧的键值对,再 put 一个新的。

3.4 TreeMap vs HashMap

最后,总结一下 Map 两个最重要的实现类 TreeMapHashMap 的核心区别,这在面试中经常被问到。

特性TreeMapHashMap
底层数据结构红黑树(一种自平衡的二叉搜索树)哈希表(数组 + 链表/红黑树)
排序性有序key 按照自然顺序或者指定的比较器顺序排列。无序。元素的存储和迭代顺序不固定。
性能增、删、查、改的时间复杂度稳定在 O(logN)理想情况下,增、删、查、改的时间复杂度为 O(1);最坏情况(哈希严重冲突)为 O(N)。
对 Key 的要求key 必须是可比较的。要么 key 的类实现了 Comparable 接口,要么在创建 TreeMap 时提供一个 Comparatorkey 的类必须正确地重写 hashCode()equals() 方法。
null 支持key 不允许nullvalue 可以为 nullkeyvalue 都允许null(但 key 只能有一个 null)。
适用场景需要一个有序的 Map 时,比如按 key 排序输出。追求极致的性能,且不关心 key 的顺序时,HashMap 是首选。

4. Set 接口的核心用法

学习了 Map 之后,再来看 Set 就会感觉非常亲切。可以把 Set 理解成一种特殊的 Map,它只关心 key,而不关心 valueSet 的核心价值在于保证集合中元素的唯一性

在 Java 的集合框架中,Set 接口继承自 Collection 接口。

一个非常巧妙的设计是,许多 Set 的实现类(如 HashSetTreeSet)的底层就是用对应的 MapHashMapTreeMap)来实现的。它们将要存入 Set 的元素作为 Mapkey,而 value 则存一个固定的、无意义的占位对象。这样一来,就天然地利用了 Mapkey 唯一的特性,来实现 Set 中元素的唯一性。

4.1 Set 的常用方法

由于 Set 继承自 Collection,它包含了 add, remove, contains, size 等常用方法。这里重点关注 add 方法的行为。

方法签名说明
boolean add(E e)尝试将元素 e 添加到 Set 中。如果 e 不存在,则添加成功,返回 true;如果 e 已存在,则添加失败,Set 不变,返回 false
boolean contains(Object o)判断 Set 中是否包含指定的元素。
boolean remove(Object o)如果 Set 中存在指定元素,则将其删除。
int size()返回 Set 中元素的数量。
Iterator<E> iterator()返回一个可以用于遍历 Set 中元素的迭代器。

Set 最强大的功能之一就是集合去重。例如,可以非常方便地利用 addAll 方法将一个 List 中的所有元素添加到一个 Set 中,从而快速得到一个不含重复元素的新集合。

4.2 TreeSet vs HashSet

Map 类似,Set 最常用的两个实现类是 TreeSetHashSet。它们之间的区别也和 TreeMapHashMap 的区别一一对应。

特性TreeSetHashSet
底层数据结构TreeMap (红黑树)HashMap (哈希表)
排序性有序。元素按照自然顺序或者指定的比较器顺序排列。无序。元素的存储和迭代顺序不固定。
性能增、删、查的复杂度稳定在 O(logN)理想情况下,增、删、查的复杂度为 O(1)
对元素的要求元素必须是可比较的 (实现 Comparable 或提供 Comparator)。元素的类必须正确地重写 hashCode()equals() 方法。
null 支持不允许添加 null 元素。允许添加一个 null 元素。
适用场景需要一个能自动排序且元素唯一的集合。追求高性能,且不关心元素顺序的去重场景。

4.3 TreeSet 使用案例

来看一个 TreeSet 的具体例子,直观地感受它的唯一性有序性

import java.util.Set;
import java.util.TreeSet;
import java.util.Iterator;public class SetExample {public static void main(String[] args) {// 1. 创建一个 TreeSet 实例Set<Integer> numberSet = new TreeSet<>();// 2. 添加元素numberSet.add(50);numberSet.add(20);numberSet.add(80);numberSet.add(20); // 尝试添加重复元素// 3. 打印 Set,观察其唯一性和有序性// 重复的 20 不会被添加进去,且输出结果是排序好的System.out.println("TreeSet中的元素: " + numberSet); // 输出: [20, 50, 80]// 4. 检查是否包含某个元素System.out.println("是否包含 80: " + numberSet.contains(80)); // 输出: trueSystem.out.println("是否包含 99: " + numberSet.contains(99)); // 输出: false// 5. 删除元素numberSet.remove(50);System.out.println("删除 50 后的Set: " + numberSet); // 输出: [20, 80]// 6. 使用迭代器遍历 SetSystem.out.println("\n--- 使用迭代器遍历 ---");Iterator<Integer> iterator = numberSet.iterator();while (iterator.hasNext()) {System.out.print(iterator.next() + " "); // 输出: 20 80 }System.out.println();}
}

5. 深入哈希表 (Hash Table)

前面我们学习了基于红黑树的 TreeMapTreeSet,它们的各项操作性能稳定在 O(logN)。但我们还提到,HashMapHashSet 在理想情况下的性能可以达到惊人的 O(1)。这背后就是数据结构——哈希表

在这里插入图片描述

5.1 哈希表的核心思想

回顾一下,在数组和链表中查找元素,需要从头到尾一个个比较,时间复杂度是 O(N);在平衡二叉搜索树中,利用元素的有序性,每次比较都能排除一半的元素,时间复杂度是 O(logN)。

那么,能不能做得更极致一点,不经过任何比较,一次就定位到元素的位置

哈希表的思想正是如此。它试图建立一种key 到其存储位置(数组下标)的直接映射关系。这个映射关系通过一个特殊的函数——哈希函数(Hash Function) 来实现。

  • 插入时:用哈希函数计算出 key 对应的数组下标,然后直接把元素存到这个位置。
  • 查找时:再次用哈希函数计算出 key 对应的数组下标,然后直接去那个位置取元素。

如果一切顺利,整个过程只需要一次计算和一次数组访问,时间复杂度就是 O(1),效率极高。

5.2 哈希冲突 (Hash Collision)

理想很丰满,但现实是,我们不可能让每一个 key 都完美地映射到一个独一无二的数组下标。原因很简单:key 的取值范围(比如所有可能的字符串)是近乎无限的,而我们的数组容量是有限的。

这就必然导致一个问题:两个不同的 key,通过哈希函数计算后,可能会得到相同的数组下标。这种情况,就称之为哈希冲突哈希碰撞

5.3 如何降低哈希冲突

虽然哈希冲突无法完全避免,但可以通过精心的设计来显著降低冲突的概率。主要有两个抓手:设计一个好的哈希函数,以及维持一个合理的负载因子。

5.3.1 设计优秀的哈希函数

一个好的哈希函数,应该能将 key 尽可能均匀地散布到数组的各个位置。常见的哈希函数设计方法有:

  • 除留余数法:这是最常用的一种方法。hash(key) = key的整数表示 % 数组长度。为了让分布更均匀,这里的“数组长度”通常会选择一个质数
  • 直接定址法hash(key) = A * key + B。这种方法简单,但只适用于 key 的分布比较连续且范围不大的情况。
  • 其他方法:还有平方取中法、折叠法等,适用于特定场景。

Java 中的 StringInteger 等类都精心设计并重写了 hashCode() 方法,来保证产生的哈希码有很好的散列效果。

5.3.2 调节负载因子 (Load Factor)

负载因子是衡量哈希表“拥挤程度”的一个关键指标。

负载因子 = 已存入的元素个数 / 哈希表的总容量

可以把哈希表想象成一个停车场。如果停车场(总容量)很大,但只停了几辆车(元素个数),那新来的车很容易就能找到车位,冲突就少。但如果停车场快满了,新来的车想找个车位就得转悠半天,冲突概率大大增加。

在这里插入图片描述

因此,当负载因子过高时,冲突会急剧增加,哈希表的性能会严重下降。为了解决这个问题,HashMap 等实现会在负载因子达到某个阈值(默认为 0.75)时,进行扩容(Rehashing)——创建一个更大的新数组,并把所有旧元素重新计算哈希值后放入新数组中。这虽然会带来一时的开销,但保证了后续操作的长期高效。

5.4 如何解决哈希冲突

即便我们尽了最大努力,冲突依然会发生。当冲突真的发生时,该怎么办呢?解决冲突的主流方案有两种:闭散列开散列

5.4.1 闭散列 (Closed Hashing)

闭散列,也叫开放定址法。它的核心思想是:如果这个位置被人占了,那就再找一个空位置存进去。所有元素都存储在哈希表这个数组内部,不会有外部的存储结构。

寻找“下一个”空位置主要有两种探测方法:

  1. 线性探测 (Linear Probing)
    最朴素的想法:如果位置 i 被占了,就去看看 i+1;如果 i+1 也被占了,就看 i+2,以此类推,直到找到一个空位。

    • 优点:实现简单。
    • 缺点:容易造成“聚集”现象。即一旦发生冲突,后面的元素也很可能继续冲突,大家挤在一起,形成一长串连续的占位,严重影响后续的查找效率。
    • 删除问题:不能直接删除元素,否则会“断开”探测路径。通常采用懒删除(标记删除),即给被删除的位置打上一个“已删除”的标记。
  2. 二次探测 (Quadratic Probing)
    为了缓解线性探测的聚集问题,二次探测在寻找下一个位置时,不再是简单地 +1,而是按照 +1², -1², +2², -2²… 的步长来跳跃式地探测。
    Hi=(H0±i2)(modm)H_i = (H_0 \pm i^2) \pmod{m} Hi=(H0±i2)(modm)

    • 优点:能有效减轻线性探测的聚集问题。
    • 缺点:实现更复杂,且对负载因子有更严格的要求(通常不能超过 0.5),否则可能找不到空位。

5.4.2 开散列 (Open Hashing)

开散列,也叫拉链法链地址法 (Separate Chaining)。这是 Java HashMap 采用的解决方式,也是目前最主流、最重要的方法。

它的核心思想是:数组的每个位置不直接存储元素,而是存储一个容器(比如链表)的头节点

  • 当一个新元素通过哈希函数定位到某个位置时,不关心这个位置是否“有人”,而是直接将这个新元素插入到该位置对应的链表中
  • 如果后续还有其他元素也映射到这个位置,它们会继续被添加到这个链表的末尾。

这样一来,所有冲突的元素都被串在同一个链条上,查找时只需先定位到数组下标,再遍历这个短链表即可。

在这里插入图片描述

为了防止链表过长导致性能退化,Java 8 的 HashMap 做了一个重要的优化:当某个位置的链表长度超过一个阈值(默认为 8)时,这个链表会自动转化为一棵红黑树,从而将该位置的查找时间复杂度从 O(N) 稳定到 O(logN)。这种设计兼顾了空间和时间效率,是哈希表工程实践中的典范。

开散列法可以看作是把一个在大集合中的搜索问题,巧妙地转化为了在多个小集合中进行搜索。

5.5 代码实现

下面是一个基于拉链法实现的哈希表示例。

基本类型版本

/*** 基于拉链法的哈希桶实现 (处理哈希冲突)* 数组的每个元素都是一个单链表*/
public class HashBuck {/*** 内部节点类,用于存储键值对*/static class Node {public int key;public int val;public Node next;public Node(int key, int val) {this.key = key;this.val = val;}}// 底层数组,每个元素是一个链表的头节点public Node[] array = new Node[10];// 当前哈希表中存储的键值对数量public int usedSize;// 默认的负载因子阈值,当达到此值时触发扩容public static final double DEFAULT_LOAD_FACTOR = 0.75;/*** 添加或更新键值对* @param key 键* @param val 值*/public void push(int key, int val) {// 1. 根据 key 计算哈希桶的索引int index = key % array.length;// 2. 遍历当前桶的链表,查找 key 是否已存在Node cur = array[index];while (cur != null) {if (cur.key == key) {// 如果 key 已存在,更新其值并直接返回cur.val = val;return;}cur = cur.next;}// 3. 如果 key 不存在,则创建一个新节点并使用头插法插入到链表中Node node = new Node(key, val);node.next = array[index];array[index] = node;usedSize++;// 4. 检查是否需要扩容if (getLoadFactor() >= DEFAULT_LOAD_FACTOR) {resize();}}/*** 对哈希表进行扩容,通常是扩大为原来的两倍*/private void resize() {// 创建一个容量为原来两倍的新数组Node[] newArray = new Node[2 * array.length];// 遍历旧数组的每个桶,将节点重新散列到新数组for (int i = 0; i < array.length; i++) {Node cur = array[i];while (cur != null) {Node curNext = cur.next; // 保存下一个节点int newIndex = cur.key % newArray.length;// 使用头插法将节点插入到新数组的对应桶中cur.next = newArray[newIndex];newArray[newIndex] = cur;cur = curNext; // 继续处理下一个节点}}// 将旧数组引用指向新数组array = newArray;}/*** 计算当前哈希表的负载因子* @return 负载因子*/private double getLoadFactor() {return usedSize * 1.0 / array.length;}/*** 根据 key 获取对应的 value* @param key 键* @return 如果找到则返回对应的 value,否则返回 -1*/public int getVal(int key) {int index = key % array.length;Node cur = array[index];while (cur != null) {if (cur.key == key) {return cur.val;}cur = cur.next;}return -1; // 未找到 key}
}

泛型版本

/*** 泛型版本的哈希桶实现* 基于拉链法处理哈希冲突,支持泛型键值对* @param <K> 键的类型* @param <V> 值的类型*/
public class HashBuck2<K, V> {static class Node<K, V> {public K key;public V val;public Node<K, V> next;public Node(K key, V val) {this.key = key;this.val = val;}}public Node<K, V>[] array = (Node<K, V>[]) new Node[10];public int usedSize;public static final double DEFAULT_LOAD_FACTOR = 0.75;/*** 添加或更新键值对* @param key 键* @param val 值*/public void push(K key, V val) {// 1. 使用 key 的 hashCode() 方法计算哈希值int hashCode = key.hashCode();// 2. 计算在数组中的索引int index = hashCode % array.length;// 3. 遍历链表查找 keyNode<K, V> cur = array[index];while (cur != null) {// 对于引用类型,必须使用 equals() 方法比较是否相等if (cur.key.equals(key)) {cur.val = val;return;}cur = cur.next;}// 4. key 不存在,创建新节点并头插到链表Node<K, V> node = new Node<>(key, val);node.next = array[index];array[index] = node;usedSize++;// 5. 检查负载因子,判断是否需要扩容if (getLoadFactor() >= DEFAULT_LOAD_FACTOR) {resize();}}private void resize() {Node<K, V>[] newArray = (Node<K, V>[]) new Node[2 * array.length];for (int i = 0; i < array.length; i++) {Node<K, V> cur = array[i];while (cur != null) {Node<K, V> curNext = cur.next;int hashCode = cur.key.hashCode();int newIndex = hashCode % newArray.length;cur.next = newArray[newIndex];newArray[newIndex] = cur;cur = curNext;}}array = newArray;}private double getLoadFactor() {return usedSize * 1.0 / array.length;}/*** 根据 key 获取对应的 value* @param key 键* @return 如果找到则返回对应的 value,否则返回 null*/public V getVal(K key) {int hashCode = key.hashCode();int index = hashCode % array.length;Node<K, V> cur = array[index];while (cur != null) {if (cur.key.equals(key)) {return cur.val;}cur = cur.next;}return null; // 未找到 key}
}

5.6 性能分析

虽然哈希表一直在和冲突做斗争,但在设计良好(哈希函数均匀、负载因子合理)的情况下,可以认为哈希表的冲突率是可控的,即每个桶中的链表长度是一个常数。因此,通常意义下,哈希表的插入、删除、查找时间复杂度可以认为是O(1)

5.7 和 Java 集合框架的关系

  1. HashMapHashSet 就是 Java 中利用哈希表实现的 MapSet
  2. Java 中的 HashMap 正是采用**哈希桶(拉链法)**方式来解决冲突的。
  3. 当冲突链表的长度大于某个阈值(8)时,Java 会将链表转化为红黑树以优化该桶的查询性能。
  4. Java 中计算哈希值实际上是调用对象的 hashCode() 方法,而进行 key 的相等性比较是调用 keyequals() 方法。因此,如果要用自定义类作为 HashMapkeyHashSet 的元素,必须正确地重写 hashCode()equals() 方法,并保证 equals() 相等的对象,其 hashCode() 一定相等。
http://www.dtcms.com/a/278110.html

相关文章:

  • 每天学一个八股(二)——详解HashMap
  • 封装---优化try..catch错误处理方式
  • 【echarts踩坑记录】为什么第二个Y轴最大值不整洁
  • Acrobat 表单中的下拉菜单(附示例下载)
  • 使用docker的常用命令
  • RS4585自动收发电路原理图讲解
  • 从 Manifest V2 升级到 Manifest V3 的注意事项
  • Extended Nested Arrays for Consecutive Virtual Aperture Enhancement
  • 财务管理体系——解读大型企业集团财务管理体系解决方案【附全文阅读】
  • Python异步编程
  • 57.第二阶段x64游戏实战-实时监控抓取lua内容
  • 利用低汇率国家苹果订阅,120 元开通 ChatGPT Plus
  • 14.使用GoogleNet/Inception网络进行Fashion-Mnist分类
  • docker基础部署
  • ID生成策略
  • 在新版本的微信开发者工具中使用npm包
  • 用信号量实现进程互斥,进程同步,进程前驱关系(操作系统os)
  • DOS下EXE文件的分析 <1>
  • MacBook Air通过VMware Fusion Pro安装Win11
  • 从代码学习深度强化学习 - DDPG PyTorch版
  • [Python 基础课程]列表
  • 【DataLoader的使用】
  • 力扣 hot100 Day43
  • Actor-Critic重要性采样原理
  • java valueOf方法
  • 【算法】贪心算法入门
  • SwiftUI 7 新 WebView:金蛇出洞,网页江湖换新天
  • 一些git命令
  • 若依框架集成阿里云OSS实现文件上传优化
  • 对于muduo我自己的理解