【LeetCodeHot100 超详细Agent启发版本】字母异位词分组 (Group Anagrams)

字母异位词分组 (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(Klog⁡K)O(K \log K)O(KlogK)。总时间复杂度为 O(NKlog⁡K)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(Klog⁡K)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(NKlog⁡K)O(NK \log K)O(NKlogK)

  • N: 字符串数量
  • K: 字符串最大长度
  • 每个字符串排序: O(Klog⁡K)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(nlog⁡n)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

相关推荐
无垠的广袤2 小时前
【VisionFive 2 Lite 单板计算机】边缘AI视觉应用部署:缺陷检测
linux·人工智能·python·opencv·开发板
froginwe112 小时前
Redis 管道技术
开发语言
u0109272712 小时前
C++中的RAII技术深入
开发语言·c++·算法
phoenix@Capricornus2 小时前
CNN中卷积输出尺寸的计算
人工智能·神经网络·cnn
创客匠人老蒋2 小时前
从数据库到智能体:教育企业如何构建自己的“数字大脑”?
大数据·人工智能·创客匠人
GJGCY2 小时前
技术解析|中国智能体4类路径深度拆解,这类底座架构优势凸显
人工智能·经验分享·ai·agent·智能体·数字员工
犀思云2 小时前
如何通过网络即服务平台实现企业数字化转型?
运维·网络·人工智能·系统架构·机器人
FIT2CLOUD飞致云2 小时前
学习笔记丨MaxKB Office Word AI翻译加载项的实现
人工智能·ai·开源·智能体·maxkb
superman超哥3 小时前
Serde 性能优化的终极武器
开发语言·rust·编程语言·rust serde·serde性能优化·rust开发工具