字母异位词分组:从“图书馆分类”到“特征指纹”——哈希表分组的经典范式

【学习记录】字母异位词分组:从"图书馆分类"到"特征指纹"------哈希表分组的经典范式

在学习了"判重(217)"、"集合归属(349)"、"频率相等(242)"和"第一个唯一字符(387)"之后,我们迎来了哈希表家族最重要的应用之一:字母异位词分组(LeetCode 49) 。如果说前面的题目都是"单个元素对单个元素"的查询,那么这道题则是"多元素对多元素"的聚合------它引入了哈希表最强大的应用场景之一:分组(Grouping)。今天,我们用图书馆分类的比喻,把这道题彻底拆透,并连接到大模型时代的"特征指纹"和"向量检索"思想。


📌 目录

  1. 题目描述
  2. 从熟悉到陌生:图书馆分类的比喻
  3. 核心概念解析(三层递进)
    • 3.1 直觉层:什么是"特征指纹"?
    • 3.2 机制层:代码执行时的"时间切片"
    • 3.3 本质层:"分组"问题的数学本质
  4. 其他解法对比
  5. 代码实现(Python)
  6. 图解示例
  7. 复杂度分析
  8. 面试考点与注意事项
  9. [AI 知识扩展:字母异位词分组与大模型的"特征指纹"](#AI 知识扩展:字母异位词分组与大模型的“特征指纹”)
  10. 三句话带走
  11. 留给你的思考题

一、题目描述

给定一个字符串数组,将字母异位词组合在一起。字母异位词指字母相同但排列不同的字符串。

示例

复制代码
输入: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 是特征指纹。

这个特征指纹必须满足两个条件:

  1. 相同对象 → 相同指纹"eat""tea" 都映射到 "aet"
  2. 不同对象 → 不同指纹"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" 一样。

这揭示了字母异位词分组这道题更深层的价值:它教会我们如何用最简单的哈希表,做最复杂的"语义分组"的原型模拟。


十、三句话带走

  1. 直觉:就像图书馆管理员用"分类标签"把同一类书放在一起,我们为每个字符串贴一个"排序后的标签"(特征指纹),然后按标签分组。
  2. 机制 :用 sorted(s) 作为哈希表的键,将相同字母组成的字符串归到同一个列表里。
  3. 本质:分组问题的核心是为等价类找到唯一的规范表示(Canonical Form)。这和大模型中的 Embedding、特征指纹是同一类思想。

十一、留给你的思考题

问题 :如果字符串包含中文字符(如 ["你好", "好你"]),用 sorted(s) 还能正常工作吗?质数乘积法还能用吗?

提示

  • sorted() 对中文字符依然有效(按 Unicode 码点排序),所以 "你好""好你" 会映射到相同的键。
  • 但质数乘积法只适用于 26 个小写字母(或有限字符集)。如果要支持全量 Unicode,需要扩展质数表到 100 万,不现实。

连接到大模型 :在处理多语言文本时,如何找到不同语言之间的"语义指纹"?------这对应着**多语言向量对齐(Multilingual Embedding Alignment)**问题。例如,英文的 "hello" 和中文的 "你好",在语义空间中应该具有相似的向量。这和异位词的 sorted() 在精神上是相通的------语言不同,但语义指纹相同


🎯 拆解完毕

今天,我们从"图书馆分类"出发,彻底拆透了 LeetCode 49。它不仅是哈希表分组的经典范例,更是"特征指纹"思想的入门课。当你能够把这道题和 RAG 系统的文档去重、向量聚类自然地连接起来时,你就不再是"刷题"------你是在建立算法思维与大模型工程的底层共鸣。😊