哈希表用空间换时间,把查找压到均摊 O(1),代价是哈希冲突、扩容抖动和一堆反直觉的边界行为。
Map 被拿来统计词频、做缓存、去重,是前端最常用的结构。但被追问"冲突了怎么办""为什么扩容是两倍""Object 和 Map 有什么区别"时,只背过 API 的人会卡住。
| 要求 | 含义 | 不满足的后果 |
|---|---|---|
| 确定性 | 同一个 key 每次算出相同的值 | 存进去就再也找不到 |
| 均匀性 | 输出尽量均匀落在桶区间 | 大量 key 挤在一个桶,退化成链表 |
| 雪崩效应 | 输入改 1 bit,输出约一半 bit 变化 | 相邻 key 落进相邻桶,形成聚集 |
| 高效 | 计算成本远低于一次线性查找 | 哈希本身成了瓶颈 |
桶的数量有限、key 空间无限,冲突必然发生。主流两派:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 链地址法(separate chaining) | 每个桶挂一条链表 | 删除简单,装载率高也能用 | 节点分散,cache 不友好,有指针开销 |
| 开放寻址(open addressing) | 冲突了就按规则找下一个空位 | 数据连续,缓存友好,无指针开销 | 删除麻烦,装载率不能高 |
开放寻址最常用线性探测:冲突就往后看 i+1、i+2。问题是一次聚集(primary clustering)——连续被占的区块越滚越大,落进这块的 key 都要一路往后找,探测长度暴涨。
删除不能把槽位置空,否则会切断后面 key 的探测链。必须打墓碑标记(tombstone):查找时跳过墓碑继续找,插入时可以复用墓碑的位置。
负载因子 = 元素数 / 桶数。到阈值(Java 0.75)就扩容,把所有元素重新哈希进新桶,这一步叫 rehash。
| 概念 | 说明 |
|---|---|
| 为什么是 0.75 | 再低浪费空间,再高冲突暴涨,是时间与空间的折中 |
| 为什么扩 2 倍 | 保持容量是 2 的幂,hash & (n - 1) 可以代替取模;元素要么留在原位,要么挪到 i + 旧容量 |
| 摊销成本 | 单次扩容 O(n),但每插入 n 个元素才触发一次,均摊到每次插入是 O(1) |
Java 8 的 HashMap 还有一条规则:桶内链表长度到 8 且表容量 ≥ 64 时转红黑树,查找从 O(n) 变 O(log n),缩回 6 再转回链表。阈值取 8 是因为 0.75 负载因子下链表长度服从泊松分布,到 8 的概率约千万分之六。
| 维度 | Object | Map |
|---|---|---|
| 键类型 | 只能是 string 或 symbol,其它会被 toString | 任意类型,对象按引用比较 |
| 键的顺序 | 整数键升序在前,字符串键按插入序 | 严格按插入顺序 |
| 原型污染 | 继承 Object.prototype,in 会命中原型链 | 干净,没有原型链干扰 |
__proto__ | 赋值改的是原型,历史遗留坑 | 就是一个普通键 |
| 长度 | Object.keys(o).length,O(n) | map.size,O(1) |
| 迭代 | 要先取 keys / entries | 本身可迭代,直接 for...of |
| 频繁增删 | 容易退化成字典模式 | 专为增删设计,表现稳定 |
V8 的 Map 是哈希表 + 插入顺序链表的组合:哈希表负责 O(1) 查找,额外一条双向链表记住插入顺序,所以迭代顺序稳定、delete 也不打乱顺序。
Set 就是把 Map 的值去掉、只留键,去重靠 SameValueZero:NaN 等于 NaN,+0 等于 -0,对象按引用。所以 new Set([NaN, NaN]).size === 1,而 new Set([{}, {}]).size === 2。
很多人以为哈希表查找永远是 O(1)。 实际上那是平均与均摊情况;全部 key 落到同一个桶时退化成 O(n),扩容那一次也是 O(n)。
很多人以为 Object 当字典和 Map 等价。 实际上 Object 的键会被转成字符串(obj[1] 和 obj['1'] 是同一个),还会继承原型属性,for...in 能遍历到原型上的键;做字典请用 Object.create(null) 或直接上 Map。
思路:边遍历边把"值 → 下标"存进 Map,对每个数先查 target - nums[i] 是否出现过。
function twoSum(nums, target) {
const seen = new Map();
for (let i = 0; i < nums.length; i++) {
const need = target - nums[i];
if (seen.has(need)) return [seen.get(need), i];
seen.set(nums[i], i);
}
return [];
}
时间 O(n),空间 O(n)。易错点:先查后存,才避免同一个元素用两次;返回的是下标不是值。
思路:把字符计数拼成 key,异位词的计数数组完全相同,自然落进同一个桶。
function groupAnagrams(strs) {
const map = new Map();
for (const s of strs) {
const count = new Array(26).fill(0);
for (const ch of s) count[ch.charCodeAt(0) - 97]++;
const key = count.join('#');
if (!map.has(key)) map.set(key, []);
map.get(key).push(s);
}
return [...map.values()];
}
时间 O(n·k),空间 O(n·k),k 是字符串长度。易错点:用排序做 key 会多一个 log k;计数数组必须转成字符串才能当 key。
思路:全部丢进 Set,只从"没有前驱"的数开始往后数,保证每个数只被访问一次。
function longestConsecutive(nums) {
const set = new Set(nums);
let best = 0;
for (const n of set) {
if (set.has(n - 1)) continue; // 不是起点,跳过
let cur = n;
let len = 1;
while (set.has(cur + 1)) {
cur++;
len++;
}
best = Math.max(best, len);
}
return best;
}
时间 O(n),空间 O(n)。易错点:不判断起点会退化成 O(n²);空数组要返回 0。
思路:Map 的迭代顺序就是插入顺序,get 命中后先 delete 再 set 把键挪到末尾,超容量就删第一个键。
class LRUCache {
constructor(capacity) {
this.cap = capacity;
this.map = new Map();
}
get(key) {
if (!this.map.has(key)) return -1;
const value = this.map.get(key);
this.map.delete(key);
this.map.set(key, value); // 重新插入,排到末尾
return value;
}
put(key, value) {
if (this.map.has(key)) {
this.map.delete(key);
} else if (this.map.size >= this.cap) {
this.map.delete(this.map.keys().next().value); // 最久未使用
}
this.map.set(key, value);
}
}
get 和 put 均摊 O(1),空间 O(capacity)。易错点:put 已存在的键也要先删再插,否则顺序不更新;容量为 0 时不能插入。