哈希

0 阅读7分钟

1. 两数之和

🏠 生活案例:婚礼找搭档

想象你是一个婚礼的主持人,现场有一群宾客,每个宾客身上都有一个编号。

  • 任务:你需要找到两个宾客,让他们身上的编号加起来刚好等于一个目标数字(比如 9)。

  • 笨办法(暴力法) :你随便拉住一个人 A,然后带着 A 去挨个问剩下的所有人。如果没找到,再换一个人 B 重复这个过程。这太累了,如果现场有一万人,你会走到腿断。

  • 聪明办法(代码逻辑) :你在门口放一个记事本(Map) 。每进来一个宾客,你先算一下: “为了凑成 9,我需要编号是多少的人?”

    1. 你查一下记事本,看看“需要的那个人”是不是已经进场了?
    2. 如果记事本上已经写了那个人的名字,恭喜你,搭档找到了!
    3. 如果没找到,你就把当前这位宾客的编号和位置写在记事本上,方便后面进来的人找他。

💻 代码实现与生活化注释

这是你图片中的 JavaScript 代码,完整逻辑如下:

JavaScript

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
var twoSum = function(nums, target) {
    // 1. 准备“记事本” (哈希表 Map)
    // Key 存数字的值,Value 存数字在数组里的下标
    let map = new Map();

    for(let i = 0; i < nums.length; i++){
        /**
         * 2. 计算:我需要谁?
         * 比如目标是 9,当前数字是 2,那我需要找的就是 7
         */
        let complement = target - nums[i];

        /**
         * 3. 查账:我要找的人在记事本里吗?
         */
        if(map.has(complement)){
            /**
             * 4. 找到了!
             * 返回那个人的下标 (map.get) 和 我自己的下标 (i)
             */
            return [map.get(complement), i];
        }

        /**
         * 5. 没找到:
         * 把我自己的编号和下标写在记事本上,留给后来的人看
         */
        map.set(nums[i], i);
    }

    // 如果最后都没找到,返回空数组
    return [];
};

🔍 核心对比:为什么用 Map 很快?

方案逻辑时间复杂度形象描述
暴力循环嵌套 for 循环O(n2)O(n^2)在人群中盲目乱撞,效率极低。
哈希表 (Map)只走一遍数组O(n)O(n)进门先看账本,每个人只看一眼,速度飞快。

💡 关键点总结

  • 哈希表的优势:在 Map 中查找一个值是否存在,速度是近乎瞬时的(O(1)O(1))。
  • 一次遍历:我们不需要先填满 Map 再查找。在遍历的过程中,一边查一边存,既保证了效率,又避免了“自己和自己匹配”的情况(因为查询时自己还没存入 Map)。

这道题是所有哈希表问题的基础。当你理解了这种**“边走边记”**的思路后,很多复杂的题目(比如之前看过的三数之和、前缀和等)其实都是这个逻辑的变种。

49. 字母异位词分组

这个问题描述的是经典的 “字母异位词分组” (Group Anagrams) 。简单来说,就是把那些“虽然字母顺序不同,但包含的字母和数量完全一样”的单词聚在一起。


🏠 生活案例:整理厨房调料罐

想象你家厨房里有很多调料罐,但标签贴得乱七八糟。有的写着 "盐糖" ,有的写着 "糖盐"

虽然字序不一样,但它们其实是同一种配料。为了方便做菜,你想把这些成分一样的罐子都放在同一个收纳盒里。

  • 你的策略: 拿到一个罐子,先把标签上的字按字典顺序排一下(比如不管是“盐糖”还是“糖盐”,排完后都叫“糖盐”)。
  • 收纳盒: 那个排好序的名字(“糖盐”)就是收纳盒的唯一编号。名字一样的,通通扔进同一个盒子里。

💻 代码实现与生活化注释

这是你图片中的 JavaScript 代码。我为你补全了逻辑,并加上了对应的“生活案例”注释:

JavaScript

/**
 * @param {string[]} strs
 * @return {string[][]}
 */
var groupAnagrams = function(strs) {
    // 1. 准备一个大收纳架 (Map),用来放不同的收纳盒
    let map = new Map();

    for (let str of strs) {
        // 2.【排序过程】把标签里的字母重新排列
        // 比如 "eat" -> ['e','a','t'] -> ['a','e','t'] -> "aet"
        // 这就像把“盐糖”和“糖盐”都统一改写成“糖盐”
        let temp = str.split('').sort().join('');

        // 3. 检查收纳架上有没有这个“统一编号”的盒子
        if (map.has(temp)) {
            // 如果有了,直接把当前的单词放进这个盒子里
            map.get(temp).push(str);
        } else {
            // 如果还没有这个编号的盒子,就新开一个盒子,并把单词放进去
            map.set(temp, [str]);
        }
    }

    // 4. 最后把所有的盒子拿出来,装进一个大袋子里返回
    return Array.from(map.values());
};

🔍 关键步骤拆解

为了让你看得更清楚,我们拿示例 1 的数据跑一遍: 输入:["eat", "tea", "tan", "ate", "nat", "bat"]

步骤原始单词 (str)排序后的“盒子编号” (temp)盒子里的内容 (Map 状态)
1eataet{ "aet": ["eat"] }
2teaaet{ "aet": ["eat", "tea"] }
3tanant{ "aet": ["eat", "tea"], "ant": ["tan"] }
4ateaet{ "aet": ["eat", "tea", "ate"], "ant": ["tan"] }
5natant{ "aet": ["eat", "tea", "ate"], "ant": ["tan", "nat"] }
6batabt{ "aet": [...], "ant": [...], "abt": ["bat"] }

💡 为什么这么做?

  • 为什么要排序? 排序是寻找“共同特征”最简单的方法。只要字母组成相同,排序后的字符串一定是一模一样的。
  • 为什么要用 Map? Map(哈希表)的查找速度极快。它能让你瞬间找到对应的“收纳盒”,而不需要每次都去遍历一遍所有的盒子。

这个解法的时间复杂度主要取决于排序,如果单词平均长度为 k,总共有 n 个单词,复杂度大约是 O(n⋅klogk)。对于题目给出的规模(单词量 104),这是一个非常高效的方案!

128. 最长连续序列

这道题是 128. 最长连续序列 (Longest Consecutive Sequence) 。它的核心挑战在于:题目要求时间复杂度必须是 O(n) ,这意味着我们不能使用排序(排序通常是 O(nlogn))。


🏠 生活案例:寻找最长的“连号兄弟”

想象一下,你手里有一堆乱七八糟的电影票根,每张票都有一个座位号(比如 100, 4, 200, 1, 3, 2)。你想知道,这些票里连续数字最长的一串有多少张。

  • 你的直觉: 如果一张票的号码是 5,你会先看有没有 4。如果有 4,说明 5 不是这串连号的开头,我们可以先不管它。
  • 你的动作: 只有当你发现手里有 1,但没有 0 的时候,你才会意识到:“哦!1 是一个可能的连号开头!” 于是你开始往后数:有没有 2?有没有 3?... 直到断掉为止。

💻 代码实现与生活化注释

这段代码利用了 Set(集合)的高效查询特性,精妙地避开了重复计算。

JavaScript

/**
 * @param {number[]} nums
 * @return {number}
 */
var longestConsecutive = function(nums) {
    // 1. 生活案例:把所有票根扔进一个“快速检索筐” (Set)
    // 这样你想找某张票时,一眼就能看到,不需要一张张翻。
    if (nums.length === 0) return 0;
    let numSets = new Set(nums);
    let maxlength = 0;

    // 2. 遍历筐里的每一个数字
    for (let num of numSets) {
        
        // 【关键逻辑】:寻找连号的“带头大哥”
        // 只有当筐里没有 num - 1 时,当前的 num 才是这串连续数字的起点。
        // 如果筐里有 num - 1,说明 num 只是某个序列的“小弟”,我们跳过它。
        if (!numSets.has(num - 1)) {
            let currentItem = num;    // 从大哥开始
            let currentLength = 1;   // 初始长度为 1

            // 3. 往后数:看看大哥后面有多少个连着的“亲兄弟”
            while (numSets.has(currentItem + 1)) {
                currentLength = currentLength + 1;
                currentItem = currentItem + 1;
            }

            // 4. 记录下目前为止发现的最长连号长度
            maxlength = Math.max(maxlength, currentLength);
        }
    }

    return maxlength;
};

🔍 为什么这个算法是 O(n)?

很多人会担心:for 循环里面套了一个 while 循环,这难道不是 O(n2) 吗?

其实不是。 * 因为我们加了 if (!numSets.has(num - 1)) 这个判断,每一个数字其实只会被“真正处理”一次

  • 比如对于序列 [1, 2, 3, 4, 5]

    • 遍历到 2, 3, 4, 5 时,因为它们都有“前一个数”,所以 if 判断不成立,直接跳过。
    • 只有遍历到 1 时,才会进入 while 循环去数后面的数。
  • 所以,所有数字加起来总共也只进入了一次 while 循环,整体依然是线性的。


📊 模拟运行 (示例 1)

输入:[100, 4, 200, 1, 3, 2]

  1. 100: 没有 99。它是大哥!开始数:有 101 吗?没有。长度 = 1。
  2. 4: 有 3。它不是开头,跳过。
  3. 200: 没有 199。它是大哥!开始数:有 201 吗?没有。长度 = 1。
  4. 1: 没有 0。它是大哥!开始数:有 2?有!有 3?有!有 4?有!有 5?没有。长度 = 4。
  5. 3: 有 2。跳过。
  6. 2: 有 1。跳过。

最终结果: 4(对应的序列是 [1, 2, 3, 4])。