LeetCode刷题攻略:哈希表篇——从入门到进阶
Hello,各位正在努力刷题的小伙伴们!
今天,我们来聊一个在算法世界里堪称“神器”的数据结构——哈希表(Hash Table) 。你可能会觉得它听起来有点“高大上”,但别担心,我会用最通俗易懂的方式,带你一步步揭开它的神秘面纱。看完这篇,保证你以后遇到相关问题,脑子里第一个想到的就是它!
一、哈希表是什么?为什么是“神器”?
想象一下,你有一本电话簿。如果你想找张三的电话,你会怎么做?肯定是直接翻到姓“张”的那一页,而不是从第一页找到最后一页,对吧?
哈希表就和这本电话簿类似。它提供了一种键(Key) 到值(Value) 的映射关系。你给我一个“键”,它能用一个神奇的“哈希函数”快速计算出“值”存储的位置,然后直接把它取出来。这个过程快到什么程度呢?平均来说,它的时间复杂度是 O(1) !这意味着无论哈希表里有10个元素还是100万个元素,查找、添加、删除一个元素的速度都几乎是恒定的。
在咱们常用的 JavaScript 中,Map 和 Set 就是哈希表的两种典型实现,它们是我们接下来解题的利器。
小提示: 这里提到的 Map 和 Set 是 JavaScript 中内置的数据结构,它们底层通常就是用哈希表实现的,所以它们也继承了哈希表高效的查找、插入和删除特性。Map 存储键值对,而 Set 存储唯一的值。
二、哈希表的工作原理:哈希函数与冲突解决
既然哈希表这么快,那它是怎么做到的呢?这就要提到它的两大核心机制:哈希函数(Hash Function) 和冲突解决(Collision Resolution) 。
1. 哈希函数:从“标签”到“地址”的转换器
哈希函数是哈希表的“大脑”,它的作用是将你提供的“键”(Key)通过一系列计算,转换成一个唯一的“地址”(或者叫“索引”),这个地址就是数据在哈希表内部存储的位置。这个过程就像你根据书的编号(Key)计算出它在图书馆的第几排第几个书架(地址)一样。
一个好的哈希函数应该具备以下特点:
- 确定性:同一个键每次计算出的地址都应该相同。
- 高效性:计算过程要尽可能快。
- 均匀性:尽量让不同的键计算出不同的地址,减少“冲突”。
2. 冲突解决:当“地址”被占用时怎么办?
尽管哈希函数设计得再好,也无法完全避免一个问题:不同的键经过哈希函数计算后,可能会得到相同的地址。这就好比图书馆里,两本书的编号不同,但计算出来的书架位置却一样,这就是哈希冲突(Hash Collision) 。当冲突发生时,哈希表需要一套机制来解决这个问题,常见的冲突解决办法有两种:
- 链地址法(Separate Chaining) :当多个键映射到同一个地址时,不直接覆盖,而是在这个地址上存储一个链表(或者其他数据结构),把所有映射到这个地址的键值对都串起来。查找时,先找到地址,再遍历链表查找目标键。
- 开放地址法(Open Addressing) :当一个地址被占用时,尝试寻找下一个空的地址来存储数据。常见的有线性探测、二次探测等。
理解了哈希函数和冲突解决,你就掌握了哈希表高效运行的秘密。它通过哈希函数快速定位数据,并通过冲突解决机制优雅地处理地址冲突,从而保证了查找、插入和删除操作的高效性。
三、哈希表实战演练场:解决常见编程问题
光说不练假把式,我们直接来看几道经典的 LeetCode 题目,感受一下哈希表的威力!
第一站:基础应用——判断元素是否存在或统计频率
这是哈希表最常见的用途:快速判断一个元素是否存在于集合中,或者统计元素的出现频率。由于其O(1)的平均查找速度,这比遍历数组或链表要快得多。
🎯 题目1: 有效的字母异位词
给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。 例如: s = "anagram", t = "nagaram" -> true; s = "rat", t = "car" -> false.
💡 思路解析:
什么是字母异位词?就是两个字符串包含的字母种类和数量都完全相同。那我们怎么判断呢?最直观的想法就是“数一数”。
我们可以用一个哈希表来统计第一个字符串 s 中每个字母出现的次数。然后,我们遍历第二个字符串 t,每遇到一个字母,就在哈希表里把对应字母的计数减一。如果在减一后发现计数小于0了,那说明 t 包含了 s 里没有的、或者数量更多的字母,它们肯定不是异位词。
var isAnagram = function(s, t) {
if (s.length !== t.length) return false
const table = new Array(26).fill(0);
for (let i = 0; i < s.length; ++i) {
table[s.codePointAt(i) - 'a'.codePointAt(0)]++;
}
for (let i = 0; i < t.length; ++i) {
table[t.codePointAt(i) - 'a'.codePointAt(0)]--;
if (table[t.codePointAt(i) - 'a'.codePointAt(0)] < 0) {
return false;
}
}
return true;
};
小提示:因为题目说明了只包含小写字母,所以我们可以用一个长度为26的数组来简化哈希表,这是一种空间优化的技巧哦!
🎯 题目2: 快乐数
判断一个数 n 是不是快乐数。快乐数定义为:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,然后重复这个过程直到这个数变为 1。如果会无限循环但始终变不到 1,那它就不是快乐数。 例如: n = 19 -> true (1² + 9² = 82 -> 8² + 2² = 68 -> ... -> 1)
💡 思路解析:
这道题的关键在于判断“无限循环”。怎么判断呢?如果在计算过程中,某个数字重复出现了,那就意味着我们进入了一个循环,永远也到不了1了。
所以,我们又可以用 Set 来帮忙了!我们创建一个 Set 来存放每次计算出的和。在每次计算出新的和 sum 之后,我们都去 Set 里检查一下:
- 如果
sum已经存在于Set中,说明我们陷入了循环,返回false。 - 如果
sum不存在,就把它加入Set,然后继续计算。 - 如果
sum等于 1,恭喜你,找到了快乐数,返回true!
var isHappy = function (n) {
let set = new Set()
while (true) {
let str = n.toString(), sum = 0
for (let i = 0; i < str.length; i++) {
let num = parseInt(str[i], 10)
sum += num ** 2
}
if (sum === 1) return true
if (set.has(sum)) return false
set.add(sum)
n = sum
}
};
第二站:进阶应用——统计与去重
现在我们来处理需要计数和保证唯一性的问题。
🎯 题目3: 两个数组的交集
给定两个数组,编写一个函数来计算它们的交集。输出结果中的每个元素一定是唯一的。 例如: nums1 = [1,2,2,1], nums2 = [2,2] -> [2]
💡 思路解析:
我们需要找到两个数组共有的元素,并且结果不能有重复。Set 数据结构天生就带有“元素唯一”的属性,简直是为这道题量身定做的!
我们可以先把一个数组(比如 nums1)的所有元素放进一个 Set 里,这样 Set 自动就帮我们去重了。然后,我们遍历另一个数组 nums2,对于 nums2 中的每个元素,我们都去 Set 里查一下它是否存在。如果存在,那它就是交集的一部分。
const set_intersection = (set1, set2) => {
const intersection = new Set();
for (const num of set1) {
if (set2.has(num)) {
intersection.add(num);
}
}
return [...intersection];
}
var intersection = function(nums1, nums2) {
const set1 = new Set(nums1);
const set2 = new Set(nums2);
return set_intersection(set1, set2);
};
🎯 题目4: 两个数组的交集 II
给定两个数组
nums1和nums2,返回它们的交集。输出结果中每个元素出现的次数,应与元素在两个数组中都出现的次数一致。我们可以不考虑输出结果的顺序。
与“两个数组的交集”不同,这里需要考虑元素的重复次数。我们可以使用哈希表(Map)来统计 nums1 中每个元素的出现频率。然后遍历 nums2,对于 nums2 中的每个元素,如果它在哈希表中存在且频率大于0,则将其加入结果集,并将哈希表中该元素的频率减一。这样就能确保交集中的元素次数与两个数组中都出现的次数一致。
var intersect = function(nums1, nums2) {
if (nums1.length > nums2.length) return intersect(nums2, nums1)
let map = new Map()
let arr = []
// 统计nums1中每个元素的出现次数
for(let i = 0; i < nums1.length; i++) {
let value = 1
if (map.has(nums1[i])) {
value = map.get(nums1[i]) + 1
}
map.set(nums1[i], value)
}
// 遍历nums2,找交集
for(let i = 0; i < nums2.length; i++) {
if(map.has(nums2[i])) {
let value = map.get(nums2[i])
if (value > 0) {
arr.push(nums2[i])
map.set(nums2[i], value - 1)
}
}
}
return arr
};
第三站:高阶玩法——分组与优化
最后,我们来看一个更巧妙的用法,它能解决一些看起来很复杂的问题。
🎯 题目5: 字母异位词分组
给你一个字符串数组,请你将字母异位词组合在一起。 例如: strs = ["eat","tea","tan","ate","nat","bat"] -> [["bat"],["nat","tan"],["ate","eat","tea"]]
💡 思路解析:
这道题的目标是把“长得不一样但实际上是亲兄弟”的字符串们(异位词)分到一组。我们怎么识别这些“亲兄弟”呢?关键在于找到它们共同的“基因”!
对于任意一个异位词组合(比如 "eat", "tea", "ate"),虽然字母顺序不同,但把它们排序后,都会得到同一个字符串 "aet"。这个排序后的字符串,就是它们的“基因”!
我们可以创建一个 Map,用这个“基因”(排序后的字符串)作为 key,用一个数组来存储所有拥有这个基因的字符串作为 value。遍历输入的数组,对每个字符串都计算出它的“基因”,然后把它塞进 Map 中对应的分组里。
var groupAnagrams = function(strs) {
if (strs.length === 1 && strs[0] === "") return [[""]]
let obj = {}
let base = 'a'.charCodeAt()
for (let i = 0; i < strs.length; i++) {
const table = new Array(26).fill(0);
for (let j = 0; j < strs[i].length; j++) {
table[strs[i][j].charCodeAt() - base]++
}
obj[table] ? obj[table].push(strs[i]) : obj[table] = [strs[i]]
}
return Object.values(obj)
};
这里的解法用了另一种更高效的“基因”计算方式:用一个长度26的数组统计字母频次,然后将这个数组作为key。效果和排序是一样的,但效率更高!
🎯 题目6: 三数之和
给你一个整数数组
nums,判断是否存在三元组[nums[i], nums[j], nums[k]]满足i != j、i != k且j != k,同时还满足nums[i] + nums[j] + nums[k] == 0。请你返回所有和为 0 且不重复的三元组。
解决这类问题的一种常见思路是,先固定一个数,然后将问题转化为“两数之和”的问题。对于“两数之和”,我们可以利用哈希表来快速查找目标值。例如,固定 nums[i] 后,我们需要找到 nums[j] 和 nums[k] 使得 nums[j] + nums[k] = -nums[i]。将 nums[j] 放入哈希表,然后查找 (-nums[i] - nums[j]) 是否存在于哈希表中。
var threeSum = function(nums) {
const result = [];
const n = nums.length;
// 先排序,方便去重
nums.sort((a, b) => a - b);
for (let i = 0; i < n - 2; i++) {
// 跳过重复的第一个数
if (i > 0 && nums[i] === nums[i - 1]) continue;
const target = -nums[i]; // 目标和
const seen = new Set(); // 哈希表存储已遍历的数
for (let j = i + 1; j < n; j++) {
const complement = target - nums[j]; // 需要找的补数
if (seen.has(complement)) {
// 找到了三元组
result.push([nums[i], complement, nums[j]]);
// 跳过重复的第二个数
while (j + 1 < n && nums[j] === nums[j + 1]) {
j++;
}
}
seen.add(nums[j]);
}
}
return result;
};
🎯 题目7: 四数之和
给你一个由
n个整数组成的数组nums,判断是否存在四元组[nums[a], nums[b], nums[c], nums[d]]满足:a、b、c、d互不相同,且nums[a] + nums[b] + nums[c] + nums[d] == target。请你返回所有和为target且不重复的四元组。
“四数之和”是“三数之和”的进一步扩展。同样,我们可以固定两个数 nums[a] 和 nums[b],然后将问题转化为“两数之和”的问题:找到 nums[c] 和 nums[d] 使得 nums[c] + nums[d] = target - nums[a] - nums[b]。这里依然可以使用哈希表来快速查找 nums[c] 和 nums[d] 的组合。
var fourSum = function(nums, target) {
const result = [];
const n = nums.length;
if (n < 4) return result;
// 先排序,方便去重
nums.sort((a, b) => a - b);
for (let a = 0; a < n - 3; a++) {
// 跳过重复的第一个数
if (a > 0 && nums[a] === nums[a - 1]) continue;
for (let b = a + 1; b < n - 2; b++) {
// 跳过重复的第二个数
if (b > a + 1 && nums[b] === nums[b - 1]) continue;
const twoSumTarget = target - nums[a] - nums[b];
const seen = new Set();
for (let c = b + 1; c < n; c++) {
const complement = twoSumTarget - nums[c];
if (seen.has(complement)) {
// 找到了四元组
result.push([nums[a], nums[b], complement, nums[c]]);
// 跳过重复的第三个数
while (c + 1 < n && nums[c] === nums[c + 1]) {
c++;
}
}
seen.add(nums[c]);
}
}
}
return result;
};
🎯 题目8: 四数相加 II
给你四个整数数组
nums1、nums2、nums3和nums4,所有数组长度均为n,请你计算并返回可以组成和为0的元组的数量。一个元组(i, j, k, l)表示:
0 <= i, j, k, l < nnums1[i] + nums2[j] + nums3[k] + nums4[l] == 0
这个问题与“四数之和”略有不同,它要求计算的是满足条件的元组数量,而不是返回具体的元组。我们可以将 nums1 和 nums2 中所有可能的两数之和存入一个哈希表,键为和,值为该和出现的次数。然后,遍历 nums3 和 nums4 中所有可能的两数之和,计算它们的负值,并在哈希表中查找是否存在对应的键。如果存在,则将该键对应的值(即出现次数)累加到结果中。这种方法可以有效地降低时间复杂度。
var fourSumCount = function(nums1, nums2, nums3, nums4) {
let map = new Map(), res = 0;
for(let i = 0; i < nums1.length; i++) {
for(let j = 0; j < nums2.length; j++) {
// let sumAB = nums1[i] + nums2[j], count = 1;
// if (map.has(sumAB)){
// count = map.get(sumAB) + 1
// map.set(sumAB, count)
// }else {
// map.set(sumAB, count)
// }
let sum = nums1[i] + nums2[j];
map.set(sum, (map.get(sum) || 0) + 1);
}
}
for(let i = 0; i < nums3.length; i++) {
for(let j = 0; j < nums4.length; j++) {
let sumCD = nums3[i] + nums4[j]
if(map.has(-sumCD)) res += map.get(-sumCD)
}
}
return res
};
四、总结:哈希表的“魔法”与挑战
怎么样,哈希表是不是非常强大?我们来回顾一下它的核心应用场景:
- 快速查找:当你需要频繁地检查一个元素是否存在时(
Set)。 - 计数统计:当你需要统计集合中每个元素出现的次数时(
Map)。 - 建立映射:当你需要将一种数据(比如字符串)映射到另一种数据(比如它所属的分组)时(
Map)。
希望这篇保姆级的哈希表攻略能对你有所帮助。记住,算法的精髓在于理解思想,然后用代码实现它。多写多练才是王道!
五、常见问题解答
Q1:哈希表和数组有什么区别?
A1: 数组通过索引直接访问元素,索引通常是连续的整数。哈希表通过哈希函数将键映射到存储位置,键可以是任意类型的数据。数组的查找速度快,但插入和删除可能需要移动大量元素。哈希表在理想情况下查找、插入、删除都非常快。
Q2:哈希冲突一定会发生吗?
A2: 理论上,只要键的数量大于哈希表的大小,就一定会发生哈希冲突(鸽巢原理)。即使键的数量小于哈希表大小,也可能因为哈希函数的设计问题而发生冲突。因此,哈希冲突是不可避免的,关键在于如何有效地解决它。
Q3:哈希表的空间复杂度是多少?
A3: 哈希表的空间复杂度通常是O(n),其中n是存储的键值对的数量。因为哈希表需要额外的空间来存储哈希桶和链表(如果使用链地址法)。
Q4:什么时候应该使用哈希表?
A4: 当你需要快速查找、插入和删除数据,并且对数据的顺序没有严格要求时,哈希表是一个非常好的选择。例如,统计词频、判断元素是否存在、实现缓存等场景。
希望这篇博客能帮助你更好地理解哈希表,并在你的编程学习和实践中有所启发。如果你有任何疑问,欢迎在评论区留言讨论!
下次我们再聊聊其他有趣的数据结构,敬请期待哦!