Pattern 01 · Hash Map / Set

01 哈希 用空间换「我见过你吗」的 O(1) 回答

面试频率 极高题目 6 道模板 2 个⭐认脸 0 道
§1 Mental Model 一句话心智模型

先记住这一句

哈希表就一句话:花 O(n) 的空间,把「我见过你吗」变成 O(1) 的回答。

写题时你真正在做的动作只有三种:见过没有(set)、见过几次(Counter)、见过的东西挂在哪(dict → 下标 / 列表)。先想清楚要问哪一种,代码几乎是自动的。

它的典型用法是把一层循环换成一次查表:暴力解里那个「回头去前面找有没有某个数」的内层循环,就是哈希表的入口。代价是内存,以及顺序信息全丢了——需要顺序的题(第 K 大、区间和)得另找工具。

§2 Recognition Signals 识别信号

看到什么,想到本章

题面出现…想到
「是否存在 / 有没有重复 / 出现过吗」set
「出现次数 / 频率 / 众数 / anagram」Counter
「两数之和 / 配对 / 补数」且数组无序dict 存「我需要的另一半」(模板 A)
「分组 / 归类 / 把同类放一起」dict[签名] → list(模板 B)
题目要求 O(n),而你的暴力解是两层循环在「回头找」哈希把内层循环换成一次查表
「最长连续序列」但不要求元素相邻set + 只从序列起点向右扩
分流条件 · 决策树同源

同样是「两数之和」,数组无序才用哈希;已排序就该用双指针(第 02 章),省掉 O(n) 空间。「子数组和恰好为 K」表面像哈希,实际主语是前缀和(第 13 章),哈希只是配件。「频率最高的 K 个」先用 Counter 数完,选 K 个的动作属于堆(第 08 章)。

§3 Template 模板代码

可默写的那几行

■ 【骨架】 不变,闭着眼默写 ■ 【插槽】 随题变化,写题前先问自己填什么

模板 A · 配对边走边问「另一半来过吗」
def two_sum(nums, target):
    seen = {}                               # 【骨架】值 -> 下标,记录"我来过"
    for i, x in enumerate(nums):            # 【骨架】
        need = target - x                   # 【插槽】"另一半"怎么算
        if need in seen:                    # 【骨架】先查后存,否则会拿自己配自己
            return [seen[need], i]          # 【插槽】答案形式:下标对 / 计数 / True
        seen[x] = i                         # 【骨架】
    return []
默写口诀:先查后存。顺序反了,[3,3] 这种用例会拿自己配自己。
模板 B · 分组给每个元素算一个签名
from collections import defaultdict

def group_by_signature(words):
    buckets = defaultdict(list)             # 【骨架】签名 -> 同组成员
    for w in words:                         # 【骨架】
        key = tuple(sorted(w))              # 【插槽】签名怎么算:排序 / 计数向量 / 归一化
        buckets[key].append(w)              # 【骨架】
    return list(buckets.values())           # 【插槽】要分组本身 / 组数 / 最大的那组
全部难度都在签名怎么设计:字母排序、26 维计数向量、行列宫编号……骨架一个字都不用改。
§4 Problems 题目分层

从建直觉到扭模板

热身建直觉,easy 为主2 道
  • 模板 A 原题。面试里它的价值不在题本身,而在你能否一口说出「为什么先查后存」。
  • set 的最小用法。顺手记住变体:len(set(nums)) < len(nums) 一行解决。
标准模板直接套3 道
  • Counter(s) == Counter(t)。面试官常追问排序解法 O(n log n) 与计数解法 O(n) 的取舍,以及 Unicode 字符集下 26 长度数组会失效。
  • 模板 B 原题。签名用 tuple(sorted(w)) 或 26 维计数元组——后者 O(n·k),前者 O(n·k log k),说得出这个差别就够了。
  • 全章最值得琢磨的一道:把数组塞进 set,只从「左边没有邻居」的数开始向右扩。这一条剪枝把 O(n²) 压回 O(n),见翻车点④。
变体模板要扭,坑的来源1 道
  • 签名思维的另一面:一个格子同时属于行、列、宫三个 set,用 (i // 3, j // 3) 当宫的键。骨架不变,键变了。
§5 Pitfalls & Self-check 常见翻车点 + 自测

会写不等于不翻车

  1. 先查后存的顺序——模板 A 里 if need in seen 必须在 seen[x] = i 之前。反过来写,[3,3] target=6 会返回 [0,0]。
  2. 存值还是存下标——题目要下标就存下标,要判断存在用 set 就够。别默认写 dict——set 的意图更清楚,读代码的人一眼知道你不关心位置。
  3. Counter 的删空问题——滑动窗口里用 Counter 当窗口状态时,减到 0 的键不会自动消失,len(cnt) 会把它算进去。要么 del,要么另外维护一个「不同字符数」。(== 比较是个例外:Python 3.10 起它会忽略零计数,细节见第 03 章翻车点④。)
  4. 128 从每个数往两边扩——对每个数都向左右扩展是 O(n²)(退化用例:[1,2,3,...,n])。正确写法是先判断 x - 1 not in s,只有序列起点才进入扩展循环。
  5. 不可哈希的键——list 不能当键,tuple 可以;dict 不能,frozenset 可以。分组题里签名一律用元组。

自测 · 30 秒识别模式

30 秒识别Q1 · 383 Ransom Note:判断能否用杂志上的字母拼出勒索信(每个字母只能用一次)。你的第一反应是哪个模式?

看答案

哈希。「每个字母只能用一次」= 计数比较,Counter(note) <= Counter(magazine)。看到「次数够不够」就是 Counter,不是滑窗——它根本不要求连续。

30 秒识别Q2 · 219 Contains Duplicate II:判断数组中是否存在两个相同的数,且它们的下标之差不超过 k。你的第一反应是哪个模式?

看答案

哈希。存「值 → 最近一次出现的下标」,边走边比较距离,一次遍历。题面里的 k 是个障眼法:要问的是「见过吗」,不是「区间和」也不是「有序数组里的一对」。

30 秒识别Q3 · 给两个已排序的数组,把第二个合并进第一个(原地,第一个数组尾部有足够空位)。你的第一反应是哪个模式?

看答案

双指针,不是本章——数组已排序,用哈希既浪费空间又丢掉顺序这个最有价值的条件。见 §2 分流条件:有序是双指针的入场券(第 02 章)。

每章自测都会混入恰好一道非本章题,防止「读哪章选哪章」的条件反射;题面也一律取自本章未收录的题,避免凭记忆而非凭信号作答。