Map和Set,简单模拟实现哈希表以及哈希表部分底层源码的分析
目录
- Map和Set的简单介绍
- 降低哈希冲突发生的概率以及当冲突发生时如何解决哈希冲突
- 简单模拟实现哈希表--1.key为整形;2.key为引用类型
- 哈希表部分底层源码的分析
1.Map和Set的简单介绍
1.1.Map的说明
Map :Key-Value 模型,什么是key - value模型呢,就比如梁山好汉的江湖绰号:豹子头 - 林冲 等等。Map 中存储的就是 key-value的键值对, Map 是一个接口类,该类没有继承自 Collection ,该类中存储的是 <K,V> 结构的键值对,并且 K 一定是唯一的,不 能重复 。
1.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<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
|
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 删除掉,然后再来进行重新插入。
Map 底层结构
|
TreeMap
|
HashMap
|
底层结构
|
红黑树
|
哈希桶
|
插入 / 删除 / 查找时间
复杂度
|
O(log2^N) |
O(1)
|
是否有序
|
关于key有序 |
无序
|
线程安全
|
不安全 | 不安全 |
插入/删除/查找区别 | 需要进行元素比较 | 通过哈希函数计算哈希地址 |
比较与覆写
|
key必须能够比较,否则会抛出
ClassCastException异常
|
自定义类型需要覆写equals和
hashCode方法
|
应用场景
|
需要 Key 有序场景下
|
Key 是否有序不关心,需要更高的
时间性能
|
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.3.Set的说明
1.4.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 。
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.概念
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
2.2.降低哈希冲突的发生的概率
两种解决方法
1.设计好的哈希函数;2.降低负载因子
2.2.1.设计好的哈希函数
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间。
- 哈希函数计算出来的地址能均匀分布在整个空间中。
- 哈希函数应该比较简单。
常用的两种哈希函数
取关键字的某个线性函数为散列地址: Hash ( Key ) = A*Key + B优点:简单、均匀。缺点:需要事先知道关 键字的分布情况 使用场景:适合查找比较小且连续的情况。力扣上这道题可以帮助我们理解: 字符串中第一个只出现一次字符
2. 除留余数法
设散列表中允许的 地址数为 m ,取一个不大于 m ,但最接近或者等于 m 的质数 p 作为除数,按照哈希函数: Hash(key) = key% p(p<=m), 将关键码转换成哈希地址
2.2.2.降低负载因子
下图是冲突率和负载因子的关系图:
从图中我们可以直到要想降低冲突的概率,只能减小负载因子,而负载因子又取决于数组的长度。
公式: 负载因子 = 哈希表中元素的个数 / 数组的长度
因为哈希表中的已有的元素个数是不可变的,所以我们只能通过增大数组长度来降低负载因子。
2.3.当冲突发生时如何解决哈希冲突(简单介绍)
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 把 key 存放到冲突位置中的 “ 下一个 ” 空位置中去。
开散列: 首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子
集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。 参照下图:
3.简单模拟实现哈希表
3.1.哈希表概念
我们之前学过的顺序结构和平衡树中,查找一个元素时,都要经过关键码的多次比较。顺序查找的效率O(N),平衡树的查找效率O(logN)。这些都不是我们想要的搜索方法,我们想要的搜索方法是O(1),可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。向该结构中插入元素时以某种哈希函数插入,取元素的时候,也通过该哈希函数取出来,该方式即为哈希(散列)方法,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
但该种方法插入元素的时候,也有一定的缺陷,就是一定会存在哈希冲突,但是可以接受。
3.2.哈希表的简单实现
代码实现
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 static final int DEFAULT_SIZE = 8;public HashBuck() {this.array = new Node[DEFAULT_SIZE];}//插入数据public void put(int key, int val) {Node node = new Node(key, val);int index = key % array.length;Node cur = array[index];//检查桶里面有无相同key的元素,有则覆盖val,没有则头插while(cur != null) {if(cur.key == key) {cur.val = val;return;}cur = cur.next;}//没有return就进行头插,底层是尾插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[] newArray = new Node[2 * array.length];//重新哈希for (int i = 0; i < array.length; i++) {Node cur = array[i];while(cur != null) {//找每个下标中哈希桶里的每个结点重新哈希后的下标int index = cur.key % newArray.length;Node curNext = cur.next;//注意先保存cur.next = newArray[index];newArray[index] = cur;cur = cur.next;}}array = newArray;}//根据key获取valpublic int get(int key) {int index = key % array.length;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;}@Overridepublic String toString() {return "Person{" +"id=" + id +'}';}
}
public class Test {public static void main(String[] args) {Person person1 = new Person("10101");Person person2 = new Person("10101");System.out.println(person1.hashCode());System.out.println(person2.hashCode());}
}
正确的处理方法:重写hashCode()方法
class Person {public String id;public Person(String id) {this.id = id;}@Overridepublic String toString() {return "Person{" +"id=" + id +'}';}@Overridepublic 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;}@Overridepublic int hashCode() {return Objects.hash(id);}
}
public class Test {public static void main(String[] args) {Person person1 = new Person("10101");Person person2 = new Person("10101");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接口,或者传比较器)
4.哈希表部分底层源码的分析
哈希表底层部分成员属性的分析:
面试问题:以下两个桶的数组容量分别是多大?
HashMap<String,Integer> map = new HashMap<>(19); //桶1HashMap<String,Integer> map = new HashMap<>(); //桶2
刚刚我们分析了成员属性和成员方法,桶的只是定义了,并没有看见给桶开辟大小??那我们如何put 进去元素呢?
首先可以确定的是桶 2 的大小为 0,至于为什么没开辟空间也可以 put 元素,我们就需要分析底层的 put 函数,接下来我们带着疑惑继续分析源码,,
结论:
1.桶2的默认大小是0,但是在put进去第一个元素时,它的容量就扩容为了16.
2.我们可以看到底层寻址的方式不是 hash % array.length,而是 (n-1) & hash,因为 JDK规定数组的长度必须是 2 的某个次幂。因为当 n 是 2 的某个次幂时,hash % array.length 与(n-1) & hash 得到的值是一样的,并且位运算的效率高。所以桶1的容量就不是19,而是2的某个次幂向上取整,所以桶1大小为32,我们可以继续看带一个参数的构造方法的源码:
本期到此结束,谢谢观看!!!
Map和Set,简单模拟实现哈希表以及哈希表部分底层源码的分析相关推荐
- Map再整理,从底层源码探究HashMap
前言 本文为对Map集合的再一次整理.内容包括:Map HashMap LinkedHashMap TreeHashMap HashTable ConcurrentHashMap Map Map< ...
- 稀疏多项式的运算用链表_用最简单的大白话聊一聊面试必问的HashMap原理和部分源码解析...
HashMap在面试中经常会被问到,一定会问到它的存储结构和实现原理,甚至可能还会问到一些源码 今天就来看一下HashMap 首先得看一下HashMap的存储结构和底层实现原理 如上图所示,HashM ...
- Python模拟屏幕点击自动完成词达人任务(附源码)
Python模拟屏幕点击自动完成微信词达人任务 该贴是以微信词达人为基础实践而写,如果我们并没有使用词达人,该源码中关键代码部分和模拟点击原理希望对大家有帮助. Python模拟屏幕点击自动完成微信词 ...
- 收了100元辛苦费,写了一个最简单的C#ASP.NET的3层架构例子代码,源码是通过代码生成器生成的【写程序的效率神奇的高】...
为什么80%的码农都做不了架构师?>>> 有一个客户购买了代码生成器,虽然我把很多基础类库的源码及配套的源码都发给他了,但是他由于时间忙的原因,还是没自己仔细看,而是希望我以他 ...
- 网络编程之 哈希表原理讲解 来自老司机的源码
鉴于博主很久没由跟新过数据结构的内容了,所以博主打算给大家讲解一下哈希表的操作 下面的内容来自于一位老司机 martin的源码,博主在这里借用一下,目的是突出哈希表的原理,明天博主就周末了,也能腾出时 ...
- 超简单的pyTorch训练-onnx模型-C++ OpenCV DNN推理(附源码地址)
学更好的别人, 做更好的自己. --<微卡智享> 本文长度为1974字,预计阅读5分钟 前言 很早就想学习深度学习了,因为平时都是自学,业余时间也有限,看过几个pyTorch的入门,都是一 ...
- 用最简单的方法配置运行OpenGL红宝书第9版源码示例
笔者真是苦逼啊,之前花了很多时间去学习"基于OpenGL的图形学"的开头部分,包括书本和老师的PPT.但是到自己尝试编译运行示例代码的时候真是困难重重.而且!在自己胡乱摸爬滚打终于 ...
- 简单实用的笑话段子小程序详细搭建教程(附源码),包含了视频、图片、段子三个模块
首先上个图,看下线上效果 扫码查看线上案例 线上服务器搭建这类的,这里就不多说了,有需要的可以看我之前的文章 >> 最新版短视频去水印小程序安装详细教程(附免费源码和去水印解析接口), ...
- c语言远控源码,远控鼠标!C语言简单小程序:舍友要砸电脑了,送源码!
关注<一碳科技>有更多干货等着你哦! 远控鼠标 远控鼠标,顾名思义就是远程控制鼠标,听起来就有些复杂对不对?是的,有些人一听到这个词,就会感觉要实现远控鼠标是一件很麻烦的事情,但其实不是的 ...
最新文章
- iOS 11.3立春后发布,电量用得快的人千万别升级!
- 在Android Studio中搜索整个项目中所有出现的字符串
- python变量运算符_Python(三) 变量与运算符
- 超时时间已到,但是尚未从池中获取连接。出现这种情况可能是因为所有池连接均在使用,并且达到了最大池大
- 渝粤题库]西北工业大学组成与系统结构
- java socket 读取文件_Java中Socket下载一个文本文件
- 威纶触摸屏与电脑连接_威纶通触摸屏和西门子PLC通讯不上解决方法
- 金融风控建模评分卡系列:机器学习特征选择方法
- 僵尸网络(CC服务器)
- 基于php的心理测试,据说是韩国最受欢迎的心理测试~~
- 【前端】零基础带你入门前端< 三 > —— 实现手机通讯录(微信通讯录)等
- lgy -oracle
- 论责任成本管理体系的构建
- excel中创建随机数(包含英文+数字随机数生成)
- 三角形的决策表优化问题
- 免费视频压缩工具、视频格式转换器、mp3格式转换器、视频转mp3、Moo0视频压缩工具
- 画中画activity状态管理
- dubbo 自定义异常
- “刷脸时代”到来!无需手机 两三秒完成支付
- 网上赚钱的门路方法,大部分人都是利用这三种方法!