文章目录
- 一、搜索
- (一)概念及场景
- (二)模型
- 二、Map
- (一)介绍
- (二)Map常用方法说明
- 1.需要注意的几个点
- 2.特别注意的几个方法
- (1)V getOrdefault(Object key,V defaultValue),这个方法可以减少我们出bug的概率
- (2)Set<Map.Entry<K,V>> entrySet()
- <1>关于Map.Entry<K,V>的说明
- (三)TreeMap使用案例
- 三、Set
- (一)常见方法说明
- 1.需要注意的几点
- 四、哈希表
- (一)概念
- (二)哈希冲突
- (三)冲突的避免
- 1. 哈希函数设计
- 2. 装填因子调节(重点)
- (四)冲突的处理
- 1. 闭散列
- 2.开散列/哈希桶(重点,Java8中桶满后会变成二叉搜索树)
- 开散列冲突严重时的解决办法
- 开散列的性能分析
- (五)哈希表和 Java 类集的关系
- (六)HashMap源码分析
- 1.HashMap是可序列化的
- 2.HashMap中各种final值的意义
- (1)对树化的分析
- (2)HashMap的初始容量为什么必须是2的次幂
- 3.真正分配容量内存的位置
- 4.HashMap中阈值(什么时候就需要对存储进行扩容)的确定
- 5.HashMap何时扩容
- 6.扩容时空间和阈值的变化
- 7.HashMap中hash的取值
- 8.哈希桶中链表插入采用尾插法
- 五、Map的几种遍历方法
- (一)toString
- (二)知道所有的key,然后手动一个一个遍历(可以尝试KeySet方法)
- (三)实例化entrySet内部类
- 六、TreeMap(TreeSet)和HashMap(HashSet)的区别
一、搜索
(一)概念及场景
Map和set是一种专门用来进行搜索的容器或者数据结构,其搜索的效率与其具体的实例化子类有关(具体数据结构包括Tree和Hash)。以前常见的搜索方法有:
1.直接遍历,时间复杂度为O(N),元素如果比较多效率会非常慢
2.二分查找,时间复杂度为O(logN),但搜索的前提是必须有序
二分查找比较适合静态类型的查找,即尽量不会对序列中的元素进行增删改,但是现实中的许多情况下又必须在查找时进行一定的增删操作,即动态查找,Map和Set就是一种适合动态查找的集合容器,查找效率既高,增加删除元素的成本也很小
(二)模型
我们在搜索的时候一般把搜索的数据称为关键字(Key),和关键字对应的称为值(Value),这两个组合起来并称为键值对,所以存在了两种模型:
- 纯key模型,就是单纯的关键字,可能是Integer,String等等类型
- Key-Value模型,Key是独一无二作为关键字的,Value则提供一种辅助,比如:
- 字符串出现的次数
- 在一家餐厅点外卖的备注
二、Map
(一)介绍
Map是一个独立的接口类,该类没有继承Collection和Iterable接口,因此我们在前面数据结构常用的方法不一定有,也无法使用迭代器和for-each语句,但是提供了toString()方法,因此如何实现自主遍历是我们后面要介绍的重点之一
该类中存储的是结构的键值对,并且K一定是唯一的,不能重复
(二)Map常用方法说明
这个时候上次学的二叉搜索树就派上用场了,作者以TreeMap为例,即底层是红黑树的数据结构的Key-Value值
方法 | 功能 |
---|---|
V get(Object key) | 返回key对应的value值 |
V getOrdefault(Object key,V defaultValue) | 返回key对应的value,key不存在时,返回默认值 |
V put(K key,V value) | 增加key和对应的value或更改原有key的value值 |
V remove(Object key) | 删除对应的Key-Value键值对 |
Set keySet() | 返回所有key的不重复集合 |
Collection values() | 返回所有value的可重复集合 |
Set<Map.Entry<K,V>>entrySet() | 返回所有的key-value映射关系 |
boolean containsKey(Object key) | 判断是否存在key |
boolean containsValue(Object value) | 判断是否包含value |
1.需要注意的几个点
- Map是一个接口,不能直接实例化对象,如果要实例化对象只能实例化其实现类TreeMap或者HashMap
- Map中存放键值对的Key是唯一的,value是可以重复的
- Map中的Key可以全部分离出来,存储到Set中来进行访问(因为Key不能重复)。
- Map中的value可以全部分离出来,存储在Collection的任何一个子集合中(value可能有重复)。
- Map中键值对的Key不能直接修改,value可以修改,如果要修改key,只能先将该key删除掉,然后再来进行重新插入
2.特别注意的几个方法
(1)V getOrdefault(Object key,V defaultValue),这个方法可以减少我们出bug的概率
(2)Set<Map.Entry<K,V>> entrySet()
我们之前说过,Map无法自己实现遍历,只能用toString()方法或者自己本来就知道所有的key值然后一个一个搜索。实际这个问题是我们无法访问到节点,root被设成private修饰符了,而其他节点必须依靠root节点才能访问到,所以加了这个方法来解决这个问题的
<>中的是类型,即Map.Entry<K,V>,而在Map中静态内部类Entry<K,V>就是节点的存储形式
从源码中我们可以看出,节点的方法实际上是public的,我们可以直接使用
所以我们现在要解决的问题就是怎么才能得到节点呢???就想到了迭代器。但是迭代器需要实现Iterable接口,Map并没有实现Iterable接口,因此才又创建了一个实例内部类EntrySet来实现Iterable接口,具体示例代码见最下方遍历Map操作具体流程图如下图:
<1>关于Map.Entry<K,V>的说明
Map.Entry<K,V>是Map内部实现的用来存放<key,value>键值对映射关系的内部类,该内部类中主要提供了<key,value>的获取,value的设置以及key的比较方式
方法 | 功能 |
---|---|
K getKey() | 返回entry中的key |
V getValue() | 返回entry中的value |
V setValue(V value) | 将键值对中的value替换为指定value |
注意:Map.Entry<K,V>并没有提供设置Key的方法
(三)TreeMap使用案例
代码示例:
public static void main(String[] args) {
TreeMap<String,Integer> map = new TreeMap<>();
//V put(K key,V value) | 增加key和对应的value或更改原有key的value值
map.put("hello", 1);
map.put("world", 2);
System.out.println(map);
//V get(Object key) | 返回key对应的value值
System.out.println(map.get("world"));
/**
* V getOrdefault(Object key,V defaultValue) | 返回key对应的value,key不存在时,返回默认值
*/
//注意,由于我们的value类型时Integer类型的,因此如果没有找到对应的key,并且采用int类型接收将极其危险
//因为此时会return null,而null无法自动拆包,int无法接收,就会报空指针异常错误
/*错误示范*/
//int num = map.get("main");
/*正确示范*/
Integer num1 = map.get("main");
System.out.println(num1);
System.out.println(map.getOrDefault("main",0));
//Set<K> keySet() | 返回所有key的不重复集合
Set<String> set = map.keySet();
System.out.println(set);
//Collection<V> values()
Collection<Integer> collection = map.values();
System.out.println(collection);
//Set<Map.Entry<K,V>>entrySet()
Set<Map.Entry<String, Integer>> mapSet = map.entrySet();
for (Map.Entry<String, Integer> entry: mapSet) {
System.out.println("key:" + entry.getKey() + " value:" + entry.getValue());
}
//V remove(Object key) | 删除对应的Key-Value键值对
map.remove("world");
System.out.println(map);
//boolean containsKey(Object key)
System.out.println(map.containsKey("world"));
//boolean containsValue(Object value)
System.out.println(map.containsValue(1));
}
运行结果:
三、Set
Set和Map的区别主要有两点:Set继承了Collection的接口,Set中只存储key
(一)常见方法说明
方法 | 功能 |
---|---|
boolean add(E e) | 添加元素,但重复元素不会被添加成功 |
void clear() | 清空集合 |
boolean contains(Object o) | 判断 o 是否在集合中 |
Iterator iterator() | 返回迭代器 |
boolean remove(Object o) | 删除集合中的 o |
int size() | 返回set中元素的个数 |
boolean isEmpty() | 检测set是否为空,空返回true,否则返回false |
Object[] toArray() | 将set中的元素转换为数组返回 |
boolean containsAll(Collection<?> c) | 集合c中的元素是否在set中全部存在,是返回true,否则返回false |
boolean addAll(Collection<? extends E> c) | 将集合c中的元素添加到set中,可以达到去重的效果 |
1.需要注意的几点
1.Set是继承自Collection的一个接口
2.Set只存储了key,并且要求key唯一
3.Set底层是用Map来实现的,Set使用key和Object的一个默认对象作为键值对来方便使用Map的集合
4.Set中的最大功能就是对集合中的元素去重
5.实现set接口的常用类有TreeSet和HashSet
6.Set中的key不能修改,只能删除,然后重新插入
7.Set中不能插入null的key
四、哈希表
(一)概念
构造一种数据结构,通过某种函数使元素的存储位置与它的关键码之间能够建立一一映射的关系,查找时通过函数直接查找到存储位置,那么就可以不经过任何比较,一次直接从表中得到要搜索的元素。
这种方法即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构叫做哈希表(HashTable)(或散列表)
(二)哈希冲突
如下图,不同关键字通过相同的哈希函数得到相同的哈希地址,这种现象称为哈希冲突或者哈希碰撞
把具有不同关键码(Key)而具有相同哈希地址的数据元素称为“同义词”
(三)冲突的避免
首先明确,哈希表底层数组的容量往往比需要存储的数据量小,因此冲突是必然的,我们能做的就是降低冲突率
1. 哈希函数设计
引起哈希冲突的一个原因是:哈希函数设计的不够合理。哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,如果哈希表允许有m个地址时,其值域必须在0~m-1之间
- 哈希函数计算出来的哈希地址应该能够均匀分布在整个哈希表中
- 哈希函数的设计应该要比较简单
常见的哈希函数:
- 直接定址法
根据关键码来设线性函数取哈希地址:Hash(Key) = A * Key + B,
优点:简单,均匀
缺点:需要事先知道关键码的分布情况
使用场景:适合查找比较小且连续的情况
2.除留余数法
当哈希表的数组大小为 m 时,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(Key) = key%p(p<=m),将关键码转为哈希地址
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越小,但无法避免哈希冲突
2. 装填因子调节(重点)
哈希表的装填因子定义为:α=填入表中的元素/散列表的长度
由上述公式我们就能看到,α是散列表装满程度的标志因子,由于表长是定长,α与填入表中的元素个数成正比。因此,α越大,表明填入表中的元素越多,产生冲突的可能性就越大,相反,就越小
由于HashMap是采用开放定址法来处理冲突的,因此负载因子在HashMap中是极为重要的,一般HashMap的负载因子取值都要求在0.75左右
已知哈希表中已有的关键字个数是不可变的,那我们只能调整的哈希表中的数组的大小
(四)冲突的处理
处理哈希冲突常用的两种解决办法:闭散列和开散列
1. 闭散列
闭散列(开放定址法):当发生哈希冲突时,如果哈希表未被填满,说明在哈希表中必然还有空位置,那么就可以把key放在冲突位置的下一个空位置中。寻找下一个空位置的方法:
(1) 线性探测
从发生冲突的位置开始向后探测,知道寻找到下一个空位置为止,如下图:
采用线性探测处理哈希冲突时,不能随便物理删除哈希表中已有的元素,如果直接删除元素,可能会对其他元素造成影响,导致其他元素查找不到,因此线性探测采用标记的伪代码来删除一个元素
(2)二次探测
线性探测的缺陷就是将产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:Hi = (H0 + i2)% m, 或者:Hi = (H0 - i2)% m。其中:i=1,2,3…,H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小.如下图:
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容
因此,闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷
2.开散列/哈希桶(重点,Java8中桶满后会变成二叉搜索树)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中,如下图:
由上图可知,开散列中的每个桶中存放的都是发生哈希冲突的元素
开散列可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索
开散列冲突严重时的解决办法
如果冲突严重,就意味着小集合的搜索性能也不好,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
- 每个桶的背后是另一个哈希表
- 每个桶的背后是一棵搜索树
开散列的性能分析
虽然作者用了很多的语句介绍哈希冲突,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,因此,通常情况下,我们认为哈希表的插入/删除/查找时间复杂度是O(1)
(五)哈希表和 Java 类集的关系
1.HashMap和HashSet就是 Java 中用哈希表实现的集合类
2.Java中使用的是哈希桶的方式解决冲突的
3.Java会在冲突链表长度大于一定阈值之后,将链表转为搜索树(红黑树)
4.Java中计算哈希值是调用类中的hashCode方法,进行key的相等性比较是调用key的equals方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的
(六)HashMap源码分析
1.HashMap是可序列化的
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
源码分析图:
2.HashMap中各种final值的意义
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
源码分析图:
(1)对树化的分析
树化的条件:HashMap的容量 >= 64 && 某一个哈希桶中链表的长度 >= 8
源码分析图:
(2)HashMap的初始容量为什么必须是2的次幂
解释:由于确定新插入的TreeNode节点的存储位置的时候需要 % 存储空间(存储空间大小设为n),而Java8底层是采用异或的方式进行取模运算,即哈希值与(n - 1)进行异或,那么我们就要保证(n - 1)的从最高位的1算起,到最低位的值都要是1,避免出现0,否则和哈希值进行异或的时候就会导致某些哈希桶的值一直取不到,既浪费空间,又提高了出现哈希碰撞的几率,因此HashMap在进行容量扩容的时候,扩容结果也一定是 2 的次幂
源码分析图:
3.真正分配容量内存的位置
首先,容量空间的分配并不是在构造方法中,而是在put方法中
其次,如果构造方法中传了具体大小的存储空间,那么最终分配的空间就是大于等于参数的2的次幂的最小值
代码分析图:
4.HashMap中阈值(什么时候就需要对存储进行扩容)的确定
在构造方法中,有参和无参的构造方法的阈值是不同的
(1)无参的构造方法中只将装填因子设为默认值,阈值没变,还是0
(2)只传了容量的构造方法,调用了两个参数的构造方法
(3)两个参数的构造方法中,也并没有关心用户传过来的容量,只是将装填因子和存储元素的阈值进行了设置,阈值被设置成了大于等于容量的最小的2次幂的值,在第一次put的时候进行再次设置
源码分析图:
5.HashMap何时扩容
当插入的元素数量size > 阈值的时候,我们进行扩容
源代码:
if (++size > threshold)
resize();
6.扩容时空间和阈值的变化
结论:当原来的空间大小 >= 16时,扩容后空间和阈值都是二倍,因此扩容越来越大,阈值可能会比负载因子 * 空间的大小小一些,当原来空间小于16时,阈值 = 负载因子 * 空间大小
源码分析图:
7.HashMap中hash的取值
目的:为了增强hash值的随机性,减少哈希冲突的几率
源码分析图:
8.哈希桶中链表插入采用尾插法
五、Map的几种遍历方法
(一)toString
代码示例:
public static void main(String[] args) {
HashMap<String,Integer> map = new HashMap<>();
map.put("hello", 1);
map.put("world", 2);
System.out.println(map);
}
运行结果:
(二)知道所有的key,然后手动一个一个遍历(可以尝试KeySet方法)
代码示例:
public static void main(String[] args) {
HashMap<String,Integer> map = new HashMap<>();
map.put("hello", 1);
map.put("world", 2);
System.out.println("key: " + "hello " + "val: " + map.get("hello"));
System.out.println("key: " + "world " + "val: " + map.get("world"));
}
运行结果:
(三)实例化entrySet内部类
该内部类实现了Iterable接口,可以使用迭代器,for-each遍历
代码示例:
public static void main(String[] args) {
HashMap<String,Integer> map = new HashMap<>();
map.put("hello", 1);
map.put("world", 2);
Set<Map.Entry<String, Integer>> set = map.entrySet();
for (Map.Entry<String, Integer> entry: set) {
System.out.println("key: " + entry.getKey() + " value: " + entry.getValue());
}
System.out.println();
Iterator<Map.Entry<String, Integer>> iterator = set.iterator();
while(iterator.hasNext()) {
System.out.println(iterator.next());
}
}
运行结果:
六、TreeMap(TreeSet)和HashMap(HashSet)的区别
Map(Set) | TreeMap(TreeSet) | HashMap(HashSet) |
---|---|---|
底层结构 | 红黑树 | 哈希表 |
插入/删除/查找时间复杂度 | O(log2N) | O(1) |
是否有序 | 有序 | 无序 |
线程安全 | 不安全 | 不安全 |
插入/删除/查找区别 | 先进行比较,再根据红黑树的特性插入删除 | 先通过哈希函数计算哈希地址,再进行插入删除操作 |
比较与覆写 | key必须要能够比较 | key不需要比较,自定义类型需要覆写HashCode方法和equals方法 |
应用场景 | 需要key有序场景下 | key有不有序不关心,但是查找效率要高 |