很多刷题的人看到“力扣No.242:有效的字母异位词”这道题,第一反应是“这不就是个入门题嘛,直接跳过”。我之前也这么想过,直到有一次在线下技术交流里聊到这道题,发现大部分人都能写出解法,但能把原理、复杂度边界、隐藏考点讲清楚的,少之又少。这道题表面是“判断两个字符串的字符构成是否相同”,但背后牵连着哈希表的本质、字符集与空间复杂度的关系、排序与计数的取舍,甚至还有面试官最爱追问的Unicode进阶场景。
这篇文章就用从业者的视角,把这道题彻底拆开——从最朴素的解法到进阶变体,从原理到工程经验,全部过一遍。
1. 为什么“有效的字母异位词”这道题值得从头到尾聊一遍
先说清楚概念。所谓“字母异位词”(Anagram),指的是两个字符串中每个字符的出现次数完全相同,只是排列顺序不同。典型例子是anagram和nagaram,字符都是三个a、一个n、一个g、一个r、一个m,只是顺序打乱了。反过来,rat和car就不是,因为字符构成压根不一样。
1.1 它听起来简单,但信息密度很高
这道题的“简单”是LeetCode官方给的难度评级。但作为过来人,我想说的是:难度标签只能说明它不需要复杂的算法设计,不代表它不值得认真对待。它几乎是后面一系列中等题、困难题的“地基”。
比如LeetCode的“字母异位词分组”题,核心思路就是把每个字符串排序后用排序结果做分组键,或者用计数数组生成唯一签名;再比如“字符串的排列”题,判断一个字符串是否包含另一个字符串的排列,本质就是滑动窗口加计数比较。这些题的思想源头,都可以追溯到这道No.242。
换句话说,如果你能把这道题的几种解法、复杂度、边界条件聊透,后面的很多字符串题会顺畅很多。
1.2 面试中的“送分题”往往最能暴露水平
面试场景里,这类题经常作为电话轮或一面的开场题。它的目的不是考验你能否解出来,而是考察你的沟通习惯、思考路径和代码风格。
同样写一个HashMap计数版本,有人会先问清楚字符集范围再决定用数组还是哈希表;有人会先判断两个字符串长度是否相等,避免后面的无意义计算;有人会在循环里只用一个for循环同时完成“加计数”和“减计数”,最后统一检查是否全零。这些差别,面试官一眼就能看出来。所以,这道题虽然简单,但它其实是一道很标准的“筛子”题——筛掉那些只会背题解、不理解原理的人。
2. 解法一:用哈希表计数,先理解“数什么”和“怎么比”
2.1 核心思路:把“是否异位词”转化为“字母频次是否相等”
判断两个字符串是否为字母异位词,本质上是在判断两件事:第一,字符集合是否一致;第二,每个字符出现的次数是否一致。只要这两点满足,顺序不同完全无所谓的。
那怎么统计“每个字符出现的次数”?最直接的办法就是用哈希表。键是字符,值是该字符在字符串中出现的次数。第一个字符串用来记录,第二个字符串用来核对。
具体步骤是:
- 如果两个字符串长度不同,直接返回
false。这是一个很小的剪枝,但很关键。 - 遍历第一个字符串中的每个字符,在哈希表中将对应计数加一。
- 遍历第二个字符串中的每个字符,在哈希表中将对应计数减一。
- 遍历结束后,检查哈希表中是否存在非零的计数。如果某个字符的计数不为零,说明两个字符串的字符频次不一致,返回
false;全部为零则返回true。
这里有一个值得注意的细节:我上面说的是“先全部加,再全部减,最后检查”,但工程上还有一个更好的写法——在遍历第二个字符串时同步检查。
public boolean isAnagram(String s, String t) { if (s.length() != t.length()) { return false; } Map<Character, Integer> map = new HashMap<>(); for (char c : s.toCharArray()) { map.put(c, map.getOrDefault(c, 0) + 1); } for (char c : t.toCharArray()) { Integer count = map.get(c); if (count == null) { return false; } map.put(c, count - 1); } for (int count : map.values()) { if (count != 0) { return false; } } return true; }这种“加一减一”的写法,看起来只是简单的计数操作,但它背后有一个很通用的思维模型:用正负抵消来比较两个序列的统计特征。以后做“找出唯一不同的字符”“判断能否通过重新排列得到另一个字符串”这类题时,这个模型都能复用。
2.2 为什么要在遍历第二个字符串时“边减边查”
不知道你有没有遇到过这种情况:第二个字符串里出现了第一个字符串完全没有出现过的字符,但你没在遍历时检查null,最后靠遍历哈希表的那一步也能发现计数不为零。所以,“边减边查”其实不是必须的,但它是一个更早失败、更快返回的优化。
比如s = "abc",t = "abx",在减到x的时候就已经可以确定不是异位词了,没必要把整个t遍历完再检查。对于超长字符串,这种提前终止能省一轮遍历的时间。当然,前提是你得记得处理map.get(c)返回null的情况,不然会直接抛出空指针异常。
2.3 哈希表方案的适用边界:字符集不确定时的首选
用HashMap最大的好处是通用。它不关心字符集是什么,不管你是ASCII字符、中文汉字、emoji,还是任意Unicode字符,都能正确处理。
面试中如果被追问“如果输入从26个英文小写字母扩展到所有Unicode字符怎么办”,哈希表方案几乎不需要修改就能继续工作。这点在后面的“进阶场景”里还会细说。
3. 解法二:用数组模拟哈希表,面试里最推荐的写法
3.1 为什么可以用数组代替哈希表
题目里有个很关键的前提条件——多数版本里只说“字符串只包含小写字母”。这句话意味着字符集是确定的:只有26个英文小写字母。
既然字符集大小已知且范围紧凑,我们就可以直接用数组来代替哈希表。a到z的ASCII码是连续递增的,用当前字符减去'a',就能得到一个0~25的索引值,映射到数组的26个位置上。数组的索引天然就是哈希表的键,索引对应的值就是计数。
public boolean isAnagram(String s, String t) { if (s.length() != t.length()) { return false; } int[] counts = new int[26]; for (int i = 0; i < s.length(); i++) { counts[s.charAt(i) - 'a']++; counts[t.charAt(i) - 'a']--; } for (int count : counts) { if (count != 0) { return false; } } return true; }注意到没有,这里只用了一个数组。遍历过程中,对s的字符做加法,对t的字符做减法,最后数组里所有位置都归零,就说明两个字符串的每个字符频次完全一致。这个写法比维护两个数组再逐个比较更简洁。
3.2 数组方案为什么比HashMap快
很多初学者会奇怪:数组方案和哈希表方案在时间复杂度上都是O(n),为什么实际运行差距那么大?
核心原因有三个:
第一,HashMap的键是Character对象。char是基本类型,放进Map时会经历装箱(boxing)操作,频繁的创建和回收对象是有开销的。
第二,HashMap的底层是数组加链表或红黑树,需要先计算哈希值、定位桶位置,遇到哈希冲突还得走链表或树查找。而数组方案是直接通过索引访问,相当于每次计数都是精确命中,没有任何查找开销。
第三,扩容问题。HashMap在元素数量超过阈值时会触发扩容,扩容涉及重新哈希,性能抖动明显。而固定大小的int[26]从头到尾都不会有这种开销。
实测下来,在处理几百万个字符的测试用例时,数组方案的耗时会明显低于HashMap方案,差距可能在一个数量级左右。虽然刷题场景里数据量通常没那么大,但这种理解能帮你建立“数据结构选型要贴合数据规模”的直觉。
3.3 一个容易写错的细节:char参与运算的类型
counts[s.charAt(i) - 'a']这里的运算有个隐藏知识点:char在Java里本质上是一个无符号16位整数。当char与char做减法时,它们会被提升为int类型参与计算,所以结果是一个int值,可以直接作为数组索引。
我见过有同学试图这么写:
counts[s.charAt(i)]++;这样编译都过不了,或者运行时报ArrayIndexOutOfBoundsException,因为字符的ASCII码(比如a是97)远超数组长度26。记住,必须先减去'a',把字符映射到0~25的区间内。
4. 解法三:排序法,最朴素但最“吃场景”的方案
4.1 排序法的思路与实现
除了计数,还有一条完全不同的思路:把两个字符串排序,如果排序后内容一致,就说明它们互为字母异位词。
为什么排序可行?因为异位词的本质是“字符多集(multiset)相同”,而排序刚好能把顺序差异抹平,留下唯一的有序序列。就像两副打乱的扑克牌,整齐排好之后比的还是同花色的牌有哪些、各有几张。
在Python里,这个解法写起来最短:
def is_anagram(s: str, t: str) -> bool: return sorted(s) == sorted(t)在Java里稍微绕一点,因为String是不可变对象,不能原地排序。常见写法是转成char[],排序,再转回String比较,或者直接用Arrays.equals比较两个char[]:
public boolean isAnagram(String s, String t) { if (s.length() != t.length()) { return false; } char[] charsS = s.toCharArray(); char[] charsT = t.toCharArray(); Arrays.sort(charsS); Arrays.sort(charsT); return Arrays.equals(charsS, charsT); }4.2 排序法的时间复杂度代价
排序法的时间复杂度是O(n log n),受限于比较排序的下界。如果字符串长度是10万,哈希表方案大约跑10万多步,排序法要跑接近160万次比较(按n log n估算,准确说每次比较本身也带常数开销)。
那为什么排序法还是一个值得掌握的方案?因为它有极具竞争力的使用场景。
当字符集非常大或不确定时,计数方案就会遇到麻烦:数组长度没法预先确定,或者需要开一个巨大的稀疏数组造成空间浪费,这时候你只能改用HashMap。而排序法根本不在乎字符集范围,你只需要一个能对所有元素排序的规则即可。所以,如果你和我一样面对的是Unicode输入且不想引入额外依赖,排序法反而是更省心的选择。
另外,排序法的代码通常最短——Python里一行搞定,非常适合快速调试或用脚本验证思路。说到底,对算法题要有一个基本认知:没有“万金油”的解法,只有“当前场景下最合适的解法”。
5. 复杂度分析之外:五个容易被忽略的工程细节
5.1 长度判断是微优化还是必要的防御?
先判断长度,能让长串不匹配的情况直接返回。很多人觉得这是多余的,但仔细想想:如果长度不等,说明字符总数不同,哪怕每个字符的频次再接近,也不可能是异位词。这一步的代价几乎为零,收益是可能省去整个遍历。所以,这个判断不是可选的,而是该写进习惯里的。
5.2 关于空间复杂度是O(1)还是O(26)的严谨讨论
力扣的官方题解里通常会写数组方案的“空间复杂度为O(1)”,很多同学直接背结论,却说不清为什么。严谨地说,数组方案的空间开销是int[26],即占用内存大小与输入字符串的长度无关,只与字符集大小相关。因为字符集是固定的26个小写字母,所以可以认为空间复杂度是常数级,记作O(1)。
但如果题目扩展成Unicode场景,字符集变成13万多个码位,你依然可以开一个足够大的数组,但这时候“空间与字符集大小相关”这一点就不能再说成O(1)了,应该表述为O(K),其中K是字符集大小。这个细节在面试中属于加分项——能准确说清“为什么这个场景下它才是O(1)”的候选人,明显比背结论的人理解更深入。
5.3 输入参数为空的边界处理
s = ""、t = "",两个空字符串互为异位词吗?严格说,抛去直觉,答案是true,因为空字符串本身就是由0个字符组成的字符串,字符构成完全一样。
代码里,这个情况靠“长度相等”判断会通过,进入循环后不会执行任何操作,最终检查数组全零返回true。所以,即使不专门写空值判断,代码的行为也是正确的。但如果有洁癖,也可以在开头写:
if (s == null || t == null) { return false; }LeetCode的测试用例通常不会出现null,但从工程角度看,防御性编程永远不会错。这个问题在线下交流时被问到过几次,我就顺手提一下。
5.4 各语言实现的差异对比
我用Java、Python、Go分别写过这个题,体验差别很大。
Python版最优雅:
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,也可以先转成list排序再比较,但Counter的性能和可读性都更好。
Go版的写法很接近C式的直白:
func isAnagram(s string, t string) bool { if len(s) != len(t) { return false } var counts [26]int for i := range s { counts[s[i]-'a']++ counts[t[i]-'a']-- } for _, c := range counts { if c != 0 { return false } } return true }这里有个小小的坑:Go里直接用s[i]取到的是byte类型,对纯ASCII字符没问题,但如果字符串包含中文字符,len()返回的是字节数,下标索引也是按字节的,直接用这个写法就会出错。刷题时题目给了“只包含小写字母”的约束,所以没问题,但写生产代码时一定要意识到字符串编码的差异。
5.5 什么时候该用“哈希表+提前减查”,什么时候该用“一个数组”?
我现在的习惯是:如果题目明确字符集是小写字母,直接用数组,无脑快;如果题目没限制字符集,用HashMap;如果只是想快速验证思路、不在意常数性能,用排序或Python的Counter。
实际面试中,最推荐的展开路径是:先说思路一——用哈希表统计频次,复杂度O(n),空间O(n);然后补充“由于题目限制是小写字母,我们可以用固定大小的数组把空间优化到O(1)”,同时给出代码。这样既展现出思考的递进性,也体现出对输入的敏感度。
6. 从这道题延伸出去的变体:它绝不是终点
如果只满足于AC(Accepted)这道题,那确实错过了很多。我建议你做完这道题之后,继续沿着它的脉络做下面几道题,你会发现它们都是“同源”的:
6.1 变体一:字母异位词分组
给定一个字符串数组,要求把所有互为异位词的字符串分到同一组。
解法思路很清晰:要么“排序后作为键”,要么“计数数组转字符串后作为键”。比如把[19, 5, 0, 13]这种计数结果拼成"1#0#1#..."格式的签名,作为键存入HashMap。这一题的难度是中等,但如果你把No.242的数组计数理解透彻了,核心困难也就只剩“如何设计一个不会碰撞的签名方案”了。
6.2 变体二:字符串的排列(滑动窗口版)
判断字符串s2是否包含s1的某个排列。比如s1 = "ab",s2 = "eidbaooo",答案是true,因为"ba"就是"ab"的一个排列。
这道题用的是滑动窗口加计数数组:维护一个长度等于s1的窗口,窗口内的字符计数与s1的计数一致,即可判定。每次移动窗口时,进来的字符加一、出去的字符减一,动态比较计数是否相等。你会发现,这里用的“数组计数+增减比较”就是No.242的同一个内核。
6.3 变体三:判断两组字符串是否只差一个字符
这类题经常出现在面经里:给定两个字符串,允许你修改其中一个字符,问能否通过一次替换让它们互为异位词。做法是统计两个字符串中不同字符的个数,如果恰好为2(一个被删除、一个被新增),同时频次差异为0,就满足条件。
本质上,仍然是“字符频次比较”的问题。
6.4 变体四:进阶版Unicode字符串
如果面试官追问“如果输入不只是小写字母,而是任意Unicode字符,比如表情符号,怎么办”,你要立刻意识到:数组的固定长度思路失效了,因为Unicode的字符空间太大。
这时候可以用HashMap做完整计数,也可以用Counter(Python)或collections.defaultdict。在Java里,s.toCharArray()对基本平面内的字符还有效,但遇到代理对(surrogate pair)表示的emoji时,一个“字符”会占两个char位置,你按char遍历就会把一个emoji拆成两半。正确做法是按code point遍历:
public boolean isAnagramUnicode(String s, String t) { if (s.codePointCount(0, s.length()) != t.codePointCount(0, t.length())) { return false; } Map<Integer, Integer> map = new HashMap<>(); int cp; for (int i = 0; i < s.length(); ) { cp = s.codePointAt(i); map.put(cp, map.getOrDefault(cp, 0) + 1); i += Character.charCount(cp); } for (int i = 0; i < t.length(); ) { cp = t.codePointAt(i); Integer count = map.get(cp); if (count == null) { return false; } map.put(cp, count - 1); i += Character.charCount(cp); } for (int count : map.values()) { if (count != 0) { return false; } } return true; }能当着面试官写出这个版本的人,对Java字符串模型的理解是过关的。如果不熟悉codePointAt和charCount,也完全可以大大方方说“生产上我会直接用第三方文本处理库来做Unicode规范化”,面试官同样认可——这比你硬着头皮处理代理对更诚实、更工程化。
7. 刷这道题时我踩过的坑,以及现在的建议
最后聊点实在的。
我第一次做这道题时,用的是“排序字符串再比较”的思路,因为当时的想法是“字符串题就喜欢考排序,排完序用equals比较,稳”。AC之后就没管了。后来在某次模拟面试里,面试官追问了一句:“如果两个字符串很长,比如一千万个字符,你的方案还合适吗?”
我当时才意识到,排序法在数据量放大之后的性能问题有多明显。也是从那次开始,我养成了一个习惯:每道题AC之后,问自己三个问题——
- 时间复杂度能不能再降?如果能,代价是什么?
- 空间复杂度有没有常数化的可能?
- 输入条件如果放宽,这个解法还能不能撑住?
这道题的三组答案让我受益很深:时间复杂度从O(n log n)降到O(n)靠计数,空间从O(n)降到O(K)靠固定数组,输入条件放宽靠HashMap和Unicode处理逻辑。
如果你刚开始刷题,我的建议是:不要因为题目标着“Easy”就跳过。把这道题的三种解法都写一遍,包括复杂的Unicode版。写完之后,按我上面说的三个问题复盘一遍。做完这些,你收获的不只是一个AC记录,而是一套可以迁移到很多字符串题上的思考框架。
就说这么多,下一篇聊聊“字母异位词分组”那题——你会发现今天这套东西,在那儿直接就能用。