Skip to content
Go back

LeetCode Hot 100——哈希篇(O(1) 补数查询与计数)

LeetCode Hot 100 · 哈希篇

哈希(HashMap / HashSet)在算法面试里的核心价值只有一句话:把「查找是否存在 / 出现了多少次」从 O(n) 降到 O(1)。Hot 100 里哈希分类的三道题,恰好对应三种经典用法——「查补数」「造 key 分组」「去重 + 判存在」。本篇按「面试手撕」的标准重写,每道题都给出暴力解 → 最优解 → 复杂度逐步推导 → CodeTop 真实变体。


1. 两数之和

题意

给定整数数组 nums 和目标值 target,找出和为 target 的两个整数的下标(每种输入只有一个答案,同一个元素不能重复使用)。

输入:nums = [2,7,11,15], target = 9
输出:[0,1]   // nums[0] + nums[1] == 9

难点与易错点

  1. 返回的是下标,不是值。所以 HashMap 的 value 必须存下标(HashMap<Integer, Integer>),不能只存布尔值标记「存在」。这是新手最容易犯的错:光判断存在了,结果拿不出下标。
  2. 同一个元素不能用两次。典型坑:nums = [3,2,4], target = 6。如果「先存后查」,遍历到 3 时 map 里已有 3→0,会误判 3+3=6 返回 [0,0]。正确做法是先查后存——查补数时当前元素还没进 map,天然避开了自己配自己。
  3. 边界:题目保证只有一个答案,所以 return new int[]{} 只是编译占位,永远不会执行;但面试时最好写出来,说明你考虑了「无解」分支。

题目本质:单次遍历 + O(1) 查询补数。对每个数 x,判断 target - x 是否已经出现在之前遍历过的元素里。

现实类比:超市收银员找补。你知道总价,手里拿着一张钱,想知道之前是否有人递过正好能凑整的另一张钱。每扫一件商品就把它登记在账本里,扫下一件时先翻账本查「有没有那张补数的钱」。

容器选择:需要 O(1) 判断某个值(补数)是否存在 → HashMap;同时要返回下标 → value 存下标。

解法一:暴力 / 直观

双重循环枚举所有数对 (i, j),判断 nums[i] + nums[j] == target

class Solution {
    public int[] twoSum(int[] nums, int target) {
        int n = nums.length;
        // 外层枚举第一个数
        for (int i = 0; i < n; i++) {
            // 内层从 i+1 开始,避免同一元素用两次、避免重复枚举 (i,j) 和 (j,i)
            for (int j = i + 1; j < n; j++) {
                if (nums[i] + nums[j] == target) {
                    return new int[]{i, j};
                }
            }
        }
        return new int[]{}; // 题目保证有解,此处仅为编译占位
    }
}

复杂度逐步推导:外层循环跑 n 次,内层对每个 i 跑 n - i - 1 次,总比较次数为 (n-1) + (n-2) + ... + 1 = n(n-1)/2,所以时间复杂度 O(n²);只用了常数个临时变量,空间 O(1)

瓶颈在哪:每一轮内层循环都在做同一件事——「在剩余元素里找一个值等于 target - nums[i]」。这个「找」是线性扫描,是 O(n²) 的根源。如果能把这个「找」变成 O(1),整体就降下来了。

解法二:优化 / 最优(哈希表)

边遍历边把元素存入 HashMap,每到一个新元素先查「补数是否已存在」。

class Solution {
    public int[] twoSum(int[] nums, int target) {
        // key: 元素值,value: 元素下标
        Map<Integer, Integer> map = new HashMap<>();

        for (int i = 0; i < nums.length; i++) {
            int complement = target - nums[i];

            // 先查询:补数是否已经存在于 map 中
            // 注意必须先查后存,防止使用同一元素两次
            if (map.containsKey(complement)) {
                return new int[]{map.get(complement), i};
            }

            // 后存入:将当前值及其下标记录,供后续元素查询
            map.put(nums[i], i);
        }

        return new int[]{};
    }
}

复杂度逐步推导

为何「先查后存」能一步到位:暴力法里两个下标有先后关系(i < j),哈希法复用了这个顺序——遍历到 nums[j] 时,nums[i](i < j)已经全部进 map,补数 target - nums[j] 若存在,必然对应一个 i < j,直接返回即正确且无重复使用。

💭 思考:两数之和为什么想到用哈希,以及为什么「先查后存」?——暴力版的内层循环每一轮都在做同一件事:「在剩余元素里线性找一个值等于 target - nums[i]」,这个 O(n) 的「找」就是 O(n²) 的根源。哈希表恰好把「找是否存在」压到 O(1),于是整体塌缩到 O(n)。至于顺序:因为要求「同一个元素不能用两次」,先查后存让当前元素还没进 map,天然避开了「自己配自己」的坑(反例 [3,2,4] 配 6)。看到「两数之和 / 找补数 / 判断是否存在」这类信号,第一反应就是「用哈希把查找降成 O(1)」。

多解法对比

解法时间复杂度空间复杂度适用场景
暴力双重循环O(n²)O(1)数据量极小,或内存极度受限
哈希表一次遍历O(n)O(n)本题标准解,任何规模都适用

本题约束下 n 可能到 10⁴,O(n²) 约 10⁸ 次比较,勉强但危险;而 O(n) 哈希解只需 10⁴ 次查询,且实现短、不容易出 bug,是面试必须给出的版本。空间换时间是本题的核心权衡。

CodeTop 变体


49. 字母异位词分组

题意

给定字符串数组,把字母组成相同、顺序不同的词(字母异位词)分到同一组,按任意顺序返回所有分组。

输入:strs = ["eat","tea","tan","ate","nat","bat"]
输出:[["bat"],["nat","tan"],["ate","eat","tea"]]

难点与易错点

  1. 如何造出「唯一标识字母构成」的 key。字母异位词本质是「每种字符出现次数相同」,需要一种方法把 eatteaate 归到同一个 key。排序是最直观的:三者排序后都是 aet
  2. Java 排序字符串的写法String 不能直接 sort,必须 str.toCharArray()Arrays.sort(chars)new String(chars)。漏掉 toCharArray 转数组这步是常见报错。
  3. 返回值的构造:直接 map.values() 返回的是 Collection,LeetCode 要求 List<List<String>>,需 new ArrayList<>(map.values()) 包装;顺序可以任意,不要纠结排序。
  4. 边界:空字符串 "" 也是合法输入,排序后仍是 "",单独成组;不要把它当成非法输入跳过。

题目本质:一个分组问题——找到一个能唯一标识「字母构成」的 key,把同 key 的字符串归为一组。

现实类比:图书馆整理书籍。不同书名但内容相同(字母相同)的书要放在同一格。给每本书的「字母排序后的书名」作为货架编号,同编号的书放一起。

容器选择HashMap<String, List<String>>,key 是排序后的规范形式,value 是同组原始字符串列表;需要按 key 快速查找/插入 → HashMap。

解法一:暴力 / 直观(排序作 key)

对每个字符串排序得到规范 key,computeIfAbsent 按 key 分组。

class Solution {
    public List<List<String>> groupAnagrams(String[] strs) {
        // key: 排序后的字符串(异位词的唯一标识)
        // value: 同属一组的原始字符串列表
        Map<String, List<String>> map = new HashMap<>();

        for (String str : strs) {
            // 字符串 → 字符数组 → 排序 → 转回字符串,得到规范化 key
            char[] chars = str.toCharArray();
            Arrays.sort(chars);
            String key = new String(chars);

            // key 不存在时自动创建空列表,再追加原始字符串
            map.computeIfAbsent(key, k -> new ArrayList<>()).add(str);
        }

        // 返回 map 中所有 value 组成的列表
        return new ArrayList<>(map.values());
    }
}

复杂度逐步推导:设数组有 n 个字符串,最长长度为 k

瓶颈在哪:时间主要耗在「每个字符串都要排序」。当字符串很长时,k log k 会拖慢整体;如果字符串只含小写字母(长度有限但 n 巨大),排序其实做了多余工作——因为字母构成只跟「每个字符出现几次」有关,跟顺序无关。

解法二:优化(字符计数作 key,O(n·k))

用「26 个字母的出现次数」构造 key,避免排序。字符集固定为小写字母时,这是更优解。

class Solution {
    public List<List<String>> groupAnagrams(String[] strs) {
        // key: 由 26 个字母出现次数拼接成的字符串(如 "1#0#0#..."),唯一标识字母构成
        // value: 同组原始字符串列表
        Map<String, List<String>> map = new HashMap<>();

        for (String str : strs) {
            int[] count = new int[26];
            // 统计每个小写字母出现的次数
            for (char c : str.toCharArray()) {
                count[c - 'a']++;
            }

            // 用 StringBuilder 拼接计数,作为 key(用 # 分隔防止 1+11 与 11+1 混淆)
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < 26; i++) {
                sb.append(count[i]).append('#');
            }
            String key = sb.toString();

            map.computeIfAbsent(key, k -> new ArrayList<>()).add(str);
        }

        return new ArrayList<>(map.values());
    }
}

复杂度逐步推导

💭 思考:排序作 key 已经能 O(n·k·log k),为什么还要「字符计数作 key」?——瓶颈在排序:每个字符串都 Arrays.sort 一次,多花了 log k 因子。而字母异位词的本质是「每种字符出现次数相同」,跟顺序无关,排序其实做了多余工作。既然字符集固定为 26 个小写字母,直接用 int[26] 计数、拼成 "1#0#0#..." 这样的 key,就把每个字符串的处理从 O(k log k) 压到 O(k)。看到「分组 + 唯一标识某个特征」的信号,先想「排序作 key 图省事」,再问一句「特征是否只跟计数有关」——是的话换计数 key,这是纯时间优化。

多解法对比

解法时间复杂度空间复杂度适用场景
排序作 keyO(n·k·log k)O(n·k)字符集不限(大小写、Unicode 都行),实现最直观
字符计数作 keyO(n·k)O(n·k)字符集固定(如小写字母),字符串较长时更快

本题约束只保证小写字母,理论上计数法更优;但面试手撕时,排序法代码最短、最不易错,先给出排序法并主动说出「若字符串很长可换计数法优化到 O(n·k)」,是展示深度和取舍的最佳方式。

CodeTop 变体


128. 最长连续序列

题意

给定未排序整数数组,找出数字连续的最长序列长度,要求时间复杂度 O(n)

输入:nums = [100,4,200,1,3,2]
输出:4
解释:最长连续序列是 [1,2,3,4],长度为 4

难点与易错点

  1. 「要求 O(n)」直接否决了排序。最直观的思路是「排序后扫描连续段」,但排序至少 O(n log n),不满足硬性约束,必须想别的办法。
  2. 朴素哈希会退化到 O(n²)。如果把每个数都当成起点,对每个起点 while 向后延伸(如 [1,2,3,...,n] 里每个数都往后数一遍),总复杂度是 O(n²)。关键技巧:只从「序列起点」开始延伸——所谓起点,是「x-1 不存在」的数。这样每个元素最多被数到一次。
  3. 先 Set 去重再遍历:重复元素(如 [1,2,2,3])若不先去重,会影响长度统计和「是否存在 x-1」的判断,用 HashSet 天然解决。
  4. 长度计算:内层 while 结束时 y 是「第一个不在序列里的数」,序列长度 = y - x,不要写成 y - x + 1

题目本质:以每个「序列起点」出发向后延伸。用 HashSet 去重后,只从序列第一个元素(不存在 x-1 的元素)开始计数,避免重复计算。

现实类比:找车队。一条公路上有很多车,每辆车编号连续。你只从「领头车」(前面没有车的那辆)开始数,数到队伍断开为止,记录最长的那支队伍。

容器选择:需要 O(1) 判断某数是否存在 → HashSet;不需要记录下标或频次 → 不需要 HashMap。

解法一:暴力 / 直观(排序)

先排序,再线性扫描连续段。

class Solution {
    public int longestConsecutive(int[] nums) {
        if (nums.length == 0) return 0;

        Arrays.sort(nums); // O(n log n)

        int maxLen = 1;
        int curLen = 1;
        for (int i = 1; i < nums.length; i++) {
            if (nums[i] == nums[i - 1]) {
                // 跳过重复元素,不断开也不增长
                continue;
            } else if (nums[i] == nums[i - 1] + 1) {
                // 连续,长度 +1
                curLen++;
                maxLen = Math.max(maxLen, curLen);
            } else {
                // 断开,重新计数
                curLen = 1;
            }
        }
        return maxLen;
    }
}

复杂度逐步推导:排序是 O(n log n),后续扫描一遍 O(n),总时间复杂度 O(n log n);空间取决于排序实现(Java 对 int 用双轴快排,原地,O(log n) 栈空间)。

瓶颈在哪:排序把「找连续」变成了简单的扫描,但排序本身 O(n log n) 已经超了题目 O(n) 的硬性要求,这一版在面试里只能作为「先说个直觉解」的铺垫。

解法二:优化 / 最优(HashSet + 只从起点延伸)

全部入 Set 去重,遍历时只从「x-1 不存在」的起点向后延伸。

class Solution {
    public int longestConsecutive(int[] nums) {
        // 将所有数字放入 HashSet,O(1) 查询是否存在
        Set<Integer> numSet = new HashSet<>();
        for (int num : nums) {
            numSet.add(num);
        }

        int maxLen = 0;

        for (int x : numSet) {
            // 只从序列起点开始遍历:x-1 不存在,说明 x 是某段连续序列的起点
            // 跳过非起点,避免 O(n²) 的重复计算
            if (numSet.contains(x - 1)) {
                continue;
            }

            // 从起点 x 向后延伸,统计序列长度
            int y = x + 1;
            while (numSet.contains(y)) {
                y++;
            }

            // y - x 即为本段序列的长度
            maxLen = Math.max(maxLen, y - x);
        }

        return maxLen;
    }
}

复杂度逐步推导(这是本题面试必问,务必讲清「为什么是 O(n) 而不是 O(n²)」):

💭 思考:为什么这题必须绕开排序,而「只从起点延伸」为什么能保住 O(n)?——看到「要求 O(n)」这个硬约束,排序 O(n log n) 直接出局,只能靠 O(1) 查询的 HashSet。朴素想法是「每个数都当起点往后数」,但 [1,2,3,...,n] 里每个数都往后数一遍会退化成 O(n²)。关键观察:一个连续序列里,只有「x-1 不存在」的那个数才是真正的起点,其余数都会被外循环的 continue 跳过,每个元素最多被某个起点的 while 查询一次。所以「去重 + 只从起点延伸」才是这题从 O(n²) 塌缩到 O(n) 的灵魂。看到「连续序列 + O(n)」这个组合,就该想到「Set 判存在 + 只从起点数」。

多解法对比

解法时间复杂度空间复杂度适用场景
排序 + 扫描O(n log n)O(log n)(或 O(1) 若原地)不要求 O(n) 时最简单
HashSet + 起点延伸O(n)O(n)本题标准解,满足 O(n) 硬约束

本题明确要求 O(n),排序法直接出局。哈希法用 O(n) 空间换 O(n) 时间,且「只从起点延伸」这个去重剪枝是本题的灵魂——面试官就是要看你能不能讲清「为什么 while 不会退化成 O(n²)」。

CodeTop 变体


小结:哈希三题覆盖了 HashMap/HashSet 的三种核心用法——「查补数」(两数之和)、「造 key 分组」(异位词分组)、「去重 + 判存在 + 剪枝」(最长连续序列)。把「查找」从 O(n) 压到 O(1),就是这类题从暴力到最优的全部秘密。

章末提问

  1. 两数之和为什么必须「先查后存」,反过来会怎样?——结论:先存后查会把「自己配自己」误判成解。因为当前元素入 map 后,若 target - x == x(如 [3,2,4] 的 target=6),查到的补数下标就是它自己,返回 [i,i] 违反「同一个元素不能用两次」;先查后存则天然避开这个坑。
  2. 最长连续序列的内层 while 会不会退化成 O(n²)?为什么不会?——结论:不会,整体仍是 O(n)。因为只有「x-1 不存在」的起点才会进入 while,序列中间的元素都在外循环被 continue 跳过,每个元素最多被某个起点的 while 查询一次,所有 contains 调用合计不超过 n 次。
  3. 异位词分组既然有 O(n·k) 的计数法,为什么面试里更该先写 O(n·k·log k) 的排序法?——结论:排序法代码最短、最不易错,是「先写对」的首选。因为计数法只在字符集固定且字符串很长时才带来可观收益,而手撕里稳定正确比常数优化更值钱——先交排序法、再主动说出「可换计数法优化」,是展示取舍的正确姿势。

Share this post on:

Previous Post
二分查找的8种变体——不只是找等于target
Next Post
LRU缓存——HashMap+双向链表实现O(1)淘汰