手写 HashMap(设计哈希表)
面试官:"你天天用 Map、dict、HashMap,能不能从零手写一个?"
听起来简单对吧?不就是 key-value 存取嘛。但一写起来你会发现——问题全来了:哈希冲突怎么办?什么时候扩容?扩容的时候旧数据怎么迁移? 这些问题搞不清楚,面试直接 GG。
今天我们就来拆解 HashMap 的核心原理,然后用 TypeScript 从零实现一个支持 O(1) 增删查的哈希表 💪
为什么需要 HashMap?
假设你有一个用户列表,需要根据用户 ID 快速查用户信息。最朴素的想法是用数组遍历:
users = [{id: 1, name: "Alice"}, {id: 2, name: "Bob"}, ...]
查 id=999 的用户 → 遍历数组 → O(n) 😭1 万个用户还行,100 万个用户就要命了。如果能让 查找速度变成 O(1),该多好?
这就是 HashMap 诞生的原因——它通过一个哈希函数把 key 转换成数组下标,直接跳到对应位置取数据,不遍历。
key "user:999" → hash("user:999") → 37 → buckets[37] → 直接拿到 value
时间复杂度:O(1) ✅核心概念
1. 哈希函数
哈希函数的核心任务是:把任意类型的 key 映射成一个非负整数(数组下标)。
一个好的哈希函数需要:
- 确定性:同一个 key 每次哈希的结果必须一样
- 均匀性:不同 key 尽量均匀分布在各个位置,减少冲突
- 高效性:计算速度快,O(1) 或接近 O(1)
// 一个简单的字符串哈希函数(DJB2 算法)
function hash(key: string, capacity: number): number {
let hash = 5381;
for (let i = 0; i < key.length; i++) {
// hash * 33 + charCode,经典的 DJB2 哈希
hash = ((hash << 5) + hash + key.charCodeAt(i)) >>> 0;
}
return hash % capacity;
}为什么要
>>> 0?JavaScript 的位运算会把数字转成 32 位有符号整数,>>> 0强制转成无符号,保证结果非负。
2. 哈希冲突
不管哈希函数设计得多好,只要 key 的数量大于桶的数量(或者 key 空间无穷大),冲突不可避免。比如:
hash("cat") % 8 = 3
hash("dog") % 8 = 3 ← 冲突了!两个 key 落到同一个桶怎么解决?主流方案有两种:
方案一:链地址法(Chaining)
每个桶不直接存数据,而是存一个链表(或数组)。冲突的元素追加到链表末尾。
buckets[0] → null
buckets[1] → [key1, val1] → null
buckets[2] → null
buckets[3] → [cat, "🐱"] → [dog, "🐕"] → null ← 冲突元素串在链表里
buckets[4] → null
...- ✅ 实现简单,负载因子可以大于 1
- ❌ 链表太长时退化成 O(n)
- 📌 Java 的
HashMap、JavaScript 的 V8 引擎用的就是这种
方案二:开放寻址法(Open Addressing)
所有元素都直接存在桶数组里。冲突时按照某种探测策略找下一个空位:
插入 "cat" → hash = 3 → buckets[3] 为空 → 放进去 ✅
插入 "dog" → hash = 3 → buckets[3] 已占用 → 探测下一个
→ buckets[4] 为空 → 放进去 ✅常见的探测策略:
线性探测:冲突了就往后找(+1, +2, +3...),简单但容易"聚集"
二次探测:按平方步长找(+1, +4, +9...),缓解聚集
双重哈希:用第二个哈希函数算步长,分布最均匀
✅ 数据存在连续内存,缓存友好
❌ 负载因子必须控制在 0.7 以下,否则性能急剧下降
📌 Python 的
dict、Go 的map用的是开放寻址的变体
3. 负载因子与扩容
负载因子(Load Factor) = 已存元素数 / 桶的数量
capacity = 8, size = 6
load factor = 6 / 8 = 0.75负载因子越大,冲突概率越高,查找越慢。当负载因子超过阈值(通常 0.75),就需要扩容(resize):
扩容前:capacity = 8, size = 6, loadFactor = 0.75
触发扩容!
扩容后:capacity = 16, 重新哈希所有元素到新桶数组
新 loadFactor = 6 / 16 = 0.375 ✅ 冲突率大幅下降容量为什么要翻倍?因为翻倍后每个元素的新位置 = 要么在原位,要么在原位 + 旧容量,方便计算。
代码实现
接下来我们用 TypeScript 手写一个完整的 HashMap。采用链地址法,支持:
put(key, value)—— 插入/更新get(key)—— 查找delete(key)—— 删除- 自动扩容
