一. Map
1. 关于Map的说明
我们来看这张图,Map本身是一个接口,没有继承Collection
接口,该类存储的是<K,V>类型的键值对,并且K是唯一的不能重复.
今天主要讲解的是HashMap,TreeMap是基于红黑树实现的,后续博主会专门出一期讲解.
2. 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 keySet() | 返回所有 key 的不重复集合 |
Collection values() | 返回所有 value 的可重复集合 |
Set<Map.Entry<K, V>> entrySet() | 返回所有的 key-value 映射关系 |
boolean containsKey(Object key) | 判断是否包含 key |
boolean containsValue(Object value) | 判断是否包含 Value |
注意:
- Map是一个接口,不能直接实例化对象,如果要实例化对象只能实例化其实现类HashMap或TreeMap.
- Map中存放键值对K是唯一的,Value可以重复.
- Map中的Key可以全部分离出来,存储到Set中来进行访问(因为Key不能重复,且Set的特性就是存储的值不能是重复的)
- Map中的Value可以全部分离出来,存储在Collection的任何一个子集合中(value可能重复)
- Map中的键值对Key的值不能直接修改,Value可以修改,如果要修改Key,只能先将Key删除掉,然后再进行重新插入.
这里对于Map不做过多讲解.
二. 了解HashMap原理+自己实现
1. 常用方法说明+使用(会用就行)
(1) 实现一个HashMap对象
HashMap是在java.util
包里的.
import java.util.HashMap;
我们看它的构造方法:
HashMap类有很多的构造方法,其中最常用的是以下两种:
//不带参数
HashMap<k,v> hashmap = new HashMap()//创建一个空的HashMap对象
//一个参数的
HashMap<k,v> hashmap = new HashMap(int initialCapacity)//指定HashMap容量大小,创建一个空的HashMap对象
除此之外还有三个不常用的构造方法
HashMap(Map<? extends K, ? extends V> m)
: 使用指定的 Map 对象创建一个 HashMap 对象HashMap(int initialCapacity, float loadFactor)
:指定 HashMap 的容量的大小和负载因子,创建一个空的 HashMap 对象.HashMap(int initialCapacity, float loadFactor, booleanaccessOrder)
:指定 HashMap 的容量大小、负载因子和迭代顺序,创建一个空的 HashMap 对象
对于参数的介绍会在后面的讲解源码时说明.
这个 <K,V> 就是咱们之前学过的泛型,那为什么是两个呢?
准确的说是一对,大家都知道身份证号吧,每个人都可以通过身份证号找到这个人的信息,其中的K就类似身份证号,而V也就是身份信息了.我们可以通过包装类或者自己实现的类来作为泛型的参数,看下图:
此时我们就指定了泛型的类型,假如是传入基本数据类型呢?
此时就报错了,那是因为传入 <K,V> 的参数需要是引用类型,而类似于 int,double,long
都是基本数据类型,不符合条件.
(2) 调用 put(k,v)
当我们创建好了一个 HashMap 对象之后 由于上面传入的参数是 <String,Integer> ,所以我们 key 的类型就是 String ,我们 Value 的类型就是 Integer .
(1) key值不重复的
public static void main(String[] args) {
HashMap<String,Integer> hashMap = new HashMap<String, Integer>();
hashMap.put("张三",18);
hashMap.put("李四",18);
hashMap.put("王五",18);
hashMap.put("赵六",18);
System.out.println(hashMap);
}
此时我们运行结果…
由于 HashMap 重写了 tosing()
方法,所以我们只需要打印 hashmap
就好
(2)key值重复的
如果传入的Key值重复会怎么样呢?
会发现此时的 value 的值已经被修改了.
官方解释:
(3) 调用 get(k)
两个常用方法:如下图红框
- 调用
get(Object key)
方法
get() 方法就是根据输入的 key 值,找到这个和 key 值对应的 value 并返回,如果没有找到的话就会返回null
.
那么如果我输入null呢?
结果依旧是null.
官方解释:
2.调用 getOrDefault(Object key,Integer DefaultValue)
.
返回 key 对应的 value 值如果key不存在就会返回设置的默认返回值(default).
注:返回值类型需要和value的类型一致!!!
如果返回默认值的类型和value类型不一致会怎样?
官方解释:
2. 什么是哈希表
在我们以前学习的数据结构中,有链表,顺序表,栈,队列,二叉树…,这些数据结构的元素关键码和存储位置之间没有对应关系,因此在查找一个元素时,必须要经过关键码的多次比较.
顺序查找的时间复杂度为O(n),平衡树中时间复杂度为树的高度,即O(logN),搜索效率取决于搜索过程中元素的比较次数.
哈希表是一种根据关键码去寻找值的数据映射结构,该结构通过把关键码映射的位置去寻找存放值的地方,说起来可能感觉有点复杂,我想我举个例子你就会明白了,最典型的的例子就是字典,大家估计小学的时候也用过不少新华字典吧,如果我想要获取“按”字详细信息,我肯定会去根据拼音an去查找 拼音索引(当然也可以是偏旁索引),我们首先去查an在字典的位置,查了一下得到“安”,结果如下。这过程就是键码映射,在公式里面,就是通过key去查找f(key)。其中,“按” 就是关键字(key),f()就是字典索引,也就是哈希函数,查到的页码4就是哈希值.(具体什么是哈希值后面会讲)
当向该结构中:
- 插入元素:根据插入元素的关键码,通过特定函数计算出该元素的存储位置,并按此位置进行存放.
- 搜索元素:根据输入元素的关键码对其进行与上述相同的运算,把求得得函数值当做元素得存储位置,在结构中按此位置取元素比较,若关键码相同也就是 key 值相同,则搜索成功
该方法为哈希(散列)方法,哈希方法中使用得转换函数称作为哈希(散列)函数,构造出来得结构为哈希表(或称为散列表)
3. 冲突概念
1. 什么是Hash
Hash中文翻译为散列,又成为“哈希”,是一类函数的统称,其特点是定义域无限,值域有限。把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值)。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。
简单的说:就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
2. 基本概念
- 若关键字为k,则其值存放在 f(k) 的存储位置上。由此,不需比较便可直接取得所查记录。称这个对应关系f为散列函数,按这个思想建立的表为散列表。
- 对不同的关键字可能得到同一散列地址,即 k1 ≠ k2,而 f(k1) = f(k2),这种现象称为碰撞(英语:Collision)。具有相同函数值的关键字对该散列函数来说称做同义词。综上所述,根据散列函数 f(k) 和处理碰撞的方法将一组关键字映射到一个有限的连续的地址集(区间)上,并以关键字key求出的哈希值作为记录在表中的存储位置,这种表便称为散列表(哈希表),这一映射过程称为散列造表或散列,所得的存储位置称散列地址。
- 若对于关键字集合中的任意一个关键字,经散列函数映象到地址集合中任何一个地址的概率是相等的,则称此类散列函数为均匀散列函数(Uniform Hash function),这就是使关键字经过散列函数得到一个“随机的地址”,从而减少碰撞。
3. 什么是Hash冲突
我们自己假设一个哈希函数为:hash(key) = key % capacity;
capacity是存储元素底层空间的大小(说白了就是数组大小)
我们看下图:
我们可以通过上面的哈希函数进行数据集合的存放,比如存放13这个数据,hash(13) = 13 % 10 = 4,就放在下标为4的位置上;
如果需要取元素,我们依然可以根据这个哈希函数进行取数据,比如我们现在要取7这个数据,hash(7) = 7 % 10 = 7,我们就可以在下标为7的位置进行取数据操作.
ps: 这里可能会有同学担心这个哈希函数只能对正数使用,如果是负数怎么办?解决办法很简单,只需要 & 7FFF FFFF FFFF FFFF
即可,如果key的符号位为1,不管key的符号为为几都会变成0,其余位不做修改,此时的hash(key)值一定>0
但是细细想一下:如果我们现在再想存放26怎么办…
总不能把现在的16给覆盖掉吧,如果覆盖掉将来就找不到了…
所以,得出一个结论:
不同的关键字,通过相同的哈希函数,有可能找到相同的位置.
这就是我们通常说的哈希冲突/碰撞.
4. 冲突避免(了解)
首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一个问题,冲突的发生是必然的.,但我们能做的应该是尽量的降低冲突率。
在学习如何降低冲突率时,我们先了解一下冲突是如何产生的.
主要原因分为以下三点:
- 哈希函数设计不合理: 如果哈希函数设计不合理,可能会导致输入值在哈希函数计算后的分布不均匀,进而增加哈希冲突的概率
- 哈希表容量过小: 当哈希表的容量较小,而要存储的数据比较多时,就容易出现哈希冲突.
- 数据集特征: 当数据集的特征与哈希函数设计不匹配时,也可能导致哈希冲突的增加.
(1) 哈希函数设计
引起哈希冲突的一个原因: 哈希函数设计的不合理.
哈希函数设计原则:
- y = hash(x) ,我们需要控制y的范围,如果哈希表允许有m个地址时,其y的范围必须在 0~m-1 之间.
举个例子:
上图我们的哈希表数组容量为10,那么放进去的数就必须是0~9,如果求出的hash(key)>=array.length 就会有因为下标非法抛异常
-
哈希函数计算出来的地址能均匀分布在整个空间中
-
下面是一些常用的哈希函数(不用自己设置,直接用就好)
常见哈希函数(了解)
- 直接定制法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况. - 除留余数法–(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址. - 平方取中法–(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况. - 折叠法–(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况. - 随机数法–(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,H(key) = random(key),其中random为随机数函数。通常应用于关键字长度不等时采用此法. - 数学分析法–(了解)
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址.
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况.
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
(2) 负载因子调节(重点)
负载因子=存储散列表的元素个数/散列表的长度
一般我们的负载因子值为0.75,但是这个不用特地的记,因为在不同的编程语言中都是有可能不一样的,如果面试官问你哈希表负载因子是多少,你可不要傻乎乎的回答0.75,这可不是面试官想要的答案.
感兴趣可以打开这个 什么是负载因子,为什么是0.75
由于我们的负载因子设置为0.75,所以一旦负载因子 > 0.75 之后就需要扩容.
如图:负载因子越大,冲突率越大,所以根据公式:
负载因子 = 存储散列链表的元素个数 / 散列链表的长度
此时就需要增加散列表的长度(扩容),来间接减少负载因子…
5.冲突解决(了解)
解决哈希冲突的常见两种方法:闭散列和开散列
(1) 闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
那如何寻找下一个空位置呢?
方法一:线性探测:
-
通过哈希函数获取待插入元素在哈希表中的位置.
-
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止
举例说明:
我们现在要往表里面放26,hash(26)=26 % 10 = 6,放在6位置,但是6位置已经被16占了,再往后一格;下标7的位置也有数;再往后一格,下标8位置没有数据,26放下标6的位置。
缺点:线性探测会把冲突的元素挨在一起,这就会造成,你如果要进行删除,比如我们现在要删除16,一旦我们不采取其他措施直接删除,我们后面想找到26就困难了
方法二:二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:Hi = ( h0 + i ^ 2 )% m, 或者:Hi= ( h0 - i ^ 2 )% m。其中:i = 1,2,3…,是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
大概意思就是冲突后的方法改变了,放在Hi的位置,H0是通过哈希函数得到的值,比如hash(26) = 26 % 10 = 6,这个6就是H0,i是第几次冲突,m是表的大小,如下图:
要往表里放25
hash(25) = 25 % 10 = 5,与下标为5里面的数据冲突,那就放在Hi = (5 + 1 ^ 2) % 10 = 6 的位置上,由于下标为6的位置也有数据,此时再进行上述步骤 Hi = (5 + 2 ^ 2) % 10 = 9,下标为9的位置没有数据,此时25就放进去了.
小结:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷.
(2) 开散列/哈希桶(重点)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表连接起来,各链表的头节点存储在哈希表中.(具体还是对着下面的图看)
举例说明:每个数组是一个桶,桶里面放的是结点,我们将这个数组的类型定义为Node[],意思就是这个数组中的每一个结点都是Node类型,我们同时又给Node类型定义了三个属性:key ,value ,next.(next 存储下一个结点的地址,就和之前学的单链表一个样,只不过多了一个属性用于存储value)
假设我现在有一个结点 key = 6,它的value是"懒羊羊",该结点的地址是0x666,我们此时把key为6的结点传过去, Hash(6) = 6 % 10 = 6 ,且对应的6下标没有数据,此时我们就可以把他放在下标为6的位置
此时再放入一个key为18的结点,value = “喜羊羊”,该节点地址0x888,计算哈希值.Hash(18) = 18 % 10 = 8,由于下标8的位置没有数据,我们将他放入8下标
此时我们再试图添加一个key为36的结点,value = “美羊羊”,地址为0x777,求出哈希值,Hash(36) = 36 % 10 = 6,由于下标为6的位置已经有数据了,此时就需要让key=6的结点的next指向key=36的这个结点.
到这里就该有同学好奇,如果这里的链表特别特别的长,那查找时间复杂度不也接近O(n)了嘛,其实不用担心,大家忘了负载因子嘛?由于在java jdk1.8中的负载因子是0.75F,所以只要哈希表中的 元素个数 / 哈希表的数组 > 0.75 就会扩容,所以最终形成的链表不会很长,时间复杂度依旧可以控制在近似O(1),并且我们在查看HashMap源码时,会发现,当链表达到一定长度时会进行树化,也就是变成红黑树增加搜索效率.
从上图可以看出,开散列中每个桶放的都是发生哈希冲突的元素.
开散列:可以认为是把一个大集合中的搜索问题转化为在小集合中做搜索了
(3) 冲突严重的解决办法
刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
- 每个桶的背后是另一个哈希表
- 每个桶的背后是一棵搜索树
6. 代码实现(可以参考着看,不做重点,重点看后面的源码分析,自己实现一个HashMap,比看我写的这个会更好)
下面手动实现一个简易的hashMap,主要实现的方法为put(),get(),和resize()方法.
class MyHashMap{
private Node[] array;
private int size = 0;
class Node {
final int key;
int value;
Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
//一般负载因子不可以改变,所以这里用 static final
public static final double DEFAULT_LOAD_FACTOR=0.75;
//提供两个构造方法,一个指定大小,一个默认大小
public MyHashMap() {
array = new Node[16];
}
public MyHashMap(int n){
array = new Node[n];
}
public void put(int key,int value){
int length = array.length;
//添加结点需要求出该结点的hash值
int hash = key % length;
Node node;
if ((node = array[hash]) == null){
//如果当前的hash对应的数组元素为空就直接添加
array[hash] = new Node(key,value);
}else {
//如果不为空就next到最后,如果碰见key一样的就覆写旧key
//此时next不为空,那就while循环尾插
while (true){
//需要判断是否key值一样,如果一样就修改
if(node.key == key){
node.value = value;
return;
}
//把if放在这里是因为这样可以保证每个结点的key值都进行了上述if操作,
//while(node.next != null) 如果将这个代码替换上面的while(true)
//那么当node.next为null时就会直接退出循环,此时的node还没有进行对比操作呢,就可能会出现错误
if(node.next == null){
//所有结点对比完退出循环
break;
}
//不断向后next
node = node.next;
}
//到这里此时的Node的下一个结点为空,且没有key值相同的结点
node.next = new Node(key,value);
}
size++;//元素个数++
if(loadFactor()){
//负载因子>0.75后就扩容
resize();
}
}
//扩容方法
//这里的链表扩容依旧选择的是尾插,头插容易实现,各位看自己喜好,jdk1.8的HashMap是采用尾插的
private void resize() {
//新长度是旧长度的2倍
long newLength = array.length* 2L;//int类型可能会越界,采用long类型
if(newLength > Integer.MAX_VALUE){
//如果要扩容的长度超过了int的最大长度,就将要扩容的长度改为Integer.MAX_VALUE(即2^31-1)
newLength = Integer.MAX_VALUE;
}
Node[] newArray = new Node[(int) newLength];//创建的新数组,原哈希表中的值都要拷贝到这里来
//现在开始扩容
for (Node value : array) {
Node node = value;//获取头节点
while (node != null) {//node为空说明该结点的链表都遍历完了,可以进行下一个链表的拷贝
Node nodeNext = node.next;
//由于后续操作会将node的下一个结点丢失,
// 所以需要设置个遍历记录node的下一个结点
//将node这个链表的所有元素添加到新的哈希表里
//求出hash值,将这些结点按照新的哈希值放入新的位置
int hash = node.key % array.length;
Node newNode;
if ((newNode = newArray[hash]) == null) {
//如果为空就直接把该节点添加到新数组下标对应的位置
newArray[hash] = node;
} else {
//如果不为空就向后next
while (newNode.next != null) {
newNode = newNode.next;
}
//此时newNode的下一个结点为空,进行尾插
newNode.next = node;
}
//由于node可能后面还有结点,所以需要将node.next置为null
node.next = null;
//通过nodeNext找到node的下一个结点
node = nodeNext;
}
}
array = newArray;
}
//根据key值返回对应的value
public int get(int key){
//求出hash值
int hash = key % array.length;
Node node;
//根据hash值找到对应的元素
if((node = array[hash]) == null){
//不存在直接返回-1
return -1;
}
//到这里说明这个hash值下标对应的数组元素存在
while (node != null){
//对比是否存在key值一样的
if(node.key == key){
//存在就返回对应的value
return node.value;
}
//没找到就next
node = node.next;
}
//如果为空了还没有找到对应的就返回-1
return -1;
}
private boolean loadFactor() {
//×1.0是为了把size变成double类型
return 1.0*this.size/array.length > 0.75;
}
public int size(){
return size;
}
public void print() {
for (int i = 0; i < array.length; i++) {
Node node = array[i];
if(node != null){
StringBuilder builder = new StringBuilder();
while (node != null){
builder.append("key:"+node.key+" val:"+node.value+" ");
node = node.next;
}
System.out.println(builder);
}
}
}
}
三. 剖析HashMap源码(重点+学习思想为主)
其实吧,我还是推荐大家看视频课,免费的视频课,讲的hashMap很棒,有兴趣点开看看,比看我讲解这个源码清晰明了好多 HashMap讲解
1.参数讲解(与红黑树有关的不做讲解):
2. HashMap()构造方法
他这个很妙,因为 按位或 操作只要一个比特位为 1 结果就是 1 ,上述代码总共向右移了32位,可以保证,不管哪个位为 1 ,它后面的所有位也都为1.
这也是为什么每次返回值+1就一定是2的次幂的原因!!!(这个思想非常棒,大家可以学习下)
3. resize() 扩容方法
看看就行,下面讲解.
(1) 初始化时扩容
(2) size达到扩容阈值时
最后当所有结点都遍历一次之后就返回新创建的数组的对象.
图解1:
4. put()方法
(0) 求Hash值的方法
其实说白了就是高比特位有很多值都不会参与到运算,所以通过向右移16位也就是一半,再和原hashCode值进行异或,这样就会以最便宜的代价减少一些哈希冲突.
HashCode()的方法不是由java实现的,所以看不了.
(1)没有元素时进行put()
我们进入putval()方法:
这里是数组为空时进行put()
先讲解一下内个红色方框的操作怎么回事:
这就是一个求下标的操作
无论是%还是&最后得出的下标都不会超过数组长度,各位可以自己算一下.
(2)put()已经存在的key
(3)put()新的key
四. HashMap经典面试题(重点)
有待更新…