【学习记录】字母异位词分组:从"图书馆分类"到"特征指纹"------哈希表分组的经典范式
在学习了"判重(217)"、"集合归属(349)"、"频率相等(242)"和"第一个唯一字符(387)"之后,我们迎来了哈希表家族最重要的应用之一:字母异位词分组(LeetCode 49) 。如果说前面的题目都是"单个元素对单个元素"的查询,那么这道题则是"多元素对多元素"的聚合------它引入了哈希表最强大的应用场景之一:分组(Grouping)。今天,我们用图书馆分类的比喻,把这道题彻底拆透,并连接到大模型时代的"特征指纹"和"向量检索"思想。
📌 目录
- 题目描述
- 从熟悉到陌生:图书馆分类的比喻
- 核心概念解析(三层递进)
- 3.1 直觉层:什么是"特征指纹"?
- 3.2 机制层:代码执行时的"时间切片"
- 3.3 本质层:"分组"问题的数学本质
- 其他解法对比
- 代码实现(Python)
- 图解示例
- 复杂度分析
- 面试考点与注意事项
- [AI 知识扩展:字母异位词分组与大模型的"特征指纹"](#AI 知识扩展:字母异位词分组与大模型的“特征指纹”)
- 三句话带走
- 留给你的思考题
一、题目描述
给定一个字符串数组,将字母异位词组合在一起。字母异位词指字母相同但排列不同的字符串。
示例:
输入:strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
输出:
[
["eat","tea","ate"],
["tan","nat"],
["bat"]
]
说明:所有输入均为小写字母,顺序可以任意返回。
二、从熟悉到陌生:图书馆分类的比喻
想象你是一个图书管理员,手里有一堆散乱的书:
["eat", "tea", "ate", "tan", "nat", "bat"]
你的任务是:把同一类的书放在一起。
你怎么判断哪些书是同一类?
- 如果你直接比较
"eat"和"tea",它们看起来不同,但你能一眼看出它们"本质上是一类"------因为它们包含的字母完全相同,只是顺序不同。 - 问题来了:你怎么跟一个"不懂英语"的机器人说清楚这件事?
解决方案 :给每本书贴一个分类标签(即"特征指纹"):
"eat"→ 排序后"aet""tea"→ 排序后"aet""ate"→ 排序后"aet"
它们都有共同的标签 "aet",所以归为一类。
同理:
"tan"→"ant""nat"→"ant""bat"→"abt"
这样,分类就变成了一个简单的分组问题:相同标签的字符串放在一起。
三、核心概念解析(三层递进)
3.1 直觉层(Why):什么是"特征指纹"?
在算法中,我们经常需要为复杂对象找一个唯一的标识符(特征指纹),用它来快速判断"哪些对象本质上是相同的"。
例如:
- 字母异位词 → 排序后的字符串(
"aet")是特征指纹。 - 文档 → Embedding 向量是特征指纹。
- 用户 → 用户 ID 是特征指纹。
这个特征指纹必须满足两个条件:
- 相同对象 → 相同指纹 (
"eat"和"tea"都映射到"aet") - 不同对象 → 不同指纹 (
"eat"映射到"aet","bat"映射到"abt",不同)
在哈希表中,键(Key)就是特征指纹,值(Value)就是具有该指纹的所有元素。
3.2 机制层(How):代码执行时的"时间切片"
python
from collections import defaultdict
def groupAnagrams(strs):
mp = defaultdict(list) # 创建一个字典,默认值为空列表
for s in strs: # 遍历每个字符串
key = ''.join(sorted(s)) # 排序,得到特征指纹
mp[key].append(s) # 把原字符串放入对应的分组
return list(mp.values()) # 返回所有分组
执行过程 (以 strs = ["eat","tea","tan","ate","nat","bat"] 为例):
初始化: mp = {}
第一轮: s = "eat" → sorted = ['a','e','t'] → key = "aet"
mp["aet"] = ["eat"]
第二轮: s = "tea" → sorted = ['a','e','t'] → key = "aet"
mp["aet"] = ["eat", "tea"]
第三轮: s = "tan" → sorted = ['a','n','t'] → key = "ant"
mp["ant"] = ["tan"]
第四轮: s = "ate" → sorted = ['a','e','t'] → key = "aet"
mp["aet"] = ["eat", "tea", "ate"]
第五轮: s = "nat" → sorted = ['a','n','t'] → key = "ant"
mp["ant"] = ["tan", "nat"]
第六轮: s = "bat" → sorted = ['a','b','t'] → key = "abt"
mp["abt"] = ["bat"]
最终结果: [["eat","tea","ate"], ["tan","nat"], ["bat"]]
3.3 本质层(What):"分组"问题的数学本质
这道题在考察你能否为复杂对象找到唯一的规范表示(Canonical Form)。
| 问题 | 规范表示 |
|---|---|
| 字母异位词分组 | 排序后的字符串(sorted(s)) |
| 数值区间合并 | 排序后的区间数组 |
| 图连通分量 | 根节点(Union-Find) |
| 文档去重 | 哈希值(SimHash) |
规范表示 的核心思想是:将等价类中的每个元素映射到同一个唯一标识符。当这个映射建立起来之后,原来的"分组问题"就变成了简单的"哈希表按键分组"问题。
四、其他解法对比
| 方法 | 核心思路 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 排序 + 字典(推荐) | 用 sorted(s) 作为键 |
O(n × k log k) | O(n × k) |
| 计数 + 字典(字符频率) | 用字符频次元组作为键 | O(n × k) | O(n × k) |
| 质数乘积(数学技巧) | 每个字母映射到质数,乘积作为键 | O(n × k) | O(n × k) |
方法二:字符频率作为键(更高效)
python
def groupAnagrams(strs):
mp = defaultdict(list)
for s in strs:
count = [0] * 26
for ch in s:
count[ord(ch) - ord('a')] += 1
key = tuple(count) # 元组作为键
mp[key].append(s)
return list(mp.values())
这种方法的时间复杂度是 O(n × k),优于排序法的 O(n × k log k)。但需要注意:Python 中 list 不可哈希,需要转为 tuple。
五、代码实现(Python)
解法一:排序 + 字典(最推荐,代码简洁)
python
from collections import defaultdict
def groupAnagrams(strs):
mp = defaultdict(list)
for s in strs:
key = ''.join(sorted(s))
mp[key].append(s)
return list(mp.values())
解法二:字符频率元组作为键(最高效)
python
from collections import defaultdict
def groupAnagrams(strs):
mp = defaultdict(list)
for s in strs:
count = [0] * 26
for ch in s:
count[ord(ch) - ord('a')] += 1
mp[tuple(count)].append(s) # tuple 可哈希
return list(mp.values())
解法三:质数乘积法(数学技巧,面试加分项)
python
from collections import defaultdict
def groupAnagrams(strs):
primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101]
mp = defaultdict(list)
for s in strs:
key = 1
for ch in s:
key *= primes[ord(ch) - ord('a')]
mp[key].append(s)
return list(mp.values())
质数乘积法基于算术基本定理 :每个整数的质因数分解是唯一的。因此,每个字母异位词组会得到同一个质数乘积,而不同组则会得到不同乘积。缺点是大字符串可能产生极大的整数(Python 的 int 可以处理,但性能可能下降)。
六、图解示例
输入: ["eat","tea","tan","ate","nat","bat"]
第一步:为每个字符串计算"特征指纹"
eat → 排序 → aet
tea → 排序 → aet ← 相同指纹,是一组
ate → 排序 → aet ← 相同指纹
tan → 排序 → ant
nat → 排序 → ant ← 相同指纹
bat → 排序 → abt ← 独一份
第二步:按指纹分组
aet: ["eat","tea","ate"]
ant: ["tan","nat"]
abt: ["bat"]
第三步:返回分组列表
七、复杂度分析
| 解法 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 排序 + 字典 | O(n × k log k) | O(n × k) |
| 字符频率元组 | O(n × k) | O(n × k) |
| 质数乘积 | O(n × k) | O(n × k) |
其中:
- n = 字符串数组的长度
- k = 字符串的最大长度
八、面试考点与注意事项
Q1:为什么用 defaultdict(list) 而不是普通字典?
答 :defaultdict(list) 在访问不存在的键时会自动创建一个空列表,省去了 if key not in mp: mp[key] = [] 的重复代码,让代码更简洁。
Q2:排序法的时间复杂度是 O(n × k log k),是否还能优化?
答:可以。用字符频率元组作为键,将时间复杂度优化到 O(n × k)。面试时,如果你能在给出排序法之后主动提出"还可以用计数法进一步优化",会加分。
Q3:如果字符串很长(如 10^5 个字符),用 sorted() 会导致性能问题吗?
答 :会。sorted() 的时间复杂度是 O(k log k),对于超长字符串(如一篇长文档)会明显慢。此时用计数法更合适(O(k))。
Q4:质数乘积法的优缺点是什么?
答:
- 优点:键是整数,哈希速度快。
- 缺点 :
- 质数乘积可能变得极大(例如 100 个字符的字符串,乘积可能超过 10^100),虽然 Python 能处理大整数,但会变慢。
- 需要维护质数列表,增加了代码复杂度。
- 只适用于固定字符集(如 26 个小写字母)。
九、AI 知识扩展:字母异位词分组与大模型的"特征指纹"
在 RAG(检索增强生成)系统中,我们经常需要为文档生成特征指纹(Feature Fingerprint),用于判断两个文档是否"本质相同"。
9.1 文档去重中的"特征指纹"
在 RAG 系统中,一个常见问题是:同一个文档被多次加载,导致向量数据库中出现重复的 Chunk。
解决方案:为每个文档生成一个"指纹"。
- 一种做法:文档的 MD5 哈希值。
- 另一种做法:对文档内容进行预处理(去停词、词干化),然后计算 SimHash(一种局部敏感哈希),用于快速判断文档是否高度相似。
这和我们今天做的 sorted(s) 本质思想完全一致------为复杂对象寻找唯一、稳定的特征标识。
9.2 从"排序字符串"到"向量化"的映射
| 字母异位词分组 | 大模型 / RAG 系统 |
|---|---|
字符串 s |
文档 / Chunk |
sorted(s) 作为特征指纹 |
Embedding 向量作为特征指纹 |
| 相同指纹 → 相同分组 | 相似向量 → 相关文档(向量检索) |
| 分组后返回同类字符串 | 聚类(Clustering)后返回同类文档 |
9.3 更深的连接:特征指纹的可视化
在 RAG 系统中,Embedding 向量的本质就是文档在语义空间中的"坐标"。不同的文档如果在语义空间中距离很近,说明它们具有相似的语义指纹,就像 "eat" 和 "tea" 排序后都是 "aet" 一样。
这揭示了字母异位词分组这道题更深层的价值:它教会我们如何用最简单的哈希表,做最复杂的"语义分组"的原型模拟。
十、三句话带走
- 直觉:就像图书馆管理员用"分类标签"把同一类书放在一起,我们为每个字符串贴一个"排序后的标签"(特征指纹),然后按标签分组。
- 机制 :用
sorted(s)作为哈希表的键,将相同字母组成的字符串归到同一个列表里。 - 本质:分组问题的核心是为等价类找到唯一的规范表示(Canonical Form)。这和大模型中的 Embedding、特征指纹是同一类思想。
十一、留给你的思考题
问题 :如果字符串包含中文字符(如
["你好", "好你"]),用sorted(s)还能正常工作吗?质数乘积法还能用吗?
提示:
sorted()对中文字符依然有效(按 Unicode 码点排序),所以"你好"和"好你"会映射到相同的键。- 但质数乘积法只适用于 26 个小写字母(或有限字符集)。如果要支持全量 Unicode,需要扩展质数表到 100 万,不现实。
连接到大模型 :在处理多语言文本时,如何找到不同语言之间的"语义指纹"?------这对应着**多语言向量对齐(Multilingual Embedding Alignment)**问题。例如,英文的 "hello" 和中文的 "你好",在语义空间中应该具有相似的向量。这和异位词的 sorted() 在精神上是相通的------语言不同,但语义指纹相同。
🎯 拆解完毕
今天,我们从"图书馆分类"出发,彻底拆透了 LeetCode 49。它不仅是哈希表分组的经典范例,更是"特征指纹"思想的入门课。当你能够把这道题和 RAG 系统的文档去重、向量聚类自然地连接起来时,你就不再是"刷题"------你是在建立算法思维与大模型工程的底层共鸣。😊