🎉🎉🎉点进来你就是我的人了
博主主页:🙈🙈🙈戳一戳,欢迎大佬指点!
人生格言:当你的才华撑不起你的野心的时候,你就应该静下心来学习!欢迎志同道合的朋友一起加油喔🦾🦾🦾
目标梦想:进大厂,立志成为一个牛掰的Java程序猿,虽然现在还是一个🐒嘿嘿
谢谢你这么帅气美丽还给我点赞!比个心
目录
1.概念
1.1概念及场景
1.2 模型
1.3 Map的说明
1.4 Map方法的介绍
1.5 Set的说明
1.6 Set方法的介绍
2、哈希表
2.1 什么是哈希表
3. 哈希冲突
3.1 概念
3.2 降低哈希冲突的发生的概率
3.2.1 设计好的哈希函数
3.2.2 降低负载因子
3.3.当冲突发生时如何解决哈希冲突(简单介绍)
闭散列:有两种(线性探测法&&二次探测法)
线性探测
二次探测
开散列:它的叫法有很多,也叫做哈希桶/链地址法/拉链法
③若遇到负载因子过大,要扩容,那么存入的数据又该怎么进行处理???(链表中的每一个数要进行重新哈希),以下为二倍扩容后的图编辑 实现一个哈希表
重写hashCode()方法
性能分析
小结
1.概念
1.1概念及场景
①Map和Set的作用:
一种专门用来进行搜索的容器或者数据结构,其搜索的效率与其具体的实例化子类有关 。
②Map和Set相比于其他类型的优点:
之前我们学过的常见搜索方式有: 直接遍历, 二分查找等
上述排序比较适合静态类型的查找,即一般不会对区间进行插入和删除操作了,而现实中的查找比如:
1. 根据姓名查询考试成绩
2. 通讯录,即根据姓名查询联系方式
3. 不重复集合,即需要先搜索关键字是否已经在集合中
可能在查找时进行一些插入和删除的操作,即动态查找,那上述两种方式就不太适合了,本节介绍的 Map 和 Set 是 一种适合 动态查找的集合容器 。
1.2 模型
1. 纯 key 模型:
eg.有一个英文词典,快速查找一个单词是否在词典中 ;快速查找某个名字在不在通讯录中
2.Key-Value 模型:
eg.统计文件中每个单词出现的次数,统计结果是每个单词都有与其对应的次数:<单词,单词出现的次数 > ;梁山好汉的江湖绰号:每个好汉都有自己的江湖绰号
而 Map中存储的就是key-value的键值对,Set中只存储了Key
1.3 Map的说明
Map 中存储的是 key-value的键值对, Map 是一个接口类,该类没有继承自 Collection ,该类中存储的是 <K,V> 结构的键值对,并且 K 一定是唯一的,不 能重复 。
1.4 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 |
Map的注意事项:
1. Map 是一个接口,不能直接实例化对象 ,如果 要实例化对象只能实例化其实现类 TreeMap 或者 HashMap。
2. Map 中存放键值对的 Key 是唯一的, value 是可以重复的(重复的情况,后面put的覆盖前面的)。
3. Map 中的 Key 可以全部分离出来,存储到 Set 中 来进行访问 ( 因为 Key 不能重复 ) 。
4. Map 中的 value 可以全部分离出来,存储在 Collection 的任何一个子集合中 (value 可能有重复 ) 。
5. Map 中键值对的 Key 不能直接修改, value 可以修改,如果要修改 key ,只能先将该 key 删除掉,然后再来进行重新插入。
TreeMap 和 HashMap 的区别:
Map 底层结构 | TreeMap | HashMap |
底层结构 | 红黑树 | 哈希桶 |
插入 / 删除 / 查找时间 复杂度 | O(log2^N) | O(1) |
是否有序 | 关于key有序 | 无序 |
线程安全 | 不安全 | 不安全 |
插入/删除/查找区别 | 需要进行元素比较 | 通过哈希函数计算哈希地址 |
比较与覆写 | key必须能够比较,否则会抛出 ClassCastException异常 | 自定义类型需要覆写equals和 hashCode方法 |
应用场景 | 需要 Key 有序场景下 | Key 是否有序不关心,需要更高的 时间性能 |
其中 Set<Map.Entry<K, V>> entry Set() 这个方法非常复杂但也非常重要,所以要做一些具体的说明:
Map.Entry<K, V> 是 Map 内部实现的用来存放 <key, value> 键值对映射关系的内部类 ,该内部类中主要提供了 <key, value> 的获取, value 的设置以及 Key 的比较方式。
如何理解????通俗来说就是:
Entry是Map里面的一个内部类,而 Map.Entry<key,val> 的作用就是把一个个map元素(key,val) 打包成一个整体,而这个整体的类型就是 Map.Entry<K,V>, 然后我们有一个Set集合,它里面存放的每个元素的类型就是 Map.Entry<K,V>。这里可以联想到我们的单链表的内部类ListNode,将 val,next 打包成一个整体,那么它的类型就是ListNode。
所以下面这段代码运行起来一定会把Set集合中存放的map中的每一个元素都输出出来:
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("hello",2);
map.put("world",1);
map.put("bit",3);
Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
for (Map.Entry<String,Integer> entry:entrySet) {
System.out.println("key: "+entry.getKey()+" val: "+entry.getValue());
}
}
该内部类Entry提供的一些方法也是比较重要的:
方法 | 解释 |
K getKey () | 返回 entry 中的 key |
V getValue () | 返回 entry 中的 value |
V setValue(V value) | 将键值对中的 value 替换为指定 value |
1.5 Set的说明
Set 与 Map 主要的不同有两点: Set 是继承自 Collection 的接口类, Set 中只存储了 Key 。
1.6 Set方法的介绍
方法 | 解释 |
boolean add (E e) | 添加元素,但重复元素不会被添加成功 |
void clear () | 清空集合 |
boolean contains (Object o) | 判断 o 是否在集合中 |
Iterator<E> 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 中,可以达到去重的效果 |
Set的注意事项:
1. Set 是继承自 Collection 的一个接口类。
2. Set 中只存储了 key ,并且要求 key 一定要唯一。
3. Set 的底层是使用 Map 来实现的,其使用 key 与 Object 的一个默认对象作为键值对插入到 Map 中的。
4. Set 最大的功能就是对集合中的元素进行去重。
5. 实现 Set 接口的常用类有 TreeSet 和 HashSet ,还有一个 LinkedHashSet , LinkedHashSet 是在 HashSet 的基础上维护了一个双向链表来记录元素的插入次序。
6. Set 中的 Key 不能修改,如果要修改,先将原来的删除掉,然后再重新插入。
7. Set 中不能插入 null 的 key 。
TreeSet 和 HashSet 的区别 :
Set 底层结构 | TreeSet | HashSet |
底层结构 | 红黑树 | 哈希桶 |
插入/删除/查找时间复杂度 | O(log2^N) | O(1) |
是否有序 | 关于 Key 有序 | 不一定有序 |
线程安全 | 不安全 | 不安全 |
插入/删除/查找区别 | 按照红黑树的特性来进行插入和删除 | 1. 先计算key哈希地址 2. 然后进行 插入和删除 |
比较与覆写 | key必须能够比较,否则会抛出 ClassCastException异常 | 自定义类型需要覆写equals和 hashCode方法 |
应用场景 | 需要Key有序场景下 | Key 是否有序不关心,需要更高的 时间性能 |
为什么HashMap和HashSet无序,而TreeMap和TreeSet有序??后面会解释到。
2、哈希表
2.1 什么是哈希表
最理想的搜索方法 , 即就是在查找某元素时 , 不进行任何比较的操作 , 一次直接查找到需要搜索的元素 , 可以达到这种要求的方法就是哈希表.
哈希表就是通过构造一种存储结构 , 通过某种函数使元素存储的位置与其关键码位形成一 一映射的关系 , 这样在查找元素的时候就可以很快找到目标元素.
哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:
存在一个数组集合 {1,7,6,4,5,9}.
哈希函数设置为:hash(key) = key % capacity;
capacity 为存储元素底层空间总的大小。
如图所示: 这样存储数据更加便于查找
采取上面的方法,确实能避免多次关键码的比较,搜索的效率也提高的,但是问题来了,拿上述图的情况来举例子的话,我接着还要插入一个元素 14,该怎么办呢?
这个就是我们本章的重点,哈希冲突,4%10 = 4;14%10 = 4,此时发生了哈希冲突。
3. 哈希冲突
3.1 概念
首先我们得知道,哈希冲突是必然的,无论怎么插入,插入多少都无法杜绝,哪怕就插入两个元素4,14都发生了哈希冲突,我们能做的就是尽量避免哈希冲突的发生。
这也就是我们哈希表这种结构存在的问题。
哈希冲突的概念:两个不同关键字key通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
3.2 降低哈希冲突的发生的概率
两种解决方法
1.设计好的哈希函数;2.降低负载因子
3.2.1 设计好的哈希函数
哈希函数设计原则:
-
哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间。
-
哈希函数计算出来的地址能均匀分布在整个空间中。
-
哈希函数应该比较简单。
常用的两种哈希函数
1. 直接定制法
取关键字的某个线性函数为散列地址: Hash ( Key ) = A*Key + B
优点:简单、均匀。
缺点:需要事先知道关 键字的分布情况 使用场景:适合查找比较小且连续的情况。
力扣上这道题可以帮助我们理解: 字符串中第一个只出现一次字符
2. 除留余数法
设散列表中允许的 地址数为 m ,取一个不大于 m ,但最接近或者等于 m 的质数 p 作为除数,按照哈希函数: Hash(key) = key% p(p<=m), 将关键码转换成哈希地址
3.2.2 降低负载因子
下图是冲突率和负载因子的关系图:
从图中我们可以直到要想降低冲突的概率,只能减小负载因子,而负载因子又取决于数组的长度。
公式: 负载因子 = 哈希表中元素的个数 / 数组的长度
因为哈希表中的已有的元素个数是不可变的,所以我们只能通过增大数组长度来降低负载因子。
3.3.当冲突发生时如何解决哈希冲突(简单介绍)
解决哈希冲突 两种常见的方法是: 闭散列 和 开散列
闭散列:有两种(线性探测法&&二次探测法)
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 把 key 存放到冲突位置中的 “ 下一个 ” 空位置中去。
线性探测
①什么是线性探测:
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
②线性探测的相关操作:
当插入操作时,通过哈希函数获取待插入元素在哈希表中的位置 ;如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到 ;下一个空位置,插入新元素
简而言之就是寻找下一个空的地方
③弊端:(可能会导致冲突元素均被放在一起)
二次探测
①如何进行二次探测:
利用这个公式进入插入。其中:i = 1,2,3…,Hi是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
对于上述线性探测中的问题如果要插入44,产生冲突,使用解决后的情况为:
②重要结论:
当表的长度为质数且表装载因子 a 不超过 0.5 时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情 况,但在插入时必须确保表的装载因子a 不超过 0.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;
public int usedSize;
public static final double DEFAULT_LOAD_FACTOR = 0.75;
public HashBuck() {
this.array = new Node[10];
}
/**
* put函数
* @param key
* @param val
*/
public void put(int key,int val) {
//1、找到Key所在的位置
int index = key % this.array.length;
//2、遍历这个下标的链表,看是不是有相同的key。有 要更新val值的
Node cur = array[index];
while (cur != null) {
if(cur.key == key) {
cur.val = val;//更新val值
return;
}
cur = cur.next;
}
//3、没有这个key这个元素,头插法
Node node = new Node(key, val);
node.next = array[index];
array[index] = node;
this.usedSize++;
//4、插入元素成功之后,检查当前散列表的负载因子
if(loadFactor() >= DEFAULT_LOAD_FACTOR) {
resize();//
}
}
//扩容
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) {
int index = cur.key % newArray.length;//获取新的下标 11
//就是把cur这个节点,以头插/尾插的形式 插入到新的数组对应下标的链表当中
Node curNext = cur.next;
cur.next = newArray[index];//先绑定后面
newArray[index] = cur;//绑定前面
cur = curNext;
}
}
array = newArray;
}
private double loadFactor() {
return 1.0*usedSize/array.length;
}
/**
* 根据key获取val值
* @param key
* @return
*/
public int get(int key) {
//1、找到Key所在的位置
int index = key % this.array.length;
//2、遍历这个下标的链表,看是不是有相同的key。有 要更新val值的
Node cur = array[index];
while (cur != null) {
if(cur.key == key) {
return cur.val;
}
cur = cur.next;
}
return -1;
}
说明:以上的代码只是简单的实现了两个重要的函数:插数据和取数据
并且只是简单的实现,底层的树化并没有实现。
问题--》
问题一:以上代码的key是整形,所以找地址的时候,可以直接用 key % array.length,如果我的key是一个引用类型呢???,我怎么找地址???
下面这段代码,两者的 id 都一样,运行结果却不一样,这就和我们刚刚的相同的key发生冲突就不一致了。
class Person {
public String id;
public Person(String id) {
this.id = id;
}
@Override
public String toString() {
return "Person{" +
"id=" + id +
'}';
}
}
public class Test {
public static void main(String[] args) {
Person person1 = new Person("134");
Person person2 = new Person("134");
System.out.println(person1.hashCode());
System.out.println(person2.hashCode());
}
}
④hashcode(解决引用类型情况下,把它变成是一个合法的整数)
但是这个时候直接输出他们的hashcode却是不相同的
重写hashCode()方法
class Person {
public String id;
public Person(String id) {
this.id = id;
}
@Override
public String toString() {
return "Person{" +
"id=" + id +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return id == person.id;
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
public class Test {
public static void main(String[] args) {
Person person1 = new Person("134");
Person person2 = new Person("134");
System.out.println(person1.hashCode());
System.out.println(person2.hashCode());
}
}
1.为什么引用类型就要谈到 hashCode() ??
因为如果key是引用类型,就不能通过模上数组的长度来寻址了。而 hashCode() 作用就是返回对象的哈希代码值,简单来说,他就是一个整数
2.按道理来说,学号相同的两个对象应该是同一个人,为什么重写 hashCode(),返回对象的哈希代码值才会一样,不重写为什么会导致最终在数组中寻找的地址不相同??
因为底层的hashCode()是Object类的方法,底层是由C/C++代码写的,我们是看不到,但是因为它是根据对象的存储位置来返回的哈希代码值,这里就可以解释了,person1和person2本质上就是两个不同的对象,在内存中存储的地址也不同,所以最终返回的哈希代码值必然是不相同的,哈希代码值不同,那么在数组中根据 hash % array.length 寻找的地址也就不相同。而重写 hashCode() 方法之后,咱们根据 Person 中的成员变量 id 来返回对应的哈希代码值,这就相当于当一个对象,多次调用,那么返回的哈希代码值就必然相同。
所以我们的哈希表的实现就可以相应的改写成这样:
public class HashBuck<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;
public void put(K key, V val) {
Node<K,V> node = new Node<>(key,val);
int hash = key.hashCode();
int index = hash % array.length;
Node<K,V> cur = array[index];
while(cur != null) {
if(cur.key.equals(key)) {
cur.val = val;
return;
}
cur = cur.next;
}
//头插
node.next = array[index];
array[index] = node;
this.usedSize++;
if(loadFactor() >= DEFAULT_LOAD_FACTOR) {
reSize();
}
}
private double loadFactor() {
return this.usedSize * 1.0 / array.length;
}
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 hash = cur.key.hashCode();
int index = hash % newArray.length;
cur.next = newArray[index];
newArray[index] = cur;
cur = cur.next;
}
}
array = newArray;
}
public V get(K key) {
int hash = key.hashCode();
int index = hash % array.length;
Node<K,V> cur = array[index];
while(cur != null) {
if(cur.key == key) {
return cur.val;
}
cur = cur.next;
}
return null;
}
}
性能分析
虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入 / 删除 / 查找时间复杂度是 O(1)
面试问题一:hashCode()和equals() 在HashMap中的作用分别是什么???
hashCode():用来找元素在数组中的位置;
equals():用来比较数组下链表中的每个元素的 key 与我的 key 是否相同。
equals也一样,如果不重写,上面的person1和person2的比较结果必然是不相同。
hashCode()和equals()就好比查字典,比如要查美丽,肯定要先查美字在多少页--hashCode(),然后它的组词有美景,美女,美丽,equals()就能找到美丽。
面试问题二:如果hashCode一样,那么equals一定一样吗? 如果equals一样,hashCode一定一样吗??
答案肯定是不一定,一定。
同一个地址下链表中的key不一定一样,就好比数组长度为10,4和14找到的都是4下标。
而equals一样,hashCode就一定一样,4和4肯定都在4下标。
所以这时候再回过头来看HashMap数据的打印时,就能明白HashMap和HashSet为什么无序了,它本身就不是一个顺序结构,至于TreeMap和TreeSet为啥有序,这就和我们之前学过的优先级队列是一个道理了。(整形的key,输出时,自然而然就排好序了,如果key是引用类型,则需要实现Comparable接口,或者传比较器)
小结
1. HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set
2. java 中使用的是哈希桶方式解决冲突的
3. java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
4. java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。