LeetCode刷题之哈希表篇——从入门到进阶

311 阅读14分钟

LeetCode刷题攻略:哈希表篇——从入门到进阶

Hello,各位正在努力刷题的小伙伴们!

今天,我们来聊一个在算法世界里堪称“神器”的数据结构——哈希表(Hash Table) 。你可能会觉得它听起来有点“高大上”,但别担心,我会用最通俗易懂的方式,带你一步步揭开它的神秘面纱。看完这篇,保证你以后遇到相关问题,脑子里第一个想到的就是它!

一、哈希表是什么?为什么是“神器”?

想象一下,你有一本电话簿。如果你想找张三的电话,你会怎么做?肯定是直接翻到姓“张”的那一页,而不是从第一页找到最后一页,对吧?

哈希表就和这本电话簿类似。它提供了一种键(Key)值(Value) 的映射关系。你给我一个“键”,它能用一个神奇的“哈希函数”快速计算出“值”存储的位置,然后直接把它取出来。这个过程快到什么程度呢?平均来说,它的时间复杂度是 O(1) !这意味着无论哈希表里有10个元素还是100万个元素,查找、添加、删除一个元素的速度都几乎是恒定的。

在咱们常用的 JavaScript 中,MapSet 就是哈希表的两种典型实现,它们是我们接下来解题的利器。

小提示: 这里提到的 MapSet 是 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 里检查一下:

  1. 如果 sum 已经存在于 Set 中,说明我们陷入了循环,返回 false
  2. 如果 sum 不存在,就把它加入 Set,然后继续计算。
  3. 如果 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

给定两个数组 nums1nums2 ,返回它们的交集。输出结果中每个元素出现的次数,应与元素在两个数组中都出现的次数一致。我们可以不考虑输出结果的顺序。

与“两个数组的交集”不同,这里需要考虑元素的重复次数。我们可以使用哈希表(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 != ji != kj != 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]] 满足:abcd 互不相同,且 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

给你四个整数数组 nums1nums2nums3nums4 ,所有数组长度均为 n ,请你计算并返回可以组成和为 0 的元组的数量。一个元组 (i, j, k, l) 表示:

  • 0 <= i, j, k, l < n
  • nums1[i] + nums2[j] + nums3[k] + nums4[l] == 0

这个问题与“四数之和”略有不同,它要求计算的是满足条件的元组数量,而不是返回具体的元组。我们可以将 nums1nums2 中所有可能的两数之和存入一个哈希表,键为和,值为该和出现的次数。然后,遍历 nums3nums4 中所有可能的两数之和,计算它们的负值,并在哈希表中查找是否存在对应的键。如果存在,则将该键对应的值(即出现次数)累加到结果中。这种方法可以有效地降低时间复杂度。

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
};

四、总结:哈希表的“魔法”与挑战

怎么样,哈希表是不是非常强大?我们来回顾一下它的核心应用场景:

  1. 快速查找:当你需要频繁地检查一个元素是否存在时(Set)。
  2. 计数统计:当你需要统计集合中每个元素出现的次数时(Map)。
  3. 建立映射:当你需要将一种数据(比如字符串)映射到另一种数据(比如它所属的分组)时(Map)。

希望这篇保姆级的哈希表攻略能对你有所帮助。记住,算法的精髓在于理解思想,然后用代码实现它。多写多练才是王道!

五、常见问题解答

Q1:哈希表和数组有什么区别?

A1: 数组通过索引直接访问元素,索引通常是连续的整数。哈希表通过哈希函数将键映射到存储位置,键可以是任意类型的数据。数组的查找速度快,但插入和删除可能需要移动大量元素。哈希表在理想情况下查找、插入、删除都非常快。

Q2:哈希冲突一定会发生吗?

A2: 理论上,只要键的数量大于哈希表的大小,就一定会发生哈希冲突(鸽巢原理)。即使键的数量小于哈希表大小,也可能因为哈希函数的设计问题而发生冲突。因此,哈希冲突是不可避免的,关键在于如何有效地解决它。

Q3:哈希表的空间复杂度是多少?

A3: 哈希表的空间复杂度通常是O(n),其中n是存储的键值对的数量。因为哈希表需要额外的空间来存储哈希桶和链表(如果使用链地址法)。

Q4:什么时候应该使用哈希表?

A4: 当你需要快速查找、插入和删除数据,并且对数据的顺序没有严格要求时,哈希表是一个非常好的选择。例如,统计词频、判断元素是否存在、实现缓存等场景。

希望这篇博客能帮助你更好地理解哈希表,并在你的编程学习和实践中有所启发。如果你有任何疑问,欢迎在评论区留言讨论!

下次我们再聊聊其他有趣的数据结构,敬请期待哦!