今天打卡代码随想录Day05。到了这个节点,算法训练节奏开始出现一个明显转向:前四天还在数组、链表这种“元素怎么存、怎么遍历”的基础结构里打转,从第五天开始,题目突然开始频繁问“这个元素出现过吗”“出现了几次”“能不能快速找到匹配项”。这类问题的核心不再是存储形式,而是查找效率,答案几乎都指向同一个数据结构——哈希表。
我刚刷Day05的时候其实有点没转过弯来,总觉得哈希表不就是Python里的字典、C++里的map吗,查一下能有多大学问。但真正把四道题刷完才发现,哈希表这套东西“会用”和“会用对”之间差着好几个级别。这篇文章就把我这一天的完整复盘写下来,包括每道题的解法思路、代码实现、以及我在实际运行中踩到的边界问题,给同样刷到这一天的朋友做个参考。
1. 为什么算法题刷到第五天,该轮到哈希表上场了
先聊一个很多人没认真想过的问题:前面的数组和链表题,本质上都在解决“数据怎么组织”的问题。到了哈希表专题,问题的性质变了,变成了“数据怎么快速查找”。
你可以把哈希表理解成一个超级快递柜。数组和链表是货架,你要找一件东西就得顺着货架一格一格看,运气好第一格就找到,运气不好看到最后才找到。快递柜不一样,每件包裹根据快递号直接计算出一个柜门编号,你把柜门一开,东西就在里面,不需要从头翻到尾。
这个“快递号到柜门编号的转换”,就是哈希函数,而那个柜门背后的空间,就是哈希桶。哈希表牺牲了一部分内存空间,换来的是接近O(1)的查找效率。第五天安排这个专题,正是因为在真实面试题目里,判断存在性、统计频率、快速匹配这类需求太常见了,靠线性扫描去硬扛,几乎所有相关题目都会超时。
我在刷Day05之前其实也会用哈希表,但用的是“做题家式的用法”——看到字谜题就扔个Counter,看到找两个集合公共元素就扔个set。这种用法没问题,但会让我完全感受不到哈希表为什么是这个性能、什么时候改选数组、什么时候又必须改用真实哈希结构。代码随想录这一天可贵的地方在于,通过四道由浅入深的题,把“哈希表解决什么问题、怎么选实现方式”的逻辑链条给你完整串起来了。
2. 哈希表的三种形态:数组、集合、映射,怎么选才对
在没系统刷题之前,我总觉得哈希表就是map、dict这一类“键值对容器”。刷完Day05才建立起一个更准确的认识:只要底层思想是“通过计算直接定位到桶位置”,数组其实也算一种哈希表,而且很多时候数组比真正意义上的哈希结构更高效。
2.1 数组:范围有限时的最优解
数组作为哈希表的核心条件是,待标识的“键”是有限的、连续的、范围可控的整数。经典的就是字母。26个英文字母,你开一个长度为26的数组,用str[i] - 'a'直接算出下标,每次操作都是O(1),而且数组的缓存命中率极高,实际运行速度比map快得多。
Day05的“有效字母异位词”就是数组哈希的教科书场景。异位词意味着两个字符串里各字符出现次数完全一致,你统计第一个字符串每个字母的出现次数,然后遍历第二个字符串逐字符减掉次数,最后检查整个数组是否全是0。整个过程不需要排序,不需要存储复杂的键值结构,26个int就够了。
2.2 集合set:只关心“出现过没有”
当数据范围不固定、不需要绑定额外信息、只关心某个元素存不存在时,set是最干净的选择。“两个数组的交集”第349题就是典型场景。你要判断数组1里的每个元素是否在数组2里出现过,把数组2转成set,然后遍历数组1逐一检查即可。
set底层是真实的哈希结构,插入和查找平均都是O(1),但它比数组多了一层哈希计算,实际耗时略高。另一个值得注意的点是set天然去重,这正好满足交集题目“输出唯一元素”的要求,省得自己再写去重逻辑。
2.3 map:键值对应关系才是刚需
map在所有哈希表形态里最重量级,因为它在“存在性判断”之上还附加了“关联信息”。Day05的重头戏“两数之和”就是map的标准场景。光知道某个数字出现过不行,你还得知道它出现在哪个位置,需要在哈希表的value里存下标。
我自己的选型经验是:看到题先问自己三个问题——键的范围是否固定且小?如果固定且小,直接用数组;需不需要去重和判断存在?需要就去重用set;需不需要为每个键记录附加数据?需要就直接上map。这三步下来,基本不会选错结构。Day05四道题恰好把这三条分支全部覆盖了一遍,刷完以后我对哈希结构怎么选就有了肌肉记忆。
3. Day05四道哈希表题,从思路到代码逐题拆解
这一天我在代码随想录打卡的题目分别是:242.有效的字母异位词、349.两个数组的交集、202.快乐数、1.两数之和。整体难度不高,但每一题都对应一个哈希表的典型应用模式,拆开来说清楚。
3.1 第242题:有效的字母异位词,用数组哈希完成“字符频次比对”
题目本身不难理解:给定两个字符串s和t,判断t是不是s的字母异位词,也就是两个字符串包含的字母完全相同,只是排列顺序不同。
最无脑的做法是对两个字符串排序后直接比较,时间复杂度O(n log n)。哈希表思路则能把复杂度压到O(n),因为字符有限,用一个长度为26的数组即可。
bool isAnagram(string s, string t) { if (s.size() != t.size()) return false; vector<int> record(26, 0); for (char c : s) { record[c - 'a']++; } for (char c : t) { record[c - 'a']--; } for (int count : record) { if (count != 0) return false; } return true; }这里我想特别强调一个我在实际提交时踩过的坑:不要忘记先判断两个字符串长度是否相等。这是异位词的必要条件,但也是一个特别容易被忽略的初始剪枝。不判断长度直接统计,最后数组全为0也能通过,但多做了很多无意义的遍历,也不够严谨。
还有一个小优化思路:第二步遍历t的时候,如果减到某个字符的计数已经小于0,说明t里该字符出现次数超过了s,可以直接返回false,不需要等到最后统一检查。我实测下来,这个提前返回对包含大量重复字符的长字符串有明显加速效果。
3.2 第349题:两个数组的交集,核心是用set完成去重与存在性判断
这题要求返回两个数组的交集,且结果中每个元素必须唯一。第一反应可能会是双重循环暴力查找,时间复杂度O(n*m),在LeetCode上勉强能过,但在面试手写环节基本属于下乘答案。
更好的解法是先建一个set存nums1的元素,再遍历nums2逐个检查是否在set里。这里有个细节需要注意:结果必须去重,所以不能把符合条件的结果直接存进vector,得再套一个set去重,或者用一个标记数组记录哪些元素已经加入结果。
我是用C++写的,核心代码如下:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) { unordered_set<int> set1(nums1.begin(), nums1.end()); unordered_set<int> resultSet; for (int num : nums2) { if (set1.count(num)) { resultSet.insert(num); } } return vector<int>(resultSet.begin(), resultSet.end()); }这里选unordered_set而不是set是个小讲究。set底层是红黑树,插入和查找是O(log n);unordered_set底层才是真正意义上的哈希表,平均O(1)。在只关心存在性、不需要有序遍历的场景里,前者总是更快。
还有值得一提的边界场景:两个数组都为空、一个为空、二者没有任何交集,这几类情况用set写法都能天然处理,不需要额外写分支。这也是哈希表解法相比双指针扫描更省心的原因。
3.3 第202题:快乐数,哈希表保存的是“循环检测的状态”
这道题很能迷惑人,因为题目本身看起来跟哈希表八竿子打不着:给定一个正整数,每次将该数替换为它每个位置上的数字的平方和,重复这个过程。如果最终能变为1,就是快乐数,如果陷入不包含1的循环,就不是快乐数。
我第一次做这题想的是“直接模拟100次,变不成1就是假的”,这种解法本质上依赖一个没有依据的假设。正确思路是把“是否出现过这个数”作为循环判定的依据——如果某个平方和结果重复出现,说明已经进入循环,永远不可能变成1。
bool isHappy(int n) { unordered_set<int> seen; while (n != 1 && !seen.count(n)) { seen.insert(n); int sum = 0; while (n > 0) { int digit = n % 10; sum += digit * digit; n /= 10; } n = sum; } return n == 1; }我实测这里有个挺大的坑是取各个位上的数字。新手容易写成先to_string(n)然后把每个char转int,这种写法本身没错,但会引入字符串转换的开销,而且char到int的转换容易出错。直接用% 10和/ 10把每一位剥离出来,是更干净的写法。
另外一个可选的进阶方案是快慢指针。用两个变量,一个每次计算一次平方和,一个每次计算两次,如果存在循环,二者一定在某个时刻相遇。这个思路不需要哈希表,空间复杂度降到O(1),非常适合作为面试中的延伸题展示自己思路开阔。
3.4 第1题:两数之和,哈希表map把O(n²)暴力降到O(n)
这题算得上LeetCode的“天下第一题”,刷题数超过1900万次。问题很简单:给一个数组和一个目标值,找出数组中两个数和为目标值的下标。暴力双重循环肯定能过,但Day05的重点是让你掌握哈希表优化。
我在这题上犯过一个特别典型的错误,而且相信很多人在初学时会犯同样的错:先把自己当前遍历的元素插入哈希表,然后去查找目标差值。这样做在遇到重复值时,会把自己匹配给自己。比如数组是[3, 3],目标值是6,遍历第一个3时先插入,然后查6-3=3,结果发现map里刚插入的3下标也是0,返回[0,0],这显然是错的。
标准解法是遍历过程中先查目标差值,查不到再把当前值及其下标插入哈希表。这样每个下标只会在后面的遍历中被其他元素匹配到,不会自我匹配。
vector<int> twoSum(vector<int>& nums, int target) { unordered_map<int, int> hash; for (int i = 0; i < nums.size(); i++) { int need = target - nums[i]; if (hash.count(need)) { return {hash[need], i}; } hash[nums[i]] = i; } return {}; }关于map的count和find,C++里map.count(key)返回0或1,比find写起来更直观,但两者性能没有本质区别。另外要注意:如果题目要求返回所有满足条件的下标对,写法又不一样,需要map里存下标列表。Day05只要求任意一对,所以上面的写法就够了,但在面试延伸时最好能主动提到这个变体。
4. 我实测一天四题下来,最容易翻车的是这五个细节
四道题本身不算难,但在LeetCode实际提交过程中,我反复翻车的地方集中在下面几个细节。这些内容代码随想录的正文里有提到,但真正感受深刻还是要自己踩一遍。
第一个是数组哈希的下标越界。有效字母异位词那题,如果用record[c - 'a']来做,必须保证输入只包含小写字母。题目确实这么限制了,但如果把代码拿到本地测试,输入一个大写字母就会出负数下标。我在本地调试时给过一个带大写字母的用例,程序直接崩溃。实际项目里字符串输入基本不可能那么干净,所以刷题时养成“先确认字符范围再决定数组大小”的习惯很有价值。
第二个是set处理负数哈希的实际问题。如果用数组下标来标记某个数出现过,遇到负数就得做偏移。LeetCode第349题的题面虽然没直接给负数用例,但我在本地测试时用了一组负数,才发现数组哈希需要手动偏移,代码写起来就不如set优雅了。这种“选型失败”的时刻越多,越能体会到前面说合理选型的重要性。
第三个是在快乐数里写死模拟次数。这个错误在网上很常见,有人直接写while循环100次甚至1000次,理由是“不循环超过1000次肯定不是快乐数”。这不能算错,但它没有一个扎实的理论根据,而且如果面试官追问“为什么不是999次”就会卡住。用哈希表记录历史状态才是真正无懈可击的做法,它直接利用了“一旦重复必然循环”的数学性质。
第四个是两数之和里“先插后查”的自我匹配。我前面已经详细写过了,这里再强调一次就是:插入和查找的先后顺序决定了算法正确性,建议大家在脑子里把这个过程完整推演几遍,比单纯记住“先查后插”更能应付变种题。
第五个是轻视哈希表的空间开销。有些题目确实能用哈希表把时间压到最低,但代价是额外空间。比如两数之和的暴力解法空间O(1),哈希解法空间O(n)。如果面试官要求原地处理,或者数据规模大到内存吃紧,你还得回到排序加双指针那条路上。所以Day05刷完后我有意识地补充做了349题的排序双指针版本,算是对哈希解法的对照理解。
5. 哈希表专题刷完,我给自己的学习节奏和复盘方式
代码随想录的Day05是哈希表专题的开端,后面还有几道进阶题在等着,但这一天的四道题足以建立一个清晰的方法框架。我自己的学习节奏是这样安排的:早上先通读一遍题目,不急着看答案,自己先试着解;每道题无论能不能解出来,都先记录自己的想法;之后对照代码随想录的思路,重点看自己遗漏了哪个关键点。
对哈希表这类题目,我还有一个特别想推荐的做法:用“一句话总结每道题的功能模式”。我在笔记里给这四道题分别写了四句话——字母异位词是“用数组统计频次”,两个数组的交集是“用set做去重存在性”,快乐数是“用set检测循环”,两数之和是“用map记录匹配项与下标”。这四句话看着简单,但每句话都是解题思路的高度浓缩,复习的时候扫一眼就能把整个解题上下文调出来。
复盘时我还习惯把每道题的错误提交记录翻一遍,分析错因。我统计了Day05这四道题,两数之和的“先插后查”错误、快乐数的“忘记用状态去重”、字母异位词的“忘记长度剪枝”,这三个是我个人最高频的错误类型,分别对应哈希表结构使用顺序、哈希表功能误判、以及初始条件考虑不全。知其然更知其所以然,后面的进阶题才不至于反复在同一个坑里翻车。
Day05给我的整体感受是:哈希表不是一种需要死记硬背数据结构的专题,而是一种思维方式的切换。它让你在遇到“查找、去重、关联”这类问题时,天然地放弃笨重的线性遍历,转而思考“怎么算出一个定义域内确定的位置”。这种思维一旦建立,后面刷滑动窗口、二叉树路径、图遍历时的很多缓存和去重操作都会顺手得多。如果你现在也在按代码随想录的节奏刷题,这一天值得放慢一点,把四道题都吃透,比囫囵吞枣多刷好几道更有价值。