代码随想录算法训练营到第六天,题单正好是四道哈希表经典题:242. 有效的字母异位词、349. 两个数组的交集、202. 快乐数、1. 两数之和。看题目数量不算多,两简单两中等,但刷完我发现这一天的内容密度其实很高。前几天的数组、链表题目都是靠双指针或遍历就能想明白的,从这天开始,刷题思维要第一次正式切换到"用额外空间换时间"的赛道上来,而且这一切换就回不去了。
如果你也在跟训练营、或者刚接触LeetCode,这四道题可以当做一个完整的哈希表入门单元来刷,因为它们的难度和用法刚好是一个递进关系:242告诉你什么时候该用数组当哈希表,349告诉你集合set怎么去重,202告诉你哈希判重能解决"循环检测"这类抽象问题,1. 两数之和则直接把HashMap推到你面前,让你从暴力循环思维正式转向哈希思维。这篇就把四道题的完整思路、代码实现和我在实际写的过程中踩过的坑都拆开讲一遍。
1. 第六天四道题的主线:从数组哈希到map的进阶路径
1.1 为什么是这四道题排在一起
先看这四道题放在同一天的内在逻辑。242是哈希表最朴素的形态——用一个固定大小的数组当计数器;349开始引入集合,因为需要去重;202表面上跟哈希表八竿子打不着,但本质是"检测一个序列是否出现重复",这也是set最擅长的事情;1. 两数之和则上升到map,因为它不光要判断"存不存在",还要把下标存下来。
所以你会看到,四道题正好对应了哈希结构的三种形态:
- 数组:适用于key取值范围明确且较小的场景
- HashSet:只需要判断"是否存在",不需要存额外信息
- HashMap:既要判断存在,还要取出对应的value
当你把这四种形态的适用边界都想清楚了,后面做哈希表专题的中等题,比如454题四数相加、383题赎金信,思路就会顺很多。我在训练营学到的最有用的一点就是:不要一上来就HashMap,先想清楚你要存的数据到底是什么,再决定用哪种结构。
1.2 什么题适合用哈希表:先看懂"查找"需求
哈希表解决的核心问题只有一个:把一个查找操作的时间复杂度从O(n)降到O(1)。注意,是均摊O(1),这点后面两数之和里还会再提。
判断一道题适不适用哈希表,我的经验是看它有没有"查找某个元素是否在某个集合中"的需求。242要查两个字符串的字符分布是否一致,349要查nums1里的元素是否在nums2中出现,202要查当前的平方和是否已经出现过,1要查target减去当前元素后剩下的数是否已经遍历过。四道题本质上全都是在做查找。
初学者最容易犯的错误是:看到一个数组题,第一反应是排序或者双指针,完全想不起来哈希表。其实判断标准很简单,如果你的算法里需要反复查询"某个元素在不在某个结构里",而且这个结构不会频繁增删,那哈希表就是第一选择。相反,如果要频繁遍历且对顺序有要求,就考虑别的办法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. LeetCode 242 有效的字母异位词:数组哈希的模板题
2.1 暴力解法的局限
先看一眼题目:给定两个字符串s和t,判断t是否是s的字母异位词。所谓异位词,就是字母组成完全一样、只是排列顺序不同,比如"anagram"和"nagaram"就是一对。
我看到这道题第一反应是排序,把两个字符串都排一下序再比较是否相等,这当然可以做,时间复杂度是O(nlogn)。但这里有个隐含信息:题目限定字符串中只有小写字母,也就是字符集只有26个。当字符集被明确限定时,直接用数组统计词频是最优解,这也是很多题解最终采用的方案。
如果不用哈希结构,还可以两层循环挨个匹配,但那就是纯粹的O(n²)了,一旦字符串边长就会超时。这些解法不是不能实现,而是没有利用到题目给的特殊条件。
2.2 用26格数组统计词频的完整思路
核心思路:用数组record[26]记录每个字母出现的次数。遍历s,遇到哪个字母就给对应位置加1;再遍历t,遇到哪个字母就给对应位置减1。最后如果record数组里每个位置都正好是0,说明两个字符串的字符分布完全一致,返回true,否则返回false。
java复制class Solution {
public boolean isAnagram(String s, String t) {
int[] record = new int[26];
for (char c : s.toCharArray()) {
record[c - 'a']++;
}
for (char c : t.toCharArray()) {
record[c - 'a']--;
}
for (int count : record) {
if (count != 0) {
return false;
}
}
return true;
}
}
关键的一步是c - 'a'。Java里字符是可以直接做减法运算的,本质上是在操作ASCII码值,比如'a' - 'a' = 0,'b' - 'a' = 1,这样就能把字母映射到数组下标。这一步用法在哈希表相关题目里很常见,一定要熟练。
整个流程走下来,时间上是两次遍历加一次检查,复杂度O(n);空间上是固定26个int,所以是O(1)。需要注意这里的O(1)不是随便说说的——无论字符串多长,record数组始终只有26个元素,空间不随输入规模增长。
2.3 为什么用数组而不用map,以及几个容易忽略的细节
很多人会问:这道题用HashMap统计每个字符出现的次数不也能做吗?确实能,但有三个比较实际的问题。
第一,HashMap在存储char类型key时,会涉及hashcode计算、哈希桶定位、可能的哈希碰撞处理,这一套流程下来,常数开销比数组直接按下标访问要大不少。第二,HashMap的存储空间开销也比固定数组大,因为底层是Node数组加链表或红黑树。第三,也是最重要的,遍历结束后你还需要逐个比较两个map是否相等,代码反而更长。
所以在字符集明确且很小的时候,数组就是比map更好的"哈希表"。这一点在代码随想录训练营里反复强调过,我觉得这也是刷题时需要建立的第一个哈希意识:哈希表是个工具,工具要选合适,不是越高级越好。
我自己写的时候还遇到一个细节:如果先判断s.length() != t.length()就直接返回false,可以省掉后面的遍历。虽然不判断也不影响最终结果,但加上长度判断确实能加快一点执行时间,尤其是在两个长度差异很大的用例上。另外,如果想进一步优化,可以只用一个数组:先对s做++,遍历t时遇到对应位置--,如果减到负数就提前返回false,省掉最后的全量检查。不过这个属于微优化,不必须。
3. LeetCode 349 两个数组的交集:set的正确打开方式
3.1 去重与集合运算:思路拆解
题目要求:给定两个数组nums1和nums2,返回它们的交集,输出结果中的每个元素唯一,不考虑输出顺序。比如nums1 = [1,2,2,1], nums2 = [2,2],交集就是[2]。
"唯一"这两个字是关键。如果直接写两层循环把相等的元素收集起来,你会发现[1,2,2,1]和[2,2]会得到[2,2],不满足要求。所以在收集结果时就需要去重。
我一开始的想法是:遍历nums1中的每个元素,到nums2里查一查,看它在不在。这个思路方向是对的,但问题在于"查一查"这个过程,如果每次都是遍历整个nums2,那复杂度就是O(n×m),这种情况下数组一长就扛不住了。
正确思路是用HashSet把nums1的元素存起来,然后遍历nums2,凡是能在set1里找到的元素,就放进结果集。set本身就能保证元素唯一性,所以用set来存结果,去重问题也顺便解决了。
3.2 两个HashSet的代码实现
java复制class Solution {
public int[] intersection(int[] nums1, int[] nums2) {
Set<Integer> set1 = new HashSet<>();
for (int num : nums1) {
set1.add(num);
}
Set<Integer> resultSet = new HashSet<>();
for (int num : nums2) {
if (set1.contains(num)) {
resultSet.add(num);
}
}
int[] result = new int[resultSet.size()];
int index = 0;
for (int num : resultSet) {
result[index++] = num;
}
return result;
}
}
注意最后一步:把resultSet转成数组。Java里直接set.toArray()返回的是Object[],不是int[],所以得手动遍历一遍填进去。这也是一个初学者很容易卡壳的地方,我第一次写的时候就直接return resultSet.toArray(),结果编译不通过,还得再写一遍转换逻辑。
关于时间复杂度的分析:两个数组各遍历一遍,加上set操作的均摊O(1),整体是O(n+m)。这里的n是nums1的长度,m是nums2的长度。空间上,set1存了nums1的所有元素,最坏情况是O(n)。
3.3 数值范围明确时也能用数组,但set更通用
其实这道题还有个数组做法:题目如果用哈希数组,比如用一个长度1001的boolean数组标记nums1出现过的元素,再遍历nums2检查对应位置是否为true,同样能得到结果。这种方案在数值范围有限时非常高效,空间是固定大小O(1)。
但问题是,数组方案依赖一个前提:我事先知道数组中元素可能的最大值。有些变体题没有给出这个范围,比如数据可能到10的9次方,那数组就开不了那么大。这时候set就体现出优势了——它对数据范围没有要求,有多少元素就开多少空间。所以刷题要做的是根据数据范围灵活选择:范围明确且较小时用数组,范围大或者不确定时用set。
3.4 踩坑记录:结果要怎么去重
这道题最坑的地方,也是最容易出错的地方,就是结果去重。我第一次写的时候用的是ArrayList来收集结果,遍历nums2时如果set1里能查到就往list里add,结果发现重复元素被加进去好几次。后来才想到应该用HashSet来收集结果,因为HashSet在add的时候就会自动把重复元素过滤掉。
在这里我踩过的另一个坑是:如果直接用nums1或nums2里的重复元素做判断,最后还要再额外去重,代码就很绕。正确的思路是把去重这件事交给set的add操作去处理,不要让代码自己去判断"这个元素之前加过没有"。要记住一点:HashSet的存在意义就是帮你消除重复,不要自己重复造轮子。
另外有个小细节,做类型转换时,不要让数组下标和元素值混淆。result[resultSet.size()]这里获取的是集合大小,不是最大元素值,很多新手容易在这里脑子转不过弯。
4. LeetCode 202 快乐数:哈希表判断循环的典型场景
4.1 无限循环的数学特征是什么
快乐数的题目描述是:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,重复这个过程直到这个数变为1,也可能是无限循环但始终变不到1。如果可以变为1,那么这个数就是快乐数。
我第一次读完题,第一反应是"这不就模拟吗",循环计算平方和,判断等于1就返回true。但问题来了:如果一直变不到1,while循环什么时候退出?如果不加终止条件,这个程序会一直跑下去,直到超时。
这里就涉及到一个关键数学事实:数字的平方和序列要么最终到达1,要么会进入一个循环。比如n=2,计算过程是这样的:2的平方是4,4的平方和是16,16的平方和是37,37的平方和是58,58的平方和是89,89的平方和是145,145的平方和是42,42的平方和是20,20的平方和是4。发现没有,4刚才已经出现过了,接下来会不断重复4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4。也就是说,一旦出现重复元素,就说明进入了循环,永远不可能到1。
所以"检测是否进入循环"就成了这道题的核心。用什么检测?HashSet。每算出一个新的平方和,先看它有没有在set里,如果出现过,说明被卡在循环里了,直接返回false;如果等于1,返回true;如果都不是,就把这个数加入set,继续计算。
4.2 模拟计算与判重的代码实现
java复制class Solution {
public boolean isHappy(int n) {
Set<Integer> seen = new HashSet<>();
while (n != 1 && !seen.contains(n)) {
seen.add(n);
n = getNext(n);
}
return n == 1;
}
private int getNext(int n) {
int sum = 0;
while (n > 0) {
int digit = n % 10;
sum += digit * digit;
n /= 10;
}
return sum;
}
}
这个代码的核心是把"取出每一位数字"封装成了一个独立方法,getNext方法里用模10和整除10交替操作来拆数字。比如n=123,第一次n%10得到3,累加3的平方,然后n变成12;第二次n%10得到2,累加2的平方,n变成1;第三次n%10得到1,累加1的平方,n变成0,循环结束。
每一步操作用set做去重判重。等循环跑完,只有两种可能:n变成了1,或者n已经在set里出现过了。如果是后者,说明进入了死循环,不是快乐数。
4.3 快乐数题解中经常被忽略的复杂度分析
很多题解在分析这道题的复杂度时会含糊地写"O(logn)",实际上应该分开理解。
getNext方法里的循环次数取决于n的位数。比如n是一个10位数,那么循环要执行10次,所以单次getNext的时间复杂度是O(log n)(因为10的d次方约等于n,d = log10(n))。
那getNext会被调用多少次呢?这里涉及一个数论上的结论:对于任意整数,其各位数字平方和会很快收敛到一个比较小的范围。即使输入的数非常大,一旦进入计算过程,数字规模会迅速下降,所以迭代次数不会是无限的(除了循环的情况)。严格来说,最坏情况下循环体内的数字会有重复,但数字范围限制在1到243之间(因为三位数的平方和最大是9²×3=243),所以set里的元素数量是有限的,可以认为迭代次数是常数级别的。
综合来看,时间复杂度是O(logn)(拆数字)乘以常数(迭代次数),空间方面set最多存几百个数,也是常数级别。这也意味着,快乐数这道题如果用快慢指针来做,时间空间都能进一步压到O(1)空间,这算是进阶思路,后面可以自己想一想。
5. LeetCode 1 两数之和:第一次认真用map
5.1 两层for循环为什么不够好
两数之和的题目描述是:给定一个整数数组nums和一个整数目标值target,请你在该数组中找出和为目标值的那两个整数,并返回它们的数组下标。比如nums = [3,2,4], target = 6,答案是[1,2]。
这道题可以说是LeetCode的"第1题",也是无数人刷题生涯的起点。最直观的解法是两层循环:外层固定一个数,内层遍历剩下的数,挨个检查是否等于target - nums[i]。这个解法思路非常自然,但时间复杂度是O(n²)。当数组长度是10万时,大约要执行100亿次比较,在LeetCode上基本就超时了。
所以我们需要一种能够快速查询"target - nums[i]是否已经出现过"的结构。如果你已经做了前面的349题,应该立刻意识到:这一步查询正是HashSet和HashMap的用武之地。
5.2 map方案:边查边存的完整实现
既然要快速查找之前遍历过的元素,一个自然想到的方案是:遍历数组,每到一个元素,检查target - nums[i]在不在已遍历的元素集合里。如果在,直接返回两个下标;如果不在,就把当前元素存进去,继续遍历下一个。
这个描述听起来像是set就够了,但不要忘了题目要求返回下标。所以不能只存元素值,还得把下标一起存下来。这正是HashMap出场的时候:key存元素值,value存元素在数组中的下标。
java复制class Solution {
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
if (map.containsKey(complement)) {
return new int[]{map.get(complement), i};
}
map.put(nums[i], i);
}
return new int[0];
}
}
核心是"先查再放"这个顺序。到第i个元素时,map里存的都是下标小于i的元素,所以查到的complement一定是之前出现过的,不会出现"一个元素自己跟自己配对"的bug。遍历一次数组,每个元素最多经历一次containsKey和一次put,时间复杂度O(n),空间复杂度O(n)。
5.3 为什么键存数值、值存下标,方向不能反
这个细节是我当时理解了一会儿才想通的。map的get方法是根据key取value,containsKey是判断某个key是否存在。题目里要查的是"有没有一个数等于target - nums[i]",这个数就是数组元素的值,所以要把它放到key的位置上,才能用containsKey去搜索。而最终要返回的是下标,所以在命中时需要取出对应的value,value自然就是下标。
如果把方向反过来,用下标当key、数值当value,那containsKey查的就是下标了,完全对不上号。这个思维方式刚开始接触map时会有点别扭,我的建议是记住一句话:你"需要查询的内容"放在key里,你"需要返回的结果"放在value里。两数之和中,要查询的是某个数值是否出现,所以数值是key;要返回的是下标,所以下标是value。这个套路在后面很多题里都通用,比如四数相加、三数之和的map优化版本,全都不例外。
5.4 那道题的隐藏升级点:从两数之和到三数之和
做完两数之和,一个很自然的延伸是:如果是三数之和呢?LeetCode 15题,要求找出所有三元组,三个数加起来等于0。如果用类似思路,固定一个数,剩下两个数再哈希,要去重会比较麻烦。这也是后面训练营会专门讲双指针做三数之和的原因。
但两数之和的map写法有一个重要的变体需要注意:如果题目改成"返回所有不重复的数对",那就不能简单用一个map了,因为重复元素会造成重复结果,需要额外去重。LeetCode 1题由于保证只有唯一答案,所以我们的写法可以是最简洁的。刷题时先看清约束条件,再决定是否能直接用最简方案。
6. 第六天的整体复盘与实战建议
6.1 四道题横向对比:结构选择规律
刷完这四道题,我把它们的结构选择和适用场景整理成了一张表,后面复习的时候非常有用:
| 题目 | 核心需求 | 选择的结构 | 选择原因 |
|---|---|---|---|
| 242. 有效的字母异位词 | 统计字符出现次数 | 数组(26格) | 字符集明确且只有26个小写字母 |
| 349. 两个数组的交集 | 判断元素是否存在并去重 | HashSet | 数据范围未知,set同时解决存在与去重 |
| 202. 快乐数 | 检测序列是否出现循环 | HashSet | 只需要判重,不关心其他信息 |
| 1. 两数之和 | 查找补数并返回下标 | HashMap | 既要判断存在,又要取出下标 |
这张表的价值在于,它不是死记硬背的,而是总结了三条规律。第一,能用数组时优先用数组,常数开销最小;第二,只用判存在时用set,简洁直接;第三,既要判存在又要取附加信息时,才考虑map。
6.2 刷题时的几个常见错误与自查清单
再分享一些实操中容易踩的坑和对应的自查方法。
242题里,最容易忘的是最后检查整个record数组,或者误写成record[i] > 0才返回false。实际上因为先加后减,某个位置可能变成负数,也必须返回false。自查方法是:遍历结束后检查是否所有元素等于0,不等就false。
349题里,最容易错的是收集结果用了List导致结果有重复。自查方法是:先看题目要求"唯一",只要出现这个词,就可以考虑用set来收集结果。
202题里,最容易犯的错是while循环写成了while (n != 1),然后死循环超时。自查方法是:确认循环终止条件里包含了"set出现重复元素"这个分支。
1题里,最容易错的是先put再containsKey,因为这样可能找到自己和自己配对。自查方法是:检查遍历顺序里put语句在containsKey语句之前还是之后,正确顺序一定是先查后放。
6.3 训练营节奏中的时间分配建议
代码随想录训练营的节奏通常是一天一篇文章加几道题。像我这种白天还要上班的人,第六天这四道题我实际花了一个半小时,其中思考加核心理解大概四十分钟,剩下时间都在过代码细节和复现。我的建议是不要把时间全花在直接看题解上,每道题先自己尝试思考5到10分钟,实在没思路再去看答案,然后合上答案自己把代码写出来。
还有一个很小的技巧:四道题做完之后,可以写一遍"解题思路回顾",不要写代码,只写每道题的核心步骤,每个步骤一两句话。比如242就是"统计s加1、统计t减1、检查是否全0"。这个做法能帮你确认是否真正理解了题目,而不仅仅是背下了代码。等到第二天接着刷新题之前,花五分钟把前一天的回顾看一遍,记忆会比只刷一遍牢固得多。
等到后面做到哈希表专题的中等题时,你会发现这四道题打下的基础其实非常关键,尤其是"先想清楚用什么结构再动手"这个习惯。它不只是对这四道题有用,整个算法训练营后半程的很多题目,本质上都在反复应用这套选择逻辑。
