HashMap 的核心就三件事:哈希函数把任意 key 映射成数组下标、冲突处理保证不同 key 不互相覆盖、动态扩容在元素增多时维持 O(1) 的查询效率。Java 的 HashMap 用的是链地址法 + 红黑树 + 0.75 负载因子扩容,本文用 JS 从零实现一个 mini 版本,把这三件事逐个拆开看。
哈希表到底在干嘛
说白了,哈希表就是一个"数组 + 哈希函数"的组合。你想存一组键值对,但数组只能用数字下标访问。哈希函数干的事情,就是把任意类型的 key 转成一个数组下标。
比如 key 是 "name",哈希函数算出 hash("name") = 3,那你就把值塞到 array[3]。取的时候同理,算一遍哈希,直接定位到那个位置,O(1) 搞定。
javascript
// 最朴素的哈希表雏形
class TinyMap {
constructor() {
this.buckets = new Array(8); // 底层数组,初始容量 8
}
// 哈希函数:把 key 转成合法下标
hash(key) {
let h = 0;
const str = String(key); // 统一转字符串
for (let i = 0; i < str.length; i++) {
h = (h * 31 + str.charCodeAt(i)) | 0; // 经典多项式哈希
}
return Math.abs(h) % this.buckets.length; // 取模映射到数组范围
}
set(key, value) {
const index = this.hash(key); // 算出下标
this.buckets[index] = value; // 直接存,先不管冲突
}
get(key) {
const index = this.hash(key);
return this.buckets[index]; // 直接取
}
}
const m = new TinyMap();
m.set("name", "张三"); // hash("name") -> 某个下标
m.set("age", 25);
console.log(m.get("name")); // "张三"
看着挺好使对吧?但有个致命问题------两个不同的 key 算出了同一个下标,后存的会把前存的覆盖掉。这就是哈希冲突。
哈希冲突:绕不开的核心问题
两个 key 哈希到同一个位置,太正常了。数组就 8 个槽,你存 100 个键值对,必撞。学术界管这叫鸽巢原理,工程上你得想办法解决。
主流方案有两种:链地址法 和开放寻址法。Java 的 HashMap 用的是链地址法,Python 的 dict 用的是开放寻址法。我把两种都实现一下,你对比着看。
链地址法:每个槽挂一条链表
思路很直白------冲突了?没关系,同一个位置挂一串,用链表串起来。查找的时候遍历链表,逐个比 key。
javascript
// 链地址法实现
class ChainMap {
constructor(size = 8) {
this.buckets = new Array(size);
for (let i = 0; i < size; i++) {
this.buckets[i] = []; // 每个槽是一个数组(当链表用)
}
this.size = 0; // 记录元素个数,扩容要用
}
hash(key) {
let h = 0;
const str = String(key);
for (let i = 0; i < str.length; i++) {
h = (h * 31 + str.charCodeAt(i)) | 0;
}
return Math.abs(h) % this.buckets.length;
}
set(key, value) {
const index = this.hash(key);
const bucket = this.buckets[index];
// 先找有没有相同 key,有就覆盖
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
bucket[i][1] = value; // 更新旧值
return;
}
}
// 没有就追加到链表尾部
bucket.push([key, value]); // [key, value] 存一对
this.size++;
// 负载因子超过 0.75 就扩容
if (this.size / this.buckets.length > 0.75) {
this.resize();
}
}
get(key) {
const index = this.hash(key);
const bucket = this.buckets[index];
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
return bucket[i][1]; // 找到了,返回值
}
}
return undefined; // 没找到
}
resize() {
const oldBuckets = this.buckets;
this.buckets = new Array(oldBuckets.length * 2); // 容量翻倍
for (let i = 0; i < this.buckets.length; i++) {
this.buckets[i] = []; // 初始化新数组
}
this.size = 0;
// 把所有旧数据重新哈希到新数组
for (const bucket of oldBuckets) {
for (const [key, value] of bucket) {
this.set(key, value); // 重新插入,hash 会自动落到新位置
}
}
}
}
Java HashMap 在 JDK 8 之后还有个优化:链表长度超过 8 且数组长度超过 64 时,链表转红黑树,查询从 O(n) 降到 O(log n)。我们用 JS 写 mini 版本就不搞这么复杂了,但思路你得知道。
开放寻址法:冲突了就往下找
另一种思路------数组里不放链表。冲突了?沿着某个规则继续往后找空位,找到就塞进去。查找也一样,算出下标后发现不是你要的 key,就按同样的规则继续找。
javascript
// 开放寻址法(线性探测)
class OpenMap {
constructor(size = 8) {
this.buckets = new Array(size);
this.size = 0;
}
hash(key) {
let h = 0;
const str = String(key);
for (let i = 0; i < str.length; i++) {
h = (h * 31 + str.charCodeAt(i)) | 0;
}
return Math.abs(h) % this.buckets.length;
}
set(key, value) {
let index = this.hash(key);
while (this.buckets[index] !== undefined) {
// 槽被占了,看看是不是同一个 key
if (this.buckets[index][0] === key) {
this.buckets[index][1] = value; // 覆盖旧值
return;
}
index = (index + 1) % this.buckets.length; // 线性探测:往下挪一格
}
this.buckets[index] = [key, value]; // 找到空位,塞进去
this.size++;
if (this.size / this.buckets.length > 0.75) {
this.resize();
}
}
get(key) {
let index = this.hash(key);
while (this.buckets[index] !== undefined) {
if (this.buckets[index][0] === key) {
return this.buckets[index][1]; // 找到了
}
index = (index + 1) % this.buckets.length; // 继续探测
}
return undefined; // 碰到空位还没找到,说明不存在
}
resize() {
const old = this.buckets;
this.buckets = new Array(old.length * 2);
this.size = 0;
for (const entry of old) {
if (entry !== undefined) {
this.set(entry[0], entry[1]); // 重新哈希
}
}
}
}
两种方案各有优劣,放一张表你直观感受下:
| 对比维度 | 链地址法 | 开放寻址法(线性探测) |
|---|---|---|
| 冲突处理 | 每个槽挂链表 | 冲突后按规则探测下一个空位 |
| 内存占用 | 需要额外存链表节点指针 | 全在数组里,没有额外开销 |
| 删除操作 | 直接删链表节点,简单 | 需要特殊标记(tombstone),麻烦 |
| 缓存友好 | 链表节点分散,缓存命中率低 | 数据连续,缓存命中率高 |
| 典型代表 | Java HashMap | Python dict |
| 最坏情况 | 所有 key 哈希到同一位置,退化成链表 | 聚集效应导致探测链越来越长 |
实际项目里选哪种,得看你的场景。数据量大、频繁删除,链地址法稳;内存敏感、缓存敏感,开放寻址法更合适。
扩容:什么时候扩,怎么扩
前面代码里你可能注意到了 resize() 方法。这个扩容机制是 HashMap 性能的关键。
核心概念叫负载因子 (load factor),就是 元素个数 / 数组容量。Java HashMap 默认负载因子阈值是 0.75------意思是数组用到四分之三的时候,就该扩了。扩容就是容量翻倍,然后把所有数据重新哈希到新数组。
为什么是 0.75?这是个经验值。太低浪费内存,太高冲突概率飙升。0.75 是时间和空间的折中点。
javascript
// 扩容过程的单独拆解,看得更清楚
function resizeDemo() {
const oldCapacity = 8;
const newCapacity = oldCapacity * 2; // 16,翻倍
const oldBuckets = [[], [], [], [], [["a", 1]], [["b", 2]], [], []];
const newBuckets = new Array(newCapacity);
for (let i = 0; i < newCapacity; i++) {
newBuckets[i] = []; // 初始化新数组
}
// 关键:重新哈希!因为取模的分母变了
// 原来 % 8,现在 % 16,所有元素的下标都可能变
for (const bucket of oldBuckets) {
for (const [key, value] of bucket) {
const newIndex = hash(key) % newCapacity; // 注意这里用新容量
newBuckets[newIndex].push([key, value]);
}
}
return newBuckets;
}
扩容的代价不小------要遍历所有元素重新哈希,时间复杂度 O(n)。所以 Java HashMap 有个优化:扩容时 2 的幂次特性让元素要么留在原位,要么挪到 原位 + 旧容量 的位置,不用重新算哈希。我们 mini 版本做了简化,直接重新算。
还有一个容易忽略的点:如果你提前知道要存多少数据,初始化时直接给够容量 ,能避免运行中多次扩容。Java 里 new HashMap<>(128) 就是这个意思。
完整实现:一个能跑的 mini HashMap
把上面的东西合在一起,这是一个功能完整的 mini HashMap:
javascript
class MiniHashMap {
constructor(initialCapacity = 8) {
this.capacity = initialCapacity; // 当前数组容量
this.size = 0; // 已存元素个数
this.loadFactor = 0.75; // 负载因子阈值
this.buckets = new Array(this.capacity);
for (let i = 0; i < this.capacity; i++) {
this.buckets[i] = []; // 链地址法:每个槽一个数组
}
}
// 哈希函数:DJB2 变体,比简单累加分布更均匀
hash(key) {
let h = 5381; // DJB2 的初始种子值
const str = String(key);
for (let i = 0; i < str.length; i++) {
h = ((h << 5) + h + str.charCodeAt(i)) | 0; // h*33 + charCode
}
return Math.abs(h) % this.capacity;
}
set(key, value) {
const index = this.hash(key);
const bucket = this.buckets[index];
// 遍历链表,看 key 是否已存在
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
bucket[i][1] = value; // key 存在,更新 value
return;
}
}
// key 不存在,追加到链表
bucket.push([key, value]);
this.size++;
// 检查是否需要扩容
if (this.size / this.capacity > this.loadFactor) {
this._resize();
}
}
get(key) {
const index = this.hash(key);
const bucket = this.buckets[index];
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
return bucket[i][1]; // 找到返回 value
}
}
return undefined; // 没找到
}
delete(key) {
const index = this.hash(key);
const bucket = this.buckets[index];
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
bucket.splice(i, 1); // 从链表中删除该节点
this.size--;
return true;
}
}
return false; // 没找到要删的
}
has(key) {
return this.get(key) !== undefined; // 判断 key 是否存在
}
_resize() {
const oldBuckets = this.buckets;
this.capacity *= 2; // 容量翻倍
this.buckets = new Array(this.capacity);
for (let i = 0; i < this.capacity; i++) {
this.buckets[i] = []; // 初始化新桶
}
this.size = 0; // 重置计数
// 重新哈希所有旧数据
for (const bucket of oldBuckets) {
for (const [key, value] of bucket) {
this.set(key, value); // 复用 set,自动落到新位置
}
}
}
}
// 跑一下试试
const map = new MiniHashMap();
map.set("name", "张三");
map.set("age", 25);
map.set("city", "杭州");
console.log(map.get("name")); // "张三"
console.log(map.has("age")); // true
map.delete("city");
console.log(map.has("city")); // false
console.log(map.size); // 2
跑起来没问题。这个实现包含了哈希表最核心的三个机制:哈希函数、链地址法解决冲突、负载因子触发扩容。
最后
手写一遍 HashMap,比看十篇原理分析都管用。几个关键 takeaway:
- 哈希表的本质就是 数组 + 哈希函数,哈希函数把 key 映射成数组下标
- 哈希冲突避不开,链地址法和开放寻址法是两大主流方案
- 负载因子 0.75 是时间和空间的平衡点,触发扩容时所有数据要重新哈希
- 扩容代价是 O(n),能避免就避免,提前预估容量是好习惯
- Java HashMap 的链表转红黑树优化(阈值 8)是工程上的极致打磨
- 哈希函数的设计直接影响冲突概率,DJB2 比简单累加好用得多
你面试的时候被问过手写 HashMap 吗?欢迎评论区聊聊你的经历。