手写一个简单的HashMap

HashMap简介

HashMap是Java中一中很是经常使用的数据结构,也基本是面试中的“必考题”。它实现了基于“K-V”形式的键值对的高效存取。JDK1.7以前,HashMap是基于数组+链表实现的,1.8之后,HashMap的底层实现中加入了红黑树用于提高查找效率。java

HashMap根据存入的键值对中的key计算对应的index,也就是它在数组中的存储位置。当发生哈希冲突时,即不一样的key计算出了相同的index,HashMap就会在对应位置生成链表。当链表的长度超过8时,链表就会转化为红黑树。面试

image-20200424212525490

手写HashMap以前,咱们讨论一个小问题:当咱们在HashMap中根据key查找value时,在数组、链表、红黑树三种状况下,平均要作多少次比较?数组

在数组中查找时,咱们能够经过key的hashcode直接计算它在数组中的位置,比较次数为1数据结构

在链表中查找时,根据next引用依次比较各个节点的key,长度为n的链表节点平均比较次数为n/2ide

在红黑树中查找时,因为红黑树的特性,节点数为n的红黑树平均比较次数为log(n)this

前面咱们提到,链表长度超过8时树化(TREEIFY),正是由于n=8,就是log(n) < n/2的阈值。而n<6时,log(n) > n/2,红黑树解除树化(UNTREEIFY)。另外咱们能够看到,想要提升HashMap的效率,最重要的就是尽可能避免生成链表,或者说尽可能减小链表的长度,避免哈希冲突,下降key的比较次数。code

手写HashMap

定义一个Map接口

也可使用Java中的java.util.Map对象

public interface MyMap<K,V> {

    V put(K k, V v);

    V get(K k);

    int size();

    V remove(K k);

    boolean isEmpty();

    void clear();
}

而后编写一个MyHashMap类,实现这个接口,并实现里面的方法。blog

成员变量

final static int DEFAULT_CAPACITY = 16;
    final static float DEFAULT_LOAD_FACTOR = 0.75f;

    int capacity;
    float loadFactor;
    int size = 0;

    Entry<K,V>[] table;
class Entry<K, V>{
    K k;
    V v;
    Entry<K,V> next;

    public Entry(K k, V v, Entry<K, V> next){
        this.k = k;
        this.v = v;
        this.next = next;
    }
}

咱们参照HashMap设置一个默认的容量capacity和默认的加载因子loadFactor,table就是底层数组,Entry类保存了"K-V"数据,next字段代表它可能会是一个链表节点。接口

构造方法

public MyHashMap(){
    this(DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR);
}

public MyHashMap(int capacity, float loadFactor){
    this.capacity = upperMinPowerOf2(capacity);
    this.loadFactor = loadFactor;
    this.table = new Entry[capacity];
}

这里的upperMinPowerOf2的做用是获取大于capacity的最小的2次幂。在HashMap中,开发者采用了更精妙的位运算的方式完成了这个功能,效率比这种方式要更高。

private static int upperMinPowerOf2(int n){
    int power = 1;
    while(power <= n){
        power *= 2;
    }
    return power;
}

为何HashMap的capacity必定要是2次幂呢?这是为了方便HashMap中的数组扩容时已存在元素的从新哈希(rehash)考虑的。

put方法

@Override
public V put(K k, V v) {
    // 经过hashcode散列
    int index = k.hashCode() % table.length;
    Entry<K, V> current = table[index];
    // 判断table[index]是否已存在元素
    // 是
    if(current != null){
        // 遍历链表是否有相等key, 有则替换且返回旧值
        while(current != null){
            if(current.k == k){
                V oldValue = current.v;
                current.v = v;
                return oldValue;
            }
            current = current.next;
        }
        // 没有则使用头插法
        table[index] = new Entry<K, V>(k, v, table[index]);
        size++;
        return null;
    }
    // table[index]为空 直接赋值
    table[index] = new Entry<K, V>(k, v, null);
    size++;
    return null;
}

put方法中,咱们经过传入的K-V值构建一个Entry对象,而后判断它应该被放在数组的那个位置。回想咱们以前的论断:

想要提升HashMap的效率,最重要的就是尽可能避免生成链表,或者说尽可能减小链表的长度

想要达到这一点,咱们须要Entry对象尽量均匀地散布在数组table中,且index不能超过table的长度,很明显,取模运算很符合咱们的需求int index = k.hashCode() % table.length。关于这一点,HashMap中也使用了一种效率更高的方法——经过&运算完成key的散列,有兴趣的同窗能够查看HashMap的源码。

若是table[index]处已存在元素,说明将要造成链表。咱们首先遍历这个链表(长度为1也视做链表),若是存在key与咱们存入的key相等,则替换并返回旧值;若是不存在,则将新节点插入链表。插入链表又有两种作法:头插法尾插法。若是使用尾插法,咱们须要遍历这个链表,将新节点插入末尾;若是使用头插法,咱们只须要将table[index]的引用指向新节点,而后将新节点的next引用指向原来table[index]位置的节点便可,这也是HashMap中的作法。

image-20200424223034753

若是table[index]处为空,将新的Entry对象直接插入便可。

get方法

@Override
public V get(K k) {
    int index = k.hashCode() % table.length;
    Entry<K, V> current = table[index];
    // 遍历链表
    while(current != null){
        if(current.k == k){
            return current.v;
        }
        current = current.next;
    }
    return null;
}

调用get方法时,咱们根据key的hashcode计算它对应的index,而后直接去table中的对应位置查找便可,若是有链表就遍历。

remove方法

@Override
public V remove(K k) {
    int index = k.hashCode() % table.length;
    Entry<K, V> current = table[index];
    // 若是直接匹配第一个节点
    if(current.k == k){
        table[index] = null;
        size--;
        return current.v;
    }
    // 在链表中删除节点
    while(current.next != null){
        if(current.next.k == k){
            V oldValue = current.next.v;
            current.next = current.next.next;
            size--;
            return oldValue;
        }
        current = current.next;
    }
    return null;
}

移除某个节点时,若是该key对应的index处没有造成链表,那么直接置为null。若是存在链表,咱们须要将目标节点的前驱节点的next引用指向目标节点的后继节点。因为咱们的Entry节点没有previous引用,所以咱们要基于目标节点的前驱节点进行操做,即:

current.next = current.next.next;

current表明咱们要删除的节点的前驱节点。

还有一些简单的size()、isEmpty()等方法都很简单,这里就再也不赘述。如今,咱们自定义的MyHashMap基本可使用了。

最后

关于HashMap的实现,还有几点咱们没有解决:

  1. 扩容问题。在HashMap中,当存储的元素数量超过阈值(threshold = capacity * loadFactor)时,HashMap就会发生扩容(resize),而后将内部的全部元素进行rehash,使hash冲突尽量减小。在咱们的MyHashMap中,虽然定义了加载因子,可是并无使用它,capacity是固定的,虽然因为链表的存在,仍然能够一直存入数据,可是数据量增大时,查询效率将急剧降低。
  2. 树化问题(treeify)。咱们以前讲过,链表节点数量超过8时,为了更高的查询效率,链表将转化为红黑树。可是咱们的代码中并无实现这个功能。
  3. null值的判断。HashMap中是容许存null值的key的,key为null时,HashMap中的hash()方法会固定返回0,即key为null的值固定存在table[0]处。这个实现起来很简单,不实现的状况下MyHashMap中若是存入null值会直接报NullPointerException异常。
  4. 一些其余问题。

相信你们本身完成了对HashMap的实现以后,对它的原理必定会有更深入的认识,本文若是有错误或是不严谨的地方也欢迎你们指出。上述的问题咱们接下来再逐步解决,至于红黑树,我也不会(摊手)。

相关文章
相关标签/搜索