哈希表与 Map / Set 实战
本节目标
- 理解哈希表的原理与冲突处理
- 掌握 Map / Set 在"查找加速"中的实战用法
- 能手写 LRU 缓存的思想版
哈希表原理:把"键"通过哈希函数映射成数组下标,理想情况 O(1) 读写。冲突(不同键映射到同一下标)用"链地址法"(同桶挂链表)或"开放寻址"解决。
// 运行环境:Node.js 14+
// 保存为 dsa-l8.js,执行:node dsa-l8.js
// 两数之和:暴力 O(n²) vs 哈希 O(n)
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 []
}
// 第一个不重复字符
function firstUniqChar(s) {
const cnt = new Map()
for (const ch of s) cnt.set(ch, (cnt.get(ch) || 0) + 1)
for (let i = 0; i < s.length; i++) if (cnt.get(s[i]) === 1) return i
return -1
}
// 字母异位词分组
function groupAnagrams(words) {
const m = new Map()
for (const w of words) {
const key = w.split('').sort().join('') // 排序后作指纹
if (!m.has(key)) m.set(key, [])
m.get(key).push(w)
}
return [...m.values()]
}
console.log(twoSum([2, 7, 11, 15], 9)) // [0, 1]
console.log(firstUniqChar('leetcode')) // 0
console.log(groupAnagrams(['eat', 'tea', 'tan', 'ate', 'nat'])) // [[eat,tea,ate],[tan,nat]]Set 去重与交集:new Set(arr) 一键去重;[...new Set(a)].filter(x=>setB.has(x)) 求交集。
名词解释
- 哈希表(Hash Table):用哈希函数把键映射到数组下标,实现平均 O(1) 的增删查。类比:快递柜,凭取件码(哈希值)直接找到格子,不必挨个翻。
- 哈希冲突(Hash Collision):不同键算出的下标相同。解决:链地址法(同桶挂链表)、开放寻址(往后找空位)。负载因子(元素数/桶数)越大越容易冲突。
- Map vs Object:Map 的键可以是任意类型(含对象),且保持插入顺序、可直接遍历;Object 键只能是字符串/Symbol,且原型链可能串扰。需要"键值映射"优先用 Map。
课后练习
- 两数之和用哈希为什么是 O(n)?
- 答案:只遍历一次,每次
Map.has/get/set平均 O(1);用"已见数字"换"不必回看",把暴力双重循环压成单循环。
- 答案:只遍历一次,每次
- 字母异位词分组为什么用"排序后的词"当 key?
- 答案:异位词字母组成相同,排序后必然得到同一个字符串,天然可作分组指纹;
Map按指纹聚合即得各组。
- 答案:异位词字母组成相同,排序后必然得到同一个字符串,天然可作分组指纹;
总结
哈希表是工程里使用频率最高的数据结构,没有之一。它的魔力在于把"大海捞针"的 O(n) 查找压成平均 O(1)——代价只是多花一份内存存索引。理解它,首先要接受"哈希冲突不可避免",所以才有链地址法、开放寻址这些兜底方案;其次要明白 JS 的 Map/Set 已经把这套机制封装得很好,日常几乎不用手写哈希函数。我常提醒:能用 Map/Set 把 O(n²) 降到 O(n) 的地方,就不要忍受暴力。但也别迷信 O(1)——极端哈希冲突或超大负载因子会让它退化;而且哈希表不保序、占内存。把"键→值映射 + 快速查找"这个需求刻进脑子,你会在无数场景(缓存、计数、去重、索引)里自然掏出它。