算法面试突击手册 —— 哈希表


一、概念与特点

大白话 + 类比

哈希表 = 图书馆的索引卡片柜

你去图书馆找一本书,如果一本一本翻架子,可能要翻几小时(O(n))。但如果有一个卡片柜,每张卡片上写着书名 → 书架位置,你查到卡片就能直接走过去拿书。这就是哈希表:用 key 瞬间定位 value,查询 O(1)

哈希函数就像”分类员”:你把书名给它,它告诉你卡片在哪个抽屉。好的哈希函数让每个抽屉的卡片数量均匀;不好的哈希函数会把所有卡片塞进同一个抽屉(哈希冲突),那就退化成翻抽屉了。

核心特征(什么时候该想到用它)

特征说明
O(1) 查找需要频繁查询某个值是否存在
key-value 映射需要建立元素和某个信息的对应关系
去重 / 计数统计出现次数、判断重复
”之前见过”遍历时想知道当前元素之前是否出现过

识别信号词

题目中出现这些关键词,优先考虑哈希表:

  • “两数之和”、“是否存在”、“查找” → 用 HashMap 存值→下标
  • “出现次数”、“频率”、“重复” → 用 HashMap 计数
  • “最长连续”、“去重” → 用 HashSet 快速判断存在性
  • “只出现一次” → 优先考虑异或,其次 HashMap
  • “分组”、“分类” → HashMap<String, List> 模式

二、应用场景

五大套路模板

套路典型题HashMap 存什么
值→下标Two Summap<值, 下标> 遍历时回头查
值→次数Majority Element, Group Anagramsmap<值, 出现次数>
值→是否存在Longest Consecutiveset<值> 只关心存在性
异或去重Single Number不显式用 HashMap,用异或性质
分组归类Group Anagramsmap<特征key, List<成员>>

Java 常用操作速查

Map<Integer, Integer> map = new HashMap<>();
map.put(key, val);           // 存
map.get(key);                // 取(不存在返回 null)
map.getOrDefault(key, 0);    // 取,不存在返回默认值
map.containsKey(key);        // 是否包含 key
map.remove(key);             // 删除
for (Map.Entry<Integer, Integer> e : map.entrySet()) { } // 遍历

Set<Integer> set = new HashSet<>();
set.add(x);                  // 添加
set.contains(x);             // 是否存在
set.remove(x);               // 删除

三、Hot 100 精讲题目


1. Two Sum 两数之和

📌 给定一个整数数组 nums 和一个目标值 target,找出和为 target 的两个数的下标。假设只有一组解。

🔍 解题分析

为什么用哈希表:暴力双循环 O(n²)。我们想要的是:遍历到 nums[i] 时,立刻知道 target - nums[i] 之前有没有出现过。这正是哈希表的专长。

核心思路(一步步拆解)

  1. 初始化一个空的 HashMap,key=数值,value=下标
  2. 从左到右遍历数组
  3. 对每个 nums[i],计算 complement = target - nums[i]
  4. 查看 complement 是否在 HashMap 中
  5. 如果在,返回 [map.get(complement), i]
  6. 如果不在,把 (nums[i], i) 存入 HashMap,继续

易错点:不能先把所有元素放入 HashMap 再查找,否则像 nums=[3,3], target=6 这种情况,同一个元素会被用两次。

✅ 完整 Java 实现

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]; // 我需要找的搭档

        // 回头看一眼:我需要的那个人之前出现过吗?
        if (map.containsKey(complement)) {
            return new int[]{map.get(complement), i};
        }

        // 把自己登记在册,供后来的人查找
        map.put(nums[i], i);
    }

    return new int[]{-1, -1}; // 不会走到这里,题目保证有解
}

⏱ 复杂度分析

  • 时间复杂度:O(n) —— 每个元素遍历一次,HashMap 查找 O(1)
  • 空间复杂度:O(n) —— 最坏情况存 n 个元素

💡 记忆口诀

“边看边登记,回头找搭档”


49. Group Anagrams 字母异位词分组

📌 给定字符串数组,将字母异位词(相同字母不同排列)分到同一组。如 ["eat","tea","tan","ate","nat","bat"][["bat"],["nat","tan"],["ate","eat","tea"]]

🔍 解题分析

为什么用哈希表:需要把”同一类”的字符串聚合。关键问题是:怎么给每种异位词生成一个统一的”分类 key”?两种方法:

  • 排序法"eat""tea" 排序后都是 "aet",用它做 key
  • 计数法:用 #a1#e1#t1 这种编码做 key

核心思路

  1. 创建 HashMap,key = 排序后的字符串(或计数编码),value = 原始字符串列表
  2. 遍历每个字符串,排序得到 key
  3. 把原字符串加到对应 key 的列表中
  4. 返回所有列表

易错点:返回 new ArrayList<>(map.values()) 即可,不需要再复制。

✅ 完整 Java 实现

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

    for (String s : strs) {
        // 核心:把字符串排序作为统一标识
        char[] chars = s.toCharArray();
        Arrays.sort(chars);
        String key = new String(chars); // "eat" → "aet"

        // 如果这个 key 还没出现过,新建一个列表
        if (!map.containsKey(key)) {
            map.put(key, new ArrayList<>());
        }
        map.get(key).add(s);
    }

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

⏱ 复杂度分析

  • 时间复杂度:O(n·klogk),n 个字符串,每个长度 k 排序
  • 空间复杂度:O(n·k),存储所有字符串

💡 记忆口诀

“字母异位都一样,排序之后一个样”


128. Longest Consecutive Sequence 最长连续序列

📌 给定未排序的整数数组,找出最长连续序列的长度。要求 O(n) 时间。如 [100,4,200,1,3,2] → 4(序列为 [1,2,3,4]

🔍 解题分析

为什么用哈希表:O(n) 时间要求,不能用排序。我们只关心”一个数是否存在于数组中”,这是 HashSet 的专长。

核心思路(关键技巧)

  1. 把所有数字放入 HashSet
  2. 遍历每个数字 num
  3. 核心剪枝:只有当 num-1 不在 Set 中时,才说明 num 是一个连续序列的起点
  4. 从起点开始,不停检查 num+1, num+2, ... 是否在 Set 中
  5. 统计最长长度

为什么这样是 O(n):虽然有两层循环,但每个数字最多被访问两次(一次作为起点检查,一次在某个序列内部被访问)。只有作为”起点”时才会进入内层 while 循环。

易错点:必须加 if (!set.contains(num - 1)) 剪枝,否则退化成 O(n²)。

✅ 完整 Java 实现

public int longestConsecutive(int[] nums) {
    Set<Integer> set = new HashSet<>();
    for (int num : nums) {
        set.add(num);   // 全部扔进 HashSet,O(1) 判断存在性
    }

    int maxLen = 0;

    for (int num : set) {
        // 关键剪枝:只从序列的"起点"开始计数
        // 如果 num-1 存在,那 num 不是起点,跳过
        if (!set.contains(num - 1)) {
            int currentNum = num;
            int currentLen = 1;

            // 向后延伸,数这个连续序列有多长
            while (set.contains(currentNum + 1)) {
                currentNum++;
                currentLen++;
            }

            maxLen = Math.max(maxLen, currentLen);
        }
    }

    return maxLen;
}

⏱ 复杂度分析

  • 时间复杂度:O(n) —— 每个数字最多进入内层循环 1 次
  • 空间复杂度:O(n) —— HashSet 存储

💡 记忆口诀

“全扔 Set 里,只从起点往前数”


136. Single Number 只出现一次的数字

📌 非空整数数组,除一个数只出现一次外,其余每个数都出现两次。找出那个只出现一次的数。要求 O(n) 时间,O(1) 空间。

🔍 解题分析

为什么不用 HashMap:HashMap 需要 O(n) 空间,但题目要求 O(1) 空间。这里用到异或运算的性质:

  • a ⊕ a = 0(相同为 0)
  • a ⊕ 0 = a(与 0 异或等于自己)
  • 交换律和结合律:a ⊕ b ⊕ a = (a ⊕ a) ⊕ b = 0 ⊕ b = b

核心思路:把数组中所有数字异或起来,成对的互相抵消为 0,最后剩下的就是落单的那个数。

类比他:就像参加舞会,一个一个人进房间。第一个进去独自跳舞;第二个相同的人进去,两人配对消失。最后留在房间里跳舞的就是那个单身狗。

✅ 完整 Java 实现

public int singleNumber(int[] nums) {
    int result = 0;

    for (int num : nums) {
        result ^= num; // 异或:相同的消掉,最后剩下那个单身的
    }

    return result;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

💡 记忆口诀

“全部异或,成对消失,落单留下”


169. Majority Element 多数元素

📌 找出数组中出现次数超过 ⌊n/2⌋ 的元素。假设一定存在。

🔍 解题分析

这道题有两种解法,面试建议两种都讲:

解法一:HashMap 计数(通用但空间 O(n))

  • 计数每个元素,找到次数 > n/2 的

解法二:摩尔投票(最优,空间 O(1))

  • 类比:就像打仗,两军对垒,一换一。多数派人数超过一半,所以无论怎么对冲,最后剩下的兵一定是多数派的。

核心思路(摩尔投票)

  1. 初始化 candidate = 0, count = 0
  2. 遍历数组:
    • 如果 count == 0,当前元素成为新的 candidate
    • 如果当前元素 == candidate,count++
    • 如果当前元素 != candidate,count—(一换一)
  3. 最后剩下的 candidate 就是多数元素

为什么正确:多数元素超过 n/2,意味着即使所有其他元素都和它对冲,它至少还剩 1 个人。

✅ 完整 Java 实现

// 解法一:HashMap 计数(面试先写这个,展现基本功)
public int majorityElement_HashMap(int[] nums) {
    Map<Integer, Integer> countMap = new HashMap<>();
    int majority = nums[0];

    for (int num : nums) {
        countMap.put(num, countMap.getOrDefault(num, 0) + 1);
        if (countMap.get(num) > nums.length / 2) {
            majority = num;
            break;
        }
    }
    return majority;
}

// 解法二:摩尔投票(展现深度理解)
public int majorityElement(int[] nums) {
    int candidate = 0;  // 当前候选人
    int count = 0;      // 候选人当前票数

    for (int num : nums) {
        // 弹尽粮绝,换候选人
        if (count == 0) {
            candidate = num;
        }
        // 是自己人就加一,是敌人就同归于尽一个
        count += (num == candidate) ? 1 : -1;
    }

    return candidate;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:HashMap O(n) / 摩尔投票 O(1)

💡 记忆口诀

“两军对垒一换一,人数过半必胜利”


🔜 下一章:02-双指针