代码随想录第七天:哈希表专题四题入门与实战全解析 很多刷算法的朋友应该都有这种体验跟着训练营按天打卡前六天还在数组、链表、双指针里打转到了第七天突然迎面撞上一组“哈希表”的题题目看着都不长做起来却处处是门道。没错代码随想录第七天正是进入哈希表专题的第一天这一天的四道题——有效的字母异位词、两个数组的交集、快乐数、两数之和——可以说是哈希表从理论到实战最经典的一组入门组合。我刷完这一天的感受很直接前面几天的内容是在教你怎么把数据“摆整齐”而哈希表这天开始教的是怎么把数据“存得巧、找得快”。这篇文章就围绕第七天的完整学习过程把每道题的解题思路、代码实现、易错点和我自己踩过的坑一次说清楚。不管你是刚跟着训练营走到第七天的新手还是准备面试想突击哈希表的老手这四道题都值得静下心来吃透。它们分别对应了哈希表的三种形态数组当哈希表、set去重、map存键值对每一题都是一种独立的哈希用法。理解了这四道题后面再遇到三数之和、四数相加、赎金信这类题目你会明显感觉到地基是稳的。1. 哈希表专题的第一天到底在学什么1.1 为什么第七天轮到哈希表先聊一个大家可能都想过的问题为什么哈希表专题被安排在第七天而不是更早或者更晚前面几天的内容数组篇解决的是“连续空间里的下标操作”链表篇解决的是“非连续空间里的指针操作”双指针法则是通过控制遍历方向来降低复杂度。这些内容有一个共同点数据本身就是“有序、可定位”的。而哈希表解决的问题完全不同——它面向的是“判断一个元素是否在集合中出现过”这类查找问题。举个例子你要检查一段英文文案里有没有重复单词如果用数组来做你得把所有字母排序再逐个比较但如果用哈希表你只需要把每个字母存进对应的桶里查询的时候直接算位置平均时间复杂度是O(1)。第七天这套题的目的就是把“哈希表能解决什么问题、什么时候用哪种哈希结构”这件事讲清楚。从训练路线来看这个位置也安排得很巧妙。前六天你已经熟悉了基本的遍历、指针移动、数组操作此刻引入哈希表正好可以用它来重构之前的一些问题比如用哈希表优化暴力解。这种“旧题新解”的感觉会让你很快意识到哈希表的实用价值。1.2 哈希表的三种形态数组、set、map怎么选哈希表在代码里的落地形态主要有三种数组、集合set、映射map。很多新手在这一天犯迷糊根本原因就是不知道这三种形态的适用边界。先说数组。数组本质上就是一张哈希表下标是键元素值是存储的内容。它的优势是极快的访问速度和极小的内存开销但前提是键的范围必须是连续的、有限的、可枚举的。比如字符串里的字符范围就是26个小写字母或者ASCII的256个字符这种情况下用数组做哈希表既快又省。再说set。set适合的场景是我们只关心“某个值存不存在”不关心它出现了几次。比如两个数组的交集我们只需要知道哪些元素在两个数组里都出现过不需要计数。set自动去重的特性在这里非常省心不用手动写去重逻辑。最后是map。map存的是“键值对”也就是我们不仅关心一个元素是否存在还关心它对应的信息。比如两数之和我们需要知道“某个数出现过没有”还要知道“它出现在哪个下标”这时候就必须用map把值和下标成对存起来。记住一个简易口诀要计数用数组要存在性用set要存关联信息用map。这个选择逻辑就是第七天整组题的核心主线。1.3 第七天在整条训练路线中的坐标如果只看单天的题目你可能会觉得第七天就是“四道哈希表题”但放在整个训练营的坐标系里看这一天更像是一座桥。前面的数组、链表题目培养了最基础的遍历思维但从第七天开始题目开始强调“空间换时间”。你不再满足于“能解出来”而是要思考“怎么用更低的复杂度解出来”。两数之和这道题就是最好的例子暴力解是O(n^2)用哈希表直接降到O(n)。这种思维转变是为后面动态规划、图论里大量使用缓存表、记忆化搜索打基础的。另外第七天涉及的set去重思维也会在后面的回溯算法、组合问题里反复出现map的键值对思维则是后面很多图论题目里邻接表的基础。所以第七天不只是一组孤立题目它悄悄埋下了好多后续专题的种子。2. 四道经典题拆解每一题都在教一种哈希用法2.1 有效的字母异位词用数组当哈希表扫清计数先看第一道题给定两个字符串s和t判断它们是不是字母异位词也就是两个字符串里每个字母出现的次数是否完全一样。我第一反应当然是排序比较——把两个字符串排序后逐位比较能解但排序的时间复杂度是O(n log n)。哈希表的做法是用一个长度为26的数组下标对应字母a到z先遍历第一个字符串每个字母出现就计数加一再遍历第二个字符串每个字母出现就计数减一。最后检查数组里有没有非零值如果有就说明两个字符串的字母构成不一致。核心代码逻辑大概是这样的bool isAnagram(string s, string t) { int record[26] {0}; for (char c : s) record[c - a]; for (char c : t) record[c - a]--; for (int i 0; i 26; i) { if (record[i] ! 0) return false; } return true; }这段代码里最值得品的是c - a这个操作。字符在计算机里本质是整数a到z在ASCII里是连续的减去a就能把字母映射到0到25的下标区间。这种“字符转下标”的套路在哈希表相关的字符串题里几乎必用。空间复杂度是O(1)因为数组长度固定为26不管字符串多长都不变。时间复杂度是O(n)遍历三次字符串但常数很小。这题的意义在于告诉新手当键的范围是有限且已知时数组就是最高效的哈希表连set和map都不用上。2.2 两个数组的交集set自动去重的正确姿势第二道题要求计算两个数组的交集输出结果里的每个元素必须是唯一的。看到“唯一”这个词第一反应就是去重而set天然就是干这个的。用set的解法思路很直接把第一个数组的所有元素放进一个set然后遍历第二个数组如果当前元素存在于set中说明是交集元素把它放进结果set里。最终结果set自动去重最后转成数组输出即可。这里有个新手很容易犯的错误直接在遍历第一个数组时就用set去重然后遍历第二个数组时把重复的元素多次加入结果。虽然结果set也能兜底去重但这样既浪费了操作时间也说明你还没吃透set的用途。正确的理解是第一个set负责快速判断存在性第二个set负责收集结果并去重一前一后分工明确。用unordered_set和set有什么区别性能上unordered_set底层是哈希表平均增删查是O(1)set底层是红黑树操作是O(log n)。所以只要题目不要求有序输出一律优先用unordered_set。如果你发现结果顺序不稳定导致测试不过再考虑set。这题还可以再进一步如果两个数组都是排序好的那用双指针也能做时间复杂度是O(n m)且不需要额外空间。但哈希表的做法更通用不依赖于输入是否有序。两种方案各有适用场景面试时能把两种都说出来会比只会一种印象分高不少。2.3 快乐数用哈希表抓循环别让程序死转快乐数这道题题目描述很简短一个数每次用各个位上的数字的平方和替换它自己如果最终能变成1就是快乐数如果陷入无限循环就不是。很多新手在这道题上卡住的点不是算平方和而是“怎么判断不是快乐数”。就一个正整数n计算过程可能无穷无尽如果你不设终止条件程序就会一直跑下去。关键在于如果一个数不是快乐数它的变换序列必然会陷入循环也就是说某个数字会重复出现。所以解法就是用一个set记录已经出现过的数字每次计算出新的数字后先检查它是否已经在set里如果在说明循环了直接返回false如果新数字等于1返回true否则把新数字加入set继续计算。bool isHappy(int n) { unordered_setint seen; while (n ! 1) { if (seen.count(n)) return false; seen.insert(n); int sum 0; while (n) { sum (n % 10) * (n % 10); n / 10; } n sum; } return true; }这个循环检测的思路在算法里其实有个更广的应用叫“快慢指针判圈”但用哈希表实现更直观、更好理解。算平方和的时候n % 10取出个位数字n / 10去掉个位这两个操作组合起来就能逐位分解一个整数。这个“拆数字”的技巧后面在回文数、水仙花数这类题里还会用到。还有个小细节负数可以直接排除吗题目给的n是正整数所以不用考虑负数。但如果面试官追问负数在平方和后可能变成正数依然需要循环检测不能简单判负就返回false。2.4 两数之和map是天然的“补数查询”工具两数之和大概是LeetCode上知名度最高的一道题了给定一个整数数组和一个目标值找出数组中和为目标值的两个数返回它们的下标。暴力解谁都会两层循环时间复杂度O(n^2)。但用哈希表一次遍历就能搞定。核心思路遍历数组时对于每个元素nums[i]我们需要知道“target - nums[i]”这个补数是否在之前出现过并且还要知道它的下标。这时候就需要map了因为光是set只告诉我们“有没有”map还能告诉我们“在哪里”。vectorint twoSum(vectorint nums, int target) { unordered_mapint, int map; for (int i 0; i nums.size(); i) { int complement target - nums[i]; if (map.count(complement)) { return {map[complement], i}; } map[nums[i]] i; } return {}; }这题的顺序感很重要先查补数再把当前元素插进map。为什么不能先插入再查询因为如果数组里有重复元素先插入可能会让同一个下标被匹配两次。比如nums [3, 3]target 6如果你先插入遍历到第二个3时map里已经有一个3了查补数3能查到返回的下标是{0, 1}这没问题。但如果数组是[3, 2, 4]target 6遍历到2时先插入2再遍历4时查补数2返回{1, 2}也没问题。真正要注意的是如果先插入再查询在极端情况下可能用同一个元素匹配自己比如nums [3]target 6虽然题目保证有解但这种细节还是养成“先查后插”的习惯更稳妥。另一个值得思考的问题map的key存什么value存什么这题的key是元素值value是下标。因为我们要“根据值查下标”所以值必须作为key。这个“谁当key”的判断是map类型题目的核心考点想清楚这一点后续很多map题你都能迎刃而解。3. 实操复现从读题到AC的完整过程3.1 做题顺序与时间分配建议第七天整体题量是四道我建议按“异位词→交集→快乐数→两数之和”的顺序来做而不是按题目编号顺序。原因很简单这个顺序正好是“数组哈希→set哈希→set判环→map哈希”的难度递进每一道都在前一道的基础上加一点新东西。时间分配上新手建议每道题先自己思考15到20分钟想不出来再看题解。这四道题里两数之和虽然是压轴但却是最容易被秒杀的因为暴力解很容易想哈希优化也比较自然反而是快乐数如果之前没见过“循环检测”的思路容易卡住。整体上四道题控制在两到三个小时比较合理。有一个实操习惯值得养成每道题AC之后再用暴力解法写一遍并对比两种方案的耗时和空间。比如两数之和你可以先写O(n^2)的暴力解再去改成哈希表写法。这种对比会让你对“空间换时间”有更具体的感觉而不是只停留在概念层面。3.2 关键代码实现与边界处理四道题的完整代码里有几个边界细节是必须亲手敲一遍才能记住的。有效的字母异位词最容易漏的是字符串长度不等的情况。虽然长度不等一定不是异位词可以提前return false但有经验的写法也会先判断长度因为长度不等时后续遍历没意义。不过即使不判断数组计数也能正确输出结果所以这个优化是锦上添花。两个数组的交集注意结果要转成vector输出。C里unordered_set转vector直接用迭代器构造即可vectorint(result.begin(), result.end())。这个语法不常用容易忘遇到就记一下。快乐数的拆位循环最容易写错的是内层while循环的条件。应该是while (n)而不是while (n 0)其实两个等价但要注意n在循环里会被不断除以10最终变成0所以循环条件是n非零。这道题如果在拆位时不小心把n提前改了外层while的判断就会出问题建议用一个临时变量来保存原始值。两数之和返回的是两个下标顺序无所谓。有些人会纠结返回{map[complement], i}还是{i, map[complement]}测试都能过但为了语义清楚建议先返回先出现的下标也就是补数的下标在前当前下标在后。还有一道隐藏的边界问题如果数组里有负数怎么办两数之和完全兼容负数因为补数的计算同样是target - nums[i]负数照样能正确匹配。但快乐数如果输入是00的平方和是0会永远循环在0上不是快乐数。题目规定n是正整数所以不用处理但如果你在做扩展练习时遇到0要能说出这个结论。3.3 测试用例与自测方法很多新手刷题只跑题目给的示例就提交结果经常在隐藏用例上翻车。正确的做法是自己构造几组边界用例。异位词这题至少测三组两个空字符串true、长度不同的字符串false、包含重复字母的字符串如“aabbcc”和“abcabc”。交集这题测一个数组为空的情况测两个数组完全相同的情况测有重复元素且交集结果需要去重的情况比如nums1 [1, 2, 2, 1]nums2 [2, 2]期望输出[2]。快乐数这题19是标准的快乐数建议手算一遍它的变换过程19→82→68→100→1体会一下什么是“最终收敛到1”。再测一个非快乐数比如22→4→16→37→58→89→145→42→20→4可以看到4再次出现说明进入循环。这个手算过程能帮你验证代码逻辑是否正确。两数之和测数组里有重复元素的情况测目标值由两个相同元素组成的情况如[3, 3]和6测包含负数和零的情况。这些用例不用全写到代码里但在脑子里过一遍能帮你提前发现自己解法里的漏洞。我自己的习惯是写完代码后在草稿纸上手推一组用例确认逻辑没有明显问题再提交实测下来能减少不少无谓的WAWrong Answer。4. 常见问题排查与避坑实录4.1 第一梯队报错数组越界与哈希碰撞第七天的报错最常见的是Runtime Error而且多半是数组越界。异位词这题如果忘记把字符减去a直接用字符的ASCII值当下标那record数组的长度26就会越界。比如字符z的ASCII值是122直接访问record[122]就崩了。另一个值得知道的概念是哈希碰撞。不同key映射到同一个哈希桶的情况就是哈希碰撞。C的unordered_set和unordered_map底层通过链地址法解决碰撞所以一般不需要你手动处理。但如果你是自己在数组里做哈希映射就要特别注意映射函数不要产生大量冲突。比如异位词这题映射函数是c - a在26个字母范围内是完美的一一映射没有碰撞这也是用数组做哈希表的一个隐藏优点。如果你在交集中用了unordered_set但发现结果顺序每次都变不用慌这是哈希表的正常行为因为遍历顺序取决于哈希桶的排列。如果题目要求顺序输出可以用set替代或者输出前排序。4.2 第二梯队问题去重去错、死循环与漏判交集题的一个典型错误是在遍历第二个数组时没有判断当前元素是否已经在结果集里导致重复添加。虽然最终转成vector前可以用set兜底去重但如果你用的是vector直接收集结果就会输出重复值。所以要么结果也用set要么在添加前检查result里是否已经存在该元素。快乐数最隐蔽的问题是死循环。你的程序看起来一直在跑不是因为你忘记写循环检测而是因为你忘记更新n的值导致每次计算的sum都相同外层while陷入死循环。这种情况IDE不会报错只会一直转圈。遇到程序卡住先检查是不是变量更新位置写错了再考虑是不是逻辑缺了终止条件。异位词这题还有可能因为你用了map但没注意不存在key的默认行为。比如用map做计数时map[c]会自动把不存在的key初始化为0再自增这没问题。但如果你用map.at(c)访问不存在的key会抛异常。C的map和unordered_map在访问不存在的key时行为不同一个会创建默认值一个会抛异常这是很容易踩的坑尤其是从Python转C的人会特别不习惯。4.3 哈希表相关的高频面试追问这四道题如果出现在面试里面试官大概率会追问几个问题我提前整理一下。两数之和的经典追问是如果数组有序能不能优化这时的最优解其实是双指针两个指针从两端向中间移动根据和与目标值的大小调整指针位置时间复杂度O(n)空间复杂度O(1)比哈希表的O(n)空间更优。这个追问考察的是你能不能根据输入特性调整算法而不是只会一种解法。快乐数的追问经常是能不能不用额外空间这就引出了快慢指针判圈法。一个指针每次走一步计算一次平方和另一个指针每次走两步如果存在循环快指针一定会追上慢指针。这个方法把空间复杂度降到O(1)是哈希表做法之外的另一种思路。异位词的追问则是如果字符集不只包含小写字母而是包含Unicode字符怎么办那数组就不适用了因为字符集范围太大用数组会浪费大量空间这时候应该用map来动态记录。这个问题问的就是你能不能判断“什么时候该用哪种哈希结构”。这些追问本质上都是在考察你对“hash思想”的理解深度而不仅仅是记忆代码。5. 第七天之后的路线与个人心得5.1 从哈希到后续专题的衔接方法第七天结束之后我建议别急着往后赶进度先花半小时做一件事情把四道题里用到的哈希思想整理成一张自己的知识卡片写到笔记里。每道题记录三行题目类型、用的哈希结构、为什么用这个结构。我当时的笔记大致是这样异位词数组哈希因为字符集有限交集set因为只关心存在性且需要去重快乐数set因为要检测循环重复两数之和map因为既要查存在性还要查下标。这张卡片的价值在后续刷三数之和、四数相加、赎金信时会直接体现出来。三数之和看似是双指针题但去重逻辑里其实也藏着哈希思想四数相加则是两数之和的map思路的扩展版把四个数组两两配对用map统计两数之和的出现次数时间复杂度O(n^2)。你会发现第七天打下的基础在后面的题目里被反复复用。5.2 我实操中踩过的坑与调整建议最后分享几个我做这四道题时真实踩过的坑算是给后来者提个醒。第一千万不要因为看懂了题解就跳过手动推演。我一开始觉得快乐数这题太简单了看完答案直接提交结果代码里把一个变量名写错了导致WA。后来我重新手推了一遍2的变换链条才真正理解了“循环检测”这个操作的本质。看懂和会写之间差的就是亲手推演这一遍。第二C的unordered_set和set的性能差异在第七天这四道题的量级下其实感觉不出来但到了后面的题目数据规模大了之后选错容器真的会超时。所以我从第七天开始就强制自己默认用unordered版本除非明确需要有序性。第三两数之和的“先查后插”顺序问题看起来是个小细节但我确实见过有朋友在面试时因为写反了顺序被面试官追问到卡壳。这种细节就属于“你知道吗”和“你理解为什么吗”的区别建议大家不仅要记住顺序还要能说出为什么这么写。第七天这四道题是我整个训练营里印象最深的一天。不是因为题目有多难而是因为它是第一次让我体会到“数据结构的选择能直接改变算法复杂度”这件事。如果你今天刚刷完这四道题不妨再回头看一眼自己写的暴力解和哈希解感受一下那种“优化前后”的差别。后面还有更多有意思的题目等着你但地基就在这里——别赶路去感受路。