轻松手撕 HashMap

HashMap 理论知识介绍

TreeMap分析

  • 时间复杂度:

    • 添加, 删除, 搜索: O(logn)
  • 特点:

    • Key 必须具备可比较性
    • 元素的分布是有顺序的
  • 在实际应用中, 很多时候的需求

    • Map 中存储的元素不需要讲究顺序
    • Map 中的 Key 不需要具备可比较性
  • 不考虑顺序, 不考虑 Key 的可比较性, Map 有更好的实现方案, 平均时间复杂度可以达到 O(1)

    • 那就是采取哈希表来实现 Map

哈希表 (Hash Table)

  • 哈希表也叫做散列表 (hash 有 “剁碎” 的意思)

  • 添加, 搜索, 删除的流程都是类似的

    • 1.利用哈希函数生成 key 对应的 index O(1)
    • 2.根据 index 操作定位数组元素 O(1)
  • 哈希表是空间换时间的典型应用

  • 哈希函数, 也叫做散列函数

  • 哈希表内部的数组元素, 很多地方也叫 Bucket (桶) , 整个数组叫 Buckets 或者 Bucket Array 或者 Table

哈希冲突

  • 哈希冲突也叫做哈希碰撞
    • 2 个不同的 key, 经过哈希函数计算出相同的结果
    • key1 != key2, hash(key1) == hash(key2)
  • 解决哈希冲突的常见方法
    • 1.开放定址法(Open Addressing)
      • 按照一定的规则(线性探测, 平方探测)向其他地址探测, 直到遇到空桶
    • 2.再哈希法(Re-Hashing)
      • 设计多个哈希函数
    • 3.链地址法(Separate Chaining)
      • 比如通过链表将同一index的元素串起来

JDK1.8的哈希冲突解决方案

  • 默认使用单向链表将元素串起来
  • 在添加元素时, 可能会由单向链表转为红黑树来存储元素
    • 比如当哈希表容量 >= 64 且 单向链表的节点数量 > 8
  • 红黑树节点数量少到一定程度时, 又会转为单向链表
  • JDK1.8中的哈希表是使用链表+红黑树解决哈希冲突
  • 思考: 这里为什么使用单链表?
    • 1.因为需要遍历链表中的key是否是要添加的key, 如果是同一个key, 则覆盖value, 如果比较到最后面都没有相同的key, 则直接将新添加的key value插入到链表的尾部.
    • 2.没有往前走的操作, prev 指针没用, 所以为了节省内存空间用单向链表

哈希函数

  • 哈希表中哈希函数的实现步骤大概如下

    • 1.先生成 key 的哈希值(必须是整数)

    • 2.再让 key 的哈希值数组的大小进行相关运算, 生成一个索引值

    • public int hash(Object key) {
          return hash_code(key) % table.length;
      }
      
  • 为了提高效率, 可以使用**&位运算取代%**运算 ( 前提: 将数组的长度设计为 2 的幂 (2^n) )

  • public int hash(Object key) {
        return hash_code(key) & (table.length - 1);
    }
    /*因为
      10101
    & 00111
    -------
      00101
      所以最后算出来的结果一定是 <= 111即数组的最大下标
      生成的值的范围是 000~111
      所以任何一个值 & 上111, 最终结果是000~111
     */
    
  • 良好的哈希函数

    • 让哈希值更加均匀分布 -> 减少哈希冲突次数 -> 提升哈希表的性能

如何生成 key 的哈希值

  • key 的常见种类可能有
    • 整数, 浮点数, 字符串, 自定义对象
  • 不同种类的 key, 哈希值的生成方式不一样, 但目标是一致的
    • 1.尽量让每个 key 的哈希值是唯一的
    • 2.尽量让 key 的所有信息参与运算(减少哈希冲突)
  • 在Java中, HashMap 的 key 必须实现 hashCode, equals 方法, 也允许 key 为 null
整数
  • 整数值当做哈希值

    • 比如 10 的哈希值就是 10
  • public static int hashCode(int value) {
        return value;
    }
    
浮点数
  • 浮点数在内存中是怎么存储的?

    • 十进制8.25的二进制是1000.01
      ->1.00001*2^3(3指数, 00001尾数)
      因为十进制的小数点后0.1是10分之1
      0.01是100分之1即10^2分之1
      所以二进制的小数点后0.1是2分之1
      0.01是4分之1即2^2分之1
      十进制1000.01->1.00001*10^3
      二进制1000.01->1.00001*2^3
      浮点数中1.x*2^y也就是说1是固定的,2也是不变的所以在内存中不用存储在内存中只存有x和y也就是尾数和指数,当然还有符号位
      在这里插入图片描述
  • 将存储的二进制格式转为整数值

  • public static int hashCode(float value) {
        return floatToIntBits(value);
    }
    

Long 和 Double 的哈希值

  • 因为在Java中规定哈希值必须是int类型, 即32位

  • public static int hashCode(long value) {
        return (int) (value ^ (value >>> 32));
    }
    
  • public static int hashCode(double value) {
        long bits = doubleToLongBits(value);
        return (int) (bits ^ (bits >>> 32));
    }
    
  • >>> 和 ^ 的作用是?

    • 高32bit 和 低32bit 混合计算出 32bit 的哈希值
    • 充分利用所有信息计算出哈希值
  • 为什么要用 ^ ?

  • 如果用的是&: 以上图为例, 结果直接是低32位的数据, 相当于直接用了低32位的数据作为哈希值. 相当于没算

  • 如果用的是 | : 以上图为例, 结果全是1, 相当于直接用了高32位的数据作为哈希值

  • 所以只有 ^ 才能办到是拿低32位和高32位混合运算出不同的数据

字符串的哈希值

  • 整数 5489 是如何计算出来的?

    • 5 * 10^3 + 4 * 10^2 + 8 * 10^1 + 9 * 10^0
  • 字符串是由若干个字符组成

    • 比如字符串jack , 由 j、a、c、k 四个字符组成 (字符的本质就是一个整数)
    • 因此, jack的哈希值可以表示为j * n^3 + a * n^2 + c * n^1 + k * n^0, 等价于 [(j * n + a) * n + c] * n + k
    • 在JDK中, 乘数n为31, 为什么使用31?
    • 31是一个奇素数, JVM会将 31 * i 优化成 (i << 5) - i
static void test() {
    String string = "jack";
    int len = string.length();
    int hashCode = 0;
    for (int i = 0; i < len; i++) {
        char c = string.charAt(i);
        hashCode = hashCode * 31 + c;
        // hashCode = (hashCode << 5) - hashCode + c;
    }
}

关于31的探讨

  • 31 * i = (2^5 - 1) * i = i * 2^5 - i = (i << 5) - i
  • 31不仅仅是符合2^n - 1, 它是个奇素数(既是奇数, 又是素数)
  • 素数和其他数相乘的结果比其他方式更容易产生唯一性, 减少哈希冲突
  • 最终选择31是经过观测分布结果后的选择

自定义对象的哈希值

  • 如果不实现hashCode方法, 默认是沿用基类Object的实现(与内存地址有关)
public class Person implements Comparable<Person> {
    private int age;
    private float height;
    private String name;

    public Person(int age, float height, String name) {
        this.age = age;
        this.height = height;
        this.name = name;
    }

    @Override
    /**
     * 用来比较2个对象是否相等
     * 用以判断2个key是否为同一个key
     */
    public boolean equals(Object obj) {
        // 内存地址
        if (this == obj) return true;
        if (obj == null || obj.getClass() != getClass()) return false;
        // if (obj == null || !(obj instanceof Person)) return false;

        // 比较成员变量
        Person person = (Person) obj;
        return person.age == age
                && person.height == height
                && person.name == null ? name == null : person.name.equals(name);
    }

    @Override
    /**
     * 必须保证equals为true的2个key的哈希值一样
     */
    public int hashCode() {
        int hashCode = Integer.hashCode(age);
        hashCode = hashCode * 31 + Float.hashCode(height);
        hashCode = hashCode * 31 + (name != null ? name.hashCode() : 0);
        return hashCode;
    }

    @Override
    public int compareTo(Person o) {
        return age - o.age;
    }
}
  • 思考几个问题
    • 哈希值太大, 整型溢出怎么办?
      • 不用作任何处理
    • 不重写 hashCode 方法会有什么后果?
      • 可能会导致2个 equals 的 key 同时存在哈希表中
    • getClass 和 instanceof 的区别?
      • instanceof 如果是子类, 也会认为是同一种类型
      • getClass 必须类名一样

自定义对象作为 key

  • 自定义对象作为 key, 最好同时重写 hashCode、equals 方法
  • equals : 用来判断 2 个 key 是否为同一个 key
    • 自反性: 对于任何非 null 的x, x.equal(x)必须返回true
    • 对称性: 对于任何非 null 的x、y, 如果 y.equals(x) 返回true, x.equals(y) 必须返回true
    • 传递性: 对于任何非 null 的x、y、z, 如果 x.equals(y)、y.equals(z) 返回true, 那么x.equals(z)必须返回true
    • 一致性: 对于任何非 null 的x、y, 只要 equals 的比较操作在对象中所用的信息没有被修改, 多次调用x.equals(y) 就会一致地返回true, 或者一致地返回false
    • 对于任何非 null 的x, x.equals(null) 必须返回false
  • hashCode : 必须保证 equals 为 true 的 2 个 key 的哈希值一样
  • 反过来 hashCode 相等的 key, 不一定 equals 为 true

手撕 HashMap 代码

一些基础定义和size、isEmpty、clear 函数实现

public class HashMap<K, V> implements Map<K, V> {
    private static final boolean RED = false;
    private static final boolean BLACK = true;
    private int size;
    private Node<K, V>[] table;
    private static final int DEFAULT_CAPACITY = 1 << 4;
    private static final float DEFAULT_LOAD_FACTOR = 0.75f;

    public HashMap() {
        table = new Node[DEFAULT_CAPACITY];
    }

    @Override
    public int size() {
        return size;
    }

    @Override
    public boolean isEmpty() {
        return size == 0;
    }

    @Override
    public void clear() {
        if (size == 0) return;
        size = 0;
        for (int i = 0; i < table.length; i++) {
            table[i] = null;
        }
    }

put方法实现

@Override
public V put(K key, V value) {
    resize();

    int index = index(key);
    // 取出index位置的红黑树根节点
    Node<K, V> root = table[index];
    if (root == null) {
        root = createNode(key, value, null);
        table[index] = root;
        size++;
        fixAfterPut(root);
        return null;
    }

    // 添加新的节点到红黑树上面
    Node<K, V> parent = root;
    Node<K, V> node = root;
    int cmp = 0;
    K k1 = key;
    int h1 = hash(k1);
    Node<K, V> result = null;
    boolean searched = false; // 是否已经搜索过这个key
    do {
        parent = node;
        K k2 = node.key;
        int h2 = node.hash;
        if (h1 > h2) {
            cmp = 1;
        } else if (h1 < h2) {
            cmp = -1;
        } else if (Objects.equals(k1, k2)) {
            cmp = 0;
        } else if (k1 != null && k2 != null
                   && k1.getClass() == k2.getClass()
                   && k1 instanceof Comparable
                   && (cmp = ((Comparable) k1).compareTo(k2)) != 0) {

        } else if (searched) { // 已经扫描了
            cmp = System.identityHashCode(k1) - System.identityHashCode(k2);
        } else { // searched == false; 还没有扫描
            if ((node.left != null && (result = node(node.left, k1)) != null)
                || (node.right != null && (result = node(node.right, k1)) != null)) {
                // 已经存在这个key
                node = result;
                cmp = 0;
            } else { // 不存在这个key
                searched = true;
                cmp = System.identityHashCode(k1) - System.identityHashCode(k2);
            }
        }

        if (cmp > 0) {
            node = node.right;
        } else if (cmp < 0) {
            node = node.left;
        } else { // 相等
            V oldValue = node.value;
            node.key = key; // 覆盖更合理
            node.value = value;
            return oldValue;
        }
    } while (node != null);

    // 看看插入到父节点的哪个位置
    Node<K, V> newNode = createNode(key, value, parent);
    if (cmp > 0) {
        parent.right = newNode;
    } else {
        parent.left = newNode;
    }
    size++;

    // 新添加节点之后的处理
    fixAfterPut(newNode);
    return null;
}

哈希值的进一步处理: 扰动计算

private int hash(K key) {
    if (key == null) return 0;
    int hash = key.hashCode();
    return (hash ^ (hash >>> 16)) & (table.length - 1);
    return 
}

装填因子

  • 装填因子(Load Factor) : 节点总数量 / 哈希表桶数组长度, 也叫做负载因子
  • 在JDK1.8 的 HashMap 中, 如果装填因子超过0.75, 就扩容为原来的2倍

我的HashMap实现

LinkedHashMap

未完…

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值