zoukankan      html  css  js  c++  java
  • HashMap的实现原理

     HashMap的底层实现原理

    参考如下博客:https://www.cnblogs.com/chengxiao/p/6059914.html#t1

    一、什么是哈希表

      在讨论哈希表之前,我们先大概了解下其他数据结构在新增,查找等基础操作执行性能

      数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)

      线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

      二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。

      哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。

      我们知道,数据结构的物理存储结构只有两种:顺序存储结构链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组

      比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。

            存储位置 = f(关键字)

      其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:

      

      查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。

     哈希冲突

      然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式,

    二、HashMap实现原理

    HashMap中的几个属性:

     1     //1.定义table数组,存放hashMap元素,默认是没有初始化的(懒加载功能)
     2     Node<K,V>[] table = null;
     3 
     4     //2.实际存储的元素个数
     5      int size;
     6 
     7     //3.负载因子0.75(负载因子越小,hash冲突几率越小)
     8      float DEFAULT_LOAD_FACTOR = 0.75f;
     9 
    10      //4.table默认初始大小为16
    11     int DEFAULT_INITIAL_CAPACITY = 1 << 4; //相当于2^4=16

     Node是HashMap的一个静态内部类

     1      /**定义节点*/
     2     class Node<K,V> implements Entry<K,V>{
     3 
     4         /**存放map集合的key值*/
     5         private K key;
     6         /**存放map集合的value值*/
     7         private V value;
     8         /**下一个节点*/
     9         private Node<K,V> next;
    10 
    11         public Node(K key, V value, Node<K, V> next) {
    12             this.key = key;
    13             this.value = value;
    14             this.next = next;
    15         }

    Entry是一个接口,定义了HashMap的方法

     1 public interface ExaMap<K,V> {
     2 
     3     /**向集合中添加数据*/
     4     public V put(K k,V v);
     5 
     6     /**根据k从集合中查询value值*/
     7     public V get(K k);
     8 
     9     /**获取集合元素个数*/
    10     public int size();
    11 
    12     /**Entry接口的作用===Node节点*/
    13     interface Entry<K,V>{
    14         K getKey();
    15 
    16         V getValue();
    17 
    18         V setValue(V v);
    19     }

    所以,HashMap的整体结构如下

      

      简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

    1、接下来看看put方法的实现

    public V put(K key, V value) {
            /**1.判断table数组是否为空(如果为空的情况下,做初始化操作)*/
            if(table == null){
                table = new Node[DEFAULT_INITIAL_CAPACITY];
            }
    
            /**2.hashMap扩容机制(添加数组之前检查数组是否需要扩容) 为什么要扩容,有什么影响?hashMap从什么时候开始扩容*/
            if(size>(DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR)){
                //对table数组进行扩容
                resize();
            }
    
    
            /**3.计算hash值指定下标位置*/
            int index = getIndex(key,DEFAULT_INITIAL_CAPACITY);
            /**判断该索引下是否有node对象*/
            Node<K,V> node = table[index];
            if(node == null){
                /**表示没有发生hash冲突,创建一个Node对象*/
                node = new Node<K,V>(key,value,null);
                /**实际存储的元素个数+1*/
                size++;
                //return node.value;
            }else{
                /**发生hash冲突:
                 * jdk1.7包含如下设计原则:
                 * 系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处
                 * 如果 bucketIndex 索引处已经有了一个 Entry 对象,
                 * 那新添加的 Entry 对象指向原有的 Entry 对象(产生一个 Entry 链)。*/
    
                /**新添加的Entry对象总是放到数组的bucketIndex索引处*/
                Node<K,V> newNode = node;
    
                while(newNode != null){
                    //修改node对象的value值
                    if(newNode.getKey().equals(key) || newNode.getKey() == key){
                        //hashCode相等,而且equals相同,说明是一个对象;修改值
                        //node.value = value;
                        return newNode.setValue(value);
                    }else{
                        //添加node对象到链表中
                        if(newNode.next == null){
                            //说明遍历到了最后一个node,添加node
                            node = new Node<K,V>(key,value,node);
                            size++;
                        }
    
                    }
                    newNode = newNode.next;
                }
    
            }
            table[index] = node;
            return null;
        }

    HashMap在设计时遵循如下原则:系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处——如果 bucketIndex 索引处已经有了一个 Entry 对象,那新添加的 Entry 对象指向原有的 Entry 对象(产生一个 Entry 链),如果 bucketIndex 索引处没有 Entry 对象,也就是上面程序代码的 e 变量是 null,也就是新放入的 Entry 对象指向 null,也就是没有产生 Entry 链。向链表添加元素时,首先遍历链表。如果key的hashCode相同,key的equals()方法也相同,即是同一个元素,则修改值;否则执行添加操作。

    h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为

            1  0  0  1  0
        &   0  1  1  1  1
        __________________
            0  0  0  1  0    = 2

      最终计算出的index=2。有些版本的对于此处的计算会使用 取模运算,也能保证index一定在数组范围内,不过位运算对计算机来说,性能更高一些(HashMap中有大量位运算)

    所以最终存储位置的确定流程是这样的:

     

    2、HashMap的扩容方法

     1      /**对table数组进行扩容*/
     2     private void resize(){
     3         //1.生成新的table,长度是之前的2倍
     4        Node<K,V>[] newTable = new Node[DEFAULT_INITIAL_CAPACITY*2];
     5         //2.重新计算index的位置,存放在新的table里面
     6         for(int i=0;i<table.length;i++){
     7             //存放在之前的table,原来的node
     8            Node<K,V> oldNode = table[i];
     9            while(oldNode != null){ //遍历链表中的所有node节点
    10                table[i] = null; //赋值为null---为了垃圾回收机制可以回收(将之前的node删除)
    11                 //存放之前table中node的key
    12                 K oldKey = oldNode.key;
    13                 //重新计算index(新的位置)
    14                int newIndex = getIndex(oldKey,newTable.length);
    15                //原来node的下一个
    16                Node<K,V> oldNext = oldNode.next;
    17                //如果index下标在新的newTable发生相同的时候,以链表进行存储
    18                oldNode.next = newTable[newIndex];
    19                //将之前的node赋值给newTable[newIndex]
    20                newTable[newIndex] = oldNode;
    21                //判断是否继续进行循环
    22                oldNode = oldNext;
    23 
    24            }
    25         }
    26 
    27         //3.将newTable赋值给旧的table
    28         table = newTable;
    29         DEFAULT_INITIAL_CAPACITY = newTable.length;
    30         newTable = null;//垃圾回收机制回收
    31     }

     

     注意:在jdk1.7中数组扩容后在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处。如下图所示:

    3、为何HashMap的数组长度一定是2的次幂?

    众所周知,HashMap是基于Hash表的Map接口实现,HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。HashMap中主要是通过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。但是知道了Hash值之后,又是怎么确定出key在数组中的索引呢?

    根据源码得知如下方法
    static int indexFor(int h, int length) { //根据hash值和数组长度算出索引值
             return h & (length-1);  

    这样做有什么好处呢 ?

    3.1、保证不会发生数组越界

    首先我们要知道的是,在HashMap,数组的长度按规定一定是2的幂。因此,数组的长度的二进制形式是:10000…000, 1后面有偶数个0。 那么,length - 1 的二进制形式就是01111.111, 0后面有偶数个1。最高位是0, 和hash值相“与”,结果值一定不会比数组的长度值大,因此也就不会发生数组越界。

    3.2、保证元素尽可能的均匀分布

    由上边的分析可知,length一定是一个偶数,length - 1一定是一个奇数。假设现在数组的长度length为16,减去1后length - 1就是15,15对应的二进制是:1111。现在假设有两个元素需要插入,一个哈希值是8,二进制是1000,一个哈希值是9,二进制是1001。和1111“与”运算后,结果分别是1000和1001,它们被分配在了数组的不同位置,这样,哈希的分布非常均匀。那么,如果数组长度是奇数呢?减去1后length - 1就是偶数了,偶数对应的二进制最低位一定是 0,例如14二进制1110。对上面两个数子分别“与”运算,得到1000和1000。结果都是一样的值。那么,哈希值8和9的元素都被存储在数组同一个index位置的链表中。在操作的时候,链表中的元素越多,效率越低,因为要不停的对链表循环比较。

    按位与运算符(&)

    参加运算的两个数据,按二进制位进行“与”运算。

    运算规则:0&0=0;  0&1=0;   1&0=0;    1&1=1;

          即:两位同时为“1”,结果才为“1”,否则为0

    例如:3&5  即 0000 0011& 0000 0101 = 00000001  因此,3&5的值得1。

    元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

    只需要关注h的最高位是0还是1即可。如果是0,这在新数组中的位置不变;如果是1,则在新数组的位置为原位置+扩容的大小

    hashMap 1.8 哈希算法例图2

    因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

    jdk1.8 hashMap扩容例图

  • 相关阅读:
    Leetcode 191.位1的个数 By Python
    反向传播的推导
    Leetcode 268.缺失数字 By Python
    Leetcode 326.3的幂 By Python
    Leetcode 28.实现strStr() By Python
    Leetcode 7.反转整数 By Python
    Leetcode 125.验证回文串 By Python
    Leetcode 1.两数之和 By Python
    Hdoj 1008.Elevator 题解
    TZOJ 车辆拥挤相互往里走
  • 原文地址:https://www.cnblogs.com/xiaocao123/p/9681573.html
Copyright © 2011-2022 走看看