字母异位词(Anagram)这道题,是我在刷算法题时遇到的第一道“看着简单、越挖越深”的题目。给定两个字符串s和t,判断t是否是s的字母异位词——也就是两个字符串包含的字符完全相同,只是排列顺序可能不同。题目级别是 Easy,但解法可以从一行排序、到哈希计数、再到定长数组优化,每一层都能展开不少面试考点。
这篇笔记会按我实际刷题和复盘的过程来写:先讲清楚题目到底在问什么,再给三种主流解法,并且把每一步的复杂度、代码细节、常见坑都拆开。适合刚开始刷题的人,也适合准备面试但想把这题答得更完整的人。你会发现,真正拉开差距的不是能不能 AC,而是你能不能把“为什么这样做”讲明白。
1. 题目到底在问什么:先别急着写代码
1.1 题干里的关键信息
“有效的字母异位词”这个问题,从字面看有三个关键词:有效、字母异位词、判断。
“字母异位词”英文对应 Anagram,核心定义是两个字符串中每个字符出现的次数完全相同,字符可以重新排列成另一个字符串。举个例子:
- 输入
s = "anagram",t = "nagaram",返回true。 - 输入
s = "rat",t = "car",返回false。
注意这里不是判断“包含关系”,不是“t 的字符都在 s 里出现过”这么简单。比如s = "aab",t = "ab",虽然t的所有字符都存在于s,但a在s里出现两次,在t里只出现一次,所以不是异位词。
这个细节非常关键。很多人第一次写的时候,会下意识用“集合去重”的思路:把s转成集合,再检查t的每个字符是否都在集合里。这样处理不了重复字符,等于没抓住题意。
所以第一步应该先把问题翻译成更严谨的数学语言:判断两个字符串是否具有相同的字符多重集合。字符串是字符的有序序列,而异位词问题把“顺序”这个维度拿掉,只比较每个字符的“数量”。想清楚这一点,后续所有解法其实都是围绕“如何比较多重集合相等”展开的。
1.2 边界条件决定了代码的鲁棒性
我在面试前复习时,习惯先把边界条件列出来,而不是直接写主逻辑。因为边界条件能暴露很多隐藏细节。
- 两个字符串长度不同,直接返回
false。异位词要求每个字符数量相等,长度不同说明总量不同,没必要继续比较。 - 两个字符串都为空,返回
true。空字符串之间互为异位词。 - 单个字符的情况,比如
s = "a",t = "a",返回true;s = "a",t = "b",返回false。 - 是否区分大小写?如果题目没有明确说明,默认区分。也就是说
"A"和"a"不算同一个字符。 - 是否只包含小写字母?常见版本会限定只包含小写字母,这是使用定长数组的前提。但如果不是这个限制,就要考虑任意 ASCII 字符,甚至 Unicode 字符。
这些边界条件不是凑数,它们直接影响数据结构选择。只要长度不等先返回false,后面遍历时可以少判断很多情况。比如手动实现计数器的时候,因为长度相等,遍历完t时所有计数都减到 0,最终校验顺序可以更简单。
1.3 三种主流解法的取舍地图
面对“比较两个字符串是否互为字母异位词”,我先想到的解法有三类,按思路简单程度排序:
| 解法 | 核心思路 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 排序后比较 | 排序后若相等则是异位词 | O(n log n) | O(n) 或 O(1) | 最短代码,思路直观 |
| 哈希表计数 | 用s构建计数,用t抵消 | O(n) | O(k),k 为字符集大小 | 通用性最强,适合任意字符 |
| 定长数组计数 | 用整数数组模拟哈希表 | O(n) | O(1),固定 26 或 128 | 只针对小字符集场景,性能最高 |
从刷题角度,三种都要会。排序解用来快速理解和验算,哈希解用来处理通用输入,数组解用来在面试中展示你对“字符集”的敏感度。
这里多说一句:很多人会觉得 Easy 题会一种解法就够了,但面试中往往要求你给出“最优解”,并且解释为什么最优。定长数组解本质上是在哈希表基础上的空间压缩,理解它能帮你建立“数据结构由问题约束决定”的意识。
2. 解法一:排序比较,最简单的答案
2.1 一行代码背后的逻辑
如果要我用一句话描述排序解法:把两个字符串分别排序,如果排序后的结果完全相同,那么原字符串就是字母异位词。
这个逻辑非常朴素:排序抹平了字符的顺序差异,只留下字符集合和数量信息。反正异位词只是顺序不同,排序后顺序被强制统一,剩下的自然只有内容。
Python 写出来极其简洁:
def is_anagram(s: str, t: str) -> bool: return sorted(s) == sorted(t)注意这里sorted(s)返回的是字符列表,比如sorted("anagram")得到['a', 'a', 'a', 'g', 'm', 'n', 'r'],sorted("nagaram")得到相同列表,所以比较结果是True。
有些同学会想写"".join(sorted(s)) == "".join(sorted(t)),也能跑,但多了一次字符串拼接,没必要。直接比较列表内容更高效,而且代码更短。
如果使用 Java,可以写成:
public boolean isAnagram(String s, String t) { if (s.length() != t.length()) return false; char[] sArr = s.toCharArray(); char[] tArr = t.toCharArray(); Arrays.sort(sArr); Arrays.sort(tArr); return Arrays.equals(sArr, tArr); }Java 的字符数组排序是原地排序,空间上比 Python 有优势。
2.2 复杂度到底是多少
排序法的时间复杂度是 O(n log n),因为主流排序算法都是这个级别。这里的 n 是字符串长度。
空间复杂度要分语言讨论。Python 的sorted()会创建新列表,额外占用 O(n) 空间;Java 的Arrays.sort()对基本类型数组使用的是 Dual-Pivot Quicksort,原地排序,额外空间接近 O(log n),但通常可以当作 O(1) 或 O(n) 的讨论区间。
这里有一个常见的面试陷阱:有人会回答“排序空间是 O(1)”,但这取决于实现。如果面对面试官,最好说“主要取决于使用的排序算法,Python 的 TimSort 需要额外空间,所以整体空间是 O(n);如果使用原地快排,可以做到 O(log n)”。
不要小看这个细节。我在模拟面试中遇到过好几次,候选人把空间复杂度说死,面试官追问一句“为什么是 O(1)”就卡住了。排序法最大的优点是实现简单、不易写错,最大的缺点是性能不是最优。当 n 比较大时,O(n log n) 和 O(n) 的差距会很明显。
2.3 排序解法的三个容易忽略的坑
第一个坑是误以为排序能处理大小写不敏感。很多题目描述会写“忽略大小写”“不区分大小写”,但排序解法天然区分大小写。比如s = "Ab"、t = "aB",如果不统一转成小写,排序后结果是['A', 'b']和['B', 'a'],不相等。所以需要先s.lower()和t.lower()再排序。
第二个坑是空格和标点符号。题目如果说“只考虑字母,忽略其他字符”,那么排序前必须先过滤掉非字母字符,否则结果会错。比如"a b"和"ab",直接排序比较,前者多一个空格,结果不相等,但实际按题意应该相等。
第三个坑是把排序法当成最优解直接交差。在 LeetCode 上它能通过,因为代码简单,但不代表你在面试中能拿到满分。面试官通常会继续问:“能不能用 O(n) 时间解决?”这时候如果你只会排序法,就比较被动。
排序法适合作为第一版正确答案,快速建立正确性,然后在此基础上继续优化。
3. 解法二:哈希表计数,最通用的正解
3.1 核心思想:用“加减抵消”判断多重集合
哈希表计数是这题最通用的解法。思路是:先遍历s,统计每个字符出现的次数;再遍历t,对每个字符做一次“抵消”——也就是把对应计数减一。最后,如果所有字符的计数都归零,说明两个字符串的字符多重集合完全相同。
这个“加减抵消”的思路很值得记住。它不只在本题有用,在很多字符串问题里都能复用。比如滑动窗口相关题目中,经常需要维护窗口内字符计数和目标字符串计数的差值;异位词分组题目中,也可以把计数结果作为分组键。
手动实现时,有一个细节值得注意:因为我们在函数开头判断过len(s) == len(t),所以如果遍历t时所有字符都能成功抵消,最后计数必然全部为 0,不需要再做一次遍历检查。
3.2 两种写法的代码对比
第一种是最简洁的 Python 写法,直接用collections.Counter:
from collections import Counter def is_anagram(s: str, t: str) -> bool: if len(s) != len(t): return False return Counter(s) == Counter(t)Counter本质就是一个字典,键是字符,值是出现次数。两个Counter可以直接比较,内部会依次比较每个键对应的计数。这段代码可读性极高,适合在代码评审时给人看。
第二种是手动实现计数器,不需要额外导入,更接近底层逻辑:
def is_anagram(s: str, t: str) -> bool: if len(s) != len(t): return False count = {} for ch in s: count[ch] = count.get(ch, 0) + 1 for ch in t: if count.get(ch, 0) == 0: return False count[ch] -= 1 return True这个写法里有个重要判断:if count.get(ch, 0) == 0。如果t中的某个字符在s里不存在,或者已经被抵消完了,说明当前字符数量已经超过s中对应字符的数量,可以直接返回false。
这里提一个我踩过的坑:最早我写的时候没有做这个判断,直接count[ch] -= 1,最后再检查所有值是否为 0。这种写法也能 AC,但不够优雅。因为一旦某个字符出现次数为负,其实已经能判定不是异位词,没必要继续遍历。而且如果t里出现了s中没有的字符,直接执行count[ch] -= 1会触发KeyError,必须先处理缺失键。
3.3 什么时候应该选哈希而不是数组
很多刷题指南一上来就教“用 26 位数组”,这个解法确实快,但它默认了一个前提:输入只包含小写英文字母。
如果题目没有明确这个限制,我的建议是先把哈希表解法写出来,因为它能处理任意字符。字符到底是什么,对于哈希表来说不重要,只要字符是可哈希的,就能作为字典的键。无论是大写字母、数字、空格,还是 Emoji,都能正确计数。
举个例子,如果输入是s = "😀a"、t = "a😀",直接用Counter或手动字典都能正确处理,因为它们按字符精确匹配。但如果用c - 'a'做数组索引,会直接出错。
还有一个实际考量:在面试中,如果你一上来就用 26 位数组,面试官可能会问“如果输入不限于小写字母,你的代码会怎样?”这时候你至少有两条路:一是解释清楚当前解法只在约束条件下成立;二是改用哈希表,展示你对通用性的理解。
我的习惯是:口头先说明“我假设输入是小写字母,所以可以用数组;如果字符集不确定,哈希表更稳妥”。然后再根据题目约束写对应的实现。
3.4 复杂度与内存细节
哈希表解法的时间复杂度是 O(n),因为两个字符串各遍历一次,每次哈希操作平均 O(1)。
空间复杂度是 O(k),k 是输入中出现的不同字符数。最坏情况下,如果字符串每个字符都不同,k = n,那么空间复杂度是 O(n)。但因为题目一般只考虑字母或有限字符集,k 通常是常数级别。
这里有一个分析误区:很多人把空间复杂度直接写成 O(1),理由是“字符串只有 26 个小写字母”。这个理由成立的前提是字符集固定。正确的表述应该是:若字符集大小为 C,则空间复杂度 O(C);因为 C 是常数,在只含小写字母时等于 O(1),但如果是 Unicode,就不能这么写。
面试时候,把“为什么是 O(C)”和“什么时候退化成 O(n)”讲清楚,会显得你理解得比较深。这比直接报结论要好得多。
4. 解法三:定长数组与 Unicode 扩展
4.1 用数组代替哈希表的原理
当字符集确定且范围很小时,可以用数组替代哈希表,这是本题的经典“最优解”。
原理不复杂:既然字符只有 26 种小写字母,那就创建一个长度为 26 的整数数组,用c - 'a'把字符映射到数组下标。字符'a'对应下标 0,'b'对应下标 1,依此类推。后续逻辑和哈希表完全一样:遍历s时对应下标加一,遍历t时对应下标减一,最终所有下标计数都为 0 则返回true。
数组比哈希表快的原因有两点。第一,数组的下标访问是直接内存寻址,不需要计算哈希值,也不需要处理哈希冲突。第二,连续内存空间对 CPU 缓存友好,当数据量不大时,性能差距会被放大。
所以这道题的最优解,本质上是“用问题的约束换取数据结构的简化”。面试官想看到的不是你背下了代码,而是你能否意识到 26 位的数组来源于“只有小写字母”这个前置假设。
4.2 代码实现:Java 的 26 位数组
Java 代码如下:
public boolean isAnagram(String s, String t) { if (s.length() != t.length()) return false; int[] counts = new int[26]; for (char c : s.toCharArray()) { counts[c - 'a']++; } for (char c : t.toCharArray()) { counts[c - 'a']--; if (counts[c - 'a'] < 0) { return false; } } return true; }这里用counts[c - 'a'] < 0作为提前终止条件。因为两个字符串长度相等,如果t中某个字符出现次数多于s,某次递减后数组对应位置就会变成负数,此时已经可以断定不是异位词,直接返回false。
Python 版本也可以用数组:
def is_anagram(s: str, t: str) -> bool: if len(s) != len(t): return False counts = [0] * 26 for ch in s: counts[ord(ch) - ord('a')] += 1 for ch in t: idx = ord(ch) - ord('a') counts[idx] -= 1 if counts[idx] < 0: return False return True这种实现的空间复杂度是 O(1),因为数组大小固定为 26,不随输入长度变化。
4.3 遇到 Unicode 怎么办
如果题目改成“字符串可能包含任意 Unicode 字符”,定长 26 数组就失效了。这时候有几个方向可以调整。
第一个方向是扩大数组。如果只考虑 ASCII 字符,可以扩大到 128 或 256。但要注意char在 Java 里是 16 位,最大到 65535,理论上可以建一个长度为 65536 的数组,但空间浪费比较明显。
第二个方向是使用基于 Unicode 码点的处理方式。在 Java 中,字符串里的一个“字符”在用户看来可能是一个 Emoji,但内部占两个char,也就是一个 surrogate pair。如果直接对char计数,可能会把同一个 Emoji 拆成两个无效代理项。更稳妥的做法是用codePoint:
public boolean isAnagram(String s, String t) { if (s.length() != t.length()) return false; Map<Integer, Integer> map = new HashMap<>(); s.codePoints().forEach(cp -> map.put(cp, map.getOrDefault(cp, 0) + 1)); t.codePoints().forEach(cp -> { if (map.getOrDefault(cp, 0) == 0) return; // 或者直接标记失败 map.put(cp, map.get(cp) - 1); }); return map.values().stream().allMatch(v -> v == 0); }这个实现比定长数组复杂,所以通常只在明确要求支持 Unicode 时才用。遇到这种问题时,我会先和面试官确认:真正的需求是支持任意 Unicode,还是只需要支持 ASCII 或小写字母。确认约束后再选方案,这是工程思维。
Python 里处理 Unicode 反而简单,因为 Python 3 的字符串天然按 Unicode 字符处理,for ch in s遍历到的就是一个完整字符,不会出现代理项分裂问题。所以 Python 的哈希表解法天然支持 Emoji。
4.4 从单题走向一类题:字母异位词分组
这道题最常见的延伸是“字母异位词分组”,也就是给定一组字符串,把互为异位词的字符串放在同一个组里。
例如输入["eat", "tea", "tan", "ate", "nat", "bat"],输出是[["eat", "tea", "ate"], ["tan", "nat"], ["bat"]]。
解法核心是给每个字符串生成一个“稳定的身份标识”。两个字符串互为异位词,它们的标识必须相同。
最简单的标识就是排序后的字符串:
from collections import defaultdict def group_anagrams(strs): groups = defaultdict(list) for word in strs: key = "".join(sorted(word)) groups[key].append(word) return list(groups.values())进阶做法是用 26 位计数元组作为标识:
def group_anagrams(strs): groups = defaultdict(list) for word in strs: counts = [0] * 26 for ch in word: counts[ord(ch) - ord('a')] += 1 groups[tuple(counts)].append(word) return list(groups.values())两种都能 AC,但第一个简洁、容易理解,第二个避免了每个字符串排序的高开销。把它们放在一起对比,能加深对“计数标识”这个模型的理解。
5. 刷题中的高频错误与面试追问实录
5.1 高频错误速查表
写这题时最容易出问题的地方,集中在下面这些情况:
| 错误类型 | 错误示例 | 正确做法 |
|---|---|---|
| 没判断长度 | 直接排序两个字符串 | 先判断长度不等直接返回false |
| 数组下标越界 | 用counts[c - 'a']处理大写字母或数字 | 确认字符集,或先转为小写,或改用哈希表 |
| 忽略字符缺失 | 遍历t时直接count[ch] -= 1,没有先判断键存在 | 用count.get(ch, 0)并判断是否等于 0 |
| 混淆包含与异位 | 用set(t).issubset(s)判断 | 需要比较每个字符出现次数 |
| 过早使用定长 26 | 在输入可能包含 Unicode 时仍用 26 位数组 | 先用哈希表,或显式说明假设 |
| 空间复杂度说死 | 把空间复杂度统一说成 O(1) | 说明取决于字符集大小 C,极端情况为 O(n) |
这些错误我在带人刷题时见过很多次,尤其是“用集合判断包含”这个错,几乎是新手标配。原因在于没有抓住“多重集合”这个本质,误以为只要字符集合相同就行。
5.2 性能对比与本机实测
为了验证不同解法的性能差距,我用长度大约 10 万字符的随机小写字符串做了一组简单测试,运行环境是普通笔记本,结果只反映数量级,不代表绝对基准。
- 排序解法:大约 25 到 35 毫秒。
- 哈希表计数器:大约 6 到 9 毫秒。
- 定长数组解法:大约 1 到 3 毫秒。
排序解法比哈希慢了一个数量级,这个差异完全符合复杂度分析:O(n log n) 和 O(n) 在 n 较大时差距明显。数组解比哈希解又快了不少,因为省去了哈希计算和潜在冲突。不过如果字符串长度只有几百,这个差异基本感觉不到。
所以我刷题时对“最优解”的态度是:优先保证正确性和可解释性,然后按题目约束选择最合适的数据结构。数组解虽然快,但如果字符集不确定,强行用反而是隐患。
5.3 面试官常问的三个追问
按我的经验,面试官看到你把这道题做完后,通常会从三个角度继续考察。
第一个追问是“为什么排序解法不是最优?”这里的坑在于回答不能只说“时间复杂度更高”,最好加上空间复杂度分析,并且给出更优方案的对比。
第二个追问是“如果字符串包含大写字母,你的代码会怎样?”这其实是在考察你是否理解c - 'a'这个映射的边界。回答思路是先说明定长数组基于小写字母假设,再提出两种方案:把字符统一转成小写,或者把数组扩展到 ASCII 128,或者直接改用哈希表。
第三个追问是“能否用 O(1) 额外空间完成?”这个问题比较难。因为如果字符集无限,严格的 O(1) 空间很难做到。常规回答是讨论字符集固定时,数组大小是常数,所以空间是 O(1)。如果要真正不用额外空间,通常需要允许修改原字符串,比如排序后比较,此时时间退化为 O(n log n)。这道题一般不会要求这种极限优化,但知道这个权衡会让面试官满意。
5.4 我推荐的答题话术
面试回答算法题时,我习惯先说思路,再写代码,最后补复杂度。针对这道题,可以这样组织语言:
“我先确认一下输入约束:如果只包含小写字母,我会用定长数组;如果不确定,我会用哈希表。原因是数组的下标访问需要字符和整数一一对应,只有字符范围可控时才能用。哈希表的通用性更强,两种做法的时间复杂度都是 O(n)。我先写一个哈希表版本,因为即使输入变成任意 Unicode 字符,这个方案也能正确处理。”
这样说有三个好处:第一,展示了你对题目的理解,而不是机械背诵;第二,体现了你做工程的判断力;第三,为后续追问留了余地。
6. 从这道题延伸出来的思维模型
6.1 字符串判断问题的三种套路
刷到一定数量后会发现,字符串问题本质上就那么几种套路。这道字母异位词题,正好串起了其中三个。
第一个套路是“排序后判断”。当顺序不重要时,排序能把隐性的无序比较变成显性的有序比较。字母异位词可以用,判断两个字符串是否为旋转字符串也可以用。
第二个套路是“计数抵消”。当需要判断两个序列的字符出现次数是否一致时,分别计数再对比,或者用一个计数结构做增减。这个思路在“字符串的排列”、滑动窗口类题目里非常常见。
第三个套路是“用定长数组代替哈希表”。一旦题目明确字符集,比如只有小写字母、只有数字、只有 0 到 255 的 ASCII,那么数组一定比哈希表更高效。这是从汉明距离、数字频次统计等题里也很常见的选择。
把这三种套路放在一起看,你会发现解题不是靠记忆代码,而是识别问题背后的模式。模式匹配对了,代码只是自然的结果。
6.2 同型题目一览
字母异位词题有一群“亲戚”,刷的时候可以连着做:
| 题目 | 变化点 | 核心解法 |
|---|---|---|
| 字母异位词分组 | 需要把异位词聚成一组 | 排序字符串或计数元组作为 key |
| 找到字符串中所有字母异位词 | 在长串中找所有异位词子串的起始位置 | 滑动窗口 + 计数比较 |
| 字符串的排列 | 判断一个串的某个排列是否在另一个串中 | 滑动窗口 + 计数比较 |
| 赎金信 | 判断 magazine 能否拼出 ransomNote | 单向计数,magazine 计数减一个字符 |
| 有效的字母异位词 | 两个字符串是否异位 | 排序或计数抵消 |
做完这些题,你会发现“计数数组”和“哈希表”来回交替使用,只是场景稍作变形。比如滑动窗口题里,窗口滑入一个字符就加一,滑出一个字符就减一,本质上也是“加减抵消”。
6.3 给新手的刷题建议
最后分享几个我在实践中的体会。
第一,不要背题解。把这道题背下来很容易,但换一个变体,比如“找到字符串中所有字母异位词”,如果你不理解计数抵消,很容易卡住。我建议每做完一道题,都尝试用一句话总结它背后的模型。比如这道题我的总结是:比较两个多重集合相等,用排序或计数抵消。
第二,一定要手写一遍哈希表版计数器。Counter(s) == Counter(t)写起来太舒服了,容易让人忽略底层逻辑。面试时如果面试官问“Counter 是怎么实现的?”你得能解释清楚。手写一遍字典计数,再对比数组写法,你会更清楚两者的边界。
第三,复杂度分析不要只说结论。每次写完代码,我都会强迫自己回答三个问题:时间为什么是这个量级?空间除了输入本身还用了多少?如果输入约束变化,结论会不会变?这三个问题练熟了,面试时反而更轻松。
最后再说一个小技巧:如果面试时时间紧张,优先写出无 bug 的哈希表版本,再用几句话讲出定长数组优化思路。面试官比较看重的是沟通能力和代码的健壮性,而不是你背了多少模板。
字母异位词这条路走通之后,你会发现自己能看到一批字符串题背后的共同影子。看懂一道 Easy,不代表只是会了一道 Easy,这也是我觉得这道题值得认真写一篇笔记的原因。