一、散列表

1、散列思想


散列表用的是数组支持按照下标随机访问数据的时候,时间复杂度是O(1)O(1)O(1)的特性。通过散列函数把元素的键值映射为下标,然后把数据存储在数组中对应下标的位置。当按照键值查询元素时,用同样的散列函数,将键值转化为数组下标,从对应的数组下标的位置取数据

2、散列函数

散列函数hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值

散列函数设计的基本要求:

  • 散列函数计算得到的散列值是一个非负整数

  • 如果key1=key2,那hash(key1)==hash(key2)

  • 如果key1!=key2,那hash(key1)!=hash(key2)

针对于第三点,即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率

3、散列冲突

常用的散列冲突解决方法有两类:开放寻址法链表法

1)、开放寻址法

开放寻址法的核心思想:如果出现了散列冲突,就重新探测一个空闲位置,将其插入

1)线性探测

插入操作:当往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止

下图中黄色的色块表示空闲位置,橙色的色块表示已经存储了数据

从图中可以看出,散列表的大小为10,在元素x插入散列表之前,已经6个元素插入到散列表中。x经过hash算法之后,被散列到位置下标为7的位置,但是这个位置已经有数据了,所以就产生了冲突。于是就顺序地往后一个一个找,看有没有空闲的位置,遍历到尾部都没有找到空闲的位置,于是再从表头开始找,直到找到空闲位置2,将其插入到这个位置

查找操作:在散列表中查找元素,通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中

删除操作:对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。不能单纯地把要删除的元素设置为空。因为在查找的过程中,一旦通过线性探测方法,找到一个空闲位置,就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。我们可以将删除的元素,特殊标记为deleted。当线性探测查找的时候,遇到标记为deleted的空间,并不是停下来,而是继续往下探测

线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久

散列表的装载因子=填入表中的元素个数/散列表的长度​

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降

2)、链表法

在散列表中,每个桶或者槽会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中

当插入的时候,只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是O(1)O(1)O(1)。当查找、删除一个元素时,同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。查找或删除的时间复杂度跟链表的长度k成正比,也就是O(k)O(k)O(k)。对于散列比较均匀的散列函数来说,理论上,k=n/mk=n/mk=n/m,其中n表示散列中数据的个数,m表示散列表中“槽”的个数

二、如何打造一个工业级水平的散列表

1、如何设计散列函数?

散列函数的设计不能太复杂,过于复杂的散列函数势必会消耗很多计算时间,也就间接的影响到散列表的性能

散列函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽里数据特别多的情况

2、装载因子多大怎么办?

装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率越大

当装载因子过大时,可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设每次扩容都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是0.8,那经过扩容之后,新散列表的装载因子就降为原来的一半,变成了0.4

当散列表的装载因子超过某个阈值时,就需要进行扩容。装载因子阈值需要选择得当,如果太大,会导致冲突过多;如果太小,会导致内存浪费严重

3、如何避免低效地扩容?

为了解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,只申请新空间,但并不将老的数据搬移到新散列表中

当有新数据要插入时,将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中。这样没有了集中的一次性数据搬移,插入操作就都变得很快了

这期间的查询操作怎么来做呢?

对于查询操作,为了兼容新、老散列表中的数据,先从新散列表中查找,如果没有找到,再去老的散列表中查找

通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度都是O(1)O(1)O(1)

Redis底层数据结构字典的渐进式rehash:http://redisbook.com/preview/dict/incremental_rehashing.html

4、如何选择冲突解决方案?

Java中LinkedHashMap采用了链表法解决冲突,ThreadLocalMap是通过线性探测的开放寻址法来解决冲突

1)、开放寻址法

当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是Java中的ThreadLocalMap使用开放寻址法解决散列冲突的原因

2)、链表法

对链表法稍加改造,可以实现一个更加高效的散列表。将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是O(logn)O(logn)O(logn)

基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表

三、LinkedHashMap详解


LinkedHashMap直接继承自HashMap,这也就说明了HashMap一切重要的概念LinkedHashMap都是拥有的,这就包括了,hash算法定位hash桶位置,哈希表由数组和单链表构成,并且当单链表长度超过8的时候转化为红黑树,扩容体系,这一切都跟HashMap一样。除此之外,LinkedHashMap比HashMap更加强大,这体现在:

  • LinkedHashMap内部维护了一个双向链表,解决了HashMap不能随时保持遍历顺序和插入顺序一致的问题
  • LinkedHashMap元素的访问顺序也提供了相关支持,也就是常说的LRU(最近最少使用)原则

图片中红黄箭头代表元素添加顺序,蓝箭头代表单链表各个元素的存储顺序。head表示双向链表头部,tail代表双向链表尾部

LinkedHashMap和HashMap相比,唯一的变化是使用双向链表(图中红黄箭头部分)记录了元素的添加顺序,HashMap的Node节点只有next指针,LinkedHashMap对于Node节点进行了扩展:

    static class Entry<K,V> extends HashMap.Node<K,V> {Entry<K,V> before, after;Entry(int hash, K key, V value, Node<K,V> next) {super(hash, key, value, next);}}

LinkedHashMap基本存储单元Entry继承自HashMap.Node,并在此基础上添加了before和after这两个指针变量。before变量在每次添加元素的时候将会指向上一次添加的元素,而上一次添加元素的after变量将指向该次添加的元素,来形成双向链接

1、put方法

LinkedHashMap并没有覆写任何关于HashMap的put方法。所以调用LinkedHashMap的put方法实际上调用了父类HashMap的方法

    public V put(K key, V value) {return putVal(hash(key), key, value, false, true);}final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;//判断当前桶是否为空,空的就需要初始化(resize中会判断是否需要初始化)if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;//根据当前key的hashcode定位到具体的桶中并判断是否为空,为空表明没有Hash冲突就直接在当前位置创建一个新桶即可if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;//如果当前桶有值(Hash冲突),那么就要比较当前桶中的key、key的hashcode与写入的key是否相等,相等就赋值给e,后面统一进行赋值及返回if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;//如果当前桶为红黑树,按照红黑树的方式写入数据else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {//如果是个链表,就需要将当前的key、value封装成一个新节点写入当前桶的后面(采用尾插法)for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);//判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}//如果在遍历链表的过程中,找到key相同时直接退出遍历if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}//如果e!=null就相当于存在相同的key,那就需要将值覆盖if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;//最后判断是否需要进行扩容if (++size > threshold)resize();afterNodeInsertion(evict);return null;}

每次添加新节点的时候实际上调用了newNode方法生成了一个新节点,放到指定hash桶中,LinkedHashMap中重写了该方法,所以LinkedHashMap调用put方法时实际上调用的是LinkedHashMap中的newNode方法,下面来看一下LinkedHashMap中的newNode方法的具体实现:

    Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {LinkedHashMap.Entry<K,V> p =new LinkedHashMap.Entry<K,V>(hash, key, value, e);linkNodeLast(p);return p;}private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {//添加新节点之前双向链表的尾部节点LinkedHashMap.Entry<K,V> last = tail;//tail指向新添加的节点tail = p;//如果添加之前尾部节点为null,那就说明之前map为null,将head指向新节点if (last == null)head = p;//如果添加之前尾部节点不为null,新节点的before指向之前链表尾部节点,之前链表尾部节点的after指向新节点else {p.before = last;last.after = p;}}

put方法调用顺序:

LinkedHashMap链表创建步骤,可用上图几个步骤来描述,蓝色部分是HashMap的方法,而橙色部分为 LinkedHashMap重写或独有的方法

2、remove方法

和put方法一样,remove方法实际上调用了父类HashMap的方法

    public V remove(Object key) {Node<K,V> e;return (e = removeNode(hash(key), key, null, false, true)) == null ?null : e.value;}final Node<K,V> removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) {Node<K,V>[] tab; Node<K,V> p; int n, index;//判断哈希表是否为空,哈希表长度是否大于0,根据要删除key的hashcode定位到具体的桶中并判断是否为空if ((tab = table) != null && (n = tab.length) > 0 &&(p = tab[index = (n - 1) & hash]) != null) {//node表示要删除的节点Node<K,V> node = null, e; K k; V v;//如果第一个节点就是要删除的节点直接赋值给nodeif (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))node = p;else if ((e = p.next) != null) {//遍历红黑树找到对应的节点if (p instanceof TreeNode)node = ((TreeNode<K,V>)p).getTreeNode(hash, key);else {//遍历链表找到对应的节点,p表示链表中要删除节点的前驱结点do {if (e.hash == hash &&((k = e.key) == key ||(key != null && key.equals(k)))) {node = e;break;}p = e;} while ((e = e.next) != null);}}if (node != null && (!matchValue || (v = node.value) == value ||(value != null && value.equals(v)))) {//删除节点if (node instanceof TreeNode)((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);else if (node == p)tab[index] = node.next;elsep.next = node.next;++modCount;--size;//afterNodeRemoval在HashMap中是一个空实现,LinkedHashMap中重写了该方法afterNodeRemoval(node);return node;}}return null;}

LinkedHashMap中重写的afterNodeRemoval方法:

    void afterNodeRemoval(Node<K,V> e) { // unlink//p为删除的节点,b为删除节点的前驱节点,a为删除节点的后继节点LinkedHashMap.Entry<K,V> p =(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;//将节点p的前后指针引用置为null便于内存释放p.before = p.after = null;//b为null,表示p是头节点if (b == null)head = a;elseb.after = a;//a为null,表示p是尾节点if (a == null)tail = b;elsea.before = b;}

LinkedHashMap删除节点的方法分为三个步骤:

1)确认待删除的节点

2)删除待删除的节点

3)从双向链表中删除待删除的节点

参考:

https://juejin.im/post/5ace2bde6fb9a028e25deca8

HashMap相关博客:

为什么使用HashMap需要重写hashcode和equals方法?

HashMap:为什么容量总是为2的n次幂

HashMap和ConcurrentHashMap源码分析(基于JDK1.7和1.8)

HashMap和Hashtable的区别

四、散列表相关题目

1、LeetCode242:有效的字母异位词

给定两个字符串s和t,编写一个函数来判断t是否是s的字母异位词(字符串只包含小写字母)

示例1:

输入: s = "anagram", t = "nagaram"
输出: true

示例2:

输入: s = "rat", t = "car"
输出: false

题解:

    public boolean isAnagram(String s, String t) {if (s.length() != t.length()) {return false;}int[] counter = new int[26];for (int i = 0; i < s.length(); i++) {counter[s.charAt(i) - 'a']++;counter[t.charAt(i) - 'a']--;}for (int count : counter) {if (count != 0) {return false;}}return true;}

2、LeetCode49:字母异位词分组

给定一个字符串数组,将字母异位词组合在一起。字母异位词指字母相同,但排列不同的字符串

示例:

输入: ["eat", "tea", "tan", "ate", "nat", "bat"],
输出:
[["ate","eat","tea"],["nat","tan"],["bat"]
]

说明:

题解:

    public List<List<String>> groupAnagrams(String[] strs) {Map<String, List<String>> map = new HashMap<>();for (int i = 0; i < strs.length; ++i) {char[] charArray = strs[i].toCharArray();Arrays.sort(charArray);String str = String.valueOf(charArray);if (map.containsKey(str)) {map.get(str).add(strs[i]);} else {List<String> temp = new ArrayList<String>();temp.add(strs[i]);map.put(str, temp);}}return new ArrayList<List<String>>(map.values());}

常用数据结构的时间、空间复杂度:

https://www.bigocheatsheet.com/

散列表、LinkedHashMap源码分析相关推荐

  1. Java类集框架 —— LinkedHashMap源码分析

    前言 我们知道HashMap底层是采用数组+单向线性链表/红黑树来实现的,HashMap在扩容或者链表与红黑树转换过程时可能会改变元素的位置和顺序.如果需要保存元素存入或访问的先后顺序,那就需要采用L ...

  2. Java集合之LinkedHashMap源码分析

    概述 HashMap是无序的, 即put的顺序与遍历顺序不保证一样. LinkedHashMap是HashMap的一个子类, 它通过重写父类的相关方法, 实现自己的功能. 它保留插入的顺序. 如果需要 ...

  3. LinkedHashMap源码分析

    简介 LinkedHashMap继承HashMap(线程不安全-),内部主体还是一个哈希表,底层用的仍然是拉链式散列结构,由数组+链表/红黑树组成(加入红黑树分析起来可能会有一点乱,本文淡化红黑树分析 ...

  4. 【Java源码分析】LinkedHashMap源码分析

    类的定义 public class LinkedHashMap<K, V> extends HashMap<K, V> {} 基于双向链表实现,属于Map的一类,其父类是Has ...

  5. HashSet及LinkedHashSet源码分析(基于JDK1.6)

    Java容器类的用途是"保存对象",分为两类:Map--存储"键值对"组成的对象:Collection--存储独立元素.Collection又可以分为List和 ...

  6. LinkedHashMap 源码详细分析(JDK1.8)

    1. 概述 LinkedHashMap 继承自 HashMap,在 HashMap 基础上,通过维护一条双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题.除此之外,Linke ...

  7. Java Collection系列之HashMap、ConcurrentHashMap、LinkedHashMap的使用及源码分析

    文章目录 HashMap HashMap的存储结构 初始化 put & get put元素 get元素 扩容 遍历Map jdk1.8中的优化 ConcurrentHashMap jdk1.7 ...

  8. 《微信小程序-进阶篇》Lin-ui组件库源码分析-列表组件List(一)

    大家好,这是小程序系列的第二十篇文章,在这一个阶段,我们的目标是 由简单入手,逐渐的可以较为深入的了解组件化开发,从本文开始,将记录分享lin-ui的源码分析,期望通过对lin-ui源码的学习能加深组 ...

  9. 基于JDK1.8---HashMap源码分析

    基于JDK1.8-HashMap源码简要分析 HashMap继承关系 HashMap:根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不 ...

最新文章

  1. springboot+flowable第二节(流程基本操作)
  2. 图像检索:FCTH(Fuzzy Color and Texture Histogram)算法
  3. 关于DNF的多媒体包NPK文件的那些事儿(4)- NPK文件操作流程
  4. 微型计算机的应用形式,微型计算机基本原理与应用
  5. 多核处理器真的能提升软件系统性能吗?
  6. 与“鸡”对话的时代孤独
  7. 视频目标分割数据集DAVIS(Denly-Annotated VIdeo Segmentation)解读
  8. 二寸证件照尺寸怎么调?这两个方法让你在家也能制作证件照
  9. 如何在 Python 中异步操作数据库?aiomysql、asyncpg、aioredis 使用介绍
  10. Tomcat中Session钝化与活化实现步骤
  11. 22湖南大学866数据结构真题(回忆版)
  12. 甘特图、IPO图、DFD图
  13. zabbix报警 Lack of free swap space on zabbix
  14. 2020/03/11美版iPhone卡贴ICCID完美激活!
  15. MATLAB实现psf,C++实现Matlab的psf2otf函数
  16. python编写网页游戏脚本_用Python脚本做一些网页游戏中力所能及的自动化任务
  17. Ring Buffer (circular Buffer)环形缓冲区简介
  18. mac 在连接wifi时出现“无互联网连接”的警告,叹号
  19. 98、网管型和非网管型交换机有什么区别
  20. 【章节总结】化学——化学反应与能量

热门文章

  1. 《这就是搜索引擎》爬虫部分摘抄总结
  2. 【BZOJ 1930】 [Shoi2003]pacman 吃豆豆 最大费用最大流
  3. 光标怎么设置sap_设置placeholder光标
  4. 缓解疲劳,闻咖啡比喝咖啡更好
  5. Ueditor 自定义多图上传路径及回显
  6. 疯狂Java讲义(十三)----第二部分
  7. VC中GetLastErro返回的错误码
  8. plsql备份还原oracle数据库,(已实践)PLSQL本地还原Oracle数据库dmp文件
  9. L1-051 打折 (5 分) Java 篇
  10. Ubuntu主机有线连接浙大校园网|浙大校园网Ubuntu有线连接