字母异位词分组 (Group Anagrams)
刚才我们通过"两数之和"掌握了哈希表 (Hash Map) 的精确检索能力。现在,这道 LeetCode 49. 字母异位词分组 (Group Anagrams),将带我们进入一个在 Agent 数据处理中更为核心的领域:特征提取 (Feature Extraction) 与 规范化 (Canonicalization)。
在 AI 领域,我们经常需要判断两条看似不同的数据(例如用户的 Prompt)是否表达了相同的核心意图。这道题就是这种思想的微缩模型。
1. 算法分析:寻找"指纹" (The Fingerprint)
核心挑战:我们需要判断两个字符串(如 "eat" 和 "tea")是否由相同的字符组成。这就意味着我们需要找到一个不变的特征 (Invariant),或者说是"指纹",使得所有互为"异位词"的字符串,生成的指纹都是一模一样的。
方法一:排序法 (Sorting as Canonicalization)
这是最直观的"规范化"手段。
逻辑: 不管字符顺序如何,只要包含的字母相同,按字母表排序后的结果一定相同。
变换:
- "eat" → "aet"
- "tea" → "aet"
- "ate" → "aet"
复杂度: 假设有 NNN 个字符串,每个字符串最大长度为 KKK。排序单个字符串需要 O(KlogK)O(K \log K)O(KlogK)。总时间复杂度为 O(NKlogK)O(NK \log K)O(NKlogK)。
方法二:计数法 (Histogram Encoding)
逻辑: 统计每个字符出现的次数(a-z 26个字母)。
变换: "eat" → (1, 0, 0, ..., 1, ..., 1, ...) ------ 这种 26 维的计数元组就是它的特征向量。
复杂度: O(NK)O(NK)O(NK)。虽然理论上更快,但在 Python 中元组操作和哈希开销较大,当 KKK 较小时(题目限制 K≤100K \le 100K≤100),排序法往往跑得更快且代码更易读。
决策: 作为 Agent 工程师,为了代码的可维护性和清晰度,我推荐先掌握排序法。它体现了将非结构化数据转化为标准结构的思想。
2. 代码优先 (Code-First) 实现
这里我们使用 Python 的 collections.defaultdict,这是处理分组类任务的神器,能极大简化代码逻辑。
python
from typing import List, Dict, Tuple
from collections import defaultdict
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
"""
使用排序特征作为 Key 进行哈希分组。
Args:
strs: 字符串数组
Returns:
分组后的字符串列表
"""
# 定义我们的"聚类"容器
# Key: 规范化后的字符串 (Canonical Form) -> 它是不可变的 (Immutable)
# Value: 原始字符串列表 (Cluster)
anagram_clusters: Dict[str, List[str]] = defaultdict(list)
for s in strs:
# 1. 特征提取 (Feature Extraction)
# 将字符串排序作为指纹。注意 sorted 返回的是列表,需要 join 回字符串或转为 tuple 才能作为 dict 的 key
# 时间复杂度: O(K log K), K 是字符串长度
signature = "".join(sorted(s))
# 2. 归类 (Clustering)
# 将原始字符串放入对应的特征桶中
anagram_clusters[signature].append(s)
# 3. 输出结果
# 我们只需要具体的分组内容,不需要 Key
return list(anagram_clusters.values())
# --- 单元测试 ---
if __name__ == "__main__":
solver = Solution()
input_strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
result = solver.groupAnagrams(input_strs)
# 打印结果查看分组情况
print(f"Input: {input_strs}")
print(f"Output: {result}")
# 预期输出 (顺序可能不同): [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]
代码详细步骤解析
步骤 1: 导入必要的模块
python
from typing import List, Dict, Tuple
from collections import defaultdict
- typing : 导入类型提示工具
List: 标注列表类型Dict: 标注字典类型Tuple: 标注元组类型(本例中未直接使用,但作为常用类型导入)
- collections.defaultdict: 自动初始化键值的字典,简化代码
意义: 类型注解提高代码可读性和 IDE 智能提示,defaultdict 避免键不存在的判断逻辑。
步骤 2: 定义解决方案类
python
class Solution:
- 作用: 创建类封装解题方法
- 意义: LeetCode 标准格式,便于代码组织和扩展
步骤 3: 定义核心方法
python
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
- 参数说明 :
self: 类实例引用strs: List[str]: 字符串列表,类型标注确保输入是字符串列表
- 返回值 :
List[List[str]]返回字符串列表的列表(每个子列表是一组异位词) - 意义: 类型注解让函数契约清晰,明确输入输出结构
步骤 4: 初始化聚类容器
python
anagram_clusters: Dict[str, List[str]] = defaultdict(list)
- 类型 :
Dict[str, List[str]]表示键是字符串,值是字符串列表 - defaultdict(list): 当访问不存在的键时,自动创建一个空列表
- Key (键): 排序后的字符串(规范化形式)
- Value (值): 原始字符串列表(同一组的所有字符串)
- 比喻: 就像把相似的数据自动归类到不同的"桶"里
为什么用 defaultdict?
- 普通字典需要先检查键是否存在:
if key not in dict: dict[key] = [] - defaultdict 自动处理,代码更简洁:
dict[key].append(value)
步骤 5: 遍历输入字符串
python
for s in strs:
- 作用: 逐个处理每个字符串
- s: 当前字符串变量
- 意义: 遍历是算法的核心,对每个元素执行特征提取和分组
步骤 6: 特征提取 - 生成签名
python
signature = "".join(sorted(s))
- sorted(s) : 将字符串转换为字符列表并排序
- 例如:
sorted("eat")→['a', 'e', 't']
- 例如:
- "".join(...) : 将排序后的字符列表重新组合成字符串
- 例如:
"".join(['a', 'e', 't'])→"aet"
- 例如:
- 时间复杂度 : O(KlogK)O(K \log K)O(KlogK),K 是字符串长度
- 作用: 生成"指纹",相同字母组合的字符串产生相同的签名
为什么需要 join?
sorted()返回的是列表['a', 'e', 't']- 列表是可变对象,不能作为字典的键
- 字符串是不可变对象,可以作为字典的键
步骤 7: 归类 - 分组存储
python
anagram_clusters[signature].append(s)
- anagram_clusters[signature]: 获取或创建对应签名的列表
- .append(s): 将原始字符串添加到该列表
- defaultdict 的优势: 如果签名不存在,自动创建空列表,无需手动判断
例子:
- 第一次遇到 "eat" → signature="aet" → 创建列表并添加 "eat"
- 第二次遇到 "tea" → signature="aet" → 找到已有列表,添加 "tea"
- 结果:
{"aet": ["eat", "tea"]}
步骤 8: 返回分组结果
python
return list(anagram_clusters.values())
- anagram_clusters.values(): 获取字典中所有值(即所有分组)
- list(...): 将视图对象转换为列表
- 返回: 只返回分组内容,不返回签名键
为什么不返回键?
- 题目要求只返回分组后的字符串列表
- 签名是内部使用的特征,不需要暴露给外部
步骤 9: 单元测试
python
if __name__ == "__main__":
solver = Solution()
input_strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
result = solver.groupAnagrams(input_strs)
print(f"Input: {input_strs}")
print(f"Output: {result}")
- 作用: 验证算法正确性
- 测试用例: 6个字符串,包含3组异位词
- 预期输出 :
[['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']] - 注意: 分组顺序可能不同,但每组内容应该一致
执行流程示例
以 strs = ["eat", "tea", "tan", "ate", "nat", "bat"] 为例:
| 迭代 | s | sorted(s) | signature | 操作 | anagram_clusters 状态 |
|---|---|---|---|---|---|
| 1 | "eat" | ['a','e','t'] | "aet" | 创建列表并添加 | {"aet": ["eat"]} |
| 2 | "tea" | ['a','e','t'] | "aet" | 添加到现有列表 | {"aet": ["eat", "tea"]} |
| 3 | "tan" | ['a','n','t'] | "ant" | 创建列表并添加 | {"aet": ["eat", "tea"], "ant": ["tan"]} |
| 4 | "ate" | ['a','e','t'] | "aet" | 添加到现有列表 | {"aet": ["eat", "tea", "ate"], "ant": ["tan"]} |
| 5 | "nat" | ['a','n','t'] | "ant" | 添加到现有列表 | {"aet": ["eat", "tea", "ate"], "ant": ["tan", "nat"]} |
| 6 | "bat" | ['a','b','t'] | "abt" | 创建列表并添加 | {"aet": ["eat", "tea", "ate"], "ant": ["tan", "nat"], "abt": ["bat"]} |
最终输出 : [['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]
时间复杂度 : O(NKlogK)O(NK \log K)O(NKlogK)
- N: 字符串数量
- K: 字符串最大长度
- 每个字符串排序: O(KlogK)O(K \log K)O(KlogK)
- 总共 N 次排序
空间复杂度 : O(NK)O(NK)O(NK)
- 需要存储所有字符串
- 字典存储分组信息
3. Java 版本实现
Java 作为企业级开发的主流语言,其语法更严格,类型系统更完善。以下是 LeetCode 官方的 Java 实现方案:
java
import java.util.*;
class Solution {
public List<List<String>> groupAnagrams(String[] strs) {
// 创建哈希表,用于存储分组结果
// Key: 排序后的字符串(签名)
// Value: 原始字符串列表
Map<String, List<String>> map = new HashMap<String, List<String>>();
// 遍历每个字符串
for (String str : strs) {
// 1. 将字符串转换为字符数组
char[] array = str.toCharArray();
// 2. 对字符数组进行排序
Arrays.sort(array);
// 3. 将排序后的字符数组转换回字符串作为 Key
String key = new String(array);
// 4. 获取或创建对应的列表
List<String> list = map.getOrDefault(key, new ArrayList<String>());
// 5. 将原始字符串添加到列表中
list.add(str);
// 6. 将列表放回哈希表
map.put(key, list);
}
// 7. 返回所有分组的值
return new ArrayList<List<String>>(map.values());
}
}
Java 语法详细解析
步骤 1: 导入必要的包
java
import java.util.*;
- java.util.*: 导入 Java 工具包的所有类
- 包含的类 :
Map: 映射接口HashMap: 哈希映射实现List: 列表接口ArrayList: 动态数组实现Arrays: 数组工具类
与 Python 对比:
- Python:
from typing import List, Dict - Java:
import java.util.*(更简洁但不够精确)
步骤 2: 定义解决方案类
java
class Solution {
- 作用: 定义一个类来封装解题方法
- 访问修饰符: 默认(包私有),LeetCode 要求
- 意义: Java 是面向对象语言,所有代码必须在类中
与 Python 对比:
- Python:
class Solution:(更简洁) - Java:
class Solution { ... }(需要大括号)
步骤 3: 定义核心方法
java
public List<List<String>> groupAnagrams(String[] strs)
- public: 公共访问修饰符,可以从任何地方访问
- List<List> : 返回类型,字符串列表的列表
- 外层
List<String>: 每个分组是一个字符串列表 - 内层
List<String>: 字符串列表
- 外层
- groupAnagrams: 方法名,采用驼峰命名法
- String[] strs : 参数,字符串数组
String[]: 字符串数组类型strs: 参数名
与 Python 对比:
- Python:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]: - Java:
public List<List<String>> groupAnagrams(String[] strs) - 区别: Java 不需要
self,类型在参数前,返回类型在方法名前
步骤 4: 创建哈希表
java
Map<String, List<String>> map = new HashMap<String, List<String>>();
- Map<String, List> : 接口类型声明
String: 键的类型(排序后的字符串)List<String>: 值的类型(原始字符串列表)
- new HashMap<String, List>() : 创建 HashMap 实例
- 使用泛型指定键值类型
<>: 泛型语法,指定类型参数
泛型的重要性:
- 提供编译时类型安全
- 避免运行时类型转换错误
- 提高代码可读性
与 Python 对比:
- Python:
anagram_clusters: Dict[str, List[str]] = defaultdict(list) - Java:
Map<String, List<String>> map = new HashMap<String, List<String>>(); - 区别: Java 需要显式指定泛型类型,Python 可以推断
步骤 5: 遍历字符串数组
java
for (String str : strs) {
- for-each 循环: Java 5 引入的增强 for 循环
- String str: 循环变量,类型为 String
- : strs: 冒号分隔,指定要遍历的数组
- { ... }: 循环体,用大括号包围
与 Python 对比:
- Python:
for s in strs: - Java:
for (String str : strs) { ... } - 区别: Java 需要声明变量类型,循环体用大括号
步骤 6: 字符串转字符数组
java
char[] array = str.toCharArray();
- str: 原始字符串对象
- .toCharArray() : String 类的方法
- 返回一个新的字符数组
- 包含字符串中的所有字符
- char[] array: 声明字符数组变量接收结果
为什么需要转数组?
- String 在 Java 中是不可变的
- 需要可变的字符数组来进行排序操作
- Arrays.sort() 只接受数组,不直接支持 String
示例:
java
String str = "eat";
char[] array = str.toCharArray();
// array = ['e', 'a', 't']
与 Python 对比:
- Python:
sorted("eat")直接返回列表 - Java:
str.toCharArray()需要显式转换
步骤 7: 排序字符数组
java
Arrays.sort(array);
- Arrays: java.util 包中的工具类
- .sort(array) : 静态方法,对数组进行排序
- 使用双轴快速排序算法
- 时间复杂度: O(nlogn)O(n \log n)O(nlogn)
- 原地排序,修改原数组
- array: 要排序的字符数组
示例:
java
char[] array = {'e', 'a', 't'};
Arrays.sort(array);
// array = ['a', 'e', 't']
与 Python 对比:
- Python:
sorted(s)返回新列表,不修改原字符串 - Java:
Arrays.sort(array)原地排序,修改原数组
步骤 8: 字符数组转字符串
java
String key = new String(array);
- new String(array) : 使用字符数组构造新的 String 对象
- 将字符数组转换为不可变的字符串
- 作为哈希表的键使用
- String key: 声明字符串变量接收结果
为什么需要转回 String?
- char[] 是可变的,不能作为 HashMap 的键
- String 是不可变的,可以作为键
- HashMap 的键必须是不可变对象
示例:
java
char[] array = {'a', 'e', 't'};
String key = new String(array);
// key = "aet"
与 Python 对比:
- Python:
"".join(['a', 'e', 't']) - Java:
new String(array) - 区别: Java 使用构造函数,Python 使用字符串方法
步骤 9: 获取或创建列表
java
List<String> list = map.getOrDefault(key, new ArrayList<String>());
- map.getOrDefault(key, defaultValue) : HashMap 方法
- 如果 key 存在,返回对应的值
- 如果 key 不存在,返回 defaultValue
- 不会将 defaultValue 放入 map
- key: 要查找的键(排序后的字符串)
- new ArrayList(): 默认值,创建新的空列表
- List list: 接收返回的列表
为什么用 getOrDefault?
- 简化代码,避免手动判断键是否存在
- Java 8 引入的便捷方法
- 类似 Python 的 defaultdict
等价的传统写法:
java
List<String> list;
if (map.containsKey(key)) {
list = map.get(key);
} else {
list = new ArrayList<String>();
}
与 Python 对比:
- Python:
anagram_clusters[signature](defaultdict 自动处理) - Java:
map.getOrDefault(key, new ArrayList<String>())(显式处理)
步骤 10: 添加字符串到列表
java
list.add(str);
- list: 列表对象
- .add(str) : List 接口的方法
- 在列表末尾添加元素
- 返回 boolean 表示是否成功
- str: 要添加的原始字符串
与 Python 对比:
- Python:
anagram_clusters[signature].append(s) - Java:
list.add(str) - 区别: 方法名不同(add vs append)
步骤 11: 将列表放回哈希表
java
map.put(key, list);
- map.put(key, value) : HashMap 方法
- 将键值对放入 map
- 如果 key 已存在,覆盖旧值
- 如果 key 不存在,添加新键值对
- key: 键(排序后的字符串)
- list: 值(字符串列表)
为什么需要 put?
- getOrDefault 不会自动将新列表放入 map
- 需要显式调用 put 来更新 map
- 确保后续访问能找到这个列表
与 Python 对比:
- Python: defaultdict 自动处理,不需要显式 put
- Java: 需要显式调用 put 更新 map
步骤 12: 返回结果
java
return new ArrayList<List<String>>(map.values());
- map.values(): 返回 map 中所有值的集合(Collection)
- new ArrayList<List>(...) : 使用 Collection 构造新的 ArrayList
- 将 Collection 转换为 List
- 符合方法返回类型要求
- return: 返回结果
为什么需要 new ArrayList?
- map.values() 返回 Collection,不是 List
- 方法签名要求返回 List
- 需要类型转换
与 Python 对比:
- Python:
list(anagram_clusters.values()) - Java:
new ArrayList<List<String>>(map.values()) - 区别: Java 需要指定泛型类型
Java 执行流程示例
以 strs = ["eat", "tea", "tan", "ate", "nat", "bat"] 为例:
| 迭代 | str | array (排序前) | array (排序后) | key | list (getOrDefault) | 操作 | map 状态 |
|---|---|---|---|---|---|---|---|
| 1 | "eat" | ['e','a','t'] | ['a','e','t'] | "aet" | [] | add "eat", put | {"aet": ["eat"]} |
| 2 | "tea" | ['t','e','a'] | ['a','e','t'] | "aet" | ["eat"] | add "tea", put | {"aet": ["eat", "tea"]} |
| 3 | "tan" | ['t','a','n'] | ['a','n','t'] | "ant" | [] | add "tan", put | {"aet": ["eat", "tea"], "ant": ["tan"]} |
| 4 | "ate" | ['a','t','e'] | ['a','e','t'] | "aet" | ["eat", "tea"] | add "ate", put | {"aet": ["eat", "tea", "ate"], "ant": ["tan"]} |
| 5 | "nat" | ['n','a','t'] | ['a','n','t'] | "ant" | ["tan"] | add "nat", put | {"aet": ["eat", "tea", "ate"], "ant": ["tan", "nat"]} |
| 6 | "bat" | ['b','a','t'] | ['a','b','t'] | "abt" | [] | add "bat", put | {"aet": ["eat", "tea", "ate"], "ant": ["tan", "nat"], "abt": ["bat"]} |
最终输出 : [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]]
Java vs Python 对比总结
| 特性 | Python | Java |
|---|---|---|
| 类型系统 | 动态类型,类型提示可选 | 静态类型,必须声明类型 |
| 泛型 | Dict[str, List[str]] |
Map<String, List<String>> |
| 默认字典 | defaultdict(list) |
getOrDefault(key, new ArrayList<>()) |
| 字符串排序 | sorted(s) 返回列表 |
Arrays.sort(array) 原地排序 |
| 字符串处理 | 不可变,直接操作 | 需要转换为数组再转回 |
| 循环语法 | for s in strs: |
for (String str : strs) { ... } |
| 方法调用 | dict[key].append(s) |
list.add(str); map.put(key, list) |
| 返回值 | list(dict.values()) |
new ArrayList<>(map.values()) |
Java 的优势:
- 类型安全:编译时捕获类型错误
- 性能:静态类型优化,运行时更快
- 工具支持:IDE 智能提示更完善
- 可维护性:代码结构更清晰
Python 的优势:
- 简洁:代码量更少
- 灵活:动态类型,开发更快
- 易读:语法更接近自然语言
- 生态:丰富的第三方库
4. Agent 视角的深度解析:从 Anagrams 到 Intent Detection
为什么这道题对理解 AI 很重要?
1. 规范化 (Canonicalization/Normalization)
在"字母异位词"中,我们把 "eat" 和 "tea" 映射到了同一个 Key "aet"。
在构建 Agent 的意图识别 (Intent Recognition) 模块时,我们做完全相同的事情:
用户 Query A: "帮我订一张去北京的票"
用户 Query B: "去北京,订票"
Agent 处理: 这两个 Query 在语义空间(Semantic Space)中极其接近。如果我们使用 Embedding 模型(如 OpenAI text-embedding-3-small),它们的向量距离会非常近。
连接: 本题中的"排序"就是一个确定性的 Embedding 函数 f(x)f(x)f(x),它把所有同义词映射到同一个点。
2. 数据去重 (Deduplication) 与 RAG
在构建 RAG (检索增强生成) 的知识库时,我们需要清洗数据。
如果文档库里有大量重复或仅语序不同的句子,会浪费 Token 和检索资源。
我们可以利用类似本题的逻辑(比如 MinHash 或 SimHash 算法)将相似的文本分到一组,只保留一个代表,从而优化向量数据库的存储。
4. 苏格拉底式总结 (Socratic Wrap-up)
你已经掌握了利用 Hash Map 进行"特征分组"的技巧。为了进一步提升你的系统设计能力,请思考以下问题:
大规模数据处理 (Big Data)
如果 strs 的数据量不是 10410^4104,而是 100 亿 个字符串,单机内存存不下这个 dict,你会如何利用 MapReduce 的思想来解决这个问题?
提示: Map 阶段生成 Key-Value,Reduce 阶段做什么?
模糊匹配 (Fuzzy Matching)
现在的逻辑是严格相等的(即字母完全一致)。如果我希望允许"错一个字母"也被归为一组(例如 "apple" 和 "apply" 算作近似),排序法还管用吗?
这时候我们需要引入什么技术?
提示: 编辑距离 Levenshtein Distance 或 Locality Sensitive Hashing - LSH