ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

力扣49题字母异位词分组:哈希表键设计与Python实现

力扣49题字母异位词分组:哈希表键设计与Python实现 力扣第49题字母异位词分组在力扣热题100里属于“祖师爷”级别的存在。很多刚开始刷题的朋友第一次接触哈希表分组类题目刷到的就是它面试里它出现的频率也相当高能在三分钟内写出正确解法并把复杂度讲清楚基本就能给面试官留下“算法基本功扎实”的第一印象。这题看着不难但真正吃透它牵扯到的细节其实不少排序与字符串拼接的用法、哈希键怎么设计、为什么Python里要用元组当字典键而不是列表、两种主流解法的复杂度差异、以及遇到非小写字母输入时怎么扩展。这篇文章我会从题目拆解开始把三种常用解法、本地调试方式、高频报错和面试变体一起讲完适合刚入门Python并正在刷力扣HOT100的读者也适合想在面试前把哈希表分组题彻底搞明白的人。1. 先搞清楚题目到底在考什么1.1 题目描述与样例题面很精简给你一个字符串数组把“字母异位词”分到同一组。字母异位词指的是由相同字母重新排列得到的单词比如eat、tea、ate它们字母组成完全一样只是顺序不同所以必须分到同一组。输入strs [eat, tea, tan, ate, nat, bat] 输出[[bat], [nat, tan], [ate, eat, tea]]这个输出顺序是无所谓的力扣判题时只比较内容不比较子数组的先后顺序。也就是说你返回的子列表内部顺序、子列表之间的排列顺序只要分组逻辑正确都能通过。1.2 考点拆解它考的不只是“分组”表面上看这题是“字符串分组”题目难度也标的是中等但很多人第一反应是去写双层循环拿每个字符串和已有的组逐个比较判断是不是异位词。这个思路在字符串数量很少时没问题一旦数据量变大时间复杂度就失控了。我理解这道题真正的考点只有一个如何为一个字符串设计一个稳定的哈希键。所谓稳定的键就是“只要是字母异位词算出来的键一定相同只要不是字母异位词算出来的键一定不同”。想到这一步分组就是一次遍历把字符串塞进字典复杂度直线下降。围绕这个核心题目实际考察了三个层次的能力第一层知道哈希表可以用来做分组统计能用字典key - list的结构收集结果第二层能找到“字母异位词之间存在的不变量”也就是排序后的字符串、字符频次统计、质数乘积等第三层能结合Python语言特性写出不出错的高效代码比如tuple可哈希而list不可哈希defaultdict和setdefault的取舍等。正因为这题把“找不变量 哈希表应用”结合得非常典型它才能常年稳坐热题100的位置也成为很多同类题的母题。2. 解法一排序法把混乱字符串翻译成统一标识2.1 核心思路异位词排序后必然相等排序法的思路非常直白既然互为字母异位词的字符串里面包含的字符完全相同那把它们的字符按顺序排好得到的字符串一定一样。eat排序后是aettea排序后也是aetate排序后还是aet。这三个单词在排序字符串这个维度上收敛到了同一个值。于是整个算法的流程就是遍历每个字符串 - 对字符串排序得到 key - 把原字符串 append 到 dict[key] 对应的列表里。第一次遇到某个 key 时用defaultdict(list)自动初始化一个空列表后面直接追加就行。这其实有点像生活中整理混在一起的卡片每张卡片上写着一个乱序单词你先把每张卡片里的字母按字母表顺序重新排好再把排好顺序相同的卡片归到同一个盒子里最后盒子里的原始卡片就是一组异位词。2.2 代码实现与Python语法细节from typing import List from collections import defaultdict class Solution: def groupAnagrams(self, strs: List[str]) - List[List[str]]: table defaultdict(list) for s in strs: key .join(sorted(s)) table[key].append(s) return list(table.values())这段代码有四个细节值得单独说sorted(s)对字符串排序返回的是字符列表。比如sorted(eat)返回[a, e, t]。这个函数不会修改原字符串而是生成新的列表所以不担心影响原数据。.join(sorted(s))把字符列表拼接成字符串。如果漏掉join直接把sorted(s)这个列表当 key代码会立刻报错因为列表是不可哈希的不能作为字典键。defaultdict(list)访问不存在的键时会自动调用list()创建一个空列表作为默认值。这比每次用if key not in table判断要简洁得多。list(table.values())字典的值是若干个列表直接转成外层列表返回正好符合题目要求的二维列表格式。2.3 复杂度分析与适用场景时间复杂度是 O(n * k * log k)其中 n 是字符串数量k 是字符串的最大长度。每个字符串都要做一次排序排序算 O(k log k)所以总耗时是这个量级。空间复杂度是 O(n * k)因为要把所有字符串都放进字典的值里这个开销是无法避免的毕竟结果本身就要占这么多空间。排序法的优点是代码短、思路清晰面试时作为第一个答案非常合适。它唯一的短板就是排序本身带了log k的常数当字符串特别长时排序开销会明显变大。面试官这时候往往会追问一句能不能更快这就自然过渡到了计数法。3. 解法二计数法把字符串压缩成频次向量3.1 核心思路不排序直接统计每个字符出现的次数互为异位词的两个字符串不仅“排序后相等”还有一个更本质的性质每个字母出现的次数完全相等。eat和tea都包含一个e、一个a、一个t。所以与其排序不如直接统计每个单词里26个小写字母各出现多少次把统计结果作为哈希键。对于只包含小写字母的输入可以用一个长度为26的数组[0] * 26来计数。数组第0位表示字母a的出现次数第1位表示字母b的出现次数以此类推。遍历字符串每读到一个字符就把对应位置加1。最后把这个数组转成元组tuple(count)作为字典的键。这里为什么必须转成元组因为数组是 list而 list 不可哈希。在Python里只有不可变类型才能作为字典的键比如字符串、数字、元组而 list 是可变类型无法计算稳定的哈希值所以直接拿 list 当 key 会抛出TypeError: unhashable type: list。3.2 代码实现与逐步讲解from typing import List from collections import defaultdict class Solution: def groupAnagrams(self, strs: List[str]) - List[List[str]]: table defaultdict(list) for s in strs: count [0] * 26 for ch in s: count[ord(ch) - ord(a)] 1 key tuple(count) table[key].append(s) return list(table.values())关键步骤拆开看[0] * 26生成长度26的全零列表代表a到z的计数。ord(ch) - ord(a)把字母转换成0到25的下标。比如ord(a) - ord(a) 0ord(b) - ord(a) 1。这是字符转下标的标准写法比手写if ch a: ... elif ch b: ...要优雅得多。tuple(count)把 list 转成 tuple比如(1, 0, 0, ..., 1)这样它就成了可哈希的不可变对象可以安全地作为字典键。table[key].append(s)同一个频次向量的字符串一定互为异位词直接归到同一组。时间复杂度降到了 O(n * k)因为每个字符串只需要完整遍历一遍遍历过程中做的都是常数时间的操作。当字符串长度很大时计数法明显优于排序法这也是面试官期望你答出来的优化方向。3.3 排序法与计数法的直观对比维度排序法计数法设计思路排序后字符串作为键字符频次向量作为键时间复杂度O(n * k * log k)O(n * k)空间复杂度O(n * k)O(n * k)代码长度更短容易写稍长多一段计数逻辑适用场景字符串较短、追求代码简洁字符串较长、需要极限性能扩展难度大小写混合时排序规则复杂扩展计数字符集相对自然我自己的习惯是面试中先快速写出排序法表示我能正确解决然后主动补充如果字符串长度很大或者数据规模很夸张可以进一步优化成计数法边说边写。这样既展示了代码能力也展示了复杂度优化的意识比闷头直接写一种解法要好得多。4. 进阶优化与本地调试的正确姿势4.1 字符集不固定时怎么扩展计数法力扣这题默认输入只包含小写字母所以长度26的数组刚刚好。但真实面试中经常出现变体“如果字符串可能包含大写字母、数字甚至空格怎么办”这时候有两条路可以走。第一条路扩大计数数组的长度。ASCII字符集一共128个常用字符用[0] * 128统计下标直接用ord(ch)不需要减去任何基准值代码反而更简单。这个方法只适用于 ASCII 范围内的字符。第二条路直接用defaultdict(int)做计数器。每个字符作为键出现次数作为值最后把计数器的所有键值对排序后转成元组作为最终键。def get_key(s: str): counter {} for ch in s: counter[ch] counter.get(ch, 0) 1 return tuple(sorted(counter.items()))为什么要sorted(counter.items())因为counter.items()本身是无序的。如果直接用元组套字典的 items两个内容的counter可能因为插入顺序不同得到不同的元组这就破坏了我们想要的“同组同键”的性质。排序之后键值对排列顺序一致才能保证键的稳定性。如果字符集合很大、字符串又特别长还可以考虑质数乘积法给26个字母分别映射一个质数比如a2, b3, c5, d7...然后遍历字符串把所有质数相乘乘积作为键。质因数分解的唯一性保证了两个异位词的乘积一定相同非异位词乘积一定不同。但乘积会暴涨Python虽然支持大整数这个方案在面试中可以作为加分项提一嘴实际生产里还是计数法更可控。4.2 本地跑通和VSCode调试力扣网页版直接写Solution类就行但我一直建议大家本地建一个 Python 文件跑一遍尤其是想验证自己写的用例时本地调试比在网页上一遍遍提交效率高得多。这里给个完整的本地测试模板from typing import List from collections import defaultdict class Solution: def groupAnagrams(self, strs: List[str]) - List[List[str]]: table defaultdict(list) for s in strs: key .join(sorted(s)) table[key].append(s) return list(table.values()) if __name__ __main__: sol Solution() test_cases [ ([eat, tea, tan, ate, nat, bat], 3), ([], 1), ([a], 1), ([abc, bca, cab, aaa], 2), ] for strs, expected_group_count in test_cases: result sol.groupAnagrams(strs) print(f输入: {strs}) print(f分组数: {len(result)}, 预期: {expected_group_count}) print(f结果: {result}) print(- * 40)在 VSCode 里建议装 LeetCode 插件写好代码后直接右键Debug可以在key .join(sorted(s))这一行打断点逐行看每个字符串生成的 key 到底是什么。这个习惯能帮你快速理解哈希键的设计思路也能排查那种“明明逻辑对但分组不对”的玄学问题。5. 高频踩坑与排查记录5.1 常见错误速查表这题代码量不大但报错种类不少。我把这些年见到的典型错误统一整理成表格方便你对号入座错误现象出错原因解决方案TypeError: unhashable type: list直接把sorted(s)或计数列表当成字典 key用.join(sorted(s))或tuple(count)转成不可变类型分组数异常变多counter.items()没有排序导致相同内容得到不同键使用tuple(sorted(counter.items()))保持键稳定输出子列表顺序不对误以为输出顺序必须和样例一致力扣不检查子数组顺序只要分组正确就行输入包含空字符串时结果不对忘记sorted()和空计数器都能正常工作确认空字符串的 key 是单独成组即可输入含大写字母时计数错乱计数数组长度只设了26ASCII 场景扩到[0] * 128通用场景用计数器扩展方案本地运行报模块找不到没安装 Python 或环境变量配置异常检查python --version必要时重装并勾选 “Add to PATH”5.2 面试追问怎么接面试官如果继续往下问通常围绕三个方向第一个方向是“优化到 O(n*k) 之后还能再快吗”。你可以说理论上排序法或计数法已经是线性级别了因为至少要把每个字符读一遍如果字符串特别长还可以用质数乘积法减少键的存储空间但要注意数值溢出问题。第二个方向是“如果只判断两个字符串是否互为异位词怎么做”。这是力扣242题直接复用计数法思路比较两个字符串的频次统计是否一致就行。第三个方向是“如果内存有限制怎么办”。这时候可以考虑对字符串先排序再基于排序后的结果做外部排序和分组适合超大文件场景。面试中能主动分析数据规模和资源瓶颈比闷头写代码更拉好感。5.3 我踩过的一个坑有段时间我在本地用字符串格式的 key比如1#0#0#...代替 tuple想着这样能看到直观结果。结果因为拼接时忘记处理两位数以上的频次比如字母出现10次时10和10会产生歧义导致边界用例分组错乱。后来发现最稳的方案就是直接用 tuple 或者带分隔符的格式比如#.join(map(str, count))绝不能裸拼接数字字符串。这个小问题让我记住了哈希键的设计不只是“能区分”就行还必须在所有边界条件下保持无歧义。6. 从这一题延伸出去同类题与刷题策略6.1 相邻题目串讲学透一道题最好的方式就是马上做它的姊妹题。和49题强相关的题目至少有三道力扣242题“有效的字母异位词”给两个字符串判断是否互为异位词。这题就是49题的判断单组版本直接计数法或者排序法都能解适合作为49题的前置练习。力扣438题“找到字符串中所有字母异位词”在一个长字符串里找到所有和模式串互为异位词的子串起点。这题需要用到滑动窗口 频次统计是计数法从“单个字符串统计”到“子串窗口统计”的升级。力扣567题“字符串的排列”判断一个字符串是否包含另一个字符串的排列。思路和438几乎一样本质是判断某个窗口内的字符频次是否与目标串完全一致。这几道题一起吃透你会形成一个完整的知识块字符串频次统计、哈希键设计、滑动窗口三件套。以后再碰到“分组”“排列”“异位词”相关的题目第一反应就不是死记模板而是主动联想“可以用哪种不变量来作为统一标识”。6.2 刷题顺序建议如果你正在按力扣HOT100刷题我的建议是把49题放在“哈希表专项”里尽早做。它不像链表、二叉树那样需要复杂的指针维护也不像动态规划那样需要大量的状态推理它考察的是一个非常通用且朴素的思维如何给一堆对象找一个稳定的分类标识。第一遍刷的时候只写排序法目标是能独立通过。第二遍刷的时候尝试直接写计数法要求自己不看答案20分钟内写完并跑通。第三遍复习时可以把这道题和242、438、567放到同一天做对比它们的联系和差异。三轮下来这题的代码你可能还是会忘但“设计稳定哈希键”的思路会牢牢长在脑子里。6.3 后续可以做的扩展练习如果你对这道题意犹未尽还可以尝试两个扩展方向第一个方向是“变形题设计”。给自己出题如果输入是数字数组要求把“同一组数字经过重排列后相等”的数字归到一起你会怎么设计键答案是直接对数字排序和排序法如出一辙。这个练习能让你意识到哈希键设计思维不限于字符串而是适用于一切“将元素重排后等价”的问题。第二个方向是“输出优化”。力扣不要求组内顺序但在实际业务里可能要求输出结果按每组字符串数量降序排列或者按字典序排列。理解了字典的分组过程后对table.values()做一次sorted(..., key...)就能实现这些扩展改动都很轻量。我个人在实际操作中的体会是刷算法题记忆力是最靠不住的资产真正值钱的是你能否在题目里“看到熟悉的结构”。49题的排序法本质上是把一个麻烦的问题——判断两个字符串是否同构——转化成了一个简单的问题——比较两个键是否相等。这个“化归”的思维比任何一行代码都值得你反复琢磨。如果你正在被力扣HOT100的题量吓到不用慌从49题这种“一只脚踩在基础上、另一只脚踩在思维上”的题目切入先把哈希键想明白再动手写会比盲目刷几十道题有用得多。
返回列表