深入理解 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 集合框架的关系

前言

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

1. 搜索树

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

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. 空树处理: 如果树是空的(即 root 为 null),那么新节点就直接成为根节点。
  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树 和 红黑树(TreeMap 和 TreeSet 的底层就是红黑树)。它们通过在插入和删除后进行一些巧妙的“旋转”操作,来时刻保持树的平衡,避免退化。

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

2. Map和Set

2.1 概念

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

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

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

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

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

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(),而是需要实例化它的实现类,最常用的就是 HashMap 和 TreeMap。

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。这是因为 key 在 Map 的结构中起着定位作用(比如在哈希表中计算位置,或在树中进行比较),一旦修改,会破坏 Map 的内部结构。如果确实需要修改 key,正确的做法是先 remove 旧的键值对,再 put 一个新的。

3.4 TreeMap vs HashMap

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

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

4. Set 接口的核心用法

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

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

一个非常巧妙的设计是,许多 Set 的实现类(如 HashSet、TreeSet)的底层就是用对应的 Map(HashMap、TreeMap)来实现的。它们将要存入 Set 的元素作为 Map 的 key,而 value 则存一个固定的、无意义的占位对象。这样一来,就天然地利用了 Map 中 key 唯一的特性,来实现 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 最常用的两个实现类是 TreeSet 和 HashSet。它们之间的区别也和 TreeMap 与 HashMap 的区别一一对应。

特性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)

前面我们学习了基于红黑树的 TreeMap 和 TreeSet,它们的各项操作性能稳定在 O(logN)。但我们还提到,HashMap 和 HashSet 在理想情况下的性能可以达到惊人的 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 中的 String、Integer 等类都精心设计并重写了 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. HashMap 和 HashSet 就是 Java 中利用哈希表实现的 Map 和 Set。
  2. Java 中的 HashMap 正是采用**哈希桶(拉链法)**方式来解决冲突的。
  3. 当冲突链表的长度大于某个阈值(8)时,Java 会将链表转化为红黑树以优化该桶的查询性能。
  4. Java 中计算哈希值实际上是调用对象的 hashCode() 方法,而进行 key 的相等性比较是调用 key 的 equals() 方法。因此,如果要用自定义类作为 HashMap 的 key 或 HashSet 的元素,必须正确地重写 hashCode() 和 equals() 方法,并保证 equals() 相等的对象,其 hashCode() 一定相等。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.tpcf.cn/news/914370.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

excel如何只保留前几行

方法一&#xff1a;手动删除多余行 选中你想保留的最后一行的下一行&#xff08;比如你只保留前10行&#xff0c;那选第11行&#xff09;。按住 Shift Ctrl ↓&#xff08;Windows&#xff09;或 Shift Command ↓&#xff08;Mac&#xff09;&#xff0c;选中从第11行到最…

实时连接,精准监控:风丘科技数据远程显示方案提升试验车队管理效率

风丘科技推出的数据远程实时显示方案更好地满足了客户对于试验车队远程实时监控的需求&#xff0c;并真正实现了试验车队的远程管理。随着新的数据记录仪软件IPEmotion RT和相应的跨平台显示解决方案的引入&#xff0c;让我们的客户端不仅可在线访问记录器系统状态&#xff0c;…

灰盒级SOA测试工具Parasoft SOAtest重新定义端到端测试

还在为脆弱的测试环境、强外部依赖和低效的测试复用拖慢交付而头疼&#xff1f;尤其在银行、医疗、制造等关键领域&#xff0c;传统的端到端测试常因环境不稳、接口难模拟、用例难共享而举步维艰。 灰盒级SOA测试工具Parasoft SOAtest以可视化编排简化复杂测试流程&#xff0c…

OKHttp 核心知识点详解

OKHttp 核心知识点详解 一、基本概念与架构 1. OKHttp 简介 类型&#xff1a;高效的HTTP客户端特点&#xff1a; 支持HTTP/2和SPDY&#xff08;多路复用&#xff09;连接池减少请求延迟透明的GZIP压缩响应缓存自动恢复网络故障2. 核心组件组件功能OkHttpClient客户端入口&#…

从“被动巡检”到“主动预警”:塔能物联运维平台重构路灯管理模式

从以往的‘被动巡检’转变至如今的‘主动预警’&#xff0c;塔能物联运维平台对路灯管理模式展开了重新构建。城市路灯属于极为重要的市政基础设施范畴&#xff0c;它的实际运行状态和市民出行安全以及城市形象有着直接且紧密的关联。不过呢&#xff0c;传统的路灯管理模式当下…

10. 常见的 http 状态码有哪些

总结 1xx: 正在处理2xx: 成功3xx: 重定向&#xff0c;302 重定向&#xff0c;304 协商缓存4xx: 客户端错误&#xff0c;401 未登录&#xff0c;403 没权限&#xff0c;404 资源不存在5xx: 服务器错误常见的 HTTP 状态码详解 HTTP 状态码&#xff08;HTTP Status Code&#xff0…

springBoot对接第三方系统

yml文件 yun:ip: port: username: password: controller package com.ruoyi.web.controller.materials;import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.materials.service.IYunService; import o…

【PTA数据结构 | C语言版】车厢重排

本专栏持续输出数据结构题目集&#xff0c;欢迎订阅。 文章目录题目代码题目 一列挂有 n 节车厢&#xff08;编号从 1 到 n&#xff09;的货运列车途径 n 个车站&#xff0c;计划在行车途中将各节车厢停放在不同的车站。假设 n 个车站的编号从 1 到 n&#xff0c;货运列车按照…

量子计算能为我们做什么?

科技公司正斥资数十亿美元投入量子计算领域&#xff0c;尽管这项技术距离实际应用还有数年时间。那么&#xff0c;未来的量子计算机将用于哪些方面&#xff1f;为何众多专家坚信它们会带来颠覆性变革&#xff1f; 自 20 世纪 80 年代起&#xff0c;打造一台利用量子力学独特性质…

BKD 树(Block KD-Tree)Lucene

BKD 树&#xff08;Block KD-Tree&#xff09;是 Lucene 用来存储和快速查询 **多维数值型数据** 的一种磁盘友好型数据结构&#xff0c;可以把它想成&#xff1a;> **“把 KD-Tree 分块压缩后落到磁盘上&#xff0c;既能做磁盘顺序读&#xff0c;又能像内存 KD-Tree 一样做…

【Mysql作业】

第一次作业要求1.首先打开Windows PowerShell2.连接到MYSQL服务器3.执行以下SQL语句&#xff1a;-- 创建数据库 CREATE DATABASE mydb6_product;-- 使用数据库 USE mydb6_product;-- 创建employees表 CREATE TABLE employees (id INT PRIMARY KEY,name VARCHAR(50) NOT NULL,ag…

(C++)STL:list认识与使用全解析

本篇基于https://cplusplus.com/reference/list/list/讲解 认识 list是一个带头结点的双向循环链表翻译总结&#xff1a; 序列容器&#xff1a;list是一种序列容器&#xff0c;允许在序列的任何位置进行常数时间的插入和删除操作。双向迭代&#xff1a;list支持双向迭代&#x…

Bash函数详解

目录**1. 基础函数****2. 参数处理函数****3. 文件操作函数****4. 日志与错误处理****5. 实用工具函数****6. 高级函数技巧****7. 常用函数库示例****总结&#xff1a;Bash 函数核心要点**1. 基础函数 1.1 定义与调用 可以自定义函数名称&#xff0c;例如将greet改为yana。❌…

Python爬虫实战:研究rows库相关技术

1. 引言 在当今数字化时代,互联网上存在着大量有价值的表格数据,这些数据以 HTML 表格、CSV、Excel 等多种格式存在。然而,由于数据源的多样性和不规范性,表格结构往往存在复杂表头、合并单元格、不规则数据行等问题,给数据的自动化处理带来了巨大挑战。 传统的数据处理工…

通过同态加密实现可编程隐私和链上合规

1. 引言 2023年9月28日&#xff0c;a16z 的加密团队发布了 Nakamoto Challenge&#xff0c;列出了区块链中需要解决的最重要问题。尤其是其中的第四个问题格外引人注意&#xff1a;“合规的可编程隐私”&#xff0c;因为Zama团队已经在这方面积极思考了一段时间。本文提出了使…

封装---统一封装处理页面标题

一.采用工具来实现(setPageTitle.ts)多个页面中用更统一的方式设置 document.title&#xff0c;可以封装一个工具函数:在utils目录下新建文件:setPageTitle.ts如果要在每个页面设置相同的网站标志可以使用下面的appNameconst appName: string import.meta.env.VITE_APP_NAMEex…

JAVA学习笔记 首个HelloWorld程序-002

目录 1 前言 2 开发首个程序 3 小结 1 前言 在所有的开发语言中&#xff0c;基本上首先程序就是输出HelloWorld&#xff0c;这里也不例外。这个需要注意的是&#xff0c;程序的核心功能是数据输出&#xff0c;是要有一个结果&#xff0c;可能没有输入&#xff0c;但是一定有…

智慧监所:科技赋能监狱管理新变革

1. 高清教育&#xff1a;告别模糊画面&#xff0c;学习更清晰传统电视的雪花屏终于成为历史&#xff01;新系统采用高清传输&#xff0c;课件文字清晰可见&#xff0c;教学视频细节分明。某监狱教育科王警官说&#xff1a;"现在播放法律课程&#xff0c;服刑人员能清楚看到…

专题:2025供应链数智化与效率提升报告|附100+份报告PDF、原数据表汇总下载

全文链接&#xff1a;https://tecdat.cn/?p42926 在全球产业链重构与数字技术革命的双重驱动下&#xff0c;供应链正经历从传统经验驱动向数据智能驱动的范式变革。从快消品产能区域化布局到垂类折扣企业的效率竞赛&#xff0c;从人形机器人的成本优化到供应链金融对中小企业的…

uniapp+vue3+ts项目:实现小程序文件下载、预览、进度监听(含项目、案例、插件)

uniapp+vue3+ts项目:实现小程序文件下载、预览、进度监听(含项目、案例、插件) 支持封装调用: 项目采用uniapp+vue3+ts +京东nutUI 开发nutUi文档:loadingPage组件:https://uniapp-nutui.tech/components/exhibition/loadingpage.html案例效果图: 略博主自留地:参考本地…