- 有效的字母异位词,这大概是很多人刷 LeetCode 时遇到的第一道哈希表入门题。我第一次认真做它是在准备校招那年的七八月份,当时只觉得这题简单,排序一比较就交了,也没细想过背后还有什么门道。直到后来在面试里被追问"如果输入不是小写字母而是任意 Unicode 字符怎么办""你那个 int[26] 数组能不能推广到所有字符集",我才意识到自己其实没有真正吃透这道题。
这篇文章想把这题从头到尾拆干净:题目到底在考什么、三种主流解法各自的适用场景、复杂度分析里容易被忽略的细节、以及从这题延伸出去的一整片知识网络。不管你是刚开始刷题的初学者,还是准备面试想系统性过一遍字符串题的老手,应该都能从里面找到点东西。我尽量把代码、推理过程、包括我自己踩过的坑都写出来,这样你在实际写的时候能少走弯路。
1. 拆题:字母异位词这个定义里藏了多少信息
1.1 题目原文与三个关键判定要素
先看题目本身:给定两个字符串 s 和 t,编写一个函数来判断 t 是否是 s 的字母异位词。若 s 和 t 中每个字符出现的次数都相同,则称 s 和 t 互为字母异位词。官方约束条件也写得很明白:s 和 t 仅包含小写字母。
这个定义看起来简单,但拆开来看其实有三个关键点。第一,两个字符串的长度必须相等,这是最基础的先决条件,连长度都不一样,字符计数再怎么统计也不可能一样。第二,字符种类和每种字符的出现次数要完全一致,这是异位词判定的核心。第三,不要求字符的相对顺序一致,这一点和回文串判断是完全不同的逻辑。
很多第一次接触的朋友容易把异位词和回文串搞混。回文串要求正着读反着读一样,比如 "aba" 就是回文串;而异位词只关心字符的出现频率,所以 "abc" 和 "bca" 是异位词,但它们显然都不是回文串。这个区分看起来很基础,但我在帮人 review 代码时,真见过有人把这两个概念搅在一起,写出先判断回文再去比字符频次的奇怪逻辑。所以第一件要做的事,就是把"顺序无关、只看频次"这八个字刻在脑子里。
1.2 约束条件才是解题的钥匙
题目的约束条件里藏着解题的钥匙:s 和 t 仅包含小写字母。这句话意味着什么?意味着字符集大小是确定的,只有 26 个英文小写字母。这就让很多解法有了明确的前提——比如用一个固定长度为 26 的整数数组来计数,而不是用通用的哈希表。
反过来想,如果题目把约束条件换成"s 和 t 包含任意 ASCII 字符",甚至"包含任意 Unicode 字符",那 int[26] 的方案就直接失效了,必须回到哈希表的思路。这也是 LeetCode 这道题那个 Follow Up 想让你回答的问题:如果输入包含 Unicode 字符,你的方案怎么调整?
我之所以说约束条件是钥匙,是因为很多人在拿到题目时只看示例,不看约束,上手就写排序。排序法固然能做,但它没有利用"仅包含小写字母"这个信息,复杂度也就不是最优的。真正吃透题目的做法,应该是先意识到字符集只有 26 个,然后思考能不能用计数的方式在线性时间内解决。拿到任何一道算法题,先读约束条件,比先读示例重要得多。
1.3 边界情况:空串、单字符与重复字符
边界情况是每一次面试都会考察的点,这道题也不例外。先考虑空串:s 和 t 都是空字符串,长度为 0,没有任何字符,它们当然互为字母异位词,所以答案是 true。如果一个是空串另一个是 "a",长度不同,直接 false。这个逻辑在"先判长度"的代码里天然就覆盖了,不需要额外特判。
单字符的情况也值得想一下:s = "a",t = "a",答案是 true;s = "a",t = "b",答案是 false。这两种情况用任何解法都能正确输出,但它们能帮助初学者快速验证自己代码的逻辑是否顺。我以前见过有人写计数数组时,把两个字符串的计数分别统计完再逐位比较,这种写法在绝大多数情况下是对的,但会多一次 26 次循环的遍历开销——不是大问题,不过确实可以优化成"一个数组同时加减"的写法,后面我会详细讲。
重复字符的情况才是真正考验细节的地方:s = "aab",t = "aba",这是 true;s = "aab",t = "abb",这是 false。后者的微妙之处在于,如果用哈希表一边遍历 t 一边递减,那么当某个字符的计数变成负数时就可以立刻判定为 false,不必等遍历完整个字符串。这是"提前返回"思想在小题目里的体现,也是我在第 5 节里会重点展开的编码细节。
2. 三种主流解法:排序、哈希表与定长数组的取舍
2.1 排序比较法:最直观但信息量最小
排序比较法的思路一句话就能说清楚:把两个字符串各自排序,如果排序后的结果相同,说明两个字符串的字符组成完全相同,因此互为异位词。
Java 写法如下:
public boolean isAnagram(String s, String t) { if (s.length() != t.length()) return false; char[] sChars = s.toCharArray(); char[] tChars = t.toCharArray(); Arrays.sort(sChars); Arrays.sort(tChars); return Arrays.equals(sChars, tChars); }Python 的写法更简洁:
def is_anagram(s: str, t: str) -> bool: if len(s) != len(t): return False return sorted(s) == sorted(t)这个解法的正确性很好理解:排序是一个重排操作,它不会改变字符的组成,只是改变顺序。如果两个字符串的字符组成相同,排序后自然得到同样的序列;反之,只要有一个字符不同,排序后的序列就会有差异。
但它的局限也很明显。第一,时间复杂度是 O(n log n),这是排序本身的开销,当字符串很长时性能并不理想。第二,它没有利用题目中"仅包含小写字母"的简化条件,属于一种"万金油"式解法,放之四海而皆准,但也因此拿不到复杂度上的优势。
不过,排序法有一个不可忽视的优点:逻辑极其简单,几乎不可能写错,而且在代码评审里一眼就能看懂。如果你在面试中时间紧张,或者面对的是一个快速验证的原型问题,排序法绝对是可以接受的第一回答。重要的是,你要能在此基础上进一步说出"但这不是最优解,因为排序引入了不必要的比较开销",然后自然地过渡到计数法。
2.2 哈希表计数法:普适性最强的主流方案
哈希表计数法的思路是:用一个哈希表记录每个字符出现的次数。先遍历字符串 s,把每个字符的计数加一;再遍历字符串 t,把每个字符的计数减一。如果最终所有字符的计数都回到零,说明两个字符串的频次一致,是异位词。
Java 写法:
public boolean isAnagram(String s, String t) { if (s.length() != t.length()) return false; Map<Character, Integer> count = new HashMap<>(); for (char c : s.toCharArray()) { count.put(c, count.getOrDefault(c, 0) + 1); } for (char c : t.toCharArray()) { count.put(c, count.getOrDefault(c, 0) - 1); if (count.get(c) < 0) return false; } return true; }这里有一个值得展开的细节:为什么遍历 t 时,一旦某个字符的计数变成负数就可以直接返回 false?因为 s 和 t 长度相等,总字符数相同。如果某个字符在 t 里出现次数超过了 s,那么必然会有另一个字符在 t 里出现次数少于 s,最终无法回到零。既然结果必为 false,提前发现"超了"就可以立刻判负,不需要等到最后。这是一个很典型的"利用总量守恒做提前剪枝"的思路,在一部分子串类题目里也能派上用场。
哈希表方案的优点是普适性:它不关心字符集是什么,不管你是小写字母、大写字母、数字、中文还是 emoji,只要是能作为键的类型,都能放进 Character 键里计数。这意味着当 Follow Up 问到"如果输入包含 Unicode 字符怎么办"时,只需要把 int[26] 数组换成 HashMap,其他逻辑几乎不用变。
但哈希表的缺点也很实际:它的常数开销比数组大。HashMap 的 put 和 get 涉及哈希计算、可能的扩容、Entry 对象的创建与回收,在极端情况下还有哈希碰撞的额外开销。对于这道题来说,键值对最多也就 26 个,性能差异不会很明显,但如果扩展到大量文本的字符频次分析,数组方案的性能优势就会体现出来。
2.3 定长数组计数法:最贴合本题约束的线性解法
既然题目说了只包含小写字母,那最贴合约束的解法就是用 int[26] 数组。对数组的每个槽位,用字符减去 'a' 得到索引,一个数组同时完成加法和减法,一次性处理两个字符串。
public boolean isAnagram(String s, String t) { if (s.length() != t.length()) return false; int[] count = new int[26]; for (int i = 0; i < s.length(); i++) { count[s.charAt(i) - 'a']++; count[t.charAt(i) - 'a']--; } for (int c : count) { if (c != 0) return false; } return true; }这个写法把所有增量和减量放在同一个循环里完成,避免了先统计 s 再统计 t 的两轮遍历,配合上"数组存取是 O(1)"的特性,整体时间是严格的 O(n)。空间上,固定大小的 int[26] 数组不随输入规模增长,可以认为是 O(1) 的额外空间。
我特别想说明一下为什么敢断言空间是 O(1)。严格来说,算法运行需要的额外空间只包括一个长度为 26 的 int 数组,无论字符串长度是 10 还是 10 万,数组大小都是 26,所以额外空间不依赖于 n,是常数级。这与排序法不同:Java 的 Arrays.sort 对基本类型数组使用双轴快速排序,原地排序,额外空间是 O(log n) 级别;但 toCharArray() 本身会创建两个与字符串等长的 char 数组,如果把这部分算进去,排序法的实际空间占用是 O(n)。很多人分析空间复杂度时只盯着排序本身的额外空间,忘了 toCharArray 的开销,这在线下面试里是一个可以追问的点。
用生活中的例子来类比:排序法像是把两副扑克牌各自按大小理一遍再比对;哈希表法像是给每张牌做一个计数账本;定长数组法则是准备 26 个格子,每个格子只对应一个字母,边翻牌边在格子里加减计数。第三种显然是最直接、成本最低的。
三种解法的核心差异,我用一张表整理出来,方便对照:
| 解法 | 时间复杂度 | 空间复杂度 | 依赖固定字符集 | 适用场景 |
|---|---|---|---|---|
| 排序比较法 | O(n log n) | O(n),含 toCharArray 复制开销 | 否 | 快速实现、非性能敏感场景 |
| 哈希表计数法 | O(n) | O(字符集大小),本题约束下 O(1) | 否 | Unicode 扩展、任意字符集 |
| 定长数组计数法 | O(n) | O(1) | 是 | 固定小写字母字符集 |
3. 复杂度那点事:面试官为什么会揪着不放
3.1 从 O(n) 到 O(n log n):究竟是哪里慢了
面试官最喜欢问的一个问题是:"你排序法的时间复杂度是多少?为什么不用计数法?"很多人能答出 O(n log n) 和 O(n),但要解释清楚"排序为什么慢",就不一定说得好了。
排序的 O(n log n) 来自比较排序的下界理论。比较排序在最坏情况下需要至少 n log n 次比较才能完成排序,因为每次比较最多只能把可能性的搜索空间缩小一半。而计数法完全不需要比较,它只需要把每个字符映射到一个固定的槽位,然后做一次常数时间的加减操作。字符串越长,两种方法的差距越明显。
我做过一个简单的实测对比:用 10 万字符长度的随机小写字符串反复跑两种解法,计数法耗时大约是排序法的几十分之一。当然,这种测试不在严格基准的条件下,不能一概而论,但趋势是稳定的。对于面试来说,你能说出"排序引入了比较的重排过程,而计数直接利用字符集大小为 26 的映射优势"就够了,不需要背下完整的理论推导。
3.2 空间复杂度的三笔账:数组、哈希表与排序的隐藏开销
空间复杂度是另一个高频考点。排序法如果只看 Arrays.sort 本身,原地排序确实只用 O(log n) 的额外空间,但别忘了前面还有一步 toCharArray(),它把字符串复制成了两个 char 数组,每个长度为 n。严格说起来,排序法的空间复杂度应该是 O(n),除非你用的是原地可变的字符数组(Java 的 String 不可变,没法原地改)。
哈希表法的空间复杂度是 O(字符集大小),对于小写字母来说是 O(1),因为最多只有 26 个不同的键。但如果字符集不限定,最坏情况下是 O(n),因为每个字符都可能不同,哈希表里最多会有 n 个键。所以严格表述应该是 O(字符集大小),面试时你可以补充说"在本题约束下等价于 O(1)"。
定长数组法的空间复杂度最干净:O(1),而且没有任何隐藏开销。这也是它作为"标准答案"的原因之一。
这里想给读者一个建议:分析复杂度时把每一个额外创建的对象都过一遍脑子,尤其是转换类型的 API。toCharArray、split、substring 这类操作在很多语言里都会复制底层数据,这些开销在题目规模小时无所谓,但在大数据量场景下可能就是性能瓶颈。漏算它们不一定会让代码写错,但会让你的复杂度分析在较真的面试官面前站不住脚。
3.3 字符集扩展:Follow Up 考察的本质
LeetCode 这道题下面有一个 Follow Up:如果输入包含 Unicode 字符,你如何处理?这个问题的本质是在考察你写代码时,有没有把约束条件写死在代码里。
int[26] 的写法依赖"字符 - 'a'"这个运算,它隐含了"所有输入字符都在 'a' 到 'z' 之间"的前提。一旦输入扩展到 Unicode,'a' 到 'z' 之外的其他字符索引就会越界。解法有两种:一种是把数组扩大到涵盖整个 Unicode 码点范围,这显然不现实,Unicode 码点有上百万个;另一种是换成 HashMap,让字符集大小变得自适应。
public boolean isAnagramAnyUnicode(String s, String t) { if (s.codePointCount(0, s.length()) != t.codePointCount(0, t.length())) { return false; } Map<Integer, Integer> count = new HashMap<>(); for (int i = 0; i < s.length();) { int cp = s.codePointAt(i); count.put(cp, count.getOrDefault(cp, 0) + 1); i += Character.charCount(cp); } for (int i = 0; i < t.length();) { int cp = t.codePointAt(i); count.put(cp, count.getOrDefault(cp, 0) - 1); if (count.get(cp) < 0) return false; i += Character.charCount(cp); } return true; }这个代码里有一个容易被忽略的细节:为什么要用 codePointAt 而不是 charAt?因为 Unicode 中有一部分字符(尤其是 emoji)在 Java 里是用两个 char 组成的代理对表示的,如果按 char 遍历,一个 emoji 会被拆成两个"半截字符"来计数,逻辑上就会出错。这是很多人在回答 Follow Up 时会漏掉的点,能在面试中主动提出来,非常加分。
不过说实话,面试中大多数时候只需要你口头说出"把数组换成 Hash 来适配任意字符集"就可以了,能够顺手补一句"还要注意用 codePoint 而不是 char 来处理代理对"的人少之又少。光这一点,就足以和大多数候选人拉开差距。
4. 从字母异位词出发:这道题如何串起一片知识网络
4.1 异位词分组的思路迁移
LeetCode 的 49 题"字母异位词分组"可以看作 242 题的进阶版。49 题给你一个字符串数组,需要把互为异位词的字符串放到同一个组里。核心思路是找一个统一的键:把每个字符串的排序结果作为哈希表的键,或者把每个字符串的字符计数字符串化后作为键,然后按这个键分组。
这两题之间的迁移关系非常明显。如果你已经吃透了 242 题的计数法,就会理解 49 题的计数键方案:把 int[26] 变成一个长度为 26 的字符串,比如 "#0#2#1#0#...",每个位置的数字代表对应字母的出现次数,这个字符串天然就是异位词的唯一标识。或者更简单,直接用排序后的字符串当键。前者的好处是时间复杂度是 O(nk) 而不是 O(nk log k),其中 k 是字符串的最大长度,在字符串普遍较长时优势明显。
我把这种解题习惯叫"先想键":当你需要把一堆东西按某个特征归类时,先把这个特征转换成一个可比较的键,再用哈希表做分组。这个思路在 242、49、438 这串题目里是通用的,理解透之后,很多看上去是新题的问题,其实都是"换个方式找键"。
4.2 滑动窗口与字母异位词检测的组合
438 题"找到字符串中所有字母异位词"是另一个经典变体,它把字母异位词检测和滑动窗口结合起来。题目要求在一个长字符串中,找到所有子串,使得子串是目标字符串的异位词。
解法是维护一个长度等于目标字符串的滑动窗口,窗口每次右移一位,新进入的字符加进计数,离开窗口的字符从计数中移除,然后比较窗口内的计数与目标字符串的计数是否一致。这里依然用 int[26] 数组作为比较载体,可以避免每次重新统计整个子串的重复计算。
如果说 242 题是"静态比较两个完整字符串",438 题就是"动态维护一个子串的计数"。两者看似不同,底层其实是同一个计数模型。我建议刷题时把这几个题放在一起做,你会发现从静态到动态的转变非常自然,而理解了这套模型之后,后面再遇到"最长无重复子串"这类滑动窗口题目,你对计数状态的理解也会明显更透彻。
4.3 异位词与排列、回文串的关联考题
异位词这个概念还和其他几个常见考点有关联。一个是"字符串的排列"(LeetCode 567),判断一个字符串是否包含另一个字符串的排列,这本质上就是 438 题的简化版本——只要找到一个合法窗口就返回 true,而不是收集所有位置。
另一个关联是"回文排列"(LeetCode 266),判断一个字符串能否通过重排变成一个回文串。判别条件是:字符串中最多只有一个字符出现奇数次,其余字符都必须出现偶数次。它用到的同样是字符频次统计。想一想:异位词要求两个字符串频次完全相同,回文排列要求频次中奇数次数的字符不超过 1,两者都在做"字符频次"的文章,只是判定条件不同。
把这些题放在一起看,你会发现字符串类的题目里,"字符频次"是最基础也是最常见的状态描述方式。从 242 题的简单计数开始,逐步扩展到分组、滑动窗口、排列判定,同一套思想能在四五道题里复用。这比孤立地刷一百道题要有效得多。
5. 我在实际编码中踩过的坑和总结的经验
5.1 语言特性导致的隐式差异
我最早用 Java 写这道题时,踩过一个很隐蔽的坑:在 for 循环里同时遍历两个字符串时,如果直接用 s.charAt(i) 和 t.charAt(i),那么长度相等这个条件必须提前校验过,否则就可能在使用 charAt(i) 时抛出 StringIndexOutOfBoundsException。这个细节在笔试里不算什么,但如果在限时环境下出现,很影响心态。
另一个语言差异在 Python 里体现得更明显。Python 的 collections.Counter 可以直接比较两个 Counter 对象是否相等:一行return Counter(s) == Counter(t)就能搞定。这个写法很优雅,但如果在面试中用它作答,建议同时说明底层的计数逻辑,否则面试官可能会觉得你在用标准库"作弊"。
C++ 的写法里,用std::sort之后比较字符串,或者用std::unordered_map计数,思路都一样。核心算法思想跨语言是通用的,语言只是表达方式。我个人的建议是,至少掌握一种语言的"数组计数"写法,因为它是实现层面最不容易出错的方案。
5.2 常见错误一览与规避方式
我把这个题目相关的高频错误整理成一张表,这些错误在代码评审中反复出现,值得提前警惕:
| 常见错误 | 根本原因 | 规避方式 |
|---|---|---|
| 字符索引越界 | 假定输入只有 'a'-'z',但字符集被扩展 | 先确认字符集约束,或改用 HashMap |
| 忘记长度判断 | 直接 charAt 双字符串遍历 | 第一步先比较长度,提前返回 |
| 代理对被拆开 | Java char 无法单独表示 emoji | 使用 codePoint 遍历 |
| 漏算 toCharArray 空间 | 只分析排序本身,忽略复制 | 复杂度分析时计入转换成本 |
| 区分不清异位词与回文串 | 概念混淆 | 明确"顺序无关,只看频次" |
5.3 面试回答时的表现顺序:从直觉到最优
最后聊一下面试中怎么答这道题。我的建议是遵循"从直觉到最优"的顺序。先给出排序法,几十秒内写完,复杂度 O(n log n);然后主动提问"如果输入包含小写字母之外的字符怎么办",再过渡到哈希表法;最后指出本题约束(仅包含小写字母)允许使用 int[26] 数组,给出 O(n) 时间、O(1) 空间的解法。
这个递进不仅展示了你有多种解法储备,更重要的是让面试官看到你有"根据约束条件选择算法"的工程意识。很多候选人只会背出最优解,当面试官问"你为什么不用 HashMap"时就答不上来了。你要能说出:在当前约束下数组访问比哈希计算更快,且避免了装箱与扩容开销;如果约束放宽,再退回 HashMap 即可。
还有一个加分项是主动讨论测试用例。不要等面试官问"你觉得有什么边界情况",而是在写完代码后就自己说:"这里应该测一下空字符串、长度不同、以及同字符不同数量的情况。"这会让面试官觉得你具备工程化思维,而不是只会在编辑器里跑题目用例。
这道题我前前后后在不同场合写了不下二十遍,从最初机械地背答案,到后来能顺着它把分组、滑动窗口、排列判定一系列题串起来,它在我心里已经从"一道简单题"变成了"一个支点"。如果你现在刚刷到这道题,我建议你别急着往下一题跑——花半小时把三种解法都写一遍,再想想文里说的那些追问,收获会比多刷五道简单题来得实在。