【Java & 数据结构】HashMap和HashSet介绍

HashMap和HashSet介绍

初识Map和Set

Map 和 Set 是 Java 集合框架中的两个接口, 其分别对应着两种不同的搜索模型. 其中 一个是纯 key 模型, 另一个则是 key-value 模型.

纯 key 模型就类似于一个集合, 里面装载着一些元素, 这些元素只包含其本身. 例如我有一个冰箱, 里面装着各式各样的饮料, 那么此时这个冰箱就可以看作是一个饮料的集合, 里面装载着饮料. 此时如果我要在里面找饮料, 找的就是饮料本身.

而 key-value 模型则是在 key 模型的基础上, 新增了一个与 key 相关的 value. 例如在一个通讯录中, 假设名字是 key, 电话是 value. 那么此时我们就可以通过名字来找到对应的电话号码. key-value模型也被称作为键值对模型, 其中 key 为键, value 为值, 是非常常见的一种模型.


Set 就属于是上面的纯 key 模型, 它允许我们装入同类型的元素 key. 同时 Set 还要求这个 Key 必须唯一, 因此 Set 可以看作是一个可以用于去重的集合.

而 Map 则对应的是 key-value 模型, 它对于 key 的要求和 Set 中的 key 要求一致, 都是不能重复. 只不过它与 Set 的不同是, 它的每一个 key 都会对应着一个 value. 那么当我们对同一个 key 插入多次的时候, 就会替换掉其对应的 value.

那此时可能就有人问了: 那它的 value 可不可以重复呢?

当然是可以的, 主要原因是 Map 相当于是使用 key 来作为关键字来进行查找 value 的. 并不会使用 value 来查找 key, 因此 value 重复与否都是无所谓的.

就好比学校的教务系统, 一般来说都是通过学号作为关键字来查找学生信息的, 此时学号就相当于 key, 学生信息就相当于 value. 那么此时如果学号重复了, 那自然是非常不妙的, 而学生信息重复(类似学生名字重复了)则不会对我们的搜索产生影响, 因此自然是可以重复的.

下面我们就简单了解一下如何使用 Set 和 Map 的两个具体实现类, 分别是 HashSet 和 HashMap.

HashSet的使用

常用方法

首先来看一下 HashSet 里面的常用方法

返回值, 方法名, 参数说明
boolean add(E e)添加元素, 已经有了就不会添加
void clear()清空集合
boolean remove(Object o)删除 o 元素
void clear()清空
boolean contains(Object o)判断是否含有o元素
Iterator<E> iterator获取迭代器
int size()获取集合大小
boolean isEmpty()判断是否为空
Object[] toArray()转换为 Object 数组
boolean containsAll(Collection<?> c)判断是否包含集合中的所有元素
boolean addAll(Collection<? extends E> c)添加集合中的所有元素

注意点

  1. Set 中只存储 key, 并且要求 key 是唯一的, 因此其很常见的用途之一就是去重
  2. Set 中的 key 不能修改, 要修改必须删掉原来的, 然后再加新的
  3. HashSet 中的 key 是不一定有序的
  4. HashSet 的底层数据结构是哈希表, 搜索, 删除和查找的时间复杂度为 O(1)
  5. 要插入自定义类型, 需要重写对应自定义类型的equals()hashCode()方法. (具体下面介绍)

HashSet 元素的取出

此时可能有人要问了, 你这个 Set 我也没看到把东西拿出来的方法, 那如果我对一些数据进行了去重后, 我想要把这些去重完的数据拿出来怎么办呢?

实际上, Set 确实没有提供对应取出元素的方法, 因此我们无法直接取出其中的元素. 但是我们可以通过将其转换为数组, 将其构造为一个 List, 通过 for-each 循环, 或者是通过迭代器的方式来取出元素, 如下所示

public class Main {
    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        set.add("apple");
        set.add("banana");
        set.add("cherry");
		
        // 使用迭代器
        Iterator<String> iterator = set.iterator();
        while (iterator.hasNext()) {
            String element = iterator.next();
            System.out.println(element);
        }
        System.out.println("=================================");
        // 使用增强for循环
        for (String element : set) {
            System.out.println(element);
        }
        System.out.println("=================================");

        // 转换为数组
        String[] array = set.toArray(new String[0]);
        for (String element : array) {
            System.out.println(element);
        }
        System.out.println("=================================");

        // 或者转换为列表
        List<String> list = new ArrayList<>(set);
        for (String element : list) {
            System.out.println(element);
        }
    }
}

HashMap的使用

常用方法

先来看一下 HashMap 本身内部的一些常用方法

返回值, 方法名, 参数说明
V get(Object key)返回 key 对应的 value
V getOrDefault(Object key, V defaultValue)返回 key 对应的 value,key 不存在,返回默认值
V put(K key, V value)设置 key 对应的 value
V remove(Object key)删除 key 对应的映射关系
replace(K key, V value)将 key 对应的 value 进行修改
Set<K> keySet()返回所有 key 的不重复集合
Collection<V> values()返回所有 value 的可重复集合
Set<Map.Entry<K, V>> entrySet()返回所有的 key-value 映射关系
boolean containsKey(Object key)判断是否包含 key
boolean containsValue(Object value)判断是否包含 value

其中我们可以看到一个方法entrySet(), 可以获取到其中的所有映射关系, 同时我们可以看到它返回的是 Map 中的一个内部类 Entry.

实际上, 这个 Entry 就类似于一个节点, 存储着 key 和 value 用于维护着其中的 key-value 映射关系. 当然, 居然能够取出这些节点, 那么自然也就有能够操作这些节点的方法.

下面是 Entry 的方法

返回值, 方法名, 参数说明
K getKey()返回 entry 中的 key
V getValue()返回 entry 中的 value
V setValue(V value)将键值对中的value替换为指定value

下面就简单演示一下如何操作这些 Entry, 假设我们要打印

public class Main {
    public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();
        map.put("name", "John");
        map.put("age", "25");

        Set<Map.Entry<String, String>> entries = map.entrySet();
        for (Map.Entry<String, String> entry : entries) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
    }
}

注意点

  1. Map 中的 key 是唯一的, 而 value 是可以重复的
  2. Map 中的 key 可以通过 keySet() 方法作为一个 Set 取出来. 而 value 则不行, 因为 key 不重复, 而 value 可能重复. 要取出 value, 则需要用其他集合来装
  3. Map 中的 Key 不能直接修改, value 可以通过对应 key 来修改. 若要修改 key, 则只能删除再插入
  4. HashMap 中的元素是不一定有序的
  5. HashMap 的底层数据结构是哈希表, 搜索, 删除和查找的时间复杂度为 O(1)
  6. 要插入自定义类型, 需要重写对应自定义类型的equals()hashCode()方法. (具体下面介绍)

可以看到, 我们在介绍的 HashMap 和 HashSet的过程中, 都提到了它的底层是一个哈希表, 那么哈希表到底是什么呢? 接下来我们就来了解一下哈希表这个数据结构

哈希表

初识哈希表

哈希表是一种通过映射关系来便于查找元素的结构, 允许不通过比较来一次性直接获取到目标元素. 例如我们要统计一串小写字母中各个字符出现的位置, 那么此时我们就可以维护一个数组大小为 26 的数组, 从 0 ~ 26 分别对应着 a ~ z, 那么此时我们就可以通过遍历一次字符串, 从而获取各个字符的位置放入数组中. 最后我们只需要通过访问对应下标就可以直接得知对应字母的位置.

那么此时这个数组就可以被看作是一个哈希表, 它维护了小写字母和其位置的映射关系, 从而使得我们可以通过访问对应字母的数值从而直接获取其位置. 例如下面这个实例

在这里插入图片描述

那此时可能有人就要问了, 你要如何让 0 下标和 字符 a 建立映射关系的呢?

首先我们需要知道的是, 这些字符本质上都是一个一个的整型数字, 例如字符 a 代表的就是 97. 具体为什么可以去了解一下 ASCII码表, 这里不细致介绍. 那么此时如果我们要让它对应 0 下标, 就让它减去 97 即可. 但是我们如果不知道是 97 怎么办呢? 没关系, 我们说过字符本身就是一个数字, 既然 a 对应的是 97, 我们直接令 a - a不就行了吗?

对于 b 也是同理, b 是 a 的后一个字符, 我们需要它在下标 1 的位置, 由于其对应的 ASCII值也是连续的, 是跟在 a 后面的 98, 因此我们依旧是让b - a即可让其到达下标 1 的位置. 剩下的其他字母都是同理的.

那么此时实际上我们就可以得到一个转换的函数, 即字符 - a就可以得到对应字符的下标. 那么此时的这个函数, 就被叫做哈希函数, 主要就是用于建立映射关系的一个计算函数.

同时我们需要知道的是, 对于不同的情景, 哈希函数也是不同的, 例如当我们这里的字母可能会出现大写字母了, 那么这个哈希函数就需要进行修改.


看了上面的介绍, 此时可能有人要问了, 那如果一个字符串中, 同一个字符出现了好几次怎么办呢? 如下图所示

在这里插入图片描述

此时发生的这个问题, 就被称作是哈希冲突, 它指的是两个不同的内容通过哈希函数计算出了同一个哈希地址, 例如在我们这个例子中, 哈希函数是字符 - a, 那么此时我们计算出的就是一个a - a = 0, 与第一个位置的 a 计算出的 a - a = 0结果是相同的.

那么如何处理这种冲突呢? 这就是接下来我们要讨论的问题

哈希冲突的处理

避免冲突

首先我们要明白的是, 如果要使用一个哈希表存储数据, 那么大概率你的这个哈希表的大小应该是要小于原始数据的, 因此我们可以得到一个结论, 冲突是不可能完全避免的. 但是我们可以从设计的角度上去尽可能的减少哈希冲突, 降低它的出现概率.

那么如何能够降低哈希冲突出现的概率呢? 首先我们知道, 哈希表非常核心的一个部分就是它的哈希函数, 那么当然, 如果一个哈希函数不合理, 自然冲突出现的概率就会大大增加. 如果要设计一个哈希函数, 那么它应该尽可能的满足下面的几个性质

  1. 哈希函数要能够计算所有的要存储的值, 并且都能够将其放置在哈希表中. 意思就是说, 哈希函数不能无法计算要存储的某些值, 也不能说计算的结果超过了表的存储范围, 存不了.
  2. 哈希函数计算出的结果要能够均匀分布在哈希表的各个位置
  3. 哈希函数应该比较简单

下面是两个设计哈希函数的常用方法

  1. 直接定制法: 直接定义一个线性函数作为哈希函数, 例如 Hash(Key) = A * Key + B. 实际上我们上面讲解的那个例子, 使用的哈希函数就是一个直接定制法, 其中 A 为 1, B 为 -a.
  2. 除留余数法: 设哈希表的大小为 m, 取任意数字 p <= m, 令 Hash(key) = Key % p. 实际上就是通过取余的方式来保证映射的位置一定在哈希表中, 并且能够起到分散映射的效果

另外还有一个能够降低冲突率的方式, 就是对哈希表进行扩容, 这个操作也被叫做负载因子调节. 那么负载因子是什么呢? 实际上也非常简单, 就是表中的元素个数/表的长度.

具体哈希表的负载因子与冲突率的关系计算, 是一个数学上的问题. 并且对于不同的哈希函数, 负载因子的影响也不同, 只不过基本上我们都认为 0.7 左右的负载因子是比较合适的. 低的负载因子实际上就代表的是表长度过于长了, 虽然冲突的概率不高, 但是与之相同的是空间利用率也不高, 并且为了能够保证较低的负载因子, 需要频繁扩容, 这也是一大开销. 而过高的负载因子虽然扩容次数更少, 空间利用率高, 但是则代表元素相对于表的长度比较多, 导致冲突的概率可能会大大增加.

Java 中的 HashMap 对于负载因子的要求默认为 0.75, 如果负载因子高于这个值, 就会对哈希表进行扩容.

下面是一个负载因子与冲突率的一个相关图, 用于参考

在这里插入图片描述

解决冲突

既然冲突无法完全避免, 那么我们就需要采取一些操作去解决冲突. 解决冲突常见的方法有两个, 一个叫做开放定址法, 另一个则叫做链地址法, 下面我们来依次了解

开放定址法

这个方法说起来也非常的简单, 实际上就是遇到冲突后, 然后找到一个空位置放入元素即可. 那么如何去找这个空的位置呢? 首先我们来看一种非常简单的方法, 线性探测法

线性探测法就是直接在当前的冲突位置后面直接一个一个的找, 找到一个空位置然后放进去就行. 当然也有可能找不到位置, 此时就需要扩容.

下面是一个图例

在这里插入图片描述

很明显这个线性探测法似乎并不是非常的好用, 一个是我们不能随意的删除元素, 因为一旦我们删除了第一个元素, 那么我们就不知道后面还有没有元素了. 例如上面的这个例子中, 我们如果删除了 4, 那我怎么知道后面有没有 44 了呢?

同时我们找冲突的元素也是要和放元素一样, 要从第一个位置开始往后一个一个的找, 还是比较繁琐的. 因此还有其他的方法来进行探测, 比如二次探测法.

二次探测法则是通过二次哈希来进行计算下一个空位置, 那此时有人就要问了: 那我还冲突怎么办呢?

因此二次探测法使用的是一种特殊设计的哈希函数, 如果第二次哈希还冲突, 就一直运算, 直到可以放入或者发现不可能放入为止(例如容量不够, 此时不可能放入, 需要扩容). 这个函数为 G(i) = (G(0) + i^2) % m. 其中 i 为 1, 2, 3 …… n, G(i) 是哈希函数, G(0) 代表第一次的哈希结果, m为哈希表的大小.


很明显, 上面我们介绍的开放定址法, 虽然一定程度上解决了冲突, 但是它实际上还会遇到一个问题, 如果哈希表的大小不够这样放怎么办? 因此, 虽然这样的方式可以解决冲突, 但是依旧对容量有一定要求, 而这种要求就导致了另一个问题, 需要扩容, 导致空间利用率会比较低, 因此我们还有另一种方法来解决冲突, 就是我们下面要介绍的链地址法.

链地址法

链地址法也很好理解, 就是我们的哈希表的每一个位置, 不再是存储一个数据, 而是存储一张表, 这张表中存储了对应的所有数据, 那么此时即使冲突, 我们这个表也可以直接存下去. 此时哈希表的每一个位置, 也被称作为是哈希桶.

那么这个哈希桶中的这个表, 由于其可能需要频繁的进行插入操作, 因此并不适合使用需要扩容的顺序表, 而更加适合使用无需扩容的链表. 例如 Java 中的 HashMap类 底层就是一个数组配合链表实现的, 数组用于存储各个链表的头节点, 链表用于存储映射的数据. 如下图所示

在这里插入图片描述

当然这种方法还有一个问题, 如果冲突严重, 那么链表的长度就会很长, 此时搜索的时候就需要遍历链表, 此时哈希表的优势: 查找时间复杂度为O(1), 也会因为链表的长度过长变为 O(n). 如下图

在这里插入图片描述

因此我们此时有两种解决方法: 1. 扩容 2. 将链表修改为更加擅长查找的数据结构

其中扩容也很好理解, 扩容后进行重新哈希, 那么此时冲突就会稍微不那么的严重, 此时就可以减少链表的长度, 从而遍历链表的开销, 使其接近 O(1). 如下图是对上面链表进行扩容处理后的结果

在这里插入图片描述

当然可能有人就要说了: 你这也没变多短啊?

实际上这与我们的哈希函数设计有关, 正常情况下哈希函数不应该设定的如此集中, 同时一般情况下数据也不会以如此集中的方式出现, 只可能会在一些特殊情况下出现.

另一个修改数据结构的方法, 实际上就是将链表重新构成为例如二叉搜索树/AVL树/红黑树这样的结构. 或者也有一种操作就是再套一个哈希表. 这样的方式也可以来减少在每一个哈希桶中的搜索开销.

例如上面说到的 Java 中的 HashMap 就采用了这样的优化. 如果它的链表长度大于了 8 并且数组长度大于了 64, 那么它就会将链表优化成一棵红黑树. 那此时可能就要有人问了: 那如果我刚变红黑树, 就删掉一个节点, 它还会变回去吗?

实际上虽然确实节点少了后, 会将红黑树退化为链表, 但是 Java 为了防止发生来回横跳的情况, 它将退化为链表的节点数设置为了 6, 当节点数小于 6 后才会去树化, 毕竟树化和去树化都是需要一定开销的. 如果都设定为 8, 那如果节点数在 7 到 8 来回横跳就会导致一直进化和退化, 反而会导致开销很大. 而设定为一个小区间, 那么此时来回横跳的概率就会大大降低.

那此时可能又有人要问了: 那既然红黑树这么好, 你刚开始直接用红黑树不就完了? 为啥还先用链表后面还转来转去的呢?

实际上这个问题, 与红黑树的实现有关, 红黑树的节点大小是比普通的节点更大一些的, 因此刚开始不使用红黑树是为了节约一些空间而做出的取舍.

模拟实现

上面介绍了关于哈希表的众多内容, 接下来我们就来简单模拟实现一个 HashMap. 我们这里就和 Java 自带的 HashMap 一样, 采用数组结合链表的方式来进行存储. 数组的各个位置就是一个一个的哈希桶, 用于存储链表头. 每个哈希桶中都是一个链表, 用于解决冲突问题.

前置知识

根据我们对于 HashMap 的了解, 我们知道其能够将一个对象作为 key 一个对象作为 value, 随后让他们建立映射关系, 后续我们就可以通过 key 来查询到对应的 value. 那么它是如何使用哈希表来存储这个映射关系的呢?

那既然 key 是用来查找的核心, 那么此时我们就可以使用 key 来进行运算, 算出对应的下标后放到哈希表中. 这样我们下一次通过 key 查找的时候, 就可以通过 key 再次算出对应的下标, 然后在哈希桶中进行查找, 随后找到对应的 value.

那此时就有了一个关键的问题: key 是一个对象, 如何将其转换为一个数组的下标呢?

此时就涉及到了一个位于 Object类中的方法, hashCode()方法, 它用于将一个对象转换为一个整型数字, 具体如何转换的我们不用关系, 它是一个本地方法, 由C++实现. 我们需要知道的是, 它对同一个对象, 返回的整数都是相同的, 下面是一个例子.

例如我现在有一个学生类

public class Student {
    int id;
}

那么此时我们来看下面代码的执行结果

public class Main {
    public static void main(String[] args) {
        Student s1 = new Student();
        System.out.println(s1.hashCode());
        System.out.println(s1.hashCode());
        Student s2 = new Student();
        System.out.println(s2.hashCode());
    }
}

在这里插入图片描述

可以看到, 对于同一个对象, 它们计算出的哈希值都是一样的.

但是此时有一个问题, 假如对于这个 Person类, 我希望只要它里面的 id 属性相等, 它们就算作是相等的, 如果作为 key 则可以看作是一个 key, 那么怎么办呢?

此时我们就需要去重写这个 hashCode()方法, 同时还要借助Objects类里面的hash()方法, 如下所示

@Override
public int hashCode() {
    return Objects.hash(id);
}

此时我们再去尝试一下, 对于相同 id 的不同对象生成的哈希值

public class Main {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.id = 10;
        Student s2 = new Student();
        s2.id = 10;
        System.out.println(s1.hashCode());
        System.out.println(s2.hashCode());
    }
}

在这里插入图片描述

可以发现, 此时计算的结果就是一样的了. 但是此时还有一个问题, 对于多个对象来说, 它的哈希值如果是一样的, 那么此时它们应该是看作为同一个对象, 也就是相等的. 而我们如果此时通过equals()去比较对象, 那么势必是不相等的, 因为底层的equals()方法是通过 == 来直接比较引用的

在这里插入图片描述

那么此时, 我们也就需要一并修改这个equals()方法, 让其也能够体现为相等的

@Override
public boolean equals(Object o) {
    // 引用相等直接返回true
    if (this == o) return true;
    // 如果 o 为 null 或者 o 的类和当前类型的不同, 直接返回 false
    if (o == null || getClass() != o.getClass()) return false;
    // 根据 id 判断是否相等
    Student student = (Student) o;
    return id == student.id;
}

因此一般情况下, hashCode()方法和equals()方法是需要一并进行重写的. 下面是两个普遍的一种约定:

  1. 如果两个对象被equals()认为是相等的, 那么它们必须要有相同的哈希值
  2. 如果两个对象有相同的哈希值, 它们既可以被equals()判定为相同, 也可以判定为不相同

同时equals()方法的设定也有一定的要求, 要求如下:

  1. 自反性: 如果对象 x 不为空, 那么x.equals(x)必须为 true
  2. 对称性: 如果有 x 和 y 两个对象(非空), 那么如果x.equals(y)为 true. 那么y.equals(x)也必须为true.
  3. 传递性: 如果有 x, y 和 z 三个对象(非空), 并且x.equals(y)y.equals(z)都为 true. 那么x.equals(z)也必须为 true
  4. 一致性: 如果有 x 和 y 两个对象(非空), 那么多次调用x.equals(y)的结果应该相同
  5. 如果对象 x 不为空, 那么x.equals(null)必须为 false

初始化

首先是节点的创建, 当然我们的节点需要有一个 key 和一个 value, 其次就是一个 next.

最后创建出的节点如下所示

static class Node<K, V> {
    K key;
    V value;
    Node<K, V> next;

    public Node(K key, V value) {
        this.key = key;
        this.value = value;
    }
}

然后就是创建用于存放节点的数组, 以及我们再创建一个 size 用于表示大小, 创建一个常量表示负载因子

public class MyHashMap<K, V> {
    static class Node<K, V> {
        K key;
        V value;
        Node<K, V> next;

        public Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }

    Node<K, V>[] nodes;
    
    int size;

    static final double LOAD_FACTOR = 0.75;
}

构造方法中, 我们需要创建数组, 并且把每一个位置都放上虚拟头节点便于进行插入操作

public MyHashMap() {
    nodes = (Node<K, V>[]) new Node[16];
    size = 0;
    // 引入虚拟头节点
    for (int i = 0; i < nodes.length; i++){
        nodes[i] = new Node<>(null, null);
    }
}

最后就是一些基本方法, 直接看代码和注释即可

// 计算当前负载因子, 用于判断是否要扩容
private double getLoadFactor() {
    return (double) size / nodes.length;
}

// 重写 toString, 便于打印
@Override
public String toString() {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < nodes.length; i++) {
        Node node = nodes[i];
        sb.append(i).append(": ");
        while (node != null) {
            sb.append(node.key).append("->");
            node = node.next;
        }
        sb.append("\n");
    }
    return sb.toString();
}

增加

我们这里关于哈希桶位置的选择, 就直接通过将哈希值对数组长度取模来进行了. 此时还有一个细节问题, 我们找到 key 对应的位置后, 是否能够直接放入哈希桶呢?

答案是不能, 因为我们说过 key 要是唯一的, 如果我们直接放入, 那万一里面已经有了一个当前 key 的值怎么办? 因此我们需要遍历一下哈希桶, 看看是不是已经有了这个 key 了, 如果有的话, 直接替换即可, 无需插入.

那么如果没有找到的话, 我们就需要插入节点, 我们这里就采取头插法来进行这个插入的操作, 实现代码如下

public boolean put(K key, V value) {
    // 计算哈希值
    int hash = key.hashCode();
    // 根据哈希值计算下标
    int index = hash % nodes.length;
    Node<K, V> cur = nodes[index];
    while(cur != null){
        // 如果 cur 的 key 与当前 key 相等, 则更新 value 并返回 true
        if(cur.key.equals(key)){
            cur.value = value;
            return true;
        }
        cur = cur.next;
    }

    // 此时是没有找到的情况, 此时直接头插
    Node<K, V> tmp = new Node<>(key, value);
    tmp.next = nodes[index].next;
    nodes[index].next = tmp;
    size++;

    return true;
}

但是此时代码没有结束, 还记得我们的负载因子吗, 我们需要检查一下负载因子, 然后看看是不是要进行扩容. 扩容的方法也很简单, 创建一个更大的数组, 然后对老表里面的所有结点进行重新哈希即可, 注意我们这里不能直接将原数组进行复制粘贴, 而是需要重新哈希, 因为我们的哈希函数是与数组长度有关的.

下面是扩容方法

private void resize() {
    // 创建两倍大小的数组
    Node<K, V>[] newNodes = (Node<K, V>[]) new Node[nodes.length * 2];

    // 遍历老表
    for (int i = 0; i < nodes.length; i++) {
        // 拿到当前桶的第一个结点, 遍历
        Node<K, V> cur = nodes[i].next;
        while(cur != null){
            // 重新哈希
            int newHash = cur.key.hashCode();
            // 根据新的哈希值重新计算下标
            int newIndex = newHash % newNodes.length;
            // 因为是重新哈希, 没有必要再遍历, 直接插入即可
            Node<K, V> tmp = new Node<>(cur.key, cur.value);
            tmp.next = newNodes[newIndex].next;
            newNodes[newIndex].next = tmp;

            // 移动cur
            cur = cur.next;
        }
    }

    // 把新表赋值给老表
    nodes = newNodes;
}

然后是在put()方法中, 添加判定是否要进行扩容的代码

public boolean put(K key, V value) {
    // 计算哈希值
    int hash = key.hashCode();
    // 根据哈希值计算下标
    int index = hash % nodes.length;
    Node<K, V> cur = nodes[index];
    while(cur != null){
        // 如果 cur 的 key 与当前 key 相等, 则更新 value 并返回 true
        if(cur.key.equals(key)){
            cur.value = value;
            return true;
        }
        cur = cur.next;
    }

    // 此时是没有找到的情况, 此时直接头插
    Node<K, V> tmp = new Node<>(key, value);
    tmp.next = nodes[index].next;
    nodes[index].next = tmp;
    size++;

    // 判定是否要进行扩容
    if(getLoadFactor() > LOAD_FACTOR){
        resize();
    }

    return true;
}

查找

学会了增加操作, 那么查找操作应该是非常简单的, 直接通过传入的 key 计算下标然后遍历链表即可.

public V get(K key) {
    // 计算哈希值和下标
    int hash = key.hashCode();
    int index = hash % nodes.length;

    // 遍历链表
    Node<K, V> cur = nodes[index].next;
    while(cur != null){
        if(cur.key.equals(key)){
            return cur.value;
        }
        cur = cur.next;
    }

    // 没找到 返回null
    return null;
}

删除

删除操作即删除 key 对应的映射关系, 也是相当简单的, 就相当于是一个查找 + 删除链表结点的操作. 当然这里需要注意删除链表结点是需要前一个位置的引用的, 由于我们有虚拟头节点, 删起来是非常简单的, 不需要处理边界情况.

最终代码如下所示

public boolean remove(K key){
    // 计算哈希值和下标
    int hash = key.hashCode();
    int index = hash % nodes.length;

    // 遍历对应链表, 进行删除
    Node<K, V> prev = nodes[index];
    Node<K, V> cur = nodes[index].next;
    while(cur != null){
        if(cur.key.equals(key)){
            // 删除, 返回true
            prev.next = cur.next;
            return true;
        }
        cur = cur.next;
        prev = prev.next;
    }

    // 没找到, 返回 false
    return false;
}

HashMap和HashSet源码阅读

HashSet的实现

其实 HashSet 的实现非常的简单, 我们一进入它的构造方法就可以明白一切

可以看到, 其实它就是一个 HashMap 而已. 此时可能有人看到这里, 就明白了一切, 但是也可能有人一脸懵逼, 为什么这两个东西还能扯上关系呢?

在这里插入图片描述

那此时为了弄明白为什么, 我们就需要看一下它是如何放入元素的

可以看到, 它就是直接将传入的 key 作为了 HashMap 中的 key, 而在 value 的位置就单单传了一个无意义的 Object 对象.

在这里插入图片描述

在这里插入图片描述

此时也就与之前我们提到的 HashMap 和 HashSet 的 Key 性质一样对上了, 既然底层都是一个实现, 那性质能不一样吗?

其他的方法也是一样的, 都是直接调用的 HashMap 的方法

在这里插入图片描述

因此 HashSet 的源码并没有什么好看的, 重头戏还得看我们接下来的 HashMap 源码实现

HashMap源码分析

初始化

我们依旧是先从构造方法来开始看起

首先是最常用的什么参数都没有的构造方法, 可以发现它就是将默认负载因子赋值给了 loadFactor 这个变量. 同时我们也可以看到上面的注释说到了, 初始的容量是 16.

当然, 有过一些看源码经验的人此时也应该知道了, 虽然这里没有直接分配内存, 但是大概率应该是在放入元素的时候会检测, 如果为空就会设定为这个默认大小.

在这里插入图片描述

此时我们可以直接去查看一下put()方法, 发现确实如此

其中的 table 就是哈希表的数组, 可以看到如果 table 为空, 那么就会进入一个 resize()方法

在这里插入图片描述

那接下来我们看看这个是否和我们说的一样, 是在 resize()方法中进行检测是否为空, 然后扩容. 下面是resize()方法的缩略代码 + 注释

// 这个是默认容量 1 左移 4 位
// 实际上就是 2 ^ 0 * 2 ^ 4 = 2 ^ 4 = 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

final HashMap.Node<K,V>[] resize() {
    // 拿到数组, table就是 hashMap 存储数据的数组
    HashMap.Node<K,V>[] oldTab = table;

    // 获取老表的容量, 为空直接为 0, 不为空就算一下
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 拿到老的最大载量
    // 最大载量就是 数组长度 * 负载因子
    // 如果内部元素的数量超过了最大载量, 那么就会触发扩容
    int oldThr = threshold;

    // 很明显此时如果是第一次对空表插入元素, 那么此时两个 old 值都是 0

    // 设定新的容量和载量
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 老容量大于零的处理, 这里不会触发, 我们不关心
        // ....
    }
    else if (oldThr > 0) {
        // 老的最大载量大于 0 的处理, 这里不会触发, 我们不关心
        // ....
    }
    else {
        // 来到最后一个分支
        // 给新容量和新载量赋默认值
        // 新容量 = 默认容量
        // 新的最大载量 = 默认负载因子 * 默认容量
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        // 新的最大载量如果为零的处理
        // ....
    }

    // 赋值 最大载量
    threshold = newThr;
    // 创建新表
    HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
    // 把新表赋值给 table
    table = newTab;
    if (oldTab != null) {
        // 这里是老数组不为空的处理
        // ...
    }

    // 返回
    return newTab;
}

接下来我们来看另外一个构造方法, 它允许我们指定容量

在这里插入图片描述

但是它实际上也是通过调用另一个构造方法实现的, 此时我们就来看看它调用的这个构造方法, 我们依旧是通过代码 + 注释的方法来看, 这个方法相较于上面的 resize() 还是简单很多的.

public HashMap(int initialCapacity, float loadFactor) {
    // 如果提供的容量小于 0, 抛异常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 如果提供的容量超过了最大值, 只给最大值
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 如果提供的负载因子不合法, 抛异常
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    
    // 赋值负载因子
    this.loadFactor = loadFactor;
    
    // 通过 tableSizeFor 赋值最大载量
    this.threshold = tableSizeFor(initialCapacity);
}

此时我们可以看到的是, 即便我们提供了容量, 他也不是直接创建表, 而是直接把这个值通过一个方法提供给了最大载量这个参数, 那首先我们来看看tableSizeFor()这个方法做了什么.

此时可以看到一大堆的位运算, 那么这个到底是做了什么呢?

实际上, 它的目的在注释中也讲的很清楚了, 目的是为了提供能够容纳目标大小的二次幂. 也就是保证最大载量一定是 2 ^ n

在这里插入图片描述

我们可以通过带入一个数字来看, 假设带入的是 35.

在这里插入图片描述

可以看到, 它实际上就是通过位移操作保证二进制的最高位及后面的所有为都是 1, 此时最后返回的时候 + 1 就返回的一定是 1 带一堆 0, 体现在十进制上就一定是 2 的 n 次方.

如果不想自己计算, 那么可以通过下面的代码来观察

public class Test {

    static final int MAXIMUM_CAPACITY = 1 << 30;

    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;
    }

    public static void main(String[] args) {
        System.out.println(tableSizeFor(35));
    }
}

在这里插入图片描述


由于它是能够将最高位以后面的所有二进制位转换为 1, 随后返回 n + 1. 那么此时它返回的 2 ^ n 一定就是大于你传入的数字的最小的 2 ^ n.

例如我们传入的 35, 实际上就被 32(2 ^ 5) 和 64 (2 ^ 6) 夹在中间, 那么此时返回大于当前数字的最小 2 ^ n. 那么就只有 2 ^ 6, 即 64.

那此时我问如果传入的是 1100, 得到的数字是多少? 那么对于 1100, 是在 1024(2 ^ 10) 和 2048 (2 ^ 11)中间的, 那么此时返回的就是 2048.


那此时让我们再回去看看, 如果最大载量不是 0, 而数组长度是 0 会发生什么, 此时就要回到我们的resize()方法.

实际上也是很简单的, 直接对应着的是一个分支

在这里插入图片描述

就是直接把新容量变成这个最大载量, 那么下面就是和我们上面看的一样, 创建newCap大小的数组用于存储数据了.

总而言之, 在初始化容量的时候, HashMap 会将容量初始化为一个 2 ^ n. 那么此时我们大概率也可以猜测, 其他的扩容应该也是要符合这个机制的, 否则这个设定就会失去意义. 那么到底是否如此呢, 接下来我们就要完整的看看这个 HashMap 的扩容机制了.

扩容机制

首先我们要了解的就是容量的设置, 这里我们可以看到, 在新数组创建前, 对于容量的确定主要就是这些分支来做的

在这里插入图片描述

其中红色的分支, 我们在介绍初始化的时候就已经了解过了, 接下来我们就来看看另外两个分支分别做了什么

首先是当老容量大于零的时候

if (oldCap > 0) {
    // 查看老容量是否大于等于最大容量
    if (oldCap >= MAXIMUM_CAPACITY) {
        // 是就直接返回老表, 并且让最大载量变为 Integer的最大值.
        // 简单地说就是, 虽然你的表理论上应该触发扩容(负载因子超了0.75)
        // 但是表已经最大了, 不扩了, 给你把最大载量改成最大值看看行不行
        threshold = Integer.MAX_VALUE;
        return oldTab;
    }
    else if (
        // 接下来让新容量等于老容量左移一位, 也就是乘以 2
        // 同时看看这个新容量是不是小于最大容量 && 老容量大于初始容量
        (newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY)
        // 同时满足两个条件就让最大载量也乘 2
        newThr = oldThr << 1; // double threshold
}

可以看到, 我们这里的容量只会一直按照乘以 2 的方式变化, 也就是说, HashMap 的扩容一直是采用 2 的 n 次方倍进行扩容的. 那么此时就有一个问题: 为什么 HashMap 扩容的时候, 要采用 2 的 n 次方倍来进行扩容呢? 这个问题, 我们将会在后面的解析中慢慢了解到答案, 我们先留着这个问题, 继续往下看.


接下来就是另外一个分支, 这个主要是涉及最大载量的修改, 我们了解一下即可

if (newThr == 0) {
    // 如果新的载量没有被更新, 那么就计算一下
    
    // 先计算一个预期值
    float ft = (float)newCap * loadFactor;
    // 如果新容量小于最大容量, 并且预期载量也小于最大容量
    // 那么就直接给预期载量
    // 否则就给 Integer 的最大值
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
              (int)ft : Integer.MAX_VALUE);
}

接下来的一部分, 就是关于 HashMap 是如何处理旧元素的了. 可能有人此时想到了我们上面实现哈希表的时候, 强调到了对所有的节点进行重复哈希, 那么 Java 中是否是这样实现的呢? 答案是, 以前确实是, 但是现在有了一些优化, 接下来我们就看看 Java 是如何转移这些节点的.

我们先不看下面很长的一段分支, 我们先来看上面一些简单的处理

if (oldTab != null) {
    for (int j = 0; j < oldCap; ++j) {
        // 遍历老表
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            // 如果当前哈希桶不为空, 那么就进行处理
            oldTab[j] = null;
            if (e.next == null)
                // 只有一个节点, 放入新表对应位置
                newTab[e.hash & (newCap - 1)] = e;
            else if (e instanceof TreeNode)
                // 如果是红黑树, 特殊处理
                ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
            else {
				// 如果底下不是红黑树, 并且有多个节点, 特殊处理
                // ....
            }
        }
    }
}

此时可能有人要问了, 这个e.hash & (newCap - 1)是个什么计算法, 我怎么看不懂呢? 实际上这个就是 HashMap 计算下标的方式, 简单的说就是重新哈希了, 和我们的取余本质其实是一样的, 都能够保证它这个下标不超过数组长度, 我们通过一个例子来看一下这个操作具体是如何实施的.

从上面初始化和扩容机制的讲解, 我们可以得知, 新容量一定是一个 2 的 n 次方, 那么此时我们就假设是 64, 然后我们再假设这个哈希值是一个 233, 那我们下面就看一下到底是怎么实现和取余类似的操作的.

已知容量为 newCap = 64, 哈希值为 hash = 233. 首先我们需要算出 newCap - 1 的二进制位, 即 63 的二进制位. 实际上对于二进制有一定了解的, 都应该知道一个 (2 ^ n) - 1的二进制位, 应该就是一连串的 1, 那此时我们就得到 63 的二进制位是 0011 1111. 而 233 的二进制位则是 1110 1001

此时对于按位与运算比较熟的, 可能就已经懂了为什么这个与操作会使得它一定不会超过这个容量了. 因为我们 63 的高位全是 0, 无论你的哈希值怎么取, 照样是 0. 而此时只有低位才有机会成为 1, 比如我们的 63 和 233 得到的结果如下

在这里插入图片描述

可以看到, 即使 233 的高位有 1, 也会因为按位与操作全部变为 0. 并且这个操作能够使得数据相对于取模来说更加的分散, 同时由于我们的容量是 64, 那它要求的下标就是[0, 63], 而这个操作还正好能够包含所有的值. 总而言之, 这个设计还是非常的精妙的.

那这个按位与来获取下标的操作, 其实也是为什么容量要取 2 ^ n 的原因之一. 但是对于这个 2 ^ n 的妙用并没有结束, 下面的搬运多个节点的优化, 同样借用了这个性质, 接下来我们就看看到底是什么优化法


下面有两个特殊处理, 一个是对于链表的, 另一个则是对于红黑树的, 实际上两个的本质都是相同的, 只不过由于红黑树的处理更加复杂一些, 例如可能树的高度会变低, 导致它要去树化之类的. 我们这里就只看这个链表的处理, 感兴趣的可以自行了解关于红黑树的特殊处理

在这里插入图片描述

首先看前半部分, 似乎是先创建了两个链表, 然后对下面的节点进行分类, 其中分类的标准似乎是一个(e.hash & oldCap) == 0. 此时可能有人就要晕了, 怎么又是位运算, 这又是什么我看不懂的神奇操作?

实际上这个操作, 也是 2 ^ n 扩容的一个妙用. 我们想一下, 对于一个老容量和新容量, 它们有什么区别呢? 此时可能有人说: 当然是两倍了, 你当我傻吗? 实际上, 这里既然用的是位运算, 那么当然要的就是二进制位的性质, 很明显对于一个 2 ^ n 的二进制数, 它的二进制应该是一个 1000000..., 而让它乘以 2, 则相当于是让它的二进制位左移一位.

例如 32 的二进制位是 0010 0000, 那么 64 的二进制位就是 0100 0000 . 那么此时假设这两个分别就是一个老容量和新容量, 此时如果我们按照上面的对于每一个元素哈希的算法, 即hash & (capacity - 1). 那么应该是怎么算的呢?

我们依旧是以 233 作为哈希值为例, 很明显此时如下图所示

在这里插入图片描述

可以发现, 除了最高位(绿色部分), 蓝色部分都没有区别. 而这个绿色的位置, 实际上就是 32 的二进制位的 1 所在的位置. 如下图所示

在这里插入图片描述

此时答案已经逐渐浮现了出来, 很明显我们要区分一个元素是否要放在老容量和新容量, 在二进制的角度来看, 蓝色区域并不是我们要关心的, 因为都一样. 而绿色区域才是唯一一个可能会改变的区域. 而恰好, 我们的老容量的 1, 还就在这个位置.

那此时我们就可以使用老容量来按位与这个哈希值, 看看这个位置是不是 1. 如果是, 则证明它在重新哈希的时候就能够吃到新增的这一位的加成, 需要换位置, 如果不是, 那么就证明无论扩不扩容, 它都会放在当前的这个位置, 也就是不需要换位置.

那么我们如何判断这个位置是不是一个 1 呢? 也很简单, 我们看看按位与的结果是不是 0 即可. 因为老容量只有这一个位置是 1, 如果你吃不到这个 1, 那老容量其他所有位都是 0, 你更不可能生出一个 1 来, 结果就是 0.

在这里插入图片描述

这实际上也就是(e.hash & oldCap) == 0的设计思路, 它通过判断老容量的最高位 1 和哈希值按位与结果是否为 0. 来判断当前的这个节点是否吃的到新容量的加成, 从而判断是否要搬到新位置去.

此时那两个链表的用途也很明显了, 一个是用于存要换位置的节点的, 一个是用于放不要换位置的节点的.

后面就是把两个链表分别插到对应的位置的代码

在这里插入图片描述

其中, 下面的插入到j + oldCap位置的, 就是换位置的链表. 这里放到j + oldCap位置也很好理解.

依旧是假设老容量为 32, 要放入233, 那我们当前的下标应该就是0000 1001.

在这里插入图片描述

那既然你要搬到新位置, 那就说明你吃的到新容量的高位, 而高位根据上面的讲解, 我们很容易就知道实际上就是32 : 0010 0000的 1 所在的位置, 那么此时我们就让 32 和 下标相加, 就可以得到新位置了, 也就是0010 1001. 二进制的相加, 体现在十进制也是相加, 自然就是直接相加即可.

增加元素

接下来来看一个 hashMap 比较核心的方法, 即put()方法.

可以看到, 它实际上是去调用了另外一个辅助的方法来实现的, 但是我们还可以发现, 它的方法参数中似乎还有一个用于计算哈希值的方法

在这里插入图片描述

此时可能有人就感到奇怪了, 为什么不直接使用 hashCode() 方法, 还要再用一个方法呢? 我们来进去看看

可以看到这个方法也是非常简单的, 他就是判断一下 key 是不是空, 如果是空就返回 0. 如果不是空, 那么就返回 hashCode()hashCode() 右移16位异或的结果.

在这里插入图片描述

那么为什么要这样操作呢? 根据注释我们似乎能够看懂一点点, 它的目的是为了能够让二进制位高位的数字能够影响到低位, 从而使值能够在表比较小的时候更加平均分布.

此时我们学习了 HashMap 是如何计算下标的, 其实对于这一点也比较好理解了. 假如我的容量很小, 是32. 那么此时我计算下标的时候采用的就是0001 1111.

此时就有一个很尴尬的问题, 假如我有一些数字, 二进制低位全部都一样, 但是高位不一样, 比如1100 1010, 1010 1010, 0010 1010. 可以看到, 这几个二进制, 最后五位全都是01010. 那么此时如果按照之前的逻辑, 这些放到 32 大小的哈希表的时候全都会冲突.

那此时我们就可以采用把高位移动过来, 影响一下低位, 从而使得这些数据也可以更加分散一些, 这就是这个操作的目的. 当然我们这里的这个例子, 并不能体现这一点, 因为位数太少了. 不过我们只需要知道它的设计目的是这个就可以了.


接下来就是进入putVal()方法, 很明显, 进入putVal()方法后, 我们可以看到, 在刚刚扩容的下面, 似乎就有一个简单的插入逻辑. 即通过计算得到的位置为空, 那么就直接放一个节点进去

在这里插入图片描述

很明显这里的下标计算就和我们之前看过的一模一样, 因此我们这里不多看了. 继续往下来看看, 如果已经有了节点, 它是如何插入的.

// p 在这里是当前哈希桶的第一个节点
Node<K,V> e; K k;

if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
    // 第一个就是 Key 重复的了, 让 e 指向 p
    e = p;
else if (p instanceof TreeNode)
    // 红黑树处理
    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
   	// 遍历链表
    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;
        }
        // 如果遇到重复, 此时 e 已经指向了重复节点, 直接 break 循环
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            break;
        
        // 如果都没触发, p 跟着 e 往后走
        p = e;
    }
}

// 如果 e 不为空, 证明找到了 Key 重复的节点, 进行处理
if (e != null) { 
    // 拿到旧值, 用于返回
    V oldValue = e.value;
    // 这个条件我们这里不关心, 我们只需要知道这里是修改 e 指向节点的 value 即可
    if (!onlyIfAbsent || oldValue == null)
        e.value = value;
    // 这个方法在 HashMap 中没有实现, 主要是给子类做特殊处理用的
    afterNodeAccess(e);
    // 返回
    return oldValue;
}

其中 p 节点相当于一个 cur 用于遍历, e 节点用于指向 Key 重复的节点. 如果上面代码没有看懂, 可以结合这两个解释来看, 可能会更加清晰一些.

同时我们可以看出, HashMap 使用的是尾插法, 并且会在插入节点后检测是否要树化, 主要看的是下面两个位置. 如果大于这个TREEIFY_THRESHOLD(这个数是 8), 就会进入treeifyBin()方法

在这里插入图片描述

同时进入这个方法后会再检测一次, 如果容量小于这个MIN_TREEIFY_CAPACITY(这个数是 64), 那就会尝试 resize(), 而不是直接树化

在这里插入图片描述

这也和我们之前提到的HashMap的树化条件一致, 其内部的链表只有在节点数大于 8, 并且数组长度大于 64 的时候才会进化为红黑树.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值