开头部分,我来写一个像博主分享刷题心得的引入,提到“代码随想录”第六天、哈希表专题。然后再展开。
1. 第六天开篇:终于开始跟哈希表较劲了
刷到代码随想录第六天,意味着你撑过了数组、链表和双指针的第一波轰炸,正式进入哈希表专题。可以说这一天的内容非常“捏一把汗”又非常爽:题目看起来简单到不像算法题,但实现起来处处是细节,尤其是两数之和这道题,几乎每次面试都会遇到。
代码随想录把哈希表放在这个位置是有道理的。前两天你用数组和链表打下了线性结构的基础,而哈希表本质上是“用空间换时间”的经典思想,它不要求你掌握复杂的数据结构操作,反而考验你对底层存储原理的理解:什么时候用数组当哈希表、什么时候用Set、什么时候用Map,这才是真正的考点。
第六天的核心就四道题:242. 有效的字母异位词、349. 两个数组的交集、202. 快乐数、1. 两数之和。题目量不算大,但每道题背后都是不同的哈希表使用场景。刷完这四道题,你会形成一种条件反射:看到“查找是否存在”“判断是否重复”这类需求,第一反应就应该往哈希表上想。这篇文章主要写我的刷题过程、踩坑记录和最终理解的底层逻辑,适合刚开始刷代码随想录的朋友,也适合准备面试前快速回顾哈希表核心套路的人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 哈希表为什么是面试宠儿:三个形态与耗时对比
2.1 数组、Set、Map到底选哪个
接触哈希表的第一天,最容易懵的就是这个问题:同样是哈希,凭什么有的题用数组、有的用HashSet、有的用HashMap?代码随想录的套路总结得很直接——看你要存什么数据以及数据范围是否可控。
数组其实是最容易被忽略的“哈希表”。因为数组本身就是一种哈希结构,下标就是key,值就是value。当你需要判断某个字符或者某个数字是否出现过,而且这个数据的范围是有限且连续的,比如小写字母26个、ASCII码128个、数字在一定范围内,数组就是最高效的哈希表。为什么?因为数组按下标随机访问是O(1)复杂度,而且不涉及哈希函数的计算和碰撞处理,直接用原始索引访问,CPU缓存的命中率也更高。
HashSet适合数据范围不确定、又需要去重的场景。它底层是一个HashMap,只是把value固定成了同一个Object。当你想知道两个数组的交集是什么,或者某个数字是否在集合里出现过,但不关心它出现了几次、也不关心它的其他属性,Set就是一个干净的方案。
HashMap则负责更复杂的场景:不仅要知道这个元素出现过,还要拿到它关联的那个值。两数之和就是最典型的代表,我们需要的是元素值和对应的数组下标,这是一个完整的键值对关系,用Map来存就是顺理成章的事。
2.2 复杂度分析:空间换时间到底换得值不值
从复杂度上看,哈希表的核心价值就是让查找操作从O(n)降到了平均O(1)。在刷题或者面试的时候,我习惯先用最笨的暴力解法测量一下复杂度,再考虑能不能用哈希优化。
以242. 有效的字母异位词为例,暴力解法是两层循环遍历两个字符串逐个比较字符,时间复杂度O(n²)。哈希解法把字符依次映射到数组索引,只需要两遍O(n)的扫描外加一次数组遍历,时间复杂度降到O(n)。代价是额外开辟了一个长度26的int数组,空间复杂度为O(1)。这个trade-off非常划算,因为26是常数,空间开销几乎是零。
到了两数之和这种题目,暴力的双层循环复杂度是O(n²),哈希解法直接砍到O(n),但空间复杂度从O(1)升到了O(n)。面试官问“能不能在O(n)时间内完成”的时候,哈希表就是那个标准答案。
2.3 哈希冲突:可别以为哈希表总是O(1)
代码随想录第六天并不会深挖哈希冲突,但我觉得作为一个刷算法题的人,有必要知道为什么说平均O(1)而不是绝对O(1)。当不同的key经过哈希函数映射到同一个索引时,哈希冲突就发生了。经典解决方式有链地址法和开放地址法。Java的HashMap采用的是链地址法,冲突元素以链表形式挂在同一个桶上,当链表长度超过阈值(8)且数组长度大于等于64时,链表会转成红黑树,把最坏情况从O(n)优化到O(logn)。
为什么讲这个?因为面试官很喜欢在一道两数之和之后追问你HashMap的原理,这几乎已经成了固定流程。你不需要把红黑树的细节都背下来,但至少得知道冲突的根本原因和基本的处理方式。比如数组扩容时为什么会重新哈希、为什么重写equals就必须重写hashCode,这些问题在面试中高频出现。刷题只是第一关,背后的原理才是拉开差距的地方。
3. 四道经典题逐题拆解:从暴力到哈希的完整推导
3.1 242. 有效的字母异位词:数组版哈希的实际应用
这道题要求判断两个字符串中每个字符出现的次数是否相同。我先写了一个暴力版本:把s中每个字符拿出来,在t里数一遍出现次数。逻辑很简单,但是效率低到我自己都嫌弃。后来按代码随想录的思路改成数组哈希,思路立刻清晰了:因为题目限制字符串只包含小写字母,那就开一个长度26的int数组,下标0对应'a',下标25对应'z'。
第一次遍历字符串s,每遇到一个字符就执行arr[c - 'a']++,统计每个字母出现的次数。第二次遍历字符串t,每遇到一个字符就执行arr[c - 'a']--。如果两个字符串是字母异位词,那么最后这个数组里的每一个值都应该是0。如果中间有任何一个位置不是0,直接返回false。
code复制// 242. 有效的字母异位词
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) {
return false;
}
int[] arr = new int[26];
for (int i = 0; i < s.length(); i++) {
arr[s.charAt(i) - 'a']++;
}
for (int i = 0; i < t.length(); i++) {
arr[t.charAt(i) - 'a']--;
}
for (int count : arr) {
if (count != 0) {
return false;
}
}
return true;
}
这里我想提醒一个细节,很多人在写第二个循环的时候会忘记判断t的长度和s是否一致。如果不一致,少一次遍历会漏掉一部分字符,导致数组里的值没有完全归零,但这并不等于不是异位词场景下应该返回false,而是你的代码逻辑本身就没覆盖完整。提前判断长度,既是剪枝,也能防止后续逻辑错误。
另外有些参考代码会在每次--之后直接判断是否小于0,一旦小于0就提前返回false。这样做是可以的,而且能加速剪枝。不过如果第一个循环里某个字符出现次数为0,而第二个循环遇到了这个字符,--后的确会变成-1,此时确实可以确定不是异位词。但我习惯最后统一判断,因为代码结构更清晰,面试讲起来也好说。这道题其实就是考你能不能把一个字符映射成数组下标,本质上是手工实现一个最简单的哈希函数。
3.2 349. 两个数组的交集:Set天然去重省事很多
两个数组的交集这道题,要求返回的元素是唯一的,也就是说结果需要去重。我看到题目第一反应是用两个Set:先把nums1的所有元素都放进Set1,再遍历nums2,如果Set1中存在当前元素,就把这个元素放到结果Set2中。利用Set的去重特性,天然避免了结果中出现重复元素。
code复制// 349. 两个数组的交集
public int[] intersection(int[] nums1, int[] nums2) {
if (nums1 == null || nums1.length == 0 || nums2 == null || nums2.length == 0) {
return new int[0];
}
Set<Integer> set1 = new HashSet<>();
Set<Integer> resultSet = new HashSet<>();
for (int num : nums1) {
set1.add(num);
}
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;
}
这道题我会多想一步:其实还可以用另一个Set做第二层去重,也就是resultSet.add(num)这一步本身已经利用Set去重了。如果你不想用两个Set,也可以用一个List加一个contains判断,但List的contains是O(n)复杂度,去重效果一样,效率差不少。能用一个Set解决的去重,就不要用List。
还有一个常见的追问:如果数组本身是有序的,能不能不用哈希?能,双指针方法可以做到O(n)时间去重合并,但哈希版本不要求输入有序,通用性更强。题目没有说明数组有序的情况下,直接用哈希是稳妥的选择。
这道题也让我体会到:Set和数组哈希的区别不仅仅是底层结构,而是应用场景的不同。数组必须知道数据的范围,而Set不需要。哈希表不是万能的,但当你不知道数据范围时,Set就是最安全的后备方案。
3.3 202. 快乐数:哈希表用来判定循环是经典套路
快乐数这道题让我一度很困惑:这跟哈希表有什么关系?题目要求判断一个数是不是快乐数,它的定义是:对一个数不断求各位数字的平方和,如果最终能变成1就是快乐数,否则无限循环。问题来了,怎么判断一个过程会不会无限循环?代码随想录给出的思路非常妙:如果出现了重复的平方和,说明已经进入了循环,那就永远不可能到达1。
所以这道题的哈希表是用来存历史出现过的平方和的。用一个HashSet记录每次计算的结果,如果新计算出的结果已经存在,说明进入了死循环,直接返回false。如果计算出的结果是1,返回true。整个过程本质上就是“检测循环是否出现”。
code复制// 202. 快乐数
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;
}
我写这段代码的时候踩过一个坑,就是while循环条件里的顺序。如果我先判断!seen.contains(n)再判断n != 1,在n为1时会先把1加入集合,再进入下一轮循环得到1,此时集合里已经有1了,判断会出错吗?其实是不会的,因为第二轮看到1已经存在就会退出,返回的是n == 1为true,仍然正确。只不过这样会多一次无意义的计算。先判断是否等于1,再判断是否重复,能避免无意义的迭代,更符合逻辑。
还有一个细节:计算各位平方和不能用Math.pow,因为会返回double导致精度问题,直接digit * digit就行。数字很大的时候,int会不会溢出?快乐数的中间值其实是可以很大的,但你仔细想想,一个数字的位数有限,平方和的增长速度其实远没有想象中快,所以用int基本安全。不过如果面试时真的遇到极端情况,提一句用long或者BigInteger会显得更严谨。
3.4 1. 两数之和:HashMap为什么能把O(n²)变成O(n)
两数之和是LeetCode的第1题,也是代码随想录第六天的压轴题,更是面试高频中的高频。题意很简洁:给定一个数组和一个目标值,返回两个下标,使得这两个下标对应的元素之和等于目标值。
暴力解法是两层循环,把所有组合都试一遍。哈希解法是:遍历数组时,对于每个元素nums[i],我们需要知道target - nums[i]是否在数组中,并且要知道它的下标。这时候HashMap的键值对能力就完美派上用场:key存元素值,value存下标。
code复制// 1. 两数之和
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];
}
这段代码有一个很关键的执行顺序:先查map再放入当前元素,而不是先放入再查。为什么?因为同一元素不能重复使用。如果先把nums[i]放进map,再查找complement,当complement恰好等于nums[i]时就会错误地返回同一个下标,也就是自己加自己等于target的情况。比如数组[3, 2, 4],目标值6。如果先放入3 -> 0,再查complement = 3,就会返回[0, 0],这显然是错的。先查后放就完美避开这个问题。
还有一个问题,返回值顺序要不要讲究?虽然题目只要求返回下标,但在面试里最好按从左到右的顺序返回,也就是先返回map.get(complement),再返回i,因为前者在数组位置上是靠前的。养成这种细节习惯在面试中会加分。
4. 刷完第六天之后的整理:我总结的模板与易错点
4.1 什么时候该用什么数据结构,一张表说清楚
刷完这四道题,我尝试总结了一套判断标准,遇到类似题目的时候可以直接套用。
| 场景 | 推荐结构 | 原因 |
|---|---|---|
| 数据范围有限且连续,比如小写字母、ASCII码 | 数组 | 下标天然是哈希索引,O(1)访问,内存占用低 |
| 只需判断元素是否存在,结果需要去重 | HashSet | 自动去重,查询O(1),无需关心数据范围 |
| 需要存储键值对,如元素值和下标 | HashMap | 一个key对应一个value,最灵活 |
| 需要统计元素出现次数,范围有限 | 数组计数 | 相比Map节省了大量装箱拆箱和哈希计算开销 |
| 需要统计元素出现次数,范围未知 | HashMap | 能动态扩展,不必预先开辟固定空间 |
这个表格不是唯一的答案,但对我这种刷题者来说很管用。刷题最怕的不是不会做,而是会做但选错了工具。哈希表三板斧之后,很多题目都能一眼看出解法雏形。
我还特别注意一个区别:做题用Java的HashSet、HashMap时,底层需要计算哈希值再定位桶,对于Integer这种装箱类型还会有装箱拆箱的开销。所以当数据范围可控时,数组是绝对的首选,不要觉得HashMap很万能就什么都用它。242题能用数组就用数组,这个选择在代码随想录的讲解中也被反复强调。
4.2 空指针与边界条件,最容易翻车的地方
哈希表相关的题,边界条件其实比很多数据结构更好处理,但依然有几个高频雷区,我不止一次在刷题和面试模拟中踩中。
第一个雷区是先取key再判断是否为空。在Java中,如果map中不存在某个key,get方法返回的是null,你用null去跟数字比较或者做运算,直接空指针。所以map.get(key)之前最好先用containsKey判断,或者用JDK 8的getOrDefault方法。两数之和里我用的就是先containsKey再get,这是最安全的写法。
第二个雷区是数组长度为0。算法题常给[]这样的边界输入,如果直接取nums[0]就会越界。很多题解的代码在高大上的逻辑之后都忘了这一步,但在面试中,面试官非常喜欢用这种边界用例来测你的代码是否健壮。写题目之前先判空和长度,就是几行代码的事,但能体现出工程素养。
第三个雷区是哈希表键值的类型。在Java中int[]作为HashMap的key时,比较的是数组引用的相等性,而不是数组内容的相等性,这会导致永远匹配不上。如果在变种题里用数组作为key,这个坑能让人debug到怀疑人生。正确的做法是把数组转换成List<Integer>或者字符串再作为key。
4.3 一套可复用的刷题思考流程
代码随想录带给我的最大收获不是某道题的解法,而是一套思考流程。拿到一道新题,我的顺序固定是这样的:
先看数据规模。如果n非常小,比如小于等于100,暴力两层循环是完全可以接受的,不需要一上来就上哈希表。如果n是10的5次方甚至更大,那基本可以确定要用O(n)或O(nlogn)的解法,哈希表就是一个合理的候选方案。
再看题目里有没有“查找是否存在”这个动作。比如判断一个元素是否出现过、找两个元素是否满足某个条件、统计某个元素出现的次数。这些都是哈希表的经典信号。反过来,如果题目要求有序输出、寻找区间、滑动窗口这类场景,哈希表往往不是最优解,可能需要排序或双指针。
最后再决定用数组、Set还是Map。固定套路:范围有限用数组、去重用Set、键值关联用Map。想清楚这三个步骤,大部分哈希表题目都能在五分钟内确定思路。
5. 实际编码中的调试技巧与效率优化
5.1 用日志打印和断点定位哈希表问题
哈希表相关的bug不太直观。我在写两数之和的时候,曾经遇到过返回结果和预期只差一个下标的情况,眼睛盯着代码看了半天也没发现哪里不对,最后靠打印日志才发现是“先放后查”导致的自己加自己问题。
调试建议是:在关键循环里把当前元素、当前哈希表的内容、期望查找的complement一起打印出来。尤其是数据量小的时候,这种打印能帮你快速理解每一步的状态。比如:
code复制System.out.println("i=" + i + ", num=" + nums[i] + ", complement=" + complement + ", map=" + map);
能清楚地看到,当i=0时map还是空的,当i=1时map里有哪些数据。这样就能理解为什么先查后放至关重要。
如果用的是IDE的断点调试,在HashMap的put和get处打断点效果也很好。不过刷算法题用日志更快,因为断点调试会频繁进入JDK的源码内部,反而让人迷失。
5.2 从代码随想录第六天延伸到面试扩展题
四道题刷完之后,我建议每道题都思考一个扩展方向,这样面试才不会被突然的追问打懵。
242题的扩展是“如果字符串包含Unicode字符怎么办”,此时数组长度不能预先确定为26,应该用HashMap来统计,因为Unicode字符的范围太大,用数组会浪费海量空间。这种追问就是考你是否真正理解数组哈希和Map哈希的边界。
349题的扩展是“两个数组都是有序的”,你可以用双指针合并的方式做到O(n)时间、O(1)额外空间,比哈希版本更省。面试官问你“还有没有更好的解法”时,能答出双指针会非常加分。
202题的扩展是“为什么快乐数一定会在有限步内循环”,这其实和弗洛伊德判圈算法相关。你可以用快慢指针来解决,一个指针每次计算一次平方和,另一个指针每次计算两次,如果两者相遇且值不是1,就说明存在循环。这能体现你的数学功底。
1题的扩展就更经典了:两数之和能不能用双指针做?可以,但前提是数组有序。如果输入无序,排序会导致下标丢失,需要额外保存下标信息,复杂度反而更高。所以典型的解法就是哈希表,这也是它为什么能出现在LeetCode第1题的原因。
6. 我在第六天踩过的坑和最终心得
说了这么多,最后分享几个我实际刷题时的具体感受吧。第一个体会来自202快乐数。我第一次看到这道题的时候完全想不到可以用哈希表,感觉它和“数位运算”更接近。直到有一天我用一个简单的测试用例跑了十几轮发现数字在不断重复,才突然明白,哈希表真正强大之处不是存储“有用数据”,而是能记住“出现过什么”。这种回想对理解判重类题目帮助极大。
第二个体会来自1. 两数之和。很多人在网上看到题解说用Map,就直接把代码背下来了,但不知道为什么先查后放。我建议你亲手跑一个[3, 2, 4]、target=6的用例,把先放后查的结果打印出来看看,你立刻会明白这个顺序有多么重要。这种“亲手踩坑”的记忆比任何讲解都深刻。
第三个体会和代码随想录的整体安排有关。第六天的四道题不是随机的,它们对应了哈希表的四种典型用法:数组手动模拟哈希、Set去重、Set判循环、Map存键值对。如果你只是单独看某一道题,会觉得解法孤立;但放在一起对比,你才会意识到这是同一套理论在不同场景下的应用。
最后一个小技巧:刷完这一天之后,去LeetCode把“哈希表”标签下的简单题再刷十道左右,比如“存在重复元素”和“有效的数独”。这些题目本质上和第六天是同一个知识族,刷完后你会对哈希表产生肌肉记忆,看到题目就能条件反射地思考该用数组、Set还是Map。到这个状态,你会觉得第六天这一关过得非常值。
