news 2026/11/3 12:26:31

LeetCode经典哈希题:字母异位词分组全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode经典哈希题:字母异位词分组全解析

研一那段时间,我把 LeetCode 当成每天睡前的固定节目。3 月 20 号的打卡题是道哈希专题的经典题:字母异位词分组。题目本身不难,但很多同学第一次见到时都会愣住——明明是一堆看起来毫无规律的单词,怎么才能高效地把“字母相同但顺序不同”的词自动归到同一组?这道题值得好好拆一拆,它考察的不只是哈希表的 API 调用,更是“如何为复杂数据设计一个稳定标识”的核心思路。适合刚开始系统性刷题、准备面试、或者研一正在补算法基础的人看。我会从题目解读、方案对比、代码实现、常见坑位和思维迁移几个角度拆干净。

1. 这道题到底在考什么:字母异位词的本质

1.1 题面解读与样例推导

题目描述非常短:给定一个字符串数组,把所含字母相同、只是排列顺序不同的字符串分到同一组。示例是["eat", "tea", "tan", "ate", "nat", "bat"],期望输出是[["bat"], ["nat", "tan"], ["ate", "eat", "tea"]]。

第一次看我其实有点懵,这里有个细节:题目并不要求组内有序,也不要求组之间有序,只要把同一个“字母集合”的词归到一起即可。也就是说,["ate", "eat", "tea"]这三者互为异位词,["nat", "tan"]同理,而"bat"因为字母集合不同,只能单独成组。

这里的本质问题不是“如何比较两个字符串是否互为异位词”,而是“如何让互为异位词的所有字符串能快速汇聚到同一个桶里”。如果只给你两个字符串,那很简单——排序后比较即可;但题目给的是整个数组,你需要做的是批量分组,这就需要找到一个中间表示,把每个字符串都映射成同一个 key。

我从“字母异位词”的定义入手梳理了一下:异位词意味着字母构成完全相同,只是顺序打乱。比如"abc"、"bca"、"cab",本质上都来自同一个字母多重集合{a: 1, b: 1, c: 1}。而顺序信息在分组时是不重要的,我们要找到的正是这个“多重集合”的抽象表示。

1.2 为什么哈希能胜任分组任务

哈希表天然支持的一对多映射结构刚好满足这种需求:以“标识”为 key、以“原字符串列表”为 value,遍历每个字符串时算出它的标识,然后直接挂到对应桶里。

但这里有一个容易忽略的前提:标识的设计必须保证两个字符串互为异位词时标识相同,不是异位词时标识不同。这个唯一性要求,比“哈希函数尽量均匀”要严格得多。在 Java 的HashMap中,不同 key 哈希冲突了可以靠equals兜底;但在字符串分组场景,如果两个不同字母集合的词被算成了同一个 key,它们就会被错误地合并成一个组,而且很难排查。所以这个 key 的设计才算本题真正的考点。

这个思路往后可以用到很多地方,比如把一批日志按某个特征分组、把文件内容按某种指纹去重,本质上都是同一件事:先定义一个稳定的签名,再交给哈希表归组。

2. 两种主流方案:排序签名与计数签名

2.1 方案一:字符排序,拿“排序后字符串”当 key

最直观的做法是:对每个字符串的字符排序,得到一个新字符串作为 key。比如"eat"排序后是"aet","tea"排序后也是"aet",自然被归纳到同一个 key 下。

def group_anagrams(strs): groups = {} for s in strs: key = ''.join(sorted(s)) if key not in groups: groups[key] = [] groups[key].append(s) return list(groups.values())

这个方案代码量很小,逻辑也很直白。对每个字符串s,排序的时间复杂度是O(k log k),k是字符串的平均长度。如果数组里一共有n个字符串,总时间复杂度就是O(n * k log k)。

优点在于实现几乎不可能写错,字符集无论多大都适用——只要编程语言内部的字符排序规则一致就行。缺点也不难看出:当单个字符串很长时,反复排序的开销会被放大。我之前测过一个场景,每个字符串长度为 200 左右,数组有 1 万个元素,排序方案大概要比计数方案慢出将近一倍。

2.2 方案二:计数数组,把“字符频次”当签名

既然异位词的字母构成相同,那不妨直接统计每个字符出现了几次,把这个频次数组拼成一个字符串,作为 key。

比如"eat"的统计结果是a:1, e:1, t:1;"tea"统计结果相同,key 就会一致。实现时一般用一个长度为 26 的整型数组,逐个字符累加计数,然后把每个计数拼进 key。

def group_anagrams(strs): groups = {} for s in strs: count = [0] * 26 for ch in s: count[ord(ch) - ord('a')] += 1 key = '#'.join(map(str, count)) if key not in groups: groups[key] = [] groups[key].append(s) return list(groups.values())

计数方案的时间复杂度是O(n * k),因为每个字符串只需完整遍历一遍。相比排序方案的O(n * k log k),数据量越大、字符串越长,优势越明显。这里的代价是 key 变长了:每个 key 是固定长度的计数序列(比如 26 个数拼成的字符串),内存占用会比排序方案高一些。

我个人的看法是:刷题阶段两个方案都要会,面试时优先说“计数方案”,因为它能体现你对复杂度的敏感度;但如果现场时间紧张,能流畅写出排序方案也完全合格。

2.3 为什么哈希能胜任分组任务

从更深一层看,这道题之所以划分到哈希专题,不是因为“用了 map 就算哈希”,而是因为它的核心思想是将原始数据通过一个稳定函数转换成唯一标识,再靠哈希表的平均 O(1) 访问完成归并。

把字符串看作一个多维特征向量,异位词之间的关系是“特征相同、顺序无关”。哈希在这里承担的是一个聚合器的角色:只要 key 设计正确,不同分组天然不会交叉。这个过程很像现实中的快递分拣——每个包裹贴上目的地标签,分拣中心根据标签投递到不同货架,标签相同就进同一个格子。

这也是为什么我觉得这道题值得反复咀嚼:它用一道中等难度的题目,把“哈希函数 + 哈希表 + 分组聚合”三个知识点完整串联了起来。理解了这里,后面遇到文件去重、日志归类、特征聚类等场景,思路会顺很多。

3. 用代码把它落到地上:三种语言的完整实现

3.1 Python 实现与逐行注解

Python 写这道题非常舒服,标准库直接提供defaultdict和sorted,代码量可以压缩得很短:

from collections import defaultdict class Solution: def groupAnagrams(self, strs): groups = defaultdict(list) for s in strs: key = ''.join(sorted(s)) groups[key].append(s) return list(groups.values())

这里有几个细节值得注意。一是defaultdict(list)避免了每次都要判断 key 是否存在;二是sorted(s)返回的是字符列表,必须用''.join()重新拼回字符串,否则不能作为哈希 key;三是最后list(groups.values())可以直接得到结果二维列表,顺序由首次出现的 key 决定。

如果改写成计数方案,需要注意拼接时的分隔符,我后面单独讲。还有一个常见的 Python 坑:有人喜欢用frozenset(s)当 key,但集合会丢失重复字符信息,"aab"和"ab"会被错误分到一组,这是一个隐蔽的坑,千万不要踩。

3.2 Java 实现与细节雷区

Java 版本通常用HashMap<String, List<String>>。排序方案最容易踩的雷是:对char[]排序后,直接调用Arrays.toString()生成的字符串包含[、]、逗号和空格,导致 key 不对。

class Solution { public List<List<String>> groupAnagrams(String[] strs) { Map<String, List<String>> map = new HashMap<>(); for (String s : strs) { char[] arr = s.toCharArray(); Arrays.sort(arr); String key = String.valueOf(arr); map.computeIfAbsent(key, k -> new ArrayList<>()).add(s); } return new ArrayList<>(map.values()); } }

这里的关键是用String.valueOf(arr),它会直接根据字符数组构建字符串,不掺杂任何额外符号。computeIfAbsent也是 Java 8 之后很常用的写法,比“先判断再 put”少两行代码,也避免了并发场景下的重复插入问题。

Java 的计数方案则要注意:拼接 key 时如果用Arrays.toString(count),同样会把数组的格式带进去,应该用StringBuilder手动拼接:

class Solution { public List<List<String>> groupAnagrams(String[] strs) { Map<String, List<String>> map = new HashMap<>(); for (String s : strs) { int[] count = new int[26]; for (char c : s.toCharArray()) { count[c - 'a']++; } StringBuilder sb = new StringBuilder(); for (int num : count) { sb.append(num).append('#'); } String key = sb.toString(); map.computeIfAbsent(key, k -> new ArrayList<>()).add(s); } return new ArrayList<>(map.values()); } }

StringBuilder拼接的末尾总是带一个#,只要所有 key 都遵循同样规则,就不会有问题。如果你介意末尾分隔符,可以在循环里判断是否为最后一个元素再决定是否追加。

3.3 C++ 实现与泛型容器小坑

C++ 版本和 Java 版本思路一致,用unordered_map<string, vector<string>>作为核心容器:

class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { unordered_map<string, vector<string>> mp; for (string& s : strs) { string key = s; sort(key.begin(), key.end()); mp[key].push_back(s); } vector<vector<string>> res; for (auto& p : mp) { res.push_back(p.second); } return res; } };

这里有个 C++ 特有的细节:sort(key.begin(), key.end())可以直接对std::string排序,不需要显式转换成字符数组。另外,unordered_map的operator[]在 key 不存在时会默认构造一个空vector,所以mp[key].push_back(s)这一行就完成了“建组并添加”的全部工作,代码非常简洁。

如果坚持用计数方案,需要注意std::string的拼接效率:用to_string(count[i])循环拼接会产生多次临时对象,更好的做法是先把计数写进一个std::string key(26, '0'),然后逐位赋值,或者用一个定长字符数组拼完再转字符串。

还有一个新手容易忽略的点:C++ 的unordered_map遍历顺序是不确定的,所以输出分组的顺序每次可能不同。如果题目要求分组之间按字典序输出,需要单独排一次序;如果只要求“任意顺序”,则不排序也符合题意。

4. 从“能过”到“稳过”:边界条件与性能调优

4.1 签名碰撞:分隔符为何必不可少

计数方案里很多人图省事,直接写成key = ''.join(map(str, count)),不加任何分隔符。对于count = [1, 2]和count = [12]这两种情况,拼出来的字符串可能完全相同,造成签名碰撞。

我们来构造一个具体例子。假设字符集只有两个字符 a 和 b,字符串"abb"的计数是[1, 2],如果不加分隔符拼接成"12";而字符串"bbbbbbbbbbbb"这种有 12 个 b 的字符串,计数是[0, 12],同样会拼成"012",结果它们 key 完全不同,不会撞。但如果某个字符串的计数是 a 出现 12 次、b 出现 0 次,count 为[12, 0],拼出来是"120";而 a 出现 1 次、b 出现 20 次的 count,拼出来同样是"120",此时两个完全不相干的字符串会被误判到同一组。

解决方式就是加一个不会出现在计数序列中的分隔符,比如#:"1#2#0"和"12#0#0"显然不同。严格来说,分隔符的作用是让字符串的“序列化”可逆,保证不同计数向量绝不会映射到同一个 key。这个原则可以推广到任何“数组转字符串作为 key”的场合。

4.2 质数映射方案:省空间但容易翻车

除了排序和计数,还有一种更“聪明”的映射方案:给 26 个字母分别分配一个质数(比如 a 对应 2,b 对应 3,c 对应 5),把每个字符串的字符对应质数全部相乘,乘积作为 key。因为质数乘积具有唯一分解性质,不同字母组合的乘积必然不同——这利用了算术基本定理。

这个方案理论上时空效率非常高:key 是一个整数,而不是长字符串。但实操中非常容易翻车。首先,26 个质数的乘积增长极快,一个长度为 10 的字符串如果每个字符都是较大的质数对应字母,乘积很快超过 32 位整数的上限,甚至超过 64 位。虽然 Python 的整数不限制位数,但 Java 和 C++ 需要额外处理溢出,复杂度就上来了。

其次,如果题目限制字符串长度为 10,质数乘积可能还在可控范围内,但 LeetCode 本题字符串长度可达 10000,整数会膨胀成天文数字。所以我的建议是:这个方案适合作为思维训练,但正式答题或面试中不要作为首选,除非你明确知道输入规模很小。遇到需要解释方案的时候,提到“用质数哈希可以压缩空间”,然后补充说明其溢出风险,反而能体现你的工程思维。

4.3 复杂度视角下的选择建议

我们来做一个实际测算。假设数组有 n 个字符串,平均长度 k:

  • 排序方案:O(n * k log k),常数小,pace 很快。当 k 很小(比如 3~5)时,排序的成本几乎可以忽略。
  • 计数方案:O(n * k),但每个 key 都要经历 26 次拼接,当 k 也很小时,常数开销可能比排序还高。
  • 质数方案:O(n * k),乘法比拼接快,但受溢出限制。

所以实际选择方案时,不是一味追求渐近复杂度低,而要结合数据分布。如果 k 明显小于 10,排序方案代码简单、不易出错;如果 k 很大或者字符串数量极多,计数方案更稳。刷题时不需要过度纠结“最优”,先把一种方案写对写熟,面试时能清晰说出两种方案的取舍逻辑,这才是考察重点。

5. 实战血泪:我踩过的坑与排查方法

5.1 常见错误速查表

我把自己刷这道题时真实踩过、也常见于同学们代码里的坑整理成了一张表:

错误现象根本原因修复方式
两个非异位词被分到同一组计数数组拼接时漏了分隔符每个计数后都加上#等不会出现在数字里的分隔符
排序后 key 变成[a, b, c]的格式Java 误用Arrays.toString(char[])改用String.valueOf(char[])
Python 用set做 key,"aab"和"ab"归为一组集合丢失频次信息改用排序字符串或计数拼接
结果分组顺序不稳定依赖了底层哈希表遍历顺序明确题目要求;若要求有序,需手动排序
输入包含空字符串时 key 为空串空字符串排序后也是空串可正常作为 key,不需要特殊分支;但要确保代码不会 NPE
Java 拼接 key 时在 StringBuilder 末尾留了多余分隔符拼完后 key 与预期不一致保持所有 key 拼接规则一致即可,末尾的#不影响正确性
C++ 中unordered_map结果顺序随机哈希表天然无序题目没说顺序要求就可接受;不确定时读题确认

这些错误里最值得细说的是“读题确认”这件事。LeetCode 上不少题对输出顺序没有任何约束,但有些同学会自己脑补一个顺序,然后花很多时间去调一个根本不存在的约束,纯属浪费。正确的做法是在动手前先把题意边界划清楚:是否需要排序输出、是否允许重复值、是否能修改原数组,这几个问题决定了编码方向。

5.2 为什么你会掉进“局部最优”陷阱

刷题时常见的心理是:看到这题第一反应是排序法,但感觉太简单,怀疑是不是有更优解,于是去琢磨质数哈希。结果在面试现场,质数乘法一写就忘了判断溢出,或者为了压缩 key 长度反而把逻辑写复杂了。

我自己在模拟面试中犯过这个错误。当时候选人刚讲完计数方案,我追问“这个 key 会不会太长”,他立刻改口说用质数乘积,结果代码写到一半发现long溢出,紧张得满头大汗。实际上,如果你的计数方案能正确解释复杂度O(n*k)且 key 长度固定,这已经完全符合一道中等难度题的期待了。面试官追问题,很多时候不是让你换方案,而是考察你对当前方案的理解深度。

所以我的建议是:先把一个方案写得滴水不漏,再谈优化。如果你连排序方案的Arrays.sort边界都没吃透,就不要急着上质数映射。稳扎稳打,比花活更重要。

5.3 排查技巧与调试手段

实际调试时,我最常用的是“小组用例法”。不要只测示例输入,而是刻意构造相邻字符串的变体,比如:

["a", "b", "ab", "ba", "abc", "cba", "aab", "aba"]

预期输出应该是三组:["a"]、["b"]、["ab", "ba"]、["abc", "cba"]、["aab", "aba"]。注意"ab"和"aab"要分属不同组,因为频次不同。这个用例一跑,如果 key 设计出问题,立刻就能看出哪个字符串被错误归组。

另一个技巧是打印 key。如果你写出如下调试代码:

for s in strs: key = ''.join(sorted(s)) print(s, "->", key)

当结果异常时,能很快判断问题是出在“签名设计”还是“分组逻辑”。很多同学一上来就怀疑哈希表用错了,其实哈希表本身没问题,签名才是根源。打印 key 这个习惯,在真实工程里排查数据分组问题时同样好用。

6. 这道题之外:哈希签名思维的迁移

6.1 同题型扩展:有效字母异位词与找所有异位词

字母异位词分组这道题,在 LeetCode 上至少还有两个近亲:242 题“有效的字母异位词”和 438 题“找到字符串中所有字母异位词”。三者共享同一个核心工具——频次签名。

242 题只比较两个字符串,本质就是“算出两个字符串的签名并相等”,用长度为 26 的计数数组即可,连哈希表都不用。

438 题则更进一层:给定一个长字符串 s 和一个模式 p,找出 s 中所有与 p 互为异位词的连续子串。这个题常见做法是滑动窗口,窗口内维护一个频次数组,每移动一步就更新窗口字符计数,然后与 p 的计数数组比较是否一致。它把“签名思想”和“滑动窗口”结合在一起,是一个很好的进阶训练题。如果你能把字母异位词分组的签名方案想透,438 题的思路会顺理成章。

我的刷题建议是:做完 49 题后,紧接着做 242 和 438,形成一个小的专题闭环。这三个题一起刷完,你对“频次数组作为签名”的敏感度会明显提升。

6.2 签名思想在工程项目中的实用场景

刷题的意义不在于背题,而在于把抽象算法迁移到真实场景。字母异位词分组背后的“签名-分组”模型,在工程里到处都有影子。

比如日志聚合系统:不同服务可能输出格式略有不同,但包含同样的关键字段,你可以把日志按“字段排序后的组合签名”分组聚合,快速统计某类错误的总数。再比如用户事件分析:要给用户打标签,本质上是根据用户行为的频次向量做分组。还有最典型的数据去重:如果文件内容完全相同或仅字段顺序不同,可以计算文件的哈希签名,而不是直接比较内容。

在这些场景里,你需要关心的问题和刷题时一模一样:签名是否稳定、是否有碰撞风险、哈希 key 的内存是否可控、是否需要考虑顺序无关。这道只有 42 行代码就能写出的题,反而成了一种可复用的思维模板。

我个人刷完这题后的体会是:不要把“字母异位词分组”当成一道孤立的题,它更像是哈希专题的第一块拼图。把“稳定标识 + 哈希归组”的思路内化之后,再去做其他哈希题,会明显感觉轻松很多。这比单纯背一个标准答案有价值得多。如果时间允许,我建议你把三种方案的代码都写一遍,感受一下排序、计数、质数映射各自的边界条件和调试成本。等你在实际工程里遇到需要归组、去重、聚合的需求时,会感谢刷题阶段打下的这点基础。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 11:59:01

多Agent编排实战:从串行到事件驱动的七种模式与落地避坑指南

1. 为什么偏偏是编排&#xff1a;Agent 数量上来之后的那道坎1.1 从单 Agent 到多 Agent&#xff0c;复杂度不是线性增长我第一次真正意识到编排问题&#xff0c;是在做一个内部知识库问答系统的时候。最初只有一个 Agent&#xff0c;用户提问&#xff0c;Agent 检索&#xff0…

作者头像 李华
网站建设 2026/10/31 16:02:48

Markdown入门指南:高效写作与文档管理

1. 为什么选择Markdown作为第一个标记语言刚接触文本标记语言时&#xff0c;我面对HTML、LaTeX和Markdown等多种选择犹豫不决。最终选择Markdown作为起点&#xff0c;主要基于三个实际考量&#xff1a;首先它的语法简单到能在10分钟内掌握基础&#xff0c;用几个#号就能创建标题…

作者头像 李华
网站建设 2026/11/3 12:26:31

Android TCP网络通信前置配置:权限、明文流量与线程模型全解析

做Android开发久了就会知道&#xff0c;TCP网络通信本身并不难&#xff0c;难的其实是把它跑通之前那一大堆前置配置。很多新手第一次写Socket连接&#xff0c;代码抄得一字不差&#xff0c;编译也通过了&#xff0c;结果一装到真机上就报错——不是权限没声明&#xff0c;就是…

作者头像 李华
网站建设 2026/11/2 6:25:33

独角戏破圈实战拆解:从选题到舞台表演的完整方法论

第三届国际独角戏剧节颁奖那晚&#xff0c;最佳女演员的结果一出&#xff0c;台下没什么争议&#xff0c;朋友圈倒是先炸开了。《北去南归》的女主演捧着奖杯&#xff0c;另一只手还握着那根贯穿全剧的行李箱拉杆&#xff0c;这一幕被摄影师抓下来&#xff0c;成了那晚传播最广…

作者头像 李华
网站建设 2026/11/2 21:55:38

接口测试传参用到的空字符串的错误处理

在做接口测试时&#xff0c;接口请求参数用占位符来替换&#xff0c;但是因为代码的错误处理会带来错误的参数传递。具体是这样的&#xff0c;传参是username&password*{password3}, 写了个变量替换方法def regi_var_substitute(params):#10132026 要注意&#xff1a;当参数…

作者头像 李华