2021-6-11-HashMap面试笔记

Posted 轻舟一曲

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了2021-6-11-HashMap面试笔记相关的知识,希望对你有一定的参考价值。

HashMap

红黑树原理源码讲解

HashMap源码分析系列

基础

散列表:数组+链表;

整合了数组快速索引和链表快速插入扩容的特性。

散列表–>哈希

哈希:也称散列,哈希对应的英文都是hash,基本原理就是把任意长度的输入,通过哈希算法变成固定长度的输出。这个压缩映射规则就是对应的哈希算法,而原始的数据映射后的二进制串就是哈希值。

Hash特点:

  1. 从hash值不可以反向推导hash原始的数据;
  2. 输入数据的微小变化会得到完全不同的hash值,相同的数据会得到相同的值;
  3. 哈希算法的执行效率要高效,长的文本也能快速的计算出哈希值;
  4. hash算法的冲突概率要小;

抽屉原理:一定存在不同的输入映射到相同输出的情况。

散列函数–>散列过程(散列)–>散列表;碰撞;

均匀散列函数:若对于关键字集合中的任一个关键字,经散列函数映象到地址集合中任何一个地址的概率是相等的,这就是使关键字经过散列函数得到一个“随机的地址”,从而减少冲突。

Hash算法也被称为散列算法,Hash算法虽然被称为算法,但实际上它更像是一种思想。Hash算法没有一个固定的公式,只要符合散列思想的算法都可以被称为是Hash算法。

常用散列函数

  1. 直接**(线性)**寻址法:H(key)=key或H(key) = a·key + b;
  2. 数字分析法:找规律找冲突概率小的,比如生日中年月冲突大,月日冲突小;
  3. 平方取中法:取关键字平方后的中间即为作为散列地址;
  4. 随机数法:取关键字作为随机函数的种子生成随机值作为散列地址,通常用于关键字长度不同的场合;
  5. 除留余数法: H(key) = key MOD p,p<=m。对p的选择很重要,一般取素数或m,若p选的不好,容易产生碰撞。

冲突处理策略:

  1. 开放寻址法:Hi=(H(key) + di) MOD m,i=1,2,…,k(k<=m-1),di为增量序列

    • di=1,2,3,…,m-1,称线性探测再散列;
    • di=12,-12,22,-22,32,…,**±k2**,(k<=m/2)称二次探测再散列;
    • di=伪随机数列,称伪随机探测再散列。
  2. 再散列法:Hi=RHi(key),i=1,2,…,k RHi均是不同的散列函数;

  3. 拉链法;

  4. 建立一个公共溢出区

原理

继承体系

HashMap是Map接口的非同步实现类

Node

底层数据结构

put

Hash碰撞,链化

JDK8引入红黑树

解决jdk1.7链化严重的问题。

扩容 (核心考点)

数组容量变大,桶位更多,查询效率提高。

源码

  • HashMap除了树化以外,很多地方都进行了优化,尤其是方法都偏向于集中了,而不是各种套娃,估计换了一个很有实力的的团队。

  • HashMap的代码行数是1.7的一倍以上,阅读源码,我们发现HashMap提供了多种转换方式以及内部类,比如keySet,EntrySet,HashIterator等,这里我们要灵活运用

常量分析

树化的另一个参数:当哈希表中的所有元素个数超过64时候,才允许树化。

static final int MIN_TREEIFY_CAPACITY = 64;

哈希表

什么时候初始化?懒加载,第一次赋值的时候才会初始化。

transient Node<K,V>[] table;
//哈希桶的大小
int threshold;

扩容阈值=当前哈希表的大小(初始16)x负载因子(默认0.75)

当哈希表中的元素超过阈值时触发扩容。

JDK1.8 HashMap底层数据结构增加一种红黑二叉树,在极限情况下11条变成红黑二叉树。
11条8(16) => 9(162=>32) => 10(322=>64) => 11(Tree)。

构造方法

4个构造方法。

  • 默认构造方式
  • 自定义容量构造方式
  • 自定义容量,加载因子构造方式
//threshold需要是2的整数次幂
this.threshold = tableSizeFor(initialCapacity);
//将传进的容量的最后一个1到第一个1全部置然后+1就是要扩的容量
static final int tableSizeFor(int cap) 
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

  • 传入Map构造

put方法

路由寻址公式:(table.length-1)&node.hash

当开始table比较小的时候,很明显hash的高16位是无法参与路由运算的。

解决?

hash:扰动函数

作用:让key的hash值的的高16也参与路由运算。

核心方法:

**putIfAbsent **: true表示如果没有哈希中没有key,就插入,没有就插,一般默认false。

**evict **:表示是否是创建过程,因为Map构造和readObject都是put已经写好的元素了!

//tab:引用当前hashMap的散列表
//p:表示当前散列表的元素
//n:散列表数组的长度
//i:表示路由寻址结果
Node<K,V>[] tab; Node<K,V> p; int n, i;
//延迟初始化逻辑,在第一次调用putval时才会初始化hashmap对象中最消耗内存的散列表
if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

第1种情况:寻址找到的桶位刚好是null,这个时候直接讲当前k-v封装成Node丢进去即可

//i=(n - 1) & hash  路由寻址算法:哈希桶大小-1与上经过扰动函数处理得到的哈希值
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

否则的话:

Node<K,V> e; K k;//临时变量

第2中情况:刚好有1个数据

//该桶位元素的键值key刚好与传入的键值key相等并且hash值也一致
if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;//找到键值key一致的元素e,用于后序替换

第3种情况:是一颗红黑树

//桶位元素节点已经树化为红黑树节点,此种情况比较复杂在后续讲解
else if (p instanceof TreeNode)
    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

第4中情况:是一个链表

else 
    //在链表上迭代进行比较
    for (int binCount = 0; ; ++binCount) 
        if ((e = p.next) == null) //找到末尾也没有找到key值一致的元素
            p.next = newNode(hash, key, value, null);//尾插
            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                treeifyBin(tab, hash);//链表达到树化条件,进行树化
            break;
        
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            break;//找到key值一致元素e,直接结束后续替换
        p = e;
    

树化的两个条件条件:

  1. 链表长度>=7 ( binCount >= TREEIFY_THRESHOLD - 1)

  2. **且桶数组>=64 **

哈希扩容条件也有两个:

  1. 哈希容量大于阈值(++size > threshold)
  2. 树化函数中树化条件不满(哈希桶数组<=64)足也会触发扩容(if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY))
if (e != null)  // 存在的话就进行替换
    V oldValue = e.value;
    if (!onlyIfAbsent || oldValue == null)//原来的值不存在
        e.value = value;
    afterNodeAccess(e);
    return oldValue;

++modCount;//表示散列表被修改的次数,替换不算
if (c)//哈希容量大于阈值回触发扩容
    resize();
afterNodeInsertion(evict);
return null;

①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果 table[i]不为空,转向③;

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

resize方法(核心)

为什么需要扩容?

为解决哈希冲突导致散列表的链化严重,影响查询效率。

final Node<K,V>[] resize() 
    //oldTab:引用扩容前的哈希表
    Node<K,V>[] oldTab = table;
    //oldCap:表示扩容之前table数组长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //oldThr:表示扩容之前触发扩容的的扩容阈值
    int oldThr = threshold;
    //扩容之后的table数组大小,下次触发扩容的阈值
    int newCap, newThr = 0;

给newCap, newThr这两个变量赋值。

//hashMap中的散列表已经初始化过了,是一次正常的扩容
if (oldCap > 0) 
    //已经达到最大阈值了
    if (oldCap >= MAXIMUM_CAPACITY) 
        threshold = Integer.MAX_VALUE;//不能再扩容了
        return oldTab;
    
    //正常扩容,oldCap << 1,扩大2倍
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY)//且扩容前阈值>=16
        newThr = oldThr << 1; //阈值也扩大2倍

//oldCap==0的两种情况,散列表未初始化
//1. public HashMap(int initialCapacity, float loadFactor)
//2. public HashMap(int initialCapacity)
//3. public HashMap(Map<? extends K, ? extends V> m) 并且map是有数据的
else if (oldThr > 0) //构造方法中传有一个容量参数
    newCap = oldThr;
else  //构造方法没有传任何参数              
    newCap = DEFAULT_INITIAL_CAPACITY;//16
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//12

if (newThr == 0) //一般new的时候传参数比如map和设置容量时候,需要自己设定newThr阈值
    float ft = (float)newCap * loadFactor;
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
              (int)ft : Integer.MAX_VALUE);

threshold = newThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//扩容
table = newTab;

扩容关键方法:

链表节点的扩容:hash&(16-1)=1111 虽然链表的后四位相同,但是再往前不一定相同。

所以再&(32-1)=11111就不一样了。

if (oldTab != null) //扩容之前,table不为null
    for (int j = 0; j < oldCap; ++j) //处理桶位中的元素
        Node<K,V> e;//处理的当前元素
        if ((e = oldTab[j]) != null) //桶位元素不为空
            oldTab[j] = null;//置空方便JVM在GC时进行回收内存
            //1.单个数据,从未有碰撞
            if (e.next == null)
                newTab[e.hash & (newCap - 1)] = e;//路由选择找到新的索引
            //2.已经树化,将红黑树的时候再讲
            else if (e instanceof TreeNode)
               ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
            //3.桶位已经形成链表
            else  
                //低位链表:存放扩容之后的数组的下标位置与当前数组的下标一致
                Node<K,V> loHead = null, loTail = null;
                //高位链表:新位置为当前数组下标位置+扩容之前数组的长度
                Node<K,V> hiHead = null, hiTail = null;
                Node<K,V> next;
                do 
                    next = e.next;
                    //  hash->...1 1111  高位链中
                    //  hash->...0 1111  低位链中
                    //oldCap->   1 0000
                    if ((e.hash & oldCap) == 0) 
                        if (loTail == null)
                            loHead = e;
                        else
                            loTail.next = e;
                        loTail = e;
                    
                    else 
                        if (hiTail == null)
                            hiHead = e;
                        else
                            hiTail.next = e;
                        hiTail = e;
                    
                 while ((e = next) != null);
                if (loTail != null) //低位链表有数据
                    loTail.next = null;
                    newTab[j] = loHead;
                
                if (hiTail != null) //高位链表有数据
                    hiTail.next = null;
                    newTab[j + oldCap] = hiHead;
                
            
        
    

return newTab;

get方法

final Node<K,V> getNode(int hash, Object key) 
    //tab:引用当前hashmap的散列表
    //first:桶位元素
    //e:当前元素
    //n:桶数组的长度
    //k:当前元素的k值
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //桶不为空并且所需要的查找(路由寻址)的hash桶位元素不为null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) 
        //桶位元素与查找的元素hash值和key值一致,查找成功
        if (first.hash == hash &&
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //桶位元素是一个链表节点或已经树化的节点
        if ((e = first.next) != null) 
            //树化节点
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //链表节点,迭代查找即可
            do 
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
             while ((e = e.next) != null);
        
    
    return null;

remove方法

key值一致

key值和value值都要一致才能删除

核心方法都是:removeNode

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) 
    //tab:引用当前hashMap中散列表
    //p:桶位当前元素或者链表的上一元素
    //n:表示散列表数组长度
    //index:路由寻址索引
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    //哈希桶数组不为空且路由选址得到的桶位元素不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) 
        //node:临时变量存储要删除的节点
        //e:当前元素
        //k:当前元素的key值
        //v:当前元素的value值
        Node<K,V> node = null, e; K k; V v;
        //桶位元素hash值和key值一致,找到要删除的元素
        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 
                do 
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) 
                        node = e;
                        break;
                    
                    p = e;
                 while ((e = e.next) != null);
            
        
        //找到的要删除的节点且非空并且判断是否需要value值也一致
        if (node != null && (!matchValue || (v [笨叔点滴16]那些狗日的面试必考题(中断管理篇)

[笨叔点滴12]面试必考:如果在中断处理函数里发生了缺页中断会怎样?为什么?

lintcode.245 子树

不止强悍游戏!RTX 3080笔记本创意设计体验

NOIP普及组初赛笔记

#yyds干货盘点# LeetCode程序员面试金典:检查子树