【数据结构】哈希表:O(1) 查找的秘密
哈希表为什么能做到“平均 O(1)”?从哈希函数、冲突处理、负载因子、扩容与 rehash 讲到工程实践与面试追问。读完你不只会用 Map/Object,还能解释它为什么快、什么时候不快。
【数据结构】哈希表:O(1) 查找的秘密
你一定经历过这种“爽到离谱”的时刻:
- 你想查一个用户 id 是否存在
- 你不想从头扫一遍数组(那是 O(n),像在全班点名找人)
- 你掏出一个
Map/Object,get一下就有了
然后你会听到一个经典结论:
哈希表查找是 O(1)。
问题是:
- 凭什么 O(1)?
- 冲突怎么办?
- 为什么有时候会退化?
- 为什么插入到一定数量后突然变慢一下?
如果你只停留在“背结论”,面试官下一句追问就能把你送走。
今天我们把哈希表讲到“能解释给别人听”的程度:
- 你能说清它的底层机制
- 你能写一个迷你实现(可运行)
- 你知道它在工程里什么时候会坑你
1. 哈希表到底是什么?先用一句人话
哈希表(Hash Table)的本质是:
用一个“函数”把 key 映射成数组下标,然后把值放进数组里。
它看起来像一个“字典”,但底层更像一个“用数学给数组开外挂”的结构。
你可以想象一个大楼(数组),每个房间都有门牌号(下标)。
- 你拿着一个名字(key)
- 通过“门牌号生成器”(哈希函数)算出门牌号
- 直接去对应房间找人
不需要全楼挨家挨户敲门,这就是它快的根源。
2. 为什么平均能做到 O(1)?关键在“数组 + 均匀分布”
如果哈希函数足够“均匀”,key 会平均散落到不同桶位:
- 查找:算下标 O(1) + 访问数组 O(1)
- 插入:同理
所以我们说哈希表是 平均 O(1)。
注意我说的是“平均”。因为哈希表的性能有一个致命前提:
key 必须分布得像撒胡椒粉一样均匀。
如果全撒在一个点上,那就从“哈希表”退化成“链表/数组”,直接 O(n)。
3. 冲突(Collision):哈希表注定会发生“撞车”
现实很残酷:
- 哈希函数把“无限多的 key”映射到“有限个下标”
- 所以不同 key 映射到同一个下标是必然的
这就叫冲突。
哈希表的难点不是“怎么把 key 变成下标”,而是:
撞车以后怎么处理,才能还尽量快?
主流的冲突处理方案就两大类:
- 链地址法(Separate Chaining):每个桶里放一个列表(链表/数组)。
- 开放寻址(Open Addressing):桶里只能放一个元素,撞了就找下一个空位。
下面我们分别讲清楚。
4. 方案一:链地址法(最直观,也最常见)
链地址法的思路很朴素:
- 桶里不是只放一个值
- 而是放一个“集合”(常用数组/链表)
index 0: [ (k1,v1) -> (k2,v2) ]
index 1: [ ]
index 2: [ (k3,v3) ]
查找步骤:
index = hash(key) % capacity- 去
buckets[index]里线性找这个 key
这时候复杂度变成:
- 平均:桶里元素很少 → 近似 O(1)
- 最坏:全挤在一个桶 → O(n)
工程里,我们靠两件事把平均桶长控制住:
- 哈希函数尽量均匀
- 负载因子到阈值就扩容(后面会讲)
5. 方案二:开放寻址(更省内存,但更“脾气大”)
开放寻址法的规则更硬:
- 一个桶只能放一个元素
- 冲突了就找下一个空桶
最简单的是线性探测(Linear Probing):
index = hash(key) % capacity
如果 index 被占了 → index+1 → index+2 ...(循环)
开放寻址的痛点:
- 删除很麻烦(需要 tombstone 标记,否则会断掉探测链)
- 容量接近满时性能会明显变差(探测距离变长)
你在面试里说出这两点,已经比大多数人强。
6. 负载因子(Load Factor):哈希表性能的“体脂率”
负载因子定义:
$$\alpha = \frac{\text{元素数量}}{\text{桶数量}}$$
- $\alpha$ 越大,桶越“挤”,冲突越多
- $\alpha$ 越小,桶越“空”,内存浪费越大
典型策略:
- 当 $\alpha > 0.75$ 扩容
- 扩容通常是容量翻倍
这里有个很反直觉但很重要的点:
扩容不是简单地把数组变大,它会触发 rehash(重分布)。
因为下标是 % capacity 算出来的,capacity 变了,所有元素的桶位置都可能变。
所以扩容会有一次“突然变慢”的时刻:你在做一次大规模搬家。
这也是很多人看到“偶尔一次 set 很慢”的原因。
7. 手写一个迷你哈希表(可运行,链地址法 + 自动扩容)
我们用 JavaScript 写一个教学版的 HashTable:
- key 支持字符串(够讲原理)
- 链地址法:每个桶是一个数组
- 负载因子超过 0.75 自动扩容并 rehash
class HashTable {
constructor(initialCapacity = 16) {
this._capacity = initialCapacity;
this._size = 0;
this._buckets = Array.from({ length: this._capacity }, () => []);
}
get size() {
return this._size;
}
_hashString(key) {
// djb2: 简单、好用、足够教学
let hash = 5381;
for (let i = 0; i < key.length; i++) {
hash = (hash * 33) ^ key.charCodeAt(i);
}
// 转成非负 32 位整数
return hash >>> 0;
}
_index(key) {
const hash = this._hashString(key);
return hash % this._capacity;
}
_findPair(bucket, key) {
for (let i = 0; i < bucket.length; i++) {
const pair = bucket[i];
if (pair[0] === key) return { pair, index: i };
}
return null;
}
_loadFactor() {
return this._size / this._capacity;
}
_rehash(newCapacity) {
const oldBuckets = this._buckets;
this._capacity = newCapacity;
this._buckets = Array.from({ length: this._capacity }, () => []);
this._size = 0;
for (const bucket of oldBuckets) {
for (const [key, value] of bucket) {
this.set(key, value);
}
}
}
set(key, value) {
if (typeof key !== 'string') {
throw new TypeError('This demo HashTable only supports string keys.');
}
if (this._loadFactor() > 0.75) {
this._rehash(this._capacity * 2);
}
const index = this._index(key);
const bucket = this._buckets[index];
const found = this._findPair(bucket, key);
if (found) {
found.pair[1] = value;
return;
}
bucket.push([key, value]);
this._size += 1;
}
get(key) {
const index = this._index(key);
const bucket = this._buckets[index];
const found = this._findPair(bucket, key);
return found ? found.pair[1] : undefined;
}
has(key) {
return this.get(key) !== undefined;
}
delete(key) {
const index = this._index(key);
const bucket = this._buckets[index];
const found = this._findPair(bucket, key);
if (!found) return false;
bucket.splice(found.index, 1);
this._size -= 1;
return true;
}
keys() {
const result = [];
for (const bucket of this._buckets) {
for (const [key] of bucket) result.push(key);
}
return result;
}
}
// quick demo
const ht = new HashTable();
ht.set('xiaoming', 18);
ht.set('xiaohong', 20);
ht.set('xiaoming', 19);
console.log(ht.get('xiaoming')); // 19
console.log(ht.has('nobody')); // false
console.log(ht.keys());
这个实现并不追求“生产可用”,但它完整体现了哈希表的核心:
- hash → index
- 冲突 → bucket 链
- 负载因子 → 扩容
- 扩容 → rehash
看懂它,你就能读懂 80% 的哈希表源码。
8. 工程实践:什么时候用 Map,什么时候用 Object?
在 JavaScript 里,你常见的“哈希表”有两类:
Object:更像“带原型链的字典”Map:更像“专门为键值对设计的哈希表”
几个关键差别:
8.1 key 的类型
Object的 key 本质是字符串/符号(Symbol)Map的 key 可以是任意类型(对象、函数、NaN 都行)
8.2 迭代顺序与语义
Map明确支持size、可迭代、语义更稳定Object有历史包袱(原型链、hasOwnProperty、枚举规则)
工程建议:
- 需要频繁增删、需要非字符串 key、需要稳定迭代:优先
Map - 就是做 JSON 数据结构、字段固定:
Object很合适
9. 面试常见追问:你能答出来就很加分
9.1 “哈希表为什么是平均 O(1),不是最坏 O(1)?”
因为会发生冲突;冲突极端情况下可能把所有元素挤在同一个桶(或同一条探测链)上,退化成 O(n)。
9.2 “扩容时为什么要 rehash?”
因为桶下标是 hash % capacity,capacity 变了,元素的位置就可能变,所以必须重新分配。
9.3 “负载因子为什么常用 0.75?”
这是一个工程权衡:
- 太低:浪费内存
- 太高:冲突显著增加,性能波动大
不同实现会选不同阈值,但核心思想一致:让冲突概率保持在可控范围。
总结
- 哈希表快的根源是:用哈希函数把 key 映射到数组下标。
- 冲突不可避免,常见解决方案是:链地址法 和 开放寻址。
- 性能关键在:均匀分布 + 控制负载因子。
- 扩容会触发 rehash,所以会出现“偶尔一次很慢”的现象。
- JavaScript 工程里:需要稳定语义与任意 key 时优先
Map。
小明金句收尾:
哈希表的 O(1) 不是白给的,是用“扩容、冲突处理、内存开销”换来的。