本系列可作为JAVA学习系列的笔记,文中提到的一些练习的代码,小编会将代码复制下来,大家复制下来就可以练习了,方便大家学习。
点赞关注不迷路!您的点赞、关注和收藏是对小编最大的支持和鼓励!
系列文章目录
JAVA数据结构 DAY9 equals、Comparable、Comparator 与 PriorityQueue 深度解析
拓展目录
手把手教你用 ArrayList 实现杨辉三角:从逻辑推导到每行代码详解
Java 中的 hashCode () 与 equals () 核心原理、契约规范、重写实践与面试全解
目录
目录
前言
小编作为新晋码农一枚,会定期整理一些写的比较好的代码,作为自己的学习笔记,会试着做一下批注和补充,如转载或者参考他人文献会标明出处,非商用,如有侵权会删改!欢迎大家斧正和讨论!
在 Java 集合框架中,Map 和 Set 是处理动态查找场景的核心容器,与传统的数组、链表不同,它们针对高效搜索、插入、删除做了专门优化,底层分别基于红黑树和哈希表实现,对应 TreeMap/TreeSet、HashMap/HashSet 两大核心系列。本文将从基础的二叉搜索树出发,逐步拆解 Map 和 Set 的设计思想、底层原理、常用 API 及实际应用,帮你彻底掌握这一核心知识点。
一、前置基础:二叉搜索树(BST)
TreeMap 和 TreeSet 的底层是红黑树(近似平衡的二叉搜索树),因此理解二叉搜索树是掌握这两个集合的前提。
1.1 二叉搜索树的核心性质
二叉搜索树(又称二叉排序树)满足:
- 左子树所有节点值小于根节点值;
- 右子树所有节点值大于根节点值;
- 左右子树也分别为二叉搜索树;
- 树中节点的 key 唯一,无重复。
1.2 核心操作:查找、插入、删除
(1)查找
从根节点开始,若目标 key 等于根节点 key 则找到;若小于则遍历左子树,大于则遍历右子树,直到节点为空(未找到)。时间复杂度:最优 O (log₂N)(完全二叉树),最差 O (N)(单支树)。
(2)插入
- 树为空时,直接将新节点作为根节点;
- 树非空时,按查找逻辑找到插入位置(父节点),若 key 已存在则插入失败,否则根据 key 大小插入到父节点的左 / 右子节点。
(3)删除(难点)
设待删除节点为cur,其父节点为parent,分三种情况处理:
- cur 左子树为空:直接将 cur 的右子树接在 parent 的对应位置(cur 为根则根更新为 cur.right);
- cur 右子树为空:直接将 cur 的左子树接在 parent 的对应位置(cur 为根则根更新为 cur.left);
- cur 左右子树均存在:采用替换法,在 cur 的右子树中找中序第一个节点(key 最小),用其值覆盖 cur,再删除该最小节点。
1.3 性能与问题
- 最优情况(完全二叉树):平均查找长度 O (log₂N);
- 最差情况(单支树):退化为链表,平均查找长度 O (N/2);
- 核心问题:插入次序会导致树的结构失衡,性能骤降。红黑树通过颜色标记 + 旋转规则解决了这一问题,保证树的高度始终为 O (log₂N),是 TreeMap/TreeSet 的底层实现。
1.4 二叉搜索树的 Java 简易实现
public class BinarySearchTree {
public static class Node {
int key;
Node left;
Node right;
public Node(int key) {
this.key = key;
}
}
private Node root = null;
// 查找
public Node search(int key) {
Node cur = root;
while (cur != null) {
if (key == cur.key) return cur;
else if (key < cur.key) cur = cur.left;
else cur = cur.right;
}
return null;
}
// 插入
public boolean insert(int key) {
if (root == null) {
root = new Node(key);
return true;
}
Node cur = root;
Node parent = null;
while (cur != null) {
if (key == cur.key) return false;
else if (key < cur.key) {
parent = cur;
cur = cur.left;
} else {
parent = cur;
cur = cur.right;
}
}
Node node = new Node(key);
if (key < parent.key) parent.left = node;
else parent.right = node;
return true;
}
// 删除(需补充具体逻辑)
public boolean remove(int key) {
Node cur = root;
Node parent = null;
while (cur != null) {
if (key == cur.key) break;
else if (key < cur.key) {
parent = cur;
cur = cur.left;
} else {
parent = cur;
cur = cur.right;
}
}
if (cur == null) return false;
// 补充三种删除情况的逻辑
return true;
}
}
二、Map 和 Set:动态查找的核心容器
Map 和 Set 是为动态查找设计的集合(支持随时插入、删除、查找),区别于静态查找的二分查找(要求序列有序,不适合频繁修改),适用于 “通讯录查询”“单词去重”“键值对映射” 等场景。
2.1 两种核心模型
Map 和 Set 的设计基于两种数据模型,也是二者的核心区别:
- 纯 Key 模型(Set):仅存储唯一的 Key,核心功能是去重 + 查找,例如 “判断单词是否在词典中”;
- Key-Value 模型(Map):存储键值对,Key 唯一,Value 可重复,核心功能是根据 Key 查找 Value,例如 “姓名对应考试成绩”。
2.2 整体体系结构
- Map:独立接口,不继承 Collection,实现类为 TreeMap(红黑树)、HashMap(哈希表);
- Set:继承 Collection 接口,底层基于 Map 实现(将 Key 作为 Set 元素,Value 为默认空对象),实现类为 TreeSet(红黑树)、HashSet(哈希表)、LinkedHashSet(哈希表 + 双向链表,保留插入顺序)。
三、Map 接口:Key-Value 键值对映射
Map 是存储<K,V>键值对的接口,Key 唯一且不可直接修改,Value 可重复、可修改,核心实现类为 TreeMap 和 HashMap。
3.1 核心内部类:Map.Entry<K,V>
Map 内部用Map.Entry<K,V>封装单个键值对,提供键值对的获取和修改方法,无设置 Key 的方法(Key 不可直接修改):
| 方法 | 解释 |
|---|---|
| K getKey() | 返回当前 Entry 的 Key |
| V getValue() | 返回当前 Entry 的 Value |
| V setValue(V value) | 修改当前 Entry 的 Value,返回旧值 |
3.2 Map 的常用 API
Map 的核心方法围绕 Key 的增删改查和键值对遍历展开,所有方法均为接口方法,由实现类实现:
| 方法 | 解释 |
|---|---|
| V get(Object key) | 根据 Key 获取 Value,Key 不存在返回 null |
| V getOrDefault(Object key, V defaultValue) | 根据 Key 获取 Value,Key 不存在返回默认值 |
| V put(K key, V value) | 插入 / 修改键值对:Key 不存在则插入,返回 null;Key 存在则修改 Value,返回旧值 |
| V remove(Object key) | 删除 Key 对应的键值对,返回该 Key 的 Value |
| Set<K> keySet() | 返回所有 Key 的 Set 集合(Key 唯一) |
| Collection<V> values() | 返回所有 Value 的 Collection 集合(Value 可重复) |
| Set<Map.Entry<K,V>> entrySet() | 返回所有键值对的 Set 集合,用于遍历 |
| boolean containsKey(Object key) | 判断是否包含指定 Key |
| boolean containsValue(Object value) | 判断是否包含指定 Value |
3.3 TreeMap 与 HashMap 的核心区别
TreeMap 基于红黑树实现,HashMap 基于哈希表实现,二者特性差异显著,决定了各自的应用场景:
| 特性 | TreeMap | HashMap |
|---|---|---|
| 底层结构 | 红黑树 | 哈希桶(数组 + 链表 / 红黑树) |
| 时间复杂度 | 插入 / 删除 / 查找均为 O (log₂N) | 插入 / 删除 / 查找均为 O (1)(理想情况) |
| Key 是否有序 | 按 Key 的自然顺序 / 自定义比较器排序 | 无序(JDK8 后为插入顺序,非排序) |
| 空值支持 | Key 不可为 null,Value 可为 null | Key 和 Value 均可为 null |
| 核心要求 | Key 必须实现 Comparable 接口或自定义比较器,否则抛 ClassCastException | 自定义类型 Key 必须覆写 equals () 和 hashCode () |
| 核心区别 | 基于比较器实现元素排序和查找 | 基于哈希函数计算地址,实现快速查找 |
| 应用场景 | 需按 Key 有序遍历的场景 | 无需 Key 有序,追求极致查找性能的场景 |
3.4 TreeMap 实战示例
import java.util.Map;
import java.util.TreeMap;
public class TreeMapTest {
public static void main(String[] args) {
Map<String, String> heroMap = new TreeMap<>();
// 插入键值对
heroMap.put("林冲", "豹子头");
heroMap.put("鲁智深", "花和尚");
heroMap.put("武松", "行者");
heroMap.put("李逵", "黑旋风");
System.out.println("集合大小:" + heroMap.size()); // 4
System.out.println("原始集合:" + heroMap); // 按Key自然排序
// 修改Value
String oldValue = heroMap.put("李逵", "铁牛");
System.out.println("李逵旧绰号:" + oldValue); // 黑旋风
// 获取Value
System.out.println(heroMap.get("鲁智深")); // 花和尚
System.out.println(heroMap.getOrDefault("史进", "九纹龙")); // 九纹龙
// 判断包含
System.out.println(heroMap.containsKey("林冲")); // true
System.out.println(heroMap.containsValue("九纹龙")); // false
// 遍历Key
for (String key : heroMap.keySet()) {
System.out.print(key + " ");
}
System.out.println();
// 遍历Value
for (String value : heroMap.values()) {
System.out.print(value + " ");
}
System.out.println();
// 遍历键值对(推荐)
for (Map.Entry<String, String> entry : heroMap.entrySet()) {
System.out.println(entry.getKey() + "---->" + entry.getValue());
}
}
}
四、Set 接口:纯 Key 的唯一集合
Set 继承自 Collection 接口,仅存储唯一的 Key,核心功能是去重和查找,底层基于 Map 实现(将 Key 作为 Set 元素,Value 为一个固定的空 Object),核心实现类为 TreeSet 和 HashSet。
4.1 Set 的常用 API
Set 的方法与 Collection 接口基本一致,无新增方法,核心围绕元素的增删改查和去重:
| 方法 | 解释 |
|---|---|
| boolean add(E e) | 添加元素,元素已存在则添加失败,返回 false |
| boolean remove(Object o) | 删除指定元素,元素不存在则返回 false |
| boolean contains(Object o) | 判断集合是否包含指定元素 |
| int size() | 返回集合元素个数 |
| boolean isEmpty() | 判断集合是否为空 |
| Iterator<E> iterator() | 返回迭代器,用于遍历元素 |
| void clear() | 清空集合 |
| boolean addAll(Collection<? extends E> c) | 将集合 c 的元素添加到 Set 中,实现去重 |
4.2 TreeSet 与 HashSet 的核心区别
与 TreeMap/HashMap 的区别对应,TreeSet 基于红黑树,HashSet 基于哈希表,LinkedHashSet 是 HashSet 的子类,在哈希表基础上维护了双向链表,保留元素的插入顺序:
| 特性 | TreeSet | HashSet | LinkedHashSet |
|---|---|---|---|
| 底层结构 | 红黑树 | 哈希桶 | 哈希桶 + 双向链表 |
| 时间复杂度 | O(log₂N) | O(1) | O(1) |
| Key 是否有序 | 自然排序 / 自定义排序 | 无序 | 按插入顺序排序 |
| 空值支持 | Key 不可为 null | Key 可为 null | Key 可为 null |
| 核心要求 | Key 需实现 Comparable 接口 | 自定义 Key 需覆写 equals () 和 hashCode () | 同 HashSet |
| 应用场景 | 需有序遍历的去重场景 | 无需有序,追求性能的去重场景 | 需保留插入顺序的去重场景 |
4.3 TreeSet 实战示例
import java.util.Iterator;
import java.util.Set;
import java.util.TreeSet;
public class TreeSetTest {
public static void main(String[] args) {
Set<String> fruitSet = new TreeSet<>();
// 添加元素
fruitSet.add("apple");
fruitSet.add("orange");
fruitSet.add("peach");
fruitSet.add("banana");
System.out.println("集合大小:" + fruitSet.size()); // 4
System.out.println("原始集合:" + fruitSet); // 按自然排序
// 重复添加,返回false
boolean isAdd = fruitSet.add("apple");
System.out.println("是否添加成功:" + isAdd); // false
// 判断包含
System.out.println(fruitSet.contains("apple")); // true
System.out.println(fruitSet.contains("watermelon")); // false
// 删除元素
fruitSet.remove("apple");
System.out.println("删除后集合:" + fruitSet); // [banana, orange, peach]
// 迭代器遍历
Iterator<String> it = fruitSet.iterator();
while (it.hasNext()) {
System.out.print(it.next() + " ");
}
}
}
五、HashMap/HashSet 底层:哈希表(散列表)
HashMap 和 HashSet 的底层是哈希表,是实现 O (1) 时间复杂度查找的核心,也是 Java 集合中最常用的容器,其设计围绕哈希函数、冲突解决、负载因子三大核心展开。
5.1 哈希表的核心概念
哈希表的核心思想是:通过哈希函数(hashFunc) 让元素的Key与存储位置建立一一映射,实现一次计算直接找到元素,无需比较。
- 哈希函数:将 Key 转换为哈希表数组下标的函数,例如
hash(key) = key % 数组长度; - 哈希表:根据哈希函数构造的存储结构,底层为数组(哈希桶),每个数组元素对应一个链表 / 红黑树(解决冲突);
- 核心优势:插入、删除、查找的时间复杂度均为 O (1)(理想情况,无冲突)。
5.2 哈希冲突:不可避免的问题
(1)冲突定义
两个不同的 Key(ki ≠ kj),通过哈希函数计算得到相同的哈希地址(hash(ki) = hash(kj)),该现象称为哈希冲突 / 哈希碰撞,这两个 Key 称为同义词。
(2)冲突的必然性
哈希表底层数组的容量是有限的,而待存储的 Key 数量是无限的,根据鸽巢原理,冲突必然发生,我们能做的是降低冲突率。
5.3 降低冲突率:哈希函数设计 + 负载因子调节
(1)哈希函数设计原则
- 定义域包含所有待存储 Key,值域在
0 ~ 数组长度-1之间; - 计算出的地址均匀分布在数组中,减少冲突;
- 函数计算简单,提升效率。
(2)常用哈希函数
| 函数名称 | 实现方式 | 适用场景 |
|---|---|---|
| 直接定制法 | Hash(Key) = A*Key + B(线性函数) | Key 范围小且连续的场景 |
| 除留余数法 | Hash(Key) = Key % p(p 为≤数组长度的质数) | 最常用,通用场景 |
| 平方取中法 | 将 Key 平方后抽取中间几位作为地址 | 未知 Key 分布,Key 位数较少的场景 |
| 折叠法 | 将 Key 分割为若干部分,叠加求和后取后几位 | Key 位数较多的场景 |
| 随机数法 | Hash(Key) = random(Key) | Key 长度不固定的场景 |
| 数学分析法 | 抽取 Key 中分布均匀的几位作为地址 | 已知 Key 分布的场景(如手机号) |
Java 中哈希函数:自定义类型需覆写hashCode()方法,JDK 会对hashCode()的结果进行二次哈希,减少哈希冲突。
(3)负载因子调节(重点)
① 负载因子定义
负载因子α = 填入表中的元素个数 / 哈希表数组长度,是哈希表装满程度的标志。
② 负载因子与冲突率的关系
α越大,哈希表中元素越多,冲突率越高;α越小,元素越少,冲突率越低,但空间利用率也越低。
③ Java 中的负载因子
Java 中 HashMap 的默认负载因子为0.75,当实际负载因子超过 0.75 时,会触发数组扩容(扩容为原长度的 2 倍),通过降低负载因子变相降低冲突率。
注意:开放定址法的负载因子需控制在 0.7~0.8 以下,否则 CPU 缓存不命中率会指数级上升。
5.4 解决哈希冲突:闭散列 vs 开散列
当哈希冲突发生时,有两种核心解决方式,Java 中 HashMap 采用开散列(哈希桶)。
(1)闭散列(开放定址法)
当发生冲突时,在哈希表中寻找下一个空位置存放元素,常用两种探测方式:
- 线性探测:从冲突位置开始,依次向后查找空位置,例如
Hi = (H0 + i) % 数组长度(i=1,2,3...);- 缺陷:容易产生数据堆积,冲突位置附近的位置被连续占用,进一步提高冲突率;
- 删除:不能直接物理删除,需采用伪删除(标记为已删除),否则会影响后续查找。
- 二次探测:从冲突位置开始,按平方数查找空位置,例如
Hi = (H0 ± i²) % 数组长度(i=1,2,3...);- 优势:解决了数据堆积问题,冲突分布更均匀;
- 要求:数组长度为质数,且负载因子≤0.5,否则可能找不到空位置。
闭散列整体缺陷:空间利用率低,是哈希表的次要解决冲突方式。
(2)开散列(哈希桶 / 链地址法)【Java 采用】
① 核心思想
哈希表底层为数组(哈希桶),每个数组元素对应一个单链表(JDK8 后,当链表长度≥8 且数组长度≥64 时,转为红黑树);发生冲突的 Key,被放入同一个数组元素对应的链表 / 红黑树中。
② 实现流程
- 通过哈希函数计算 Key 的哈希地址,得到数组下标;
- 若该下标对应的桶为空,直接将元素作为链表头节点存入;
- 若该桶已存在元素,将元素添加到链表尾部(JDK8)/ 头部(JDK7);
- 查找时,先计算下标,再在对应桶的链表 / 红黑树中查找 Key。
③ 核心优势
- 空间利用率高,无需预留大量空位置;
- 无数据堆积问题,冲突仅影响单个桶;
- 支持物理删除,不影响其他元素查找。
④ 冲突严重的解决
若单个桶的冲突过于严重(链表过长),可将桶的底层结构从链表改为哈希表或红黑树,将大集合的搜索问题转化为小集合的搜索问题。
5.5 哈希表的 Java 简易实现(哈希桶)
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; // 负载因子阈值
// 构造方法:初始化数组长度为8
public HashBucket() {
array = new Node[8];
size = 0;
}
// 插入/修改键值对
public int put(int key, int value) {
int index = key % array.length;
// 遍历链表,若Key存在则修改Value
for (Node cur = array[index]; cur != null; cur = cur.next) {
if (cur.key == key) {
int oldValue = cur.value;
cur.value = value;
return oldValue;
}
}
// Key不存在,头插法插入新节点
Node node = new Node(key, value);
node.next = array[index];
array[index] = node;
size++;
// 负载因子超过阈值,扩容
if (loadFactor() >= LOAD_FACTOR) {
resize();
}
return -1;
}
// 扩容:数组长度翻倍,重新哈希所有元素
private void resize() {
Node[] newArray = new Node[array.length * 2];
for (int i = 0; i < array.length; i++) {
Node cur = array[i];
while (cur != null) {
Node next = cur.next; // 保存下一个节点
// 重新计算哈希地址
int index = cur.key % newArray.length;
// 头插法插入新数组
cur.next = newArray[index];
newArray[index] = cur;
cur = next;
}
}
array = newArray; // 替换为新数组
}
// 计算当前负载因子
private double loadFactor() {
return size * 1.0 / array.length;
}
// 根据Key获取Value
public int get(int key) {
int index = key % array.length;
Node cur = array[index];
while (cur != null) {
if (cur.key == key) {
return cur.value;
}
cur = cur.next;
}
return -1; // Key不存在
}
}
5.6 哈希表的性能分析
在实际使用中,哈希表的冲突率被控制在较低水平,每个桶的链表 / 红黑树长度为常数,因此:
- 插入、删除、查找的平均时间复杂度为 O (1);
- 当冲突严重时(如哈希函数设计不合理),时间复杂度会退化为 O (log₂N)(红黑树)或 O (N)(链表)。
5.7 Java 中哈希表的核心注意事项
自定义类型作为 HashMap 的 Key 或 HashSet 的元素时,必须同时覆写 equals () 和 hashCode () 方法,且满足:
- equals () 相等的对象,hashCode () 必须相等:保证相同的对象映射到同一个哈希桶;
- hashCode () 相等的对象,equals () 不一定相等:允许哈希冲突,冲突后通过 equals () 判断是否为同一对象。
若仅覆写 equals (),未覆写 hashCode (),会导致相同的对象生成不同的哈希地址,无法正确去重和查找。
六、Map/Set 的核心总结与应用场景
6.1 核心总结
-
Tree 系列(TreeMap/TreeSet):
- 底层:红黑树(平衡二叉搜索树);
- 特性:Key 有序,时间复杂度 O (log₂N),Key 不可为 null(TreeMap/TreeSet);
- 要求:Key 需实现 Comparable 接口或自定义比较器。
-
Hash 系列(HashMap/HashSet):
- 底层:哈希桶(数组 + 链表 / 红黑树);
- 特性:Key 无序,时间复杂度 O (1),Key/Value 可为 null(HashMap/HashSet);
- 要求:自定义 Key 需覆写 equals () 和 hashCode ()。
-
LinkedHashSet:
- 底层:哈希桶 + 双向链表;
- 特性:保留插入顺序,时间复杂度 O (1),兼具 HashSet 的性能和有序性。
6.2 典型应用场景
| 场景 | 推荐容器 | 原因 |
|---|---|---|
| 按姓名 / 学号有序查询信息 | TreeMap/TreeSet | 需 Key 有序遍历 |
| 通讯录快速查询(姓名→电话) | HashMap | 无需有序,追求 O (1) 查找性能 |
| 数组 / 集合去重 | HashSet/LinkedHashSet | 高效去重,LinkedHashSet 可保留插入顺序 |
| 统计单词出现次数 | HashMap<String, Integer> | Key 为单词,Value 为次数,快速统计和查询 |
| 缓存系统(Key→数据) | HashMap | 高速存取,符合缓存的性能要求 |
6.3 经典 OJ 练习
掌握 Map/Set 后,可解决以下经典算法题,巩固知识点:
- 只出现一次的数字:利用 HashSet 的去重特性;
- 宝石与石头:利用 HashSet 存储宝石,快速判断石头是否为宝石;
- 坏键盘打字:利用 HashSet 存储正常按键,筛选坏键;
- 前 K 个高频单词:利用 HashMap 统计频率,结合排序 / 堆实现;
- 复制带随机指针的链表:利用 HashMap 建立原节点→新节点的映射。
注:这里将会更新一个链接,包含OJ练习的记录和联系网址链接。
七、总结
Map 和 Set 是 Java 集合框架中处理动态查找的核心,其设计分别基于红黑树和哈希表两大数据结构,对应 Tree 系列和 Hash 系列,二者各有优劣:
- 若需要Key 有序,选择 TreeMap/TreeSet,牺牲一点性能换取有序性;
- 若追求极致性能,无需有序,选择 HashMap/HashSet,是日常开发的首选;
- 若需要保留插入顺序且高效,选择 LinkedHashSet/LinkedHashMap。
掌握 Map/Set 的关键,不仅要熟记 API,更要理解其底层原理(二叉搜索树、红黑树、哈希表),尤其是哈希表的哈希函数、冲突解决、负载因子,这也是面试的高频考点。只有理解底层,才能在实际开发中选择合适的容器,写出高效、健壮的代码。

总结
以上就是今天要讲的内容,本文简单记录了java数据结构,仅作为一份简单的笔记使用,大家根据注释理解,您的点赞关注收藏就是对小编最大的鼓励!

477

被折叠的 条评论
为什么被折叠?



