☰
代码随想录第七天:哈希表核心思维与刷题避坑全复盘
2026/10/21 5:11:22 网站建设 项目流程

大家刷《代码随想录》系列时容易陷入一个节奏误区:一开始很猛,链表、哈希表一章接一章,题目刷完就下一节,结果到后面才发现前面的底层逻辑没吃透,返工成本极高。我自己的体会是,前期某几天的内容一旦压得够扎实,后面环形链表、滑动窗口、动态规划都会顺很多。今天这篇就专门复盘代码随想录系列里的第七天,也就是哈希表这一整块的学习记录和实操踩坑过程,不光是题目解法,更多是思路层面怎么真正吃透这一类数据结构题。

第七天在整套题单里属于典型的“离散知识点集中爆发”的阶段:代码量不大,但对哈希思想的理解要求高,而且一旦你掌握了判断“需不需要”和“怎么维护一张表”的思维方式,后面很多中等、困难题都能降维打击。

1. 看待哈希表的方式:不是因为语言有字典才用它

很多初学者对哈希表的认知停留在“它是一种查得快的数据结构”。第七天题目做完之后你会发现,这只是表象。哈希表的本质其实是一个“空间换时间”的映射工具——你愿意花一份额外内存把某个值和另一个值快速关联起来,以换取O(1)复杂度的判断和查询。

我在实操中习惯把它类比成快递柜:快递放进去之前,你已经记好了取件码和格子的对应关系。取件时直接刷码,不需要一家一家快递站去翻包裹。哈希表做的事情一模一样:把某个中间结果存在表里,用同一个标准去校验新来的数据。

这个思维方式在第七天的三道核心题目里体现得非常彻底:判断两个字符串是否为有效字母异位词、求两个数组的交集、以及判断快乐数。它们表面问法各不相同,但本质都是“是否存在”或“是否出现过”这一类问题,而哈希表正是解“存在性判断”的最优数据结构。

用代码随想录里的一个观点来说就是:你需要一张表,因为你需要知道某个元素之前是否出现过。这句话是第七天最值得记住的一句话。你理解了它,后面做两数之和、四数相加、最长连续序列都会顺理成章。

我之前在指导A同学时发现一个常见问题:他会用哈希表,但不知道为什么自己在用哈希表。刷完第七天的题目后他说了一句:“原来我用字典就是为了回答一个问题——这个东西我之前见过没有?”这句总结比任何教程都到位。

实际做题时,判断用哪种哈希结构也很好选:

  • 数据范围小且是连续字符,比如字母异位词那道题,用数组就够了,性能还更好;
  • 数据范围大或不确定,比如数组交集、快乐数,用HashSet存“出现过没有”即可;
  • 需要存值和它的“额外信息”,比如出现次数或下标,那就用HashMap。

其实这三种选择几乎覆盖了日常算法题里90%的哈希场景。第七天特别有价值的点就在这里:它用几道题,就把哈希表在不同场景下的选择逻辑给你练通了。

2. 从字母异位词说起:数组有时候比真正的哈希表更哈希

第七天开头那道“有效字母异位词”的常规做法是用长度为26的数组统计每个字母出现的次数。这个解法看起来简单,但背后藏着哈希表一个核心概念:哈希函数设计。你把字母a-z映射到数组下标0-25,这本质上就是一次极其精简的哈希过程,只不过它没有碰撞、不需要处理冲突,因为字母有限且一一对应。

实操细节上有一个很基础但容易坑的点:如何正确取到相对下标。如果字符是小写字母,直接用ord('a')作为基准,每个字母减去这个基准值就得到0到25的索引。我在某次代码评审中看到有人直接对字符转ASCII码后跟a的ASCII码比较大小,逻辑没错但代码冗余;还有人在统计结束后忘了判断两个数组是否相等,直接用==比较列表却忽略了列表和数组类型差异,这类低级错误在笔试题里经常导致超时或者报错。

这个题目时间复杂度的优化空间其实不大,O(n)基本就是下界。但空间上有一点点讲究:你完全可以在一个数组上先加后减,最后检查数组是否全零,这样就不需要两个数组了。我自己更喜欢这种写法,边遍历s边加、边遍历t边减,最后一次性判断,代码短而且思路清晰。

def isAnagram(self, s: str, t: str) -> bool: if len(s) != len(t): return False table = [0] * 26 for ch in s: table[ord(ch) - ord('a')] += 1 for ch in t: table[ord(ch) - ord('a')] -= 1 for cnt in table: if cnt != 0: return False return True

代码随想录里在这道题上给出了一个提醒:哈希表不只有HashSet和HashMap,数组本身就是一种极简的哈希表,适用于“数据范围可以提前确定且不大”的场景。这个提示虽然简短,但对后面做题影响很大——比如你在处理字符统计、桶排序、或者某些需要标记状态的题时,数组往往比真正的HashMap性能好得多。

有的同学可能会问:这题不是也可以用Python的collections.Counter直接比较吗?当然可以,但面试时如果只会用库函数,反而容易让人怀疑你对底层原理的理解程度。第七天这个阶段,我建议自己显式地写一遍数组计数逻辑,这样你才会真正理解为什么Counter能工作、它内部大致是怎么做的。

从实际效率说,数组方案在时间和空间上都是最优的:时间复杂度O(n),空间复杂度O(1)(也就是固定的26个元素大小),而HashSet或HashMap方案空间上是O(n)级别。虽然n不大时差异可以忽略,但这道题作为第七天开胃菜,值得用最标准、最底层的写法过一遍。

3. 数组交集与HashSet:去重与存在性的一次到位

第二道核心题“两个数组的交集”,是哈希表第二层用法的绝佳示范:不仅要判断存在性,还要去重输出。这里就需要用到集合的特性,也就是HashSet。

我在练习时尝试了两种方案:

  • 用两个集合分别存储两个数组的元素,然后遍历小的集合,判断每个元素是否也在大的集合中,是则加入结果列表;
  • 用一个集合存储第一个数组的去重结果,遍历第二个数组时直接查集合,命中就加入结果列表,同时从集合中删除该元素避免重复输出。

第二种方案时间效率略高一些,因为只需要一个集合,而且删除操作可以保证去重的效果。但这两种的时间复杂度其实都是O(m+n),差别主要是常数级别的。

还有一种更符合代码风格的写法是使用Python内置的集合运算符intersection,一行搞定。但我在实际练习中并不建议一上来就写这种写法,因为它的内部细节都被封装掉了,你无法体会“维护一张表去判断是否出现”的过程。面试时也可以用,但前提是你已经把底层逻辑讲清楚了,再用语法糖来精简代码,这才能起到加分效果。

这道题有哪些我实际踩过的坑:

  • 没有考虑输入数组可能为空的情况。虽然空集求交集结果也是空,但代码里如果不做防御性判断,有时会因为None值报错;
  • 把结果列表转换成集合又转回列表,多此一举。熟悉集合操作后,完全可以一步到位;
  • 遍历时直接修改原数组或集合。比如在遍历第二个数组的过程中从集合里删除元素,这个操作在Python里看似没问题,但如果遍历对象本身就是那个集合,就会抛RuntimeError。

从第七天这几道题的内容安排来看,一个明显的特点是:相同数据结构在不同题目里会用出不同层级。字母异位词里你用数组;数组交集里你用Set;快乐数里你不仅要Set,还要靠它打破循环检测的死结。

这个区分度在设计层面是有意为之的。它让你意识到,同样是“查重”,不同的题目特征对应最合适的那一种实现方式。有些题用数组卡边界,有些题用集合做唯一化,有些题必须上Map才能保存额外状态。脑子里有这条决策链,刷题效率才会真正提上来。

4. 快乐数:循环检测中哈希表的隐形战场

快乐数这道题,第一次看到的人十有八九会觉得“好花哨的数学题”。它的题目描述是:一个数不断替换成各数位数字的平方和,如果最终能变成1就是快乐数,否则会进入无限循环。问题的关键就落在这个“无限循环”上:你怎么知道它在循环里?怎么快速判断并且终止?

不少人的第一反应是维护一个计数器,跑到一定次数就超时退出。这种方法不严谨,因为循环周期无法提前预估,而且不同数开始循环的节奏也不一样。其实在拿到一个新数的时候,你要回答的就是那个熟悉的问题:“我之前见过这个数吗?”如果见过,说明你已经走到之前走过的老路上,循环已成立,这个数永远不可能到1。

所以这道题本质上就是在最合适的数据结构上自然而然地运用哈希表,也就是HashSet。每次生成一个新的平方和,就丢进集合里看是否已存在,存在就直接返回False,否则加入并继续迭代。整个判断逻辑一气呵成,完全不需要额外的记忆化搜索或状态机。

def isHappy(self, n: int) -> bool: seen = set() while n != 1 and n not in seen: seen.add(n) n = sum(int(d) ** 2 for d in str(n)) return n == 1

实际操作时还有一个细节容易漏掉:计算平方和时,先转字符串再逐位处理,是最直观也最不容易越界的写法。但如果你追求极致性能,完全可以禁用字符串,用整数取余的方式逐位拆:

def get_next(num): total = 0 while num > 0: digit = num % 10 total += digit * digit num //= 10 return total

这种写法避免了str和int的来回转换,在面试白板题或是需要高频计算时更耐打。第七天练习时可以两种都写一遍,训练一下“从同等功能中选出更合适的实现”的敏感度。

快乐数这道题如果搜索题解,你会发现还有数学规律:非快乐数最终会进入已知的固定循环,其中包含数字4。比如从任意非快乐数开始,你迟早会得到4、16、37、58、89、145、42、20这些数。理论上你可以直接判断数字是否为该循环中的某几个值来快捷得出结论,但在普通算法训练中完全没有必要去背这个规律,用Set已经足够清楚可靠了。不做这种优化的原因也很简单:哈希表的判重本来就是O(1),性能瓶颈根本不在这一层,何必引入一个面试时容易讲不清楚的“黑魔法”?

这道题实际考察的核心,其实是费米估算式的常识判断:一个很大很大的数,经过一次数位平方和后会急剧变小。比如999999999999,一次平方和是972,三轮后就是个位数级别。加上Set判断循环,用不了几个循环复杂度就收了。很多人害怕“它会一直跑不停”,其实完全没有必要——数位平方和天然会把结果压向一个小区域,这是快乐数问题真正被归类为“哈希表相关”而不是“数学难题”的原因。

5. 两数之和的另一种打开方式:Map的键值设计,先查后存是精髓

第七天常规题单里,两数之和往往是正式引入HashMap的第一道题。题目异常经典:给定数组和目标值,返回两个下标。

第一次接触的人,最自然的想法肯定是双重循环暴力求解。暴力法当然能做出来,但当你写下它的时候,其实已经放弃了一个优化机会:双重循环的第二层在做的是“查找当前元素是否存在匹配项”,而查找这个行为,明明可以用哈希表把时间复杂度降到O(1)。这就是第七天讲两数之和的真正意义——把暴力里的“查找”升级成哈希表里的“命中”。

代码随想录第七天的两数之和解法里,有一个特别有意思、值得反复咀嚼的细节:遍历过程中要“先查后存”。

def twoSum(self, nums: List[int], target: int) -> List[int]: record = {} for i, num in enumerate(nums): diff = target - num if diff in record: return [record[diff], i] record[num] = i return []

先查后存,意味着当前数字不会和自己匹配。如果不小心先存了再查,那对于“两个相同数字恰好组成target”的情况,比如nums=[3,3], target=6,会出现“自己等于自己”的错误配对。很多同学第一次写这里都会犯这个错,我也犯过。把这个问题想透后,你会发现这个“先查后存”的顺序并不是玄学,而是一种严谨的工程习惯——先把已有信息用来判断,再更新状态,避免新状态污染当前决策。

在某些变种题里,比如“找到数组中和为target的两数并返回其值不是下标”,则可以用另一种思路:把数组转成Set,再遍历查找互补元素是否存在。但是,如果数组里有重复元素,Set会丢失数量信息,这时就必须用Map来存元素和它出现的次数。从两数之和这类基础题里你就能看出,哈希表选型的关键不是“哪个更高级”,而是“哪个不会丢信息”。

从项目实操的角度看,这个“先查后存”的思路不止用于两数之和。我后来在做一个模拟项目X的内存键值对模块时,也遇到过类似场景:需要判断当前批次中的记录是否和之前批次产生冲突。如果我先插入了当前记录再去做冲突检测,就会把新记录本身当成历史记录误报冲突。后来就是套用了这道题的“先查后存”逻辑,把冲突检测放在插入之前,Bug直接消失。这就是刷题与实际编码直接相通的最好证明。

6. 什么时候用Set什么时候用Map:一个简单但关键的决策模型

第七天做完之后,一个非常实际的问题会摆在面前:那以后遇到新题,到底该用HashMap还是HashSet?甚至该不该用数组?

我根据第七天的题目和我自己做过的实际项目,总结了下面这套判断流程,可以直接抄走:

  • 如果只关心“某个值是否出现过”,不需要关心次数、位置或对应关系,那就用Set。比如数组交集、快乐数、链表判环,都属于这一类。
  • 如果不仅要判断出现过没有,还要取回“它出现时生成的额外数据”,比如出现次数、数组下标、对应节点,那就用Map。两数之和要下标,四数相加要计数,都是Map的菜。
  • 如果值的范围已知而且不大,并且只是连续整数、字符这类可枚举数据,优先考虑用数组当哈希表。比如字母异位词、固定范围内的频次统计,用数组速度最快且不用处理哈希碰撞。
  • 如果既要存多个值的关系,又需要维护顺序(比如最近最少使用等),那么单纯的哈希表就不够了,要哈希表配合双向链表这类结构。这不是第七天需要掌握的东西,但你在后续刷题中会反复遭遇,提前知道这条决策链有助于不迷路。

这层决策模型比背一百道题重要得多。第七天的题量不算大,但你把这套“哈希结构选型思维”建立起来,后面碰到需要哈希参与的中等题,几乎不会走弯路。

另一个经验点是:不要把HashMap的value只当成“数字值”来用。它可以是字符下标、频次、状态标记、最后一个位置、父节点索引、颜色标记……什么都能存。你设了什么value,就意味着你打算在查中它之后取出什么信息用。这是设计HashMap解题方案时的第一步——先想清楚“如果查到了,我需要用什么信息做后续决策”,再决定value类型是什么。

第七天很多时候被低估,因为它题面短、难度低,看起来不够“厉害”。但我认真做完之后发现,哈希表很多最常见的坑,比如先查后存、边界映射、循环检测、重复元素去重,恰恰都在这一天里集中练了一遍。这也是我为什么愿意花大量篇幅来写第七天而不是某个更加炫技的章节的原因。

7. 第七天实操调试记录:我在跑题时常见的报错与误判

在带A同学完整跑完第七天所有题目后,我把我们两个在该过程中遇到的典型问题整理了出来,这些问题在LeetCode讨论区里其实也高频出现,值得存一波速查。

第一个高频问题:字母异位词里用了collections.defaultdict(int),却在对value加减时忘了考虑负数的情况。其实不管是defaultdict还是普通dict,计数加加减减完全可行,关键在于最后检查时只查有没有非零值。负数也是非零,所以不需要特殊处理。真正会出问题的是在中间某个环节试图做“裁剪”或“提前return”,导致漏掉了末尾需要判断全零的步骤。

第二个高频问题:数组交集时直接把set(nums1)和set(nums2)的结果取交集再转list,本身没错,但如果你先遍历一个集合又要同时从另一个已转换成列表的结构里删除,就会出现“迭代时修改”的经典异常。Python对“在遍历集合时修改集合”是直接报错的,这一点和Java里的ConcurrentModificationException异曲同工。解决办法就是:遍历副本、遍历列表、或者用序列化后的新结果接收。

第三个高频问题:快乐数里有人会把“n是否已经出现”的判断放在加入Set之后。表面看只是顺序问题,实际上会导致一个死循环:已经见过的数又被反复加入,集合永远在膨胀,但代码却无法及时返回False。把if n in seen放在seen.add(n)之前,这是必须的写序规范。

第四个高频问题:两数之和里用Map存值和下标的映射时,如果数组里有重复值,后出现的值会覆盖前面值的下标。这会导致返回的下标不是我们想要的那一组?实际上不会。因为“先查后存”的顺序保证了:当后一个重复值出现时,我们早就用它在Map中的前一个位置和当前值进行了匹配并直接return了,根本不会走到覆盖那一步。但如果你改成“先存后查”,重复值就会造成错误覆盖和漏判。这个例子最能说明“先查后存”不是风格偏好,而是算法正确性的保证。

这些问题我在培训或帮助他人攻克类似题目时,几乎每次都遇到。它们看起来是零散的代码细节,但底层其实都是对哈希表基本操作时机和语义的不够熟悉造成的。等你连续做过几遍第七天的题型,这些问题基本都能根治。

8. 第七天里的双指针思路:为什么不建议把哈希和双指针割裂开

代码随想录第七天虽然主推哈希表,但有一个很重要的隐含事实:很多哈希表题其实也存在双指针解法。比如字母异位词可以先排序再双指针比较,数组交集也可以用排序加双指针归并。那为什么第七天还是要主攻哈希表?

因为双指针通常只是“省空间”的选择,而哈希表的优势是“省时间”。两者没有绝对优劣,只有场景适配。刷题训练时,最好两个都掌握,并且要有意识地在同一道题上比较两种方案的差异。下面用我自己整理的表格展示两种思路在不同维度上的对比,便于直观理解。

维度哈希表方案排序+双指针方案
时间复杂度O(n),线性完成O(n log n),受限于排序
空间复杂度O(n),需要额外哈希结构O(1)或O(log n)视排序算法而定
是否修改原数据不修改通常需要排序,可能修改原数组
适用场景需要快速判断存在性、查下标或计数数据量极大且对空间敏感、数据可排序
典型题目两数之和、四数相加三数之和、四数之和

第七天的题目里,哈希表方案普遍更直接,而双指针方案作为结束后延伸思考也很有价值。我自己练习时的策略是:第七天按哈希表思路全部过一遍,休息之后再把字母异位词和数组交集用双指针重写一次。这种“一道题两种解法”的训练方式,比单纯刷更多新题要有效得多。

有意思的是,两数之和如果用双指针,必须要先排序,但排序后原数组下标就丢失了。你可以用元组保存原下标再排序,可这样一来空间复杂度也上来了,整体复杂度反而不如哈希表方案优雅。所以第七天把它放在哈希表章节是有道理的:它就是想让你别把简单问题复杂化。

再往后刷三数之和、四数之和时,你会发现哈希表方案也不是不行,但去重逻辑会变得非常麻烦,这时排序+双指针才是正解。第七天如果打好了双指针和哈希表的对比意识,你在面对三数之和时就能自然地切换思路,不会死磕一种解法。

9. 做完第七天的自测方法与心态调整

题单刷完不意味着彻底掌握。我给自己和A同学都设置了一套快速“检验清单”,不靠看一眼题觉得“会了”就跳过,而是直接动笔默写,确保每个关键点都能闭眼写出来。这套方法在后续的高阶题目里也持续有效。

自测维度大致包括高频题、盲写能力、同类新题、复杂场景:

  • 字母异位词:能否在纸上写出“一加一减单数组”版本?
  • 数组交集:能否跟面试官讲清楚Set和Map的选型理由?
  • 快乐数:能否解释为什么HashSet能检测循环且不会无限跑?
  • 两数之和:能否默写“先查后存”的循环代码而不出错?
  • 哈希结构选择:给一个随机题面,能否在10秒内说出该用数组、Set还是Map?

以上这些,我当时反复测了三轮,直到最后一遍完全通过才觉得第七天真正过关。经过这个阶段我发现,刷题快慢根本没意义,真正决定后续章节效率的,是你对“哈希表为什么这样用”的理解深度。尤其是后续接触设计类题目时,很多难点都藏在哈希表的底层结构里,比如负载因子、哈希碰撞、扩容机制,这些在第七天虽然不深入涉及,但如果你连“什么时候用Set什么时候用Map”都没想明白,底层优化更是无从谈起。

心态上也需要提前打个预防针:哈希表这一章学完后,见到题就想哈希是正常的,但见到什么题都硬套Map就过了。第七天的最后一天,我建议你试着脱离“这节学哈希”的条件反射,遇到新题先想清楚:数据是否需要映射?是否关心位置、次数?是否需要有序?如果都不需要,也许双指针或滑动窗口才是更优解。这种主动思考的习惯,比按时刷完整个代码随想录系列更有价值。

从始至终,第七天真正的关键词只有一个:把哈希表当作一种条件反射式的思维工具,而不是背着API调库。当你看到“判断”“出现过”“重复”“交集”“计数”“下标”这些词,脑子里立刻弹出一条路径——该不该用哈希、用哪种哈希、存什么key、存什么value、先查还是先存——这一章就算真正学到手了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询