时间紧,基础也就那样,想冲一冲校招和社招的算法面试,最靠谱的试卷其实就是LeetCode Hot 100。我的Day 1计划很简单:把哈希、双指针这两类最基础的题型吃透,而不是急着刷数量。身边不少朋友刷了几百题还是心里没底,问题多半出在复习没有主线——Hot 100正好提供了这么一条主线,哈希负责把“查找”变快,双指针负责把“暴力”变省。
这篇文章不打算写那种“Day 1打卡”的流水账,而是把我这一天的完整思路记下来:为什么第一天押这两类题、哈希表到底怎么用才算用对、双指针两种范式怎么区分、做题过程中真实踩过的坑是怎么被排查掉的。适合两类人看:一是准备校招/社招算法面试、时间不足三个月的,二是刷了不少题但感觉没记住、想重新搭框架的。
1. 把Day 1押在哈希和双指针上,是最稳的刷题策略
1.1 为什么Hot 100被当作面试题的“公因数”
Hot 100在算法面试里的地位,类似手机上的系统预装应用,你未必天天打开,但关键时候离不开它。它的题不是按难度排的,而是按“面试中出现频率”筛出来的,所以很多题你会觉得眼熟:两数之和、三数之和、无重复字符的最长子串、盛最多水的容器,基本都在里面。
有一个数据可以参考:我对身边进大厂的同学做过小范围统计,算法面里大约七成题目,要么和Hot 100重合,要么是Hot 100中某道题的变体。这意味着时间不够时,把Hot 100刷透,比随机刷乱序题库效率高得多。
但刷Hot 100有个容易踩的坑:很多人按题目编号从1刷到100,第一题两数之和做出来了,第二题两数相加也还行,到第三题无重复字符的最长子串就不是那个味了。因为Hot 100的编排并不是知识专题式,而是混合排列,顺着刷容易学一个忘一个。所以我的策略是先按“数据结构+算法范式”分类,先把最基础、出现率最高的哈希和双指针打穿,再向外扩展。
1.2 哈希和双指针刚好互补,能覆盖大量题型的“元模型”
说哈希和双指针互补,是因为它们在解决同一件事上走了两条完全相反的路:哈希牺牲空间换时间,用额外存储让查找变快;双指针牺牲一定的时间复杂度优化常数、甚至降阶,但基本不占额外空间。
比如同样是处理“找两个元素满足某种关系”的题,哈希的思路是“先存起来,再查询”,双指针的思路是“排序之后一左一右夹逼”。前者适合无序情况,后者适合有序或可以排序的情况。这两个思路相互补充,覆盖了两数之和、三数之和、盛水容器、字母异位词分组、最长连续序列、和为K的子数组等一系列Hot 100高频题。
在刷题计划里,把它们放在同一天还有个好处:能形成一个“看题先归类”的习惯。看到题目,先判断它属于“查找优化型”还是“遍历优化型”,前者往哈希想,后者往双指针想。这个判断习惯一旦建立,后面刷链表、数组、字符串类题目都会受益。
2. 哈希表:看似平淡,实际上是很多题的第一道突破口
2.1 哈希表干了三件事:去重、计数、建索引
哈希表的底层不复杂,就是数组加哈希函数,把任意键映射到一个槽位,理想情况存取都是O(1)。Python里的dict和set、C++里的unordered_map和unordered_set,底层就是哈希表;它们平均O(1),但最坏情况因为哈希冲突会退化到O(n)。面试考“哈希表怎么实现”的概率不高,真正决定你能不能AC的,是“什么时候该往哈希上想”。
我用了这么多年,哈希能解决的无非三类诉求:
- 去重:用一个Set,见到的元素就往里丢,已经存在的就说明重复了。典型题是最长连续序列的查重环节,以及链表判环时的节点记录。
- 计数:用一个Map/字典,键是元素,值是该元素出现的次数或频次。典型题是“和为K的子数组”中统计前缀和出现的次数。
- 建索引:用一个Map记录“值到下标”或“值到位置”,典型题就是两数之和,遍历一遍,边存边查。
这里有个习惯值得刻意练习:看到题,先问自己“我需要知道什么信息?这个信息能不能作为键?键对应的值是什么?”比如字母异位词分组,我最初的想法是对每个词排序当键,后来发现还可以用26个字母的计数数组当键。键的形式不同,解题的维度就不同。
顺便说一句,哈希树和哈希算法这些热词,和刷题里的哈希表不是一回事。哈希树在区块链等领域常被用于快速校验数据,但都建立在哈希函数之上;刷题阶段用不到哈希树,但知道它存在,看文章时不至于混淆。另外,平时听过“加盐哈希存储”的读者,也别急着往这想,那是指密码存储时给哈希值加随机盐防撞库,和算法题里的哈希表只是同一个基础概念的工程应用。
2.2 从两数之和到和为K的子数组,识别哈希的变形
两数之和是全网最经典的哈希入门题。暴力解法就是双重循环,O(n²);用哈希的目标就是把“找target - nums[i]”这一内层循环从O(n)降到O(1)。
def twoSum(nums, target): seen = {} for i, num in enumerate(nums): if target - num in seen: return [seen[target - num], i] seen[num] = i return []注意是边遍历边存,而不是先全部存完再查,否则同一个元素会被自己“补”成target。这道题的价值不在于代码有多难,而在于让你记住哈希的“边存边查”模式。
有了这个基础,再做“和为K的子数组”就顺多了。题目要求统计连续子数组和等于K的个数。常规思路是固定左端点、移动右端点累加,O(n²);优化方向是引入前缀和。令前缀和pre[i]表示从0到i的和,那么子数组[j+1, i]的和等于pre[i] - pre[j],只要pre[i] - pre[j] = K,即pre[j] = pre[i] - K。
于是问题又变成了“找多少个子前缀和等于pre[i] - K”。这正好用哈希计数来做:
def subarraySum(nums, k): prefix = 0 count = 0 mp = {0: 1} for num in nums: prefix += num count += mp.get(prefix - k, 0) mp[prefix] = mp.get(prefix, 0) + 1 return count这一步把O(n²)优化到O(n),而且代码量并不大。关键在于初始化mp[0] = 1,因为当前缀和本身等于K时,要能从0开始计数。这个细节我一开始漏了,直接导致样例过不去,下面实战部分会细说。
2.3 空间换时间不是无脑换,哈希也有账要算
哈希好用,但有个前提你得想清楚:空间换时间,换来的时间值不值,付出的空间扛不扛得住。
举例,有一类题目数据范围很小,比如数值只有0到100,那直接用数组当哈希表可能更好,甚至更快。因为数组下标访问是天然的O(1),没有哈希函数和冲突的开销。反之,如果键是字符串、元组这类复杂对象,或者数据范围很大很稀疏,才真的需要dict或unordered_map。
另一个常见误区是用哈希去存“所有”信息,结果空间复杂度被抬高了。比如最长连续序列这题,很多人一上来就排序,O(n log n),其实题目要求O(n),就只好用哈希。做法是先把所有元素放进Set,然后只对“当前元素减1不在Set里”的元素启动向后探测,这样每个元素最多被访问两次,总复杂度O(n)。
这个例子说明,哈希不是让你把所有事都扔给额外存储,而是用它换一个重新设计遍历流程的机会。空间换时间要算账:换来的时间复杂度降低是否关键,付出的空间是否在可接受范围,这两个问题想清楚,哈希才算用对。
3. 双指针:一快一慢、一左一右,两类范式要分清楚
3.1 相向双指针:左右往中间走,先把暴力降一个数量级
相向双指针的典型场景是排序数组上的查找问题:左右两个指针分别指向数组两端,根据当前两个指针指向元素的关系决定移动左还是右。
以三数之和为例,暴力解法是三层循环O(n³),用排序加双指针优化到O(n²):固定第一个数,剩下的区间用左右指针夹逼。关键点有三个:一是有序性,必须先把数组排序;二是去重,固定数和左右指针移动时都要跳过重复值,否则结果里全是重复三元组;三是移动规则,两数之和大于目标时右指针左移,小于目标时左指针右移,等于时就记录并同时收缩两边。
相向双指针另一个容易考的是“盛最多水的容器”,它的移动规则和“和”无关,而是谁矮移动谁。很多人死记这个结论,过一阵又忘了。我当时理解透了才记住:容器的面积是两边较短的那根决定高度,所以只有移动较矮的那一端,面积才有变大的可能;如果移动高的那端,高度不变或变矮,宽度还在缩小,面积只会更小。这个“移动收益”分析,比背规则可靠。
3.2 同向双指针:滑动窗口的收缩时机是灵魂
同向双指针又称滑动窗口,两个指针都从左往右移动,右指针负责扩展窗口,左指针负责收缩窗口。它的经典使用场景是“连续子数组/子串满足某个条件”。
无重复字符的最长子串是很好的入门题:
def lengthOfLongestSubstring(s): seen = set() left = 0 ans = 0 for right, ch in enumerate(s): while ch in seen: seen.remove(s[left]) left += 1 seen.add(ch) ans = max(ans, right - left + 1) return ans这里每个字符最多进出窗口一次,总复杂度从暴力O(n²)降到O(n)。
滑动窗口最难理解的一点是:为什么右指针不用回溯?因为窗口只会在一个方向移动,左指针走过的字符已经不可能再对当前最优解有贡献。大多数人在这一步会卡住,我的建议是画图,把指针位置和Set内容画出来,连续推演几个例子,比看十遍讲解都有用。
3.3 边界条件反复错?移动规则用“写死”代替“感觉”
双指针的代码往往很短,但错起来很隐蔽,我统计过多次出错的高频点,几乎全在边界:
- 退出条件写错:相向双指针用left < right还是left <= right,取决于你是否要处理“左右指针指向同一元素”的情况。两数之和类题用left < right,因为同一元素不能重复使用;回文判断也常写成left < right,避免中间字符被重复比较。
- 指针移动时机不对:记录答案之后再移动指针,还是移动之后才记录,结果完全不同。我见过不少人把“相等时记录并同时收缩”写成了“先移动再判断”,导致漏解。
- 窗口内状态没同步更新:滑动窗口的Set或计数Map必须在指针移动时同步增删,少了这一步,窗口统计就是错的,而且很难通过样例发现。
我给自己的硬性要求是:双指针题先写下移动规则的“纯文字版”。比如“当和小于target,left加1;当和大于target,right减1;当两者相等,记录并同时收缩”,再翻译成代码。把规则写死,写代码的时候就不会靠感觉瞎动。
3.4 一道题看清指针移动条件:盛最多水的容器
盛最多水的容器非常适合验证上面说的“移动收益”分析。题目给一个高度数组,要求选出两根柱子,使它们和x轴围成的容器能装最多水。
水的体积 = min(height[left], height[right]) × (right - left)。如果移动较高的指针,min高度不会增加,可能变小,宽度一定变小,所以面积必然不增;而移动较矮的指针,虽然宽度变小,但min高度有可能变大,所以面积有增大的机会。所以要找最大面积,每次都应该移动较矮的一端。
这道题让我明白,双指针的核心不是“左右挪一挪”这种表面操作,而是每一步都在消除“不可能成为最优解”的候选者。能证明某部分候选永远不可能是最优,指针就可以安全地越过它。理解了这一层,很多双指针题,包括接雨水,都能想得通。
4. Day 1的实战记录:从读题到AC的完整链路
4.1 我的做题顺序:审题10分钟,思考15分钟,动手30分钟
很多刷题新手败在“看题5分钟,写代码1小时,最后没AC还背答案”。我Day 1给自己定的节奏是这样的:
| 阶段 | 时间 | 核心目标 |
|---|---|---|
| 审题 | 10分钟 | 搞清输入、输出、约束,尤其是数据范围 |
| 思考 | 15分钟 | 先想暴力解,再想暴力慢在哪,最后设计优化 |
| 动手 | 30分钟 | 把优化思路写成代码,跑测试用例 |
审题阶段只做一件事:把题目的输入、输出、约束条件全部看明白,尤其是数据范围。比如n最大10^5说明O(n²)大概率超时,这时就去想O(n)或O(n log n);如果看到“字符串只包含小写字母”,那数组哈希可能比字典更快,因为可以直接开一个26长度的计数数组。
思考阶段我会在纸上画样例,试着用最暴力的方法解一遍,再想“暴力慢在哪一步”。慢在查找,就试哈希;慢在重复遍历,就试双指针或滑动窗口。这个“从暴力到优化”的推导链条,比直接记住最优解重要得多,因为面试官真正想听的也是这个推导过程。
动手阶段才是写代码。我要求自己每写一个关键步骤都能说出理由,而不是默写模板。比如写哈希表存下标的行,我会在想:存这行是为了让后面的查询变成O(1),而不是单纯因为“这道题要用哈希”。
4.2 一次真实翻车:暴力解法TLE之后的排查链路
Day 1里最值得记录的不是顺利AC的题,而是一次真实翻车。我在做“和为K的子数组”的时候,第一反应是滑动窗口,写着写着发现不对劲——滑动窗口通常要求窗口内满足单调性,即数组全为正数才能保证窗口越大和越大。但这道题数组里有负数,窗口收缩后和可能变大也可能变小,滑动窗口的单调性假设被破坏,直接用它会有漏解。
我当时的第一版实现是用暴力,固定左端点枚举右端点,小型数据能过,提交后TLE,一看数据范围是10^4左右,O(n²)的运算量在超时边缘。排查链路是这样的:
- 先确认复杂度,算10^4的平方是10^8,大概率超时,问题不是代码小细节,而是算法复杂度不达标。
- 再确认滑动窗口是否可用,有负数,前缀和不是单调的,不能用同向双指针。
- 转向前缀和加哈希计数,把问题转成“统计pre[j] = pre[i] - K出现的次数”。
- 实现时注意初始化mp[0] = 1,因为前缀和本身等于K的情况要从0开始计数。
这个翻车经历让我记住了一个非常重要的区分:滑动窗口只适用于单调性成立的问题,遇到负数或条件不具备单调性,就别硬套,换哈希前缀和反而更通用。
4.3 错题本应该记什么,才能让第二遍更高效
Day 1结束之后,我花了二十分钟整理错题本。和大多数人把代码抄一遍不同,我只记四样东西:
- 这题的标签:比如“哈希-前缀和”、“双指针-相向”。
- 暴力方法为什么慢:比如“双重循环找两个数,内层查找O(n)”。
- 优化思路的一句话:比如“空间换时间,用哈希表把内层O(n)查询变成O(1)”。
- 最关键的边界条件:比如“和为K的子数组,mp[0]要初始化为1”。
这样做的好处是,第二遍复习时我不需要重新读一遍题目和代码,而是直接看标签和优化思路,在脑子里把解法过一遍。过不出来的,才值得重新做。这个方法看起来简单,但真的帮我节省了大量二刷时间,我从“题做了几百道”变成了“题会了大几百道”。
5. 第一天的复盘清单与后续安排
5.1 复盘时问自己三个问题
当天刷完一计算,我发现真正有效的不是刷了多少题,而是复盘。复盘时候只问自己三个问题:
第一,我今天遇到的题,分别属于哪一类?能不能用一句话说出识别特征?比如“要求连续子数组满足某个条件,且数组没有负数,优先想滑动窗口”“无序找两数关系,优先想哈希”。
第二,有没有哪道题我是背了答案而不是理解了解法?背答案的标准是:换一个相近的输入,解法就不成立了。如果有,回头把推导过程重新走一遍,用白纸从暴力推演到优化。
第三,我今天的代码里有没有“凭感觉写的部分”?有就标红,贴上原因。比如我把相向双指针的退出条件写错过,标红原因就是“没区分left < right和left <= right的语义”。标红记录比单纯抄一遍代码有用十倍。
5.2 接下来几天的刷题节奏
Day 1结束了,但我没打算第二天直接开新专题。我会在Day 2先花二十分钟把Day 1的高频题重新默写一遍,特别是两数之和、无重复字符的最长子串、盛最多水的容器这三道,因为它们分别代表了哈希、同向双指针、相向双指针的“元模型”。默写对了,再进入下一个专题。
后续我打算按这样的节奏推进:每个专题至少集中练两天,第一天跟本专题的经典题,第二天刷变体题和混合题。第三天就开始穿插复习旧专题,用随机选题来检验是不是真的会了。Hot 100一共100道,按这个节奏,一个半月能过完一遍,再留出半个月做二刷和三刷,时间上完全来得及。
最后再分享一个个人体会:第一天刷题,别把目标定成“做出100道中的20道”,而应该定成“建立对哈希和双指针的肌肉记忆”。肌肉记忆来自重复推演,不来自背答案。我做盛最多水的容器时,第一次看完题解觉得懂了,第二天合上书重写,指针移动条件还是写反。第二遍自己从头把“移动较矮一端才有收益”推导一遍,才真正内化。所以Day 1的意义,不是进度条前进了几个点,而是你是否真的具备了自己推导出解法链条的能力。这个能力有了,后面的90多道题才会越刷越顺。