文章目录
- 前言
- 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 插入
向二叉搜索树中插入一个新节点,其过程和查找非常相似。首先要做的,就是找到这个新节点应该被安放的“空位”,以确保插入后,整棵树的性质不被破坏。
插入操作的步骤可以分解为:
- 空树处理: 如果树是空的(即
root
为null
),那么新节点就直接成为根节点。 - 寻找插入位置: 如果树不为空,从根节点开始,像执行查找操作一样,根据新节点的
key
与当前节点的key
的大小关系,决定是向左走还是向右走。 - 找到“父”节点: 这个过程会一直持续,直到找到了一个
null
链接。这个null
链接所连接的“父”节点,就是新节点将要被挂载的地方。 - 完成插入: 将新节点链接到“父”节点的左边或右边,具体取决于新节点的
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
,因为这会留下两个“孤儿”子树,破坏整体结构。
这里的核心思想是替换法:
- 在
cur
的子树中,找到一个合适的节点来“顶替”cur
的位置。这个“顶替者”必须满足:比cur
左子树的所有节点都大,同时比cur
右子树的所有其他节点都小。 - 满足这个条件的节点有两个选择:
cur
左子树中的最大值节点(即左子树中最右边的节点)。cur
右子树中的最小值节点(即右子树中最左边的节点)。
- 通常选择后者,即在
cur
的右子树中寻找值最小的节点(我们称之为target
)。 - 将
target
的值赋给cur
。 - 现在问题就转化成了删除
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
通常会作为一个内部类,因为它和树紧密相关。每个节点需要包含三个核心信息:
- 存储的数据值(
val
)。 - 指向左子节点的引用(
left
)。 - 指向右子节点的引用(
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是一类专门用于搜索的容器或数据结构,其搜索效率与其具体的实现类密切相关。回顾一下常见的搜索方式:
- 直接遍历:时间复杂度为O(N),当元素较多时效率低下。
- 二分查找:时间复杂度为O(logN),但前提是序列必须有序。
这两种方法更适合静态数据的查找,即数据集合不经常发生插入和删除操作的场景。例如:
- 根据姓名查询固定的考试成绩。
- 在通讯录中根据姓名查询联系方式。
- 检查一个单词是否在某个固定的词典中。
然而,当需要频繁地进行插入和删除,即动态查找时,上述两种方式就不太适用了。因此,Map
和 Set
作为专为动态查找设计的集合容器,就显得尤为重要。
2.2 模型
一般把用于搜索的数据称为关键字(Key),与关键字对应的值称为(Value),它们共同构成了一个Key-Value键值对。基于此,搜索模型可以分为两种:
- 纯Key模型: 只关心Key本身是否存在。
- 快速查找一个单词是否在词典中。
- 快速查找某个名字是否在通讯录中。
- 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
的核心区别,这在面试中经常被问到。
特性 | TreeMap | HashMap |
---|---|---|
底层数据结构 | 红黑树(一种自平衡的二叉搜索树) | 哈希表(数组 + 链表/红黑树) |
排序性 | 有序。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
的区别一一对应。
特性 | TreeSet | HashSet |
---|---|---|
底层数据结构 | 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)
闭散列,也叫开放定址法。它的核心思想是:如果这个位置被人占了,那就再找一个空位置存进去。所有元素都存储在哈希表这个数组内部,不会有外部的存储结构。
寻找“下一个”空位置主要有两种探测方法:
-
线性探测 (Linear Probing)
最朴素的想法:如果位置i
被占了,就去看看i+1
;如果i+1
也被占了,就看i+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 集合框架的关系
HashMap
和HashSet
就是 Java 中利用哈希表实现的Map
和Set
。- Java 中的
HashMap
正是采用**哈希桶(拉链法)**方式来解决冲突的。 - 当冲突链表的长度大于某个阈值(8)时,Java 会将链表转化为红黑树以优化该桶的查询性能。
- Java 中计算哈希值实际上是调用对象的
hashCode()
方法,而进行key
的相等性比较是调用key
的equals()
方法。因此,如果要用自定义类作为HashMap
的key
或HashSet
的元素,必须正确地重写hashCode()
和equals()
方法,并保证equals()
相等的对象,其hashCode()
一定相等。