散列表、LinkedHashMap源码分析

一、散列表

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)线性探测

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

扫描二维码关注公众号,回复: 8876148 查看本文章

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

查找操作:在散列表中查找元素,通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中
在这里插入图片描述在这里插入图片描述
删除操作:对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。不能单纯地把要删除的元素设置为空。因为在查找的过程中,一旦通过线性探测方法,找到一个空闲位置,就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。我们可以将删除的元素,特殊标记为deleted。当线性探测查找的时候,遇到标记为deleted的空间,并不是停下来,而是继续往下探测
在这里插入图片描述
线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久

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

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

2)、链表法

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

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

1、如何设计散列函数?

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

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

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

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

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

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

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

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

当有新数据要插入时,将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中。这样没有了集中的一次性数据搬移,插入操作就都变得很快了
在这里插入图片描述
这期间的查询操作怎么来做呢?

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

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

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

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

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

1)、开放寻址法

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

2)、链表法

对链表法稍加改造,可以实现一个更加高效的散列表。将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O ( l o g n ) 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 1st
                            treeifyBin(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 key
                V 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;
            //如果第一个节点就是要删除的节点直接赋值给node
            if (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;
                else
                    p.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;
        else
            b.after = a;
        //a为null,表示p是尾节点
        if (a == null)
            tail = b;
        else
            a.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/

发布了177 篇原创文章 · 获赞 407 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/qq_40378034/article/details/102730778