极致简单的哈希表

前言

大佬大佬,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[] keysObject[] 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; // 记录可复用的空位
}

查找过程中记录首个删除位置,供后续插入操作复用空间

性能优化策略

解决聚集问题

当连续位置被占用形成"区块"时,查找效率会急剧下降。可改用以下策略优化:

  1. 二次探测index = (hash + i^2) % size
  2. 双重哈希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));
			}
		}
	}
}

结语:大道至简

通过开放寻址法,我们用简单的数组实现了完整功能的哈希表。虽然牺牲了极端情况下的性能,但获得了:

✅ 实现复杂度大幅降低

✅ 内存连续访问效率高

✅ 无额外链表节点开销

编程之美常在于平衡 ------ 在简洁与性能、空间与时间之间找到最合适的折中点。理解最基础的实现,才能更好地掌握复杂结构的精妙之处。

相关推荐
非ban必选21 分钟前
spring-ai-alibaba之Rag 增强问答质量
java·人工智能·spring
码农12138号38 分钟前
BUUCTF在线评测-练习场-WebCTF习题[RoarCTF 2019]Easy Java1-flag获取、解析
java·web安全·网络安全·ctf·buuctf·任意文件下载漏洞
rockmelodies1 小时前
【JAVA安全】Java 集合体系详解
java·python·安全·集合
Z_W_H_1 小时前
【SpringBoot】实战-开发接口-用户-注册
java·spring boot·spring
生活百般滋味,人生需要笑对。 --佚名1 小时前
springboot如何redis锁
java·spring boot·redis
月初,2 小时前
SpringBoot集成Minio存储文件,开发图片上传等接口
java·spring boot·后端
oioihoii2 小时前
C++11迭代器改进:深入理解std::begin、std::end、std::next与std::prev
java·开发语言·c++
Huckings2 小时前
Android车载系统时间同步方案具体实现
android·java
Ziegler Han2 小时前
Java的Gradle项目,使用SLF4J+Log4j2+log4j2.xml
java·log4j·slf4j
white camel2 小时前
重学SpringMVC一SpringMVC概述、快速开发程序、请求与响应、Restful请求风格介绍
java·后端·spring·restful