前言
大佬大佬,JDK的HashMap
运行效率确实高,但太难理解,有没有更加简单的HashMap
实现? 有的,兄弟,有的!
传统的数组+链表实现略显复杂。本文将展示如何仅用数组实现功能完整的哈希表,通过开放寻址法实现极致简化! 当发生哈希冲突时,不使用链表而是顺序查找数组中的下一个空位,这种方法称为线性探测(Linear Probing),是开放寻址法中最简单的策略。
实现思路
我们将基于数组实现一个简单的哈希表,并实现基本的操作,包括put, get, remove, resize。
开放寻址法(线性探测)在查找时,遇到非空且不等于目标键的位置会继续向后探测,直到遇到null
才停止。
假设我们有一个简单哈希表,冲突时使用线性探测:
text
索引: 0 1 2 3 4 5
键: A B C - D -
哈希: h(A)=0, h(B)=1, h(C)=1, h(D)=4
插入值时,由于C和B所在索引相同,所以C在B的下一个索引2,查找过程: 查找C: h(C)=1 → 位置1 (B) → 继续探测位置2 (C) → 找到 查找D: h(D)=4 → 位置4 (D) → 找到
如果我们在位置1直接删除B(设为null):
text
索引: 0 1 2 3 4 5
键: A null C - D -
此时查找C: h(C)=1 → 位置1 (null) → 停止查找(误判C不存在) 但实际上C在位置2,只是探测链被中断了!
为了避免这种情况,我们引入一个特殊的的值(一个无法被用户插入的值,如果用户可插入的值被当作是已删除,那么是有问题的),即已删除状态,用DELETED
表示,而不是直接置为null
。
我们定义一个Entry
类来表示键值对,并且数组中每个位置可以是三种状态:空、已删除、有效Entry
。
但是,由于我们使用数组,而且删除需要特殊处理,设计思路如下: 数组类型为Entry[]
,初始化哈希表时,为每个元素设为默认的Entry
对象,每个Entry
包含key, value, 以及一个删除标记。 但是这样的话,即使不插入值,value部分仍然占用空间,浪费内存,而且只需要一个Object
类型的key和Object
类型的value。 我们可以不用Entry
[],而是用两个数组来实现:Object[] keys
和Object[] values
。这样我们就可以独立处理两个状态。
key有三种情况,如果key==null,表示这个位置是空的,即没有被使用过, 如果key==DELETED,表示这个位置已被删除,否则,表示一个有效的键值对。
java
public class SimpleHashMap<K, V> {
private static final int DEFAULT_CAPACITY = 16;
private static final float LOAD_FACTOR = 0.75f;
private static final Object DELETED = new Object(); // 特殊删除标记
private Object[] keys;
private Object[] values;
private int size;
private int capacity;
private int threshold;
public SimpleHashMap() {
this(DEFAULT_CAPACITY);
}
public SimpleHashMap(int capacity) {
this.capacity = capacity;
this.threshold = (int) (capacity * LOAD_FACTOR);
this.keys = new Object[capacity];
this.values = new Object[capacity];
}
public int getSize() {
return size;
}
public int getCapacity() {
return capacity;
}
public int getThreshold() {
return threshold;
}
// 核心方法:寻找键的插入位置
private int findSlot(K key) {
int startIdx = Math.abs(key.hashCode() % capacity);
int idx = startIdx;
int firstDeleted = -1; // 记录首个删除位置
do {
if (keys[idx] == null) {
return firstDeleted != -1 ? firstDeleted : idx;
}
if (keys[idx] == DELETED && firstDeleted == -1) {
firstDeleted = idx; // 记录第一个删除位置
}
if (keys[idx] != DELETED && keys[idx].equals(key)) {
return idx; // 找到相同键
}
idx = (idx + 1) % capacity; // 线性探测下一位
} while (idx != startIdx); // 遍历整个数组
return firstDeleted; // 数组已满
}
public void put(K key, V value) {
if (key == null) {
throw new IllegalArgumentException("Key cannot be null");
}
if (size >= threshold) {
resize(); // 达到负载因子扩容
}
int idx = findSlot(key);
if (idx == -1) {
throw new IllegalStateException("HashMap full");
}
if (keys[idx] == null || keys[idx] == DELETED) {
keys[idx] = key;
size++;
}
values[idx] = value;
}
public V get(K key) {
int idx = findSlot(key);
if (idx >= 0 && keys[idx] != null && keys[idx] != DELETED) {
return (V) values[idx];
}
return null;
}
public void remove(K key) {
int idx = findSlot(key);
if (idx >= 0 && keys[idx] != null && keys[idx] != DELETED) {
keys[idx] = DELETED;
values[idx] = null;
size--;
}
}
private void resize() {
int newCapacity = capacity * 2;
if (newCapacity > (1 << 30)) {
newCapacity = (1 << 30); // 最大容量限制
}
Object[] oldKeys = keys;
Object[] oldValues = values;
capacity = newCapacity;
threshold = (int) (newCapacity * LOAD_FACTOR);
keys = new Object[newCapacity];
values = new Object[newCapacity];
size = 0;
// 重新插入所有元素
for (int i = 0; i < oldKeys.length; i++) {
if (oldKeys[i] != null && oldKeys[i] != DELETED) {
put((K) oldKeys[i], (V) oldValues[i]);
}
}
}
}
关键实现解析
1. 哈希冲突处理方案
java
int startIdx = Math.abs(key.hashCode() % capacity);
int idx = startIdx;
do {
idx = (idx + 1) % capacity; // 关键探测逻辑
} while (idx != startIdx);
通过循环探测后续位置解决冲突,当到达数组末尾时回到数组开头继续查找
2. 删除标记的妙用
java
private static final Object DELETED = new Object();
public void remove(Object key) {
keys[idx] = DELETED; // 标记为特殊删除状态
values[idx] = null;
}
使用特殊标记DELETED
而不是直接设为null
,防止切断后续元素的探测链
3. 查找时的优化处理
java
int firstDeleted = -1; // 记录首个删除位置
if (keys[idx] == DELETED && firstDeleted == -1) {
firstDeleted = idx; // 记录可复用的空位
}
查找过程中记录首个删除位置,供后续插入操作复用空间
性能优化策略
解决聚集问题
当连续位置被占用形成"区块"时,查找效率会急剧下降。可改用以下策略优化:
- 二次探测 :
index = (hash + i^2) % size
- 双重哈希 :
index = (hash1(key) + i * hash2(key)) % size
性能对比分析
实现方式 | 平均查找时间 | 空间效率 | 实现复杂度 |
---|---|---|---|
数组+链表 | O(1) | 中等 | 中等 |
纯数组线性探测 | O(1)~O(n) | 较高 | 简单 |
红黑树实现 | O(log n) | 较低 | 复杂 |
实际在Java标准库中,HashMap使用链表+红黑树的混合结构,在哈希分布不均匀时仍能保持O(log n)的时间复杂度
测试用例
代码如下:
java
public class SimpleHashMapTest {
@Test
public void testBasicPutAndGet() {
SimpleHashMap<String, Integer> map = new SimpleHashMap<>();
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);
assertEquals(1, map.get("A"));
assertEquals(2, map.get("B"));
assertEquals(3, map.get("C"));
assertNull(map.get("D")); // 不存在的键
}
@Test
public void testUpdateValue() {
SimpleHashMap<String, String> map = new SimpleHashMap<>();
map.put("key", "value1");
assertEquals("value1", map.get("key"));
map.put("key", "value2"); // 更新值
assertEquals("value2", map.get("key"));
}
@Test
public void testCollisionHandling() {
// 创建一个小容量映射来强制冲突
SimpleHashMap<Integer, String> map = new SimpleHashMap<>(4);
// 计算哈希碰撞的键(在容量为4时)
// 0: 0,4,8,12...
// 1: 1,5,9,13...
// 2: 2,6,10,14...
// 3: 3,7,11,15...
map.put(0, "A");
map.put(4, "B"); // 碰撞到0位置
map.put(8, "C"); // 继续碰撞
assertEquals("A", map.get(0));
assertEquals("B", map.get(4));
assertEquals("C", map.get(8));
}
@Test
public void testRemoveOperation() {
SimpleHashMap<String, String> map = new SimpleHashMap<>();
map.put("K1", "V1");
map.put("K2", "V2");
map.put("K3", "V3");
// 移除存在的键
map.remove("K2");
assertNull(map.get("K2"));
assertEquals("V1", map.get("K1"));
assertEquals("V3", map.get("K3"));
// 移除不存在的键(应该无影响)
map.remove("K4");
assertEquals(2, map.getSize());
}
@Test
public void testDeletedSlotReuse() {
SimpleHashMap<Integer, String> map = new SimpleHashMap<>(4);
map.put(0, "A");
map.put(4, "B"); // 碰撞到0位置
map.put(8, "C"); // 继续碰撞
// 删除中间元素
map.remove(4);
// 应该在相同位置插入新元素
map.put(12, "D"); // 同样碰撞到0位置,应该复用已删除的槽位
// 验证所有键
assertEquals("A", map.get(0));
assertNull(map.get(4)); // 已删除
assertEquals("C", map.get(8));
assertEquals("D", map.get(12));
}
@Test
public void testResizeOperation() {
// 初始容量4,阈值 = 4 * 0.75 = 3
SimpleHashMap<Integer, String> map = new SimpleHashMap<>(4);
// 初始容量验证
assertEquals(4, map.getCapacity());
// 添加3个元素 - 达到阈值但尚未扩容
map.put(1, "A");
map.put(2, "B");
map.put(3, "C");
assertEquals(4, map.getCapacity()); // 尚未扩容
// 添加第4个元素 - 应触发扩容
map.put(4, "D");
assertEquals(8, map.getCapacity()); // 容量翻倍
assertEquals(4, map.getSize());
// 验证所有元素仍然可访问
assertEquals("A", map.get(1));
assertEquals("B", map.get(2));
assertEquals("C", map.get(3));
assertEquals("D", map.get(4));
// 添加更多元素测试阈值更新
map.put(5, "E");
map.put(6, "F"); // 6个元素,阈值 = 8 * 0.75 = 6,尚未扩容
assertEquals(8, map.getCapacity());
// 添加第7个元素,应触发扩容
map.put(7, "G");
assertEquals(16, map.getCapacity());
}
@Test
public void testNullKeyHandling() {
SimpleHashMap<String, String> map = new SimpleHashMap<>();
// 测试null键处理
assertThrows(IllegalArgumentException.class, () -> map.put(null, "value"));
}
@Test
public void testLargeDataset() {
SimpleHashMap<Integer, String> map = new SimpleHashMap<>();
int count = 1000;
// 插入1000个键值对
for (int i = 0; i < count; i++) {
map.put(i, "Value" + i);
}
// 验证所有值
for (int i = 0; i < count; i++) {
assertEquals("Value" + i, map.get(i));
}
// 删除半数元素
for (int i = 0; i < count; i += 2) {
map.remove(i);
}
// 验证删除后的状态
for (int i = 0; i < count; i++) {
if (i % 2 == 0) {
assertNull(map.get(i));
}
else {
assertEquals("Value" + i, map.get(i));
}
}
}
}
结语:大道至简
通过开放寻址法,我们用简单的数组实现了完整功能的哈希表。虽然牺牲了极端情况下的性能,但获得了:
✅ 实现复杂度大幅降低
✅ 内存连续访问效率高
✅ 无额外链表节点开销
编程之美常在于平衡 ------ 在简洁与性能、空间与时间之间找到最合适的折中点。理解最基础的实现,才能更好地掌握复杂结构的精妙之处。