field noteENTRY / 007

Personal Field Archive / Received 2026.09.11

哈希表|LeetCode 第一轮复盘

从查询目标反推数据结构,复盘集合、键值映射、频率表与特征分组之间的选择边界。

章节状态:第 5 组已完成并复盘。当前已完成 LC 217、1、242、349、205、49。当前主要训练目标不是记住 dict、set 或 Counter 的固定写法,而是根据题目真正需要维护的信息,判断应该使用“存在性集合”“键值映射”“频率表”还是“特征分组”,并理解哈希表如何把反复的线性查找转化为平均 O(1) 查询。

一、第 5 组题目清单

顺序 LeetCode 难度 题目 核心训练 当前状态
1 217 简单 Contains Duplicate set、存在性检查、去重 已完成
2 1 简单 Two Sum dict、补数、维护历史信息 已复盘
3 242 简单 Valid Anagram 频率表、Counter、次数比较 已复盘
4 349 简单 Intersection of Two Arrays 集合交集、结果去重 已复盘
5 205 简单 Isomorphic Strings 双向映射、一一对应 已复盘
6 49 中等 Group Anagrams 特征作为 key、分组、tuple 已复盘

二、哈希表的基础心智模型

这一组最重要的变化,是不能再把“哈希表题”理解成“看到题目就创建一个 {}”。真正应该先问的是:

我在遍历过程中,需要保存什么信息?
之后我要根据什么 key 快速查询这些信息?

根据需求不同,数据结构也不同。

1. 只关心“出现过没有” → set

例如 LC 217:

当前数字以前出现过吗?

需要维护的只有元素本身:

seen = set()

典型操作:

if num in seen:
    ...
seen.add(num)

2. 需要“key 对应额外信息” → dict

例如 Two Sum:

数字 → 它之前出现的 index

因此需要:

seen[num] = i

哈希表不只是告诉我们 num 是否存在,还能立即取回对应的 index。

3. 关心“出现多少次” → 频率表 / Counter

例如 Valid Anagram:

字符 → 出现次数

可以手动使用:

counts = {}

也可以使用:

Counter(s)

Counter 本质上是 Python 已经实现好的“元素 → 出现次数”映射工具。

4. 关心“一一对应关系” → 映射表

例如 Isomorphic Strings:

s 中字符 → t 中字符

而且映射必须双向唯一,所以需要同时约束:

s → t
t → s

5. 需要“分类” → 特征 key → 一组元素

例如 Group Anagrams:

一个单词的规范化特征 → 所有具有这个特征的单词

这里 key 不再是原始元素本身,而是从原始元素计算出的“分类特征”。

这六道题实际上逐渐扩展了哈希表的用途:

217:元素 → 是否出现
1:数字 → index
242:字符 → 次数
349:集合 → 去重与交集
205:字符 → 对应字符
49:特征 → 一组元素

三、LC 217. Contains Duplicate

已完成思路

这题只需要判断:

是否存在某个数字至少出现两次。

因此不需要保存次数、index 或其他信息,只需要保存“以前见过哪些数字”。

class Solution:
    def containsDuplicate(self, nums: List[int]) -> bool:
        seen = set()

        for num in nums:
            if num in seen:
                return True

            seen.add(num)

        return False

核心认识

如果使用数组反复向后寻找相同数字,就会产生重复遍历;set 把:

这个数字以前出现过吗?

转化为平均 O(1) membership 查询。

这题建立了本组最基础的判断:

只关心存在性 / 去重
→ 优先考虑 set

重做检查点

  • 为什么这里不需要 dict?
  • set 与 list 做 num in ... 的平均查询成本有什么差异?
  • 为什么不需要记录数字出现的具体次数?

四、LC 1. Two Sum

我的初始思路

已经能够把条件:

num1 + num2 = target

变形为:

need = target - num

也就是说,当遍历到当前数字 num 时,我知道自己还需要什么数字。

最初的问题在于:虽然知道了 need,但想到的是继续遍历数组寻找 need。如果每个数字都向后寻找一次,整体仍然是 O(n²)。

真正的思维转折

不是:

我现在需要 need
→ 去未来的数组中遍历寻找 need

而是:

我一路向前遍历
→ 把以前见过的数字保存起来
→ 当前数字计算出 need
→ 查询过去是否已经见过 need

例如:

nums = [2, 7, 11, 15]
target = 9

看到 2:
need = 7
过去没有 7
保存 2 → index 0

看到 7:
need = 2
过去有 2
返回 [0, 1]

因此哈希表维护的是:

数字 → index

正确写法

class Solution:
    def twoSum(self, nums: List[int], target: int) -> List[int]:
        seen = {}

        for i, num in enumerate(nums):
            need = target - num

            if need in seen:
                return [seen[need], i]

            seen[num] = i

为什么必须先查询,再保存当前数字

对于:

nums = [3, 3]
target = 6

第一个 3:

need = 3
seen 中没有 3
保存 3 → 0

第二个 3:

need = 3
seen 中已有 3 → 0
返回 [0, 1]

这样保证使用的是两个不同位置。

复杂度

遍历数组一次,每次哈希查询和插入平均 O(1):

时间:O(n)
空间:O(n)

本题真正训练的模式

如果我能快速知道“某个东西以前出现过吗”就好了
→ 维护历史信息
→ 用哈希表把未来的查询降为平均 O(1)

重做检查点

  • 为什么知道 need 以后继续遍历仍然是 O(n²)?
  • seen 为什么存 num → index,而不是只存数字?
  • 为什么查询必须发生在保存当前数字之前?
  • 这题和 LC 217 的共同结构是什么?

五、LC 242. Valid Anagram

我的第一次写法

class Solution:
    def isAnagram(self, s: str, t: str) -> bool:
        s_letters = set(s)

        for letter in t:
            if t !in s_letters:
                return False

        return True

这里首先有代码错误:循环变量是 letter,而不是整个 t;Python membership 的否定写法也是 not in。

但更根本的问题是,即使修正成:

if letter not in s_letters:

算法仍然不正确。

为什么 set 不够

例如:

s = "aab"
t = "abb"

两者的集合都是:

{'a', 'b'}

但它们显然不是 anagram:

s:a × 2,b × 1
t:a × 1,b × 2

所以题目不是在问:

包含哪些字符?

而是在问:

每个字符分别出现多少次?

我的修正思路

先为 s 建立频率表:

字符 → 次数

再遍历 t,不断把对应次数减一;最后所有值都应该恰好回到 0。

class Solution:
    def isAnagram(self, s: str, t: str) -> bool:
        s_letters = {}

        for l in s:
            if l not in s_letters:
                s_letters[l] = 0
            s_letters[l] += 1

        for l in t:
            if l not in s_letters:
                return False
            s_letters[l] -= 1

        for count in s_letters.values():
            if count != 0:
                return False

        return True

Counter 是什么

Python 提供:

from collections import Counter

那么:

Counter("aab")

大致表示:

{
    'a': 2,
    'b': 1
}

因此:

return Counter(s) == Counter(t)

本质上仍然是在比较两个字符频率表。

这一行代码没有改变算法思想,也没有把复杂度变成 O(1);建立两个 Counter 仍然必须遍历输入,整体为 O(n + m)。

本题真正训练的区别

217:
关心“出现过吗”
→ set

242:
关心“出现多少次”
→ dict / Counter

重做检查点

  • 为什么 set(s) == set(t) 不能判断 anagram?
  • 手写 dict 时为什么第一次遇到字符不能直接 += 1?
  • Counter 替代的是哪一部分工作?
  • 一行 Counter(s) == Counter(t) 的时间复杂度为什么不是 O(1)?

六、LC 349. Intersection of Two Arrays

我的正确写法

class Solution:
    def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
        nums1_col = Counter(nums1)
        result = []

        for num in nums2:
            if num in nums1_col:
                if num not in result:
                    result.append(num)

        return result

这段代码能够得到正确结果,但数据结构选择还可以更准确。

问题一:不需要 Counter

题目只关心某个数字是否存在于两个数组中,并不关心它出现了几次。

因此:

Counter(nums1)

保存的次数信息是多余的,set(nums1) 更贴合题意。

问题二:result 使用 list 会产生线性查询

if num not in result:

由于 result 是 list,这个 membership 检查最坏需要遍历整个结果列表。

而题目本身要求结果不重复,因此 set 同时解决:

存在性查询
+
结果去重

更合适的写法

class Solution:
    def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
        nums1_set = set(nums1)
        result = set()

        for num in nums2:
            if num in nums1_set:
                result.add(num)

        return list(result)

还可以利用 Python 集合交集:

return list(set(nums1) & set(nums2))

本题真正训练的数据结构选择

需要次数
→ Counter

只需要存在性
→ set

不能因为 Counter 也能完成 membership 查询,就认为它是最合适的数据结构。应该保存完成任务所需的最小信息。

重做检查点

  • 为什么 Counter 在这里属于“能用,但信息过量”?
  • num not in result 为什么可能拖高复杂度?
  • 为什么 set 同时满足交集查询和结果去重?
  • & 对两个 set 表示什么?

七、LC 205. Isomorphic Strings

我的初始思路

最初认为需要记录:

字符的位置 + 出现次数

例如:

egg:
e 在 index 0,出现 1 次
g 在 index 1、2,出现 2 次

add:
a 在 index 0,出现 1 次
d 在 index 1、2,出现 2 次

这个描述能够观察到 isomorphic 的外在模式,但落实到代码会很复杂,因为它没有抓住真正需要保持的不变量。

真正的判定条件

Isomorphic 要求:

s 中同一个字符每次都必须映射到 t 中同一个字符,并且两个不同字符不能映射到 t 中同一个字符。

对于:

egg
add

真正需要维护的是:

e → a
g → d

第三个位置再次看到 g → d,与原映射一致,因此合法。

对于:

foo
bar

会出现:

f → b
o → a
o → r   ← 与原来的 o → a 冲突

因此 False。

为什么单向映射还不够

考虑:

s = "ab"
t = "cc"

如果只维护:

a → c
b → c

每个 s 字符自身都没有发生“一个字符映射到两个字符”的冲突,但两个不同字符映射到了同一个 c,仍然不合法。

所以需要双向唯一:

s → t
t → s

正确写法

class Solution:
    def isIsomorphic(self, s: str, t: str) -> bool:
        s_to_t = {}
        t_to_s = {}

        for a, b in zip(s, t):
            if a in s_to_t and s_to_t[a] != b:
                return False

            if b in t_to_s and t_to_s[b] != a:
                return False

            s_to_t[a] = b
            t_to_s[b] = a

        return True

本题真正训练的模式

哈希表不只是保存“有没有”或“多少次”,也可以维护约束关系:

key → 与它对应的 value

205 的关键是不去记录所有 index,而是在遍历对应位置时直接检查映射是否始终一致。

重做检查点

  • 为什么“位置 + 次数”能描述现象,却不是最简洁的不变量?
  • 为什么 s_to_t 单向映射不足?
  • ab 与 cc 为什么可以击穿单向映射?
  • 两张哈希表分别防止哪一种映射冲突?

八、LC 49. Group Anagrams

从 LC 242 推导到 LC 49

242 的问题是:

两个字符串的字符频率是否相同?

49 则把问题扩大为:

如何把一组互为 anagram 的字符串自动归到同一组?

关键变化不是不断拿两个单词互相比较,而是:

能不能为每个单词计算一个规范化特征,使所有 anagram 得到完全相同的 key?

于是问题变成:

word
  ↓
计算分类特征 key
  ↓
groups[key].append(word)

方法一:排序后的字符串作为 key

例如:

eat → aet
tea → aet
ate → aet

tan → ant
nat → ant

bat → abt

所有 anagram 排序后都会变成相同字符串,因此可以:

class Solution:
    def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
        groups = {}

        for word in strs:
            key = ''.join(sorted(word))

            if key not in groups:
                groups[key] = []

            groups[key].append(word)

        return list(groups.values())

方法二:26 个字母的频率作为 key

对于只包含小写英文字母的字符串,可以建立:

count = [0] * 26

每个位置对应一个字母:

index 0  → a
index 1  → b
...
index 25 → z

通过:

count[ord(c) - ord('a')] += 1

统计字符频率。

那么 eat、tea、ate 最终得到完全相同的 26 个计数。

为什么需要 tuple(count)

list 是可变对象,不能直接作为 dict 的 key:

groups[count]   # 不可行

因此转换成不可变、可哈希的 tuple:

key = tuple(count)

完整写法:

class Solution:
    def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
        groups = {}

        for word in strs:
            count = [0] * 26

            for c in word:
                count[ord(c) - ord('a')] += 1

            key = tuple(count)

            if key not in groups:
                groups[key] = []

            groups[key].append(word)

        return list(groups.values())

tuple 在这里做了什么

它没有改变 26 个计数代表的内容,而是把:

[1, 0, 0, ..., 1, ..., 1]

转换成:

(1, 0, 0, ..., 1, ..., 1)

后者不可变,因此能够被哈希并作为字典 key。

两种方法的复杂度

假设有 n 个字符串,每个字符串平均长度为 k。

排序法对每个字符串排序:

O(k log k)

总时间约:

O(n · k log k)

字符计数法只遍历每个字符串一次:

O(k)

总时间约:

O(n · k)

26 个计数是固定大小,因此不会随字符串长度增长。

本题真正训练的新模式

49 第一次非常明确地训练:

不是查询一个现成的 key
而是先从对象中计算出“分类特征”
再用这个特征作为 hash key

即:

特征 key → 属于这一类的所有元素

重做检查点

  • 为什么不应该对所有字符串两两调用 Anagram 判断?
  • 什么性质使一个特征能够作为正确的分组 key?
  • 为什么排序后的字符串可以作为 key?
  • 为什么 26 个字符频率也可以作为 key?
  • 为什么 list 不能作为 dict key,而 tuple 可以?
  • groups.values() 为什么正好是题目要求返回的内容?

九、当前形成的哈希表统一思维

1. 先问“我需要查询什么”,再决定数据结构

不要看到题目属于“哈希表组”就机械创建 dict。

应该先区分:

只需要知道存在性?
→ set

需要 key 对应额外信息?
→ dict

需要统计次数?
→ dict / Counter

需要维护对应关系?
→ dict

需要按特征分类?
→ dict[key] = group

2. 哈希表常用来保存“过去的信息”

Two Sum 是这一点最典型的例子:

当前元素
→ 根据当前元素计算需要查询的东西
→ 到哈希表中查询过去已经维护的信息

这种结构能够避免针对每个当前元素再次遍历过去或未来的数据。

3. key 不一定是原始数据

217 中 key 就是数字本身;205 中 key 是字符;但 49 中 key 是经过计算得到的“规范化特征”。

因此遇到分类问题时应该问:

有没有一种表示,使属于同一类的对象一定得到相同表示?

4. “存在”“次数”“位置”“映射”是不同的信息需求

这几道题最容易混淆的地方,是不同容器看起来都“能做”。

例如 Counter 也可以用于 349,但题目不需要次数;dict 也能模拟 set,但 217 不需要 value。

选择数据结构时应该尽量让其语义和问题一致:

217:存在
1:位置
242:次数
349:存在 + 去重
205:映射
49:分类

5. 哈希表是在用空间换查询时间

Two Sum 从暴力的 O(n²) 下降到平均 O(n),代价是额外维护最多 O(n) 大小的哈希表。

因此“哈希表更快”不是没有成本,而是:

增加额外空间
→ 保存未来可能需要的信息
→ 避免重复线性查找

6. Python API 不能替代算法理解

Counter(s) == Counter(t)

虽然只有一行,但仍然是在建立并比较两个频率表。

set(nums1) & set(nums2)

虽然只有一行,但仍然是在建立两个集合并求交集。

因此复盘时不能只记 API,而应该能够解释它们背后的数据结构和复杂度。

十、当前暴露出的共性问题

1. 已经能想到“我要找什么”,但还需要进一步想到“提前维护什么”

Two Sum 中已经自然得到:

need = target - num

但第一反应仍然是遍历寻找 need。下一阶段需要形成:

如果我要反复查某个东西
→ 能不能在遍历过程中提前维护一个哈希结构?

2. 容易把“存在性”与“频率”混在一起

242 第一次使用 set,说明需要进一步区分:

集合相同
≠
每个元素出现次数相同

aab 与 abb 是最重要的反例。

3. 数据结构虽然能完成任务,但未必最贴合题意

349 使用 Counter 可以工作,但不需要频率信息;result 使用 list 又让去重检查变成线性查询。

因此不能只问:

这个数据结构能不能做?

还要问:

它是否保存了多余信息?
它的关键操作复杂度是否符合我的目标?

4. 需要从“观察外在模式”进一步提炼“不变量”

205 中“位置 + 次数”确实观察到了 egg 与 add 的结构,但代码难以落实。真正的不变量是:

同一个字符始终映射到同一个字符
不同字符不能映射到同一个字符

算法设计时,应尽量寻找能够在每一步直接检查的不变量,而不是记录所有表面现象。

5. 需要逐渐掌握“设计 key”

49 是本组最重要的升级之一。此前的 key 基本来自原始元素本身;49 要主动构造:

排序结果
或
字符频率 tuple

作为 key。

这意味着哈希表题进一步从“会使用 dict”进入:

如何找到能够代表问题状态或分类的正确 key?

十一、哈希表题统一检查清单

  1. 题目真正需要的是“存在”“次数”“位置”“映射”还是“分类”?
  2. 我是否正在对同一批数据反复做线性查找?能否提前维护哈希表?
  3. 如果只关心存在性,是否应该使用 set 而不是 dict / Counter?
  4. 如果需要额外信息,dict 的 key 和 value 分别应该表示什么?
  5. 如果需要频率,是否应该维护 元素 → 次数?
  6. 当前哈希表保存的是“过去的信息”还是“分类结果”?每次遍历如何更新?
  7. 如果题目要求一一映射,是否只检查了一个方向?反方向是否也必须唯一?
  8. 如果题目要求分组,我能否为每个对象计算一个稳定的规范化 key?
  9. 两个逻辑上同类的对象是否一定产生相同 key?不同类对象是否可能错误地产生相同 key?
  10. 这个 key 是否可哈希?如果使用 list,是否需要转换成 tuple?
  11. 是否在结果 list 上做了 x in result 之类的线性查询,从而破坏整体复杂度?
  12. 使用 Counter、集合运算等 Python API 时,我是否仍然能解释其底层逻辑和复杂度?
  13. 当前优化是否用额外 O(n) 空间换取了平均 O(1) 查询?这个 trade-off 是什么?
  14. 能否给出一个反例,证明更简单的数据结构为什么不够?

十二、延迟重做安排

在 3~7 天后从空白重做:

217、1、242、349、205、49

完成标准不只是通过测试,还应能够说明:

  • 为什么这题适合哈希表;
  • 为什么选择 set、dict、Counter 中的某一种;
  • key 和 value 分别代表什么;
  • 哈希表在遍历过程中维护了什么状态;
  • 为什么能够避免原本的重复遍历;
  • 时间复杂度和空间复杂度分别是什么;
  • 是否存在更简单但错误的数据结构选择,并能给出反例;
  • 如果是分组题,为什么设计出的 key 能稳定代表一个类别。

如果能够在不参考答案的情况下重做这六题,并能从“存在性、频率、位置、映射、分类特征”五种信息需求出发独立选择数据结构,可以将第 5 组基础哈希表部分标记为稳定掌握。