导航
这是我去年碰到的一道面试题,当时直接整懵逼了。源码大致看过,原理也知道,突然要写还真写不出来。于是这两天又把这个问题翻出来,尝试写了一版。
1 明确需求
- 实现一个简易的 HashMap,包含 hashmap 的核心功能(put、get)
- 实现拉链法即可,不必实现红黑树
- 最好实现扩容逻辑
- 不支持 key 为 null 的情况(要支持也可以,需要单独处理,比较简单)
为了让需求更加明确,写了单元测试:
@Test
public void should_work() {
MyMap<Integer, Integer> m = new MyHashMap<>();
m.put(1, 1001);
m.put(2, 1002);
m.put(1, 1003);
m.put(3, 1004);
m.remove(3);
assertEquals(2, m.size());
assertEquals(1002, (int)m.get(2));
assertEquals(1003, (int)m.get(1));
assertTrue(m.containsKey(1));
assertTrue(m.containsValue(1003));
assertFalse(m.containsKey(3));
assertFalse(m.containsValue(1004));
}
@Test
public void should_resize() {
MyHashMap<Integer, Integer> m = new MyHashMap<>();
for (int i = 0; i < 50; i++) {
m.put(i, i+1000);
}
assertEquals(128, m.getCapacity());
for (int i = 50; i < 100; i++) {
m.put(i, i+1000);
}
assertEquals(256, m.getCapacity());
}
一共写了两个 case。第一个 case 测试 map 的基本功能,第二个 case 测试扩容。
2 大概思路
先把接口定义出来,然后开始实现。
接口有 Map 和 Entry 两个。
关键实现:
- 拉链法。定义一个 table 数组,作为“桶”,桶中放入 Entry 的链表。插入元素时,使用“头插法”。
- 直接用 Objects.hash() 方法计算 hash 值,然后取余数找到对应的桶,简化操作。
- 根据负载因子计算阈值,发生扩容时,对元素进行遍历,逐个根据 hash 值重新插入新的桶中。
- 只做扩容,不做缩容(我看到 JDK1.8 的源码中没有做缩容)。
3 实现步骤
3.1 定义接口
定义 MyMap 接口,以及内部的 Entry 接口。
public interface MyMap<K, V> {
int size();
void put(K key, V val);
V get(K key);
void remove(K key);
boolean containsKey(K key);
boolean containsValue(V val);
interface Entry<K, V> {
K getKey();
V getValue();
void setValue(V val);
int hashCode();
}
}
3.2 写单元测试
针对接口写测试。代码已经在前面写了,此处略。
(在实际过程中,我先把单元测试的代码都注释掉,然后按照细分功能点逐步打开注释,打开一个,实现一个。)
3.3 MyHashMap 初始化
定义一个实现类 MyHashMap,实现 MyMap 接口。做一些基本的初始化操作:
- 定义两个常量:INIT_CAPACITY,LOAD_FACTOR。在原版 HashMap 中,负载因子是可以自定义的,我们这里写死即可。
- 定义字段:size(元素的个数),table(桶的数组,用于存放所有元素),capacity(table数组的容量),threshold(阈值,等于 capacity * LOAD_FACTOR)。
相关代码片段:
public class MyHashMap<K, V> implements MyMap<K, V> {
private static final int INIT_CAPACITY = 16;
private static final double LOAD_FACTOR = 0.75;
private int size = 0;
private int capacity;
private int threshold;
private Node<K, V>[] table;
public MyHashMap() {
resize();
}
private void resize() {
if (table == null) {
capacity = INIT_CAPACITY;
table = new Node[capacity];
threshold = (int) (capacity * LOAD_FACTOR);
return;
}
...
}
...
}
3.4 定义内部的 Node 类,实现 Entry
Node 可以实现链表操作。
static class Node<K, V> implements MyMap.Entry<K, V> {
private K key;
private V val;
private int hashCode;
Node<K, V> next;
Node(K key, V val, Node<K, V> next) {
this.key = key;
this.val = val;
this.next = next;
hashCode = hash(key);
}
@Override
public K getKey() {
return key;
}
@Override
public V getValue() {
return val;
}
@Override
public void setValue(V val) {
this.val = val;
}
@Override
public int hashCode() {
return hashCode;
}
}
3.5 实现 put 操作
先尝试寻找相同 key 的元素,如果找到了,进行更新操作。
如果没找到,说明是一个新的键值对,用头插法进行插入。如果达到扩容条件,进行扩容。
@Override
public void put(K key, V val) {
Node<K, V> node = findNode(key);
if (node != null) {
node.setValue(val);
return;
}
int idx = getIndex(key);
table[idx] = new Node<>(key, val, table[idx]);
size++;
if (size > threshold) {
resize();
}
}
private Node<K, V> findNode(K key) {
int idx = getIndex(key);
Node<K, V> node = table[idx];
while (node != null) {
if (Objects.equals(node.getKey(), key)) {
return node;
}
node = node.next;
}
return null;
}
private int getIndex(K key) {
int hashCode = hash(key);
return hashCode % table.length;
}
3.6 实现 get 操作
get 操作非常简单。主要就是上一步的 findNode 方法。先找到桶,然后再去链表里找。
@Override
public V get(K key) {
Node<K, V> node = findNode(key);
return node == null ? null : node.getValue();
}
3.7 实现 remove 操作
先找到桶(链表),然后删除链表中的目标元素。
@Override
public void remove(K key) {
int idx = getIndex(key);
table[idx] = removeFromList(table[idx], key);
}
private Node<K, V> removeFromList(Node<K, V> head, K key) {
Node<K, V> dummyHead = new Node<>(null, null, head);
Node<K, V> pre = dummyHead;
Node<K, V> cur = head;
while (cur != null) {
if (Objects.equals(cur.getKey(), key)) {
pre.next = cur.next;
size--;
return dummyHead.next;
}
pre = cur;
cur = cur.next;
}
return dummyHead.next;
}
3.8 实现 containsKey 和 containsValue
这两个比较简单。containsKey 就是 findNode 查一下。containsValue 需要做一次遍历。
@Override
public boolean containsKey(K key) {
return findNode(key) != null;
}
@Override
public boolean containsValue(V val) {
for (Node<K, V> head : table) {
while (head != null) {
if (Objects.equals(head.getValue(), val)) {
return true;
}
head = head.next;
}
}
return false;
}
至此,基本 api 都实现了。
3.9 实现 resize 扩容
这是最后一步,做完大功告成。
- 将容量扩大为原来的 2 倍
- 创建一组新的桶,对原来的元素进行迁移
- 更新容量、阈值等字段
@SuppressWarnings({"unchecked"})
private void resize() {
if (table == null) {
capacity = INIT_CAPACITY;
table = new Node[capacity];
threshold = (int) (capacity * LOAD_FACTOR);
return;
}
int newCap = capacity * 2;
Node<K, V>[] newTable = new Node[newCap];
for (Node<K, V> head : table) {
while (head != null) {
Node<K, V> next = head.next;
int newIdx = head.hashCode() % newCap;
head.next = newTable[newIdx];
newTable[newIdx] = head;
head = next;
}
}
table = newTable;
capacity = newCap;
threshold = (int) (capacity * LOAD_FACTOR);
}
4 完整源码
已上传 github。地址:
https://github.com/plough/sort-algorithms/tree/master/src/main/java/com/plough/datastructure/map
5 HashMap 附加问题
5.1 为什么容量一定要是 2 的整数倍?
在我们简易版本的实现中,其实是没有这个要求的。对于原版的 HashMap,保持容量为 2 的整数倍,可以提高散列均匀性,减少 hash 碰撞概率。
为什么?
原版中,getIndex 的实现是 hash(key) & (n-1)。当 n 为偶数时,最后一位始终为 1,完成 & 运算后,最后一位可能是 1 或 0。如果 n 为奇数,& 运算后,最后一位只可能是 0,也就是说所有奇数位置都无法使用。
5.2 为什么负载因子是 0.75?
首先,一定要有一个负载因子,而且它必然有一个相对合适的取值。如果负载因子过大,hashmap 会变得紧凑,增加了碰撞概率;如果负载因子过小,hashmap 中会有很多空闲的桶,浪费空间。
那么,为什么是 0.75 呢?这是一个数学问题。这个值是根据“泊松分布”算出来的,最佳取值在 0.7-0.8 之间,于是便取了 0.75。
至于具体怎么算的,我数学不好,就没深究了。感兴趣可以其他地方再查查资料。
