在软件开发中,数据的高效组织和检索是核心任务。Java集合框架提供了强大的Map和Set接口,它们是专门用于搜索操作的数据结构。本文将基于文档内容,系统地介绍Map和Set的核心概念、实现原理和使用方法,帮助读者深入理解这些关键数据结构。
一、数据结构基础:二叉搜索树
1.1 二叉搜索树的概念
**二叉搜索树(Binary Search Tree, BST)**的概念。二叉搜索树是一种特殊的二叉树结构,它或者是一棵空树,或者是具有以下性质的二叉树:
-
若左子树不为空,则左子树上所有节点的值都小于根节点的值
-
若右子树不为空,则右子树上所有节点的值都大于根节点的值
-
左右子树也分别为二叉搜索树
这种性质使得二叉搜索树在查找操作上非常高效,查找时间复杂度取决于树的高度。
1.2 二叉搜索树的核心操作
二叉搜索树的三种基本操作:
1. 查找操作
从根节点开始查找,如果目标值小于当前节点值,则在左子树中查找;如果大于,则在右子树中查找;如果相等,则找到目标。
2. 插入操作
-
如果树为空树,则直接插入新节点作为根节点
-
如果树不为空,按照查找逻辑确定插入位置
-
插入新节点,保持二叉搜索树的性质
3. 删除操作(难点)
删除操作分为三种情况:
-
删除节点只有左子树:用左子树替换被删除节点
-
删除节点只有右子树:用右子树替换被删除节点
-
删除节点左右子树都存在:使用替换法,在右子树中寻找中序下的第一个节点(最小节点),用其值填补被删除节点,再递归删除这个最小节点
1.3 二叉搜索树的性能分析
文档指出了二叉搜索树的性能瓶颈:树的结构严重影响性能。
-
最优情况:二叉搜索树为完全二叉树,平均比较次数为O(log₂N)
-
最差情况:二叉搜索树退化为单支树,平均比较次数为O(N/2)
文档提出的问题非常重要:如何避免二叉搜索树退化为单支树? 答案是使用红黑树这种自平衡二叉搜索树。实际上,Java的TreeMap和TreeSet就是基于红黑树实现的。
二、搜索数据结构的概念与应用场景
2.1 搜索数据结构的重要性
文档强调了为什么需要专门的搜索数据结构。传统的搜索方式有两种:
-
直接遍历:时间复杂度O(N),当元素多时效率低
-
二分查找:时间复杂度O(log₂N),但要求序列有序,不适合动态查找
现实中的查找往往是动态查找,需要在查找过程中进行插入和删除操作,如:
-
根据姓名查询考试成绩
-
通讯录中根据姓名查询联系方式
-
检查关键字是否已在集合中(去重)
2.2 搜索模型
搜索模型主要分为两种:
-
纯Key模型:
-
检查一个单词是否在词典中
-
检查某个名字是否在通讯录中
-
这种模型对应Set接口
-
-
Key-Value模型:
-
统计文件中每个单词出现的次数:
<单词, 出现次数> -
梁山好汉的江湖绰号:
<好汉, 绰号> -
这种模型对应Map接口
-
Map存储的是key-value键值对,而Set只存储key。
三、Map接口详解
3.1 Map的基本概念
Map是一个接口类 ,该类没有继承自Collection接口。Map中存储的是<K,V>结构的键值对,并且K必须是唯一的,不能重复。
3.2 Map.Entry<K,V>内部类
Map通过内部类Map.Entry<K,V>来存放键值对的映射关系。这个内部类提供了:
-
K getKey():返回entry中的key -
V getValue():返回entry中的value -
V setValue(V value):将键值对中的value替换为指定value
重要提示 :Map.Entry<K,V>没有提供设置Key的方法,因为Map中的Key一旦确定就不能修改。
3.3 Map的常用方法
文档提供了完整的Map常用方法表:
| 方法 | 解释 |
|---|---|
V get(Object key) |
返回key对应的value |
V getOrDefault(Object key, V defaultValue) |
返回key对应的value,key不存在时返回默认值 |
V put(K key, V value) |
设置key对应的value |
V remove(Object key) |
删除key对应的映射关系 |
Set<K> keySet() |
返回所有key的不重复集合 |
Collection<V> values() |
返回所有value的可重复集合 |
Set<Map.Entry<K, V>> entrySet() |
返回所有的key-value映射关系 |
boolean containsKey(Object key) |
判断是否包含key |
boolean containsValue(Object value) |
判断是否包含value |
3.4 使用Map的重要注意事项
文档强调了几个关键点:
-
Map是接口,不能直接实例化对象,只能实例化其实现类TreeMap或HashMap
-
Map中键值对的Key是唯一的,value可以重复
-
TreeMap的key不能为null ,否则抛出
NullPointerException,但value可以为空 -
HashMap的key和value都可以为空
-
Map中的Key可以全部分离出来存储到Set中(因为Key不能重复)
-
Map中的value可以全部分离出来存储在Collection的任何一个子集合中(value可能有重复)
-
Map中键值对的Key不能直接修改,value可以修改。如果要修改key,只能先删除再重新插入
3.5 TreeMap与HashMap的区别
文档提供了详细的对比表格:
| 特性 | TreeMap | HashMap |
|---|---|---|
| 底层结构 | 红黑树 | 哈希桶 |
| 插入/删除/查找时间复杂度 | O(log₂N) | O(1) |
| 是否有序 | 关于Key有序 | 无序 |
| 线程安全 | 不安全 | 不安全 |
| 操作原理 | 需要进行元素比较 | 通过哈希函数计算哈希地址 |
| 比较与覆写 | key必须能够比较,否则抛出ClassCastException |
自定义类型需要覆写equals和hashCode方法 |
| 应用场景 | 需要Key有序的场景 | Key是否有序不关心,需要更高的时间性能 |
3.6 TreeMap使用示例
文档提供了TreeMap的使用案例,展示了主要方法的用法:
import java.util.TreeMap;
import java.util.Map;
public static void TestMap() {
Map<String, String> m = new TreeMap<>();
// 插入key-value
m.put("林冲", "豹子头");
m.put("鲁智深", "花和尚");
m.put("武松", "行者");
m.put("宋江", "及时雨");
// put方法返回之前的值,如果是新插入则返回null
String str = m.put("李逵", "黑旋风");
// 获取value
System.out.println(m.get("鲁智深")); // 输出:花和尚
System.out.println(m.get("史进")); // 输出:null
// 检查key是否存在
System.out.println(m.containsKey("林冲")); // 输出:true
// 遍历所有key
for (String key : m.keySet()) {
System.out.print(key + " ");
}
}
四、Set接口详解
4.1 Set的基本概念
文档指出,Set是继承自Collection的接口类,与Map主要有两点不同:
-
Set继承自Collection接口
-
Set中只存储了Key,不存储value
4.2 Set的重要特性
-
Set中只存储key,并且key必须是唯一的
-
TreeSet的底层是使用Map实现的,其使用key与Object的一个默认对象作为键值对插入到Map中
-
Set的最大功能是对集合中的元素进行去重
-
实现Set接口的常用类有
TreeSet、HashSet和LinkedHashSet -
Set中的Key不能修改,如果要修改,需先删除再重新插入
-
TreeSet中不能插入null的key,但HashSet可以
-
LinkedHashSet是在HashSet的基础上维护了一个双向链表来记录元素的插入次序
4.3 TreeSet与HashSet的区别
文档提供了TreeSet和HashSet的对比:
| 特性 | TreeSet | HashSet |
|---|---|---|
| 底层结构 | 红黑树 | 哈希桶 |
| 插入/删除/查找时间复杂度 | O(log₂N) | O(1) |
| 是否有序 | 关于Key有序 | 无序 |
| 线程安全 | 不安全 | 不安全 |
| 操作原理 | 按照红黑树的特性进行插入和删除 | 先计算key哈希地址,再进行插入和删除 |
| 比较与覆写 | key必须能够比较,否则抛出ClassCastException |
自定义类型需要覆写equals和hashCode方法 |
| 应用场景 | 需要Key有序的场景 | Key是否有序不关心,需要更高的时间性能 |
4.4 TreeSet使用示例
import java.util.TreeSet;
import java.util.Set;
import java.util.Iterator;
public static void TestSet() {
Set<String> s = new TreeSet<>();
// 添加元素
s.add("apple");
s.add("orange");
s.add("peach");
s.add("banana");
// 检查元素是否存在
System.out.println(s.contains("apple")); // 输出:true
// 删除元素
s.remove("apple");
// 遍历Set
Iterator<String> it = s.iterator();
while (it.hasNext()) {
System.out.print(it.next() + " ");
}
}
五、哈希表原理深度解析
5.1 哈希表的基本概念
在顺序结构和平衡树中,元素关键码与其存储位置之间没有直接对应关系,因此查找时需要多次比较。哈希表的目标是实现O(1)时间复杂度的查找。
哈希表的基本思想是:通过哈希函数建立元素的存储位置与其关键码之间的映射关系。这样,在查找时可以直接通过哈希函数计算出元素的位置,无需比较。
5.2 哈希冲突
哈希冲突 是指不同关键字通过相同哈希函数计算出相同的哈希地址。例如,使用哈希函数hash(key) = key % 10,元素4和14都会映射到位置4。
5.3 哈希函数设计
哈希函数设计方法:
-
直接定制法 :
Hash(Key) = A * Key + B,适合查找比较小且连续的情况 -
除留余数法 :
Hash(key) = key % p(p是质数),最常用 -
平方取中法:取关键字的平方的中间几位作为哈希地址
-
折叠法:将关键字分割成位数相等的几部分,然后叠加求和
-
随机数法 :
H(key) = random(key),其中random为随机数函数 -
数学分析法:选择关键字中分布均匀的若干位作为散列地址
5.4 负载因子
负载因子(Load Factor) 是哈希表的一个重要概念:α = 填入表中的元素个数 / 散列表的长度
-
α越大,表明填入表中的元素越多,产生冲突的可能性越大
-
α越小,表明填入表中的元素越少,产生冲突的可能性越小
-
Java系统库限制负载因子为0.75,超过此值将扩容哈希表
5.5 解决哈希冲突的方法
两种解决哈希冲突的方法:
1. 闭散列(开放定址法)
当发生哈希冲突时,寻找哈希表中的下一个空位置。主要有两种探测方法:
-
线性探测:从冲突位置开始,依次向后探测
-
二次探测 :使用公式
Hᵢ = (H₀ ± i²) % m寻找下一个位置
闭散列的缺陷是空间利用率较低,且删除元素时需要使用标记的伪删除法。
2. 开散列/哈希桶(重点掌握)
开散列法是Java实际采用的方法。当发生冲突时,将具有相同哈希地址的元素放在同一个链表中,每个链表称为一个"桶"。
这种方法将大集合的搜索问题转化为小集合的搜索问题。当某个桶中的链表过长时,Java会将其转换为红黑树,以保持O(log n)的查找效率。
5.6 哈希表性能分析
文档指出,虽然哈希表一直在和冲突做斗争,但在实际使用中,我们认为哈希表的冲突率是不高的,冲突个数是可控的。因此,通常认为哈希表的插入/删除/查找时间复杂度是O(1)。
5.7 哈希表实现的关键点
几个关键点:
-
负载因子控制:当负载因子超过阈值(如0.75)时,需要扩容
-
扩容策略:通常扩容为原来的2倍
-
自定义类型作为key :必须正确覆写
equals()和hashCode()方法 -
冲突处理:链表过长时转换为红黑树
六、Java中Map和Set的实现关系
Java中Map和Set的实现机制:
-
TreeMap和TreeSet基于红黑树(一种自平衡的二叉搜索树)实现
-
HashMap和HashSet基于哈希表实现
-
Java使用哈希桶方式解决冲突
-
Java在冲突链表长度大于一定阈值后,将链表转换为红黑树
-
使用自定义类作为HashMap的key或HashSet的值时,必须正确覆写
hashCode()和equals()方法
七、实战应用与OJ练习
几个OJ练习题目,这些都是检验Map和Set掌握程度的良好练习:
-
只出现一次的数字:使用HashSet找出只出现一次的数字
-
复制带随机指针的链表:使用HashMap建立原节点和新节点的映射
-
宝石与石头:使用HashSet高效判断字符是否在集合中
-
坏键盘打字:使用HashSet记录坏掉的键
-
前K个高频单词:使用HashMap统计频率,然后排序
八、总结与最佳实践
8.1 如何选择Map/Set实现类?
-
需要有序性:选择TreeMap/TreeSet
-
需要最高性能:选择HashMap/HashSet
-
需要保持插入顺序:选择LinkedHashMap/LinkedHashSet
-
线程安全要求:使用ConcurrentHashMap或Collections.synchronizedMap()
8.2 自定义类型作为key的注意事项
-
必须正确覆写
equals()和hashCode()方法 -
equals()相等的对象,hashCode()必须相等 -
作为TreeMap/TreeSet的key:必须实现Comparable接口或提供Comparator
8.3 性能优化建议
-
合理设置初始容量:减少扩容次数
-
选择合适的负载因子:平衡空间和时间效率
-
避免频繁的扩容操作:预估数据量,设置合适的初始容量
8.4 学习路径建议
-
先理解二叉搜索树的原理
-
掌握红黑树的基本概念(自平衡二叉搜索树)
-
学习哈希表的原理和实现
-
实践Map和Set的常用方法
-
通过OJ题目巩固理解
Map和Set是Java集合框架的核心组成部分,深入理解它们的实现原理和使用方法,对于编写高效、可靠的Java程序至关重要。无论是处理数据检索、去重还是构建缓存系统,Map和Set都能提供强大的支持。希望通过本文的解析,读者能够更好地掌握这些重要的数据结构。