1. 从day1坚持到day10,打卡节奏是怎么立住的
先说点实在话:hot100打卡这个计划,我自己其实立了好几次。前几次都倒在第三天左右,原因无他,就是一开始太贪。第一天恨不得刷五道题,第二天看到难题又憋着死磕几个小时,第三天心态直接崩掉。这次能走到day10,靠的不是意志力,而是把节奏调对了。
day10这个节点很有意思。它刚好过了“新手保护期”——前三四天的题目相对友好,能给你正反馈;到了第7天到第10天,题目开始出现明显的弯弯绕,比如双指针的去重、滑动窗口的边界、递归写法的分治思想。如果前十天的题感没建立起来,这里最容易卡住。所以day10不是简单的“又坚持了一天”,它是一个分水岭,能检验你前几天刷题时到底是在背代码,还是在理解思路。
我给自己定的节奏是:每天两到三题,按题型分组刷,比如某一天全做链表,某一天全做二叉树,这样同类题放在一起对比,效率比随机单题高很多。day10我排的重点是“双指针 + 滑动窗口 + 一道分治题”,做完最大的感受是:这些题表面花样百出,底层思维高度重合。下面把这天的完整过程拆开讲,包括每道题的思路演进、踩坑经历,以及那些题解里不会写但你实战一定会撞上的细节。
2. day10主菜:三道双指针题,一道比一道顺
2.1 三数之和:排序后双指针,去重才是真正的考点
三数之和这道题属于hot100里“你听说过,但不一定写得对”的类型。题目描述很简短:给定一个整数数组,找出所有和为0且不重复的三元组。
暴力解是三重循环,时间复杂度O(n³),数据规模稍微大一点就挂了。正常的优化路径是:先排序,再固定一个数,剩下两个数用双指针去凑。排序的好处有两个,一是让双指针移动有了方向感——和大了右指针左移,和小了左指针右移;二是让重复元素相邻,方便去重。
我直接给出一版能过的Python实现,然后讲几个容易写错的地方:
def three_sum(nums): nums.sort() n = len(nums) res = [] for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue # 剪枝:最小的数都大于0,后面不可能凑出0 if nums[i] > 0: break left = i + 1 right = n - 1 while left < right: total = nums[i] + nums[left] + nums[right] if total < 0: left += 1 elif total > 0: right -= 1 else: res.append([nums[i], nums[left], nums[right]]) left += 1 right -= 1 # 跳过重复值 while left < right and nums[left] == nums[left - 1]: left += 1 while left < right and nums[right] == nums[right + 1]: right -= 1 return res这个代码比很多版本多了两个个细节:固定元素的去重用nums[i] == nums[i - 1],双指针找到一组答案后,要同时移动左右指针,并且在移动后跳过重复值。
我第一次写这道题时,是在找完一组答案后只移动了 left,然后right没动,导致下一轮 total 又大于0,右指针才移动。这样写功能上没错,但会多出几轮无意义的循环。更大的坑是:去重条件写成了nums[left] == nums[left + 1],这种写法方向搞反了,会跳过本该保留的答案。你想一下,数组[-1, -1, -1, -1, 0, 1, 1],固定第一个-1,左指针在第二个-1上,如果你提前判断“下一个和当前相等就跳过”,左指针直接滑到最后一个-1,结果这组答案[-1, -1, 0]根本凑不出来。所以一定要确定“用当前这个数找到答案,或者已经决定抛弃它”之后,再去跳过后续重复值。
三数之和刷完给我的教训是:双指针的经典结构不难背,难的是把去重的时机和方向搞清楚。花十分钟理解为什么nums[left] == nums[left - 1]和nums[left] == nums[left + 1]结果天差地别,比多刷三遍题目都有用。
2.2 无重复字符的最长子串:滑动窗口的边界移动逻辑
这道题是hot100里几乎人人都会遇到的题,特别是在面试中,它考察的不只是“能不能写出来”,而是“能不能讲清楚窗口为什么这样滑”。
题目要求:给定一个字符串,找出其中不含重复字符的最长子串长度。
最简单的思路:枚举每个左端点,然后用哈希表不断向右扩展,遇到重复字符就停止。这样做就是O(n²)。滑动窗口版本的优化点在于:左端点移动的时候,不用从头重新扫一遍,右端点可以留在原地继续走。
def length_of_longest_substring(s: str) -> int: seen = {} left = 0 max_len = 0 for right, ch in enumerate(s): if ch in seen and seen[ch] >= left: left = seen[ch] + 1 seen[ch] = right max_len = max(max_len, right - left + 1) return max_len我当初在这个题上卡了很久,原因是我一直纠结一个问题:窗口里明明有重复字符,为什么max_len记录的是“窗口大小”而不是“新窗口大小”?
想通之后就明白了:max_len记录的是历史最大值,只要当前窗口无重复,那么它的大小就是一个合法的候选答案。即使某个时刻窗口因为重复字符变小了,也不影响之前已经算出的最大值。这里真正关键的,是seen[ch] >= left这个判断——它保证了“旧的重复位置”被窗口滑动出去之后,不再被当作有效位置。如果不写这个判断,遇到“abbc”这类字符串时,走到第二个b之后,left可能被错误地拉回前面某个b的位置,导致答案偏大。
2.3 盛最多水的容器:双指针为什么永远不会漏掉更优解
前两道双指针题,移动指针的逻辑是从“和的目标”出发的:和太小就左边往右,和太大就右边往左。但盛最多水的容器这道题不太一样,它没有一个明确的目标值,只有面积的计算公式:面积 = min(height[left], height[right]) * (right - left)。
我第一次做的时候,想的是“分布枚举所有组合”,然后被数据规模劝退了。后来看题解才知道要用双指针,但真正让我信服的,是明白它的正确性来源:如果当前左边界比右边界矮,那么以左边界作为左端点的情况下,右指针不管怎么往左缩,宽度都在减小,而高度最多不变(因为高度被矮的左边界限制死了),面积只会越来越小。所以这个左边界已经“没有继续尝试的价值”,可以安心左移。
def max_area(height): left, right = 0, len(height) - 1 ans = 0 while left < right: area = min(height[left], height[right]) * (right - left) ans = max(ans, area) if height[left] < height[right]: left += 1 else: right -= 1 return ans这里的代码很短,短到你会怀疑“是不是太简单了”。但我个人认为,这道题对初学者最大的价值在于:它迫使你去证明“舍弃一边是安全的”,而不是靠感觉移动指针。刷题刷多了你会发现,双指针题三分看代码,七分看怎么解释移动的正确性。能把这个逻辑讲清楚,比代码跑通重要得多。
3. 一道题暴露的真实水平:最大子数组和的分治写法
3.1 贪心和分治,两种思路的境界差在哪里
day10计划里我本来只准备做三道双指针题,但刷到盛最多水的容器之后,觉得双指针的手感已经建立起来了,就随手加了一道hot100里的经典题:最大子数组和。
题意很朴素:给定一个整数数组,找出一个具有最大和的连续子数组,返回其最大和。
这道题的常见解法是动态规划或者贪心:从左往右扫一遍,维护cur,如果cur变成负数了就直接清零,重新开始累加。很多刷题博主喜欢把这题当成“入门第一题”,因为它十几行就能写完:
def max_sub_array(nums): cur = 0 ans = nums[0] for num in nums: cur = max(num, cur + num) ans = max(ans, cur) return ans这版写起来太顺了,顺到我一开始以为这题已经结束了。直到我翻到题解里有一行“还可以用分治做”,整个人突然开始不自在。因为分治要求把数组从中间劈开,递归求解左右两半,同时还要考虑“跨过中点的最大子数组和”。我当时的反应是:跨过中点的部分,不就是左右两半各出一段拼起来吗?那两边分别最优的时候,拼起来就一定最优吗?
这个问题看起来简单,实际上是我对分治法理解不深的一个缩影。我意识到不能只看题解,得亲手把分治版写出来,并且验证它为什么对。
3.2 我卡住的点:跨中点的最大和到底怎么算
分治的思路是把问题拆成三个互斥的部分:
- 完全落在左半部分的最大子数组和
- 完全落在右半部分的最大子数组和
- 必须跨过中点的最大子数组和
前两个交给递归处理就行了。真正需要手写的是第三个:给定数组的一个中间位置mid,向左右两边扩展,找到包含mid的最大连续段。
最直观的办法是从mid向左扫描,不断累加,记录过程中碰到的最大值left_sum;再从mid + 1向右扫描,做同样的操作得到right_sum。两者相加就是跨越中点的最大和。
def max_sub_array(nums): def divide_and_conquer(left, right): if left == right: return nums[left] mid = (left + right) // 2 # 完全在左边 left_max = divide_and_conquer(left, mid) # 完全在右边 right_max = divide_and_conquer(mid + 1, right) # 跨过中点,从中间向两侧扩展 cur = 0 left_continuous_max = nums[mid] for i in range(mid, left - 1, -1): cur += nums[i] left_continuous_max = max(left_continuous_max, cur) cur = 0 right_continuous_max = nums[mid + 1] for i in range(mid + 1, right + 1): cur += nums[i] right_continuous_max = max(right_continuous_max, cur) cross_max = left_continuous_max + right_continuous_max return max(left_max, right_max, cross_max) return divide_and_conquer(0, len(nums) - 1)我当时写出来之后,跑自己的测试用例没发现大问题,但有个地方让我后怕:我第一次写跨中点部分时,直接把left_continuous_max初始化为0了,这导致数组全为负数时,左右扩展的最大值永远是0,跨中点部分算出来变成0,最大子数组和自然算错。改成初始化为nums[mid]之后才恢复正常。
这个错看起来很小,但它点醒了我一个事:分治递归里最怕的不是递归逻辑写错,而是“合并”这一步的初始条件没有考虑清楚边界情况。
3.3 复盘这道分治题时,我攒下的几个认知
把两种解法摆在面前,我重新审视了一下“贪心能过,为什么还要学分治”这个问题。答案不是“分治更优雅”,而是它逼着你把问题拆成子结构,并且搞清楚子结构的答案怎么合并成原问题的答案。贪心法更像是走一步看一步的直觉,分治法强迫你对问题做全局的结构化理解。
这道题还有一个隐含的技术点:分治递归在工程里并不总是最好的选择,比如求最大子数组和,贪心的空间复杂度是O(1),代码更短;而分治的空间复杂度是O(log N)(递归栈),而且常数更大。那为什么很多教科书偏爱分治?因为它能通用到任意切分问题,很多高级数据结构(比如线段树)维护区间最大连续子段和的思路,本质就是把“跨中点合并”这件事抽象成了可复用的逻辑。
对于正在刷hot100的读者,我的建议是:简单题用贪心或动态规划一眼做完之后,不要急着往下刷,花十分钟想一想这题还能怎么拆。拆分过程才是提升算法的真实路径。过度追求“最优代码”反而会错过这种思考的乐趣。
4. 十天刷下来,我对hot100的题型地图有了新认知
4.1 看似零散的题目,其实是围绕几个核心套路展开的
day10做完,我忍不住把前九天做的题拉出来做了个回顾,发现hot100的题型分布并不是散弹打鸟。它大量出现的核心套路,翻来覆去就那几个:
- 哈希表辅助的查找与去重
- 双指针解决有序数组或区间问题
- 滑动窗口解决子串连续性问题
- 递归/分治解决树和数组区间问题
- 动态规划的递推状态设计
我把前十天的题按套路归类做了一个小表,方便自己看清哪些东西练得还不够:
| 套路 | 例题特征 | 我前9天的练习量 | day10新增题 |
|---|---|---|---|
| 哈希表 | 判断是否有重复、记忆化查找 | 较充足 | 无 |
| 双指针 | 有序数组、面积、三数之和 | 中等 | 三数之和、盛水容器 |
| 滑动窗口 | 子串、连续区间最长/最短 | 偏少 | 无重复最长子串 |
| 递归/分治 | 树、排序、跨区间合并 | 中等 | 最大子数组和 |
| 动态规划 | 状态转移、边界初始化 | 偏少 | 暂无 |
这个表给我最直接的信号是:前九天我其实一直在用哈希表和双指针做题,对滑动窗口和动态规划的状态设计接触太少。打卡进行到第十天,最大的感受不是“我变强了”,而是“我知道自己哪里还不成体系了”。
4.2 做题不是做一道扔一道,同类题要凑一起练
hot100刷到第10天,我最想分享的一个经验是:不要按题号顺序刷,要按套路来。刚开始我是按题库序号一道道往后做的,结果经常出现这类情况:今天做了一道树的递归题,明天又做了一道树但完全不同的递归题,中间还隔着一个排序题。由于间隔太久,我完全没法把两道题联系在一起,每道题都像是从零开始。
后来我改成“按套路刷”以后,感受完全不一样。比如把“三数之和、四数之和、最接近的三数之和”放一起,三数之和通了,后面两道就只是改参数;把滑动窗口的“无重复字符最长子串、最小覆盖子串、字符串排列”放一起,你会开始意识到窗口扩容和收缩的时机才是这套题的灵魂。
day10的三道双指针题,就是我有意安排的对比实验。事实证明效果很好:写完三数之和那种“指针移动是为了找目标值”的题,再写盛最多水的容器那种“指针移动是因为安全淘汰”的题,你能清晰感觉到两种双指针的差异。这种差异靠脑补很难体会,必须亲手做完对比才深刻。
4.3 第10天适合做的“回顾动作”:十个题错因汇总
除了按套路归类,我觉得day10还值得做一次错因汇总。为什么是第10天?因为前九天积累的错误样本已经足够覆盖常见的几大类了,太早做没有统计意义,太晚做又容易忘记当初怎么错的。
我给自己整理了一个粗糙的错因表,贴在这里供参考:
| 错误类型 | 出现次数 | 典型场景 |
|---|---|---|
| 边界索引差一 | 高频 | 双指针的left <= right还是< |
| 初始值设错 | 高频 | 最大子数组和的cur = 0导致全负数数组算错 |
| 没有考虑空输入 | 中频 | 返回None还是0 |
| 状态更新时机 | 中频 | 滑动窗口里max_len更新太早或太晚 |
| 把递归和迭代混用 | 低频 | 递归里改了全局变量却没恢复 |
这个表对我的意义在于:刷题真正需要刻意练习的其实只有那几类问题。我在了解自己的错误分布后,后面的打卡会特别留意“边界索引”这类高频错因,做题时直接在草稿上写清楚循环不变量。如果你也在刷hot100,强烈建议找个固定节点做一次类似的错因统计——它比盲目多刷十道题更值。
5. 给还在打卡路上的人:关于hot100的几个实在建议
5.1 一天别贪多,手感比数量重要
我见过不少人的打卡计划,第一天三道,第二天四道,第三天可能就只剩一道,第四天就断更了。根本原因不是懒,而是目标感错位:把“刷题数量”当成了努力的标准,而不是把“理解一个套路”当标准。
我的做法是每天顶多三题,而且必须满足一个条件:至少两题属于同一个套路。这样就算今天只做了三道,你也等于把一个套路学了三遍。日拱一卒的效果在day10体现得非常明显:遇到同类型题,我大概扫一眼题目描述,脑海中就能浮现出代码的框架,剩下的只是填充细节。
5.2 错题本不要记题解,要记“为什么没想到”
很多人整理错题就是把题解抄一遍,抄完再也不看。这样做的效率极低。我建议在错题本里只记三件事:一是当时陷入的误区是什么,二是哪些关键词或条件提示了自己应该用这个套路,三是如果下次再遇到,第一步该怎么走。
举个例子,我不可能在错题本里把“最大子数组和的分治版代码”完整贴一遍,我只会写一行:分治时跨中点的最大和要从中间向两侧扩展,并且初始值不能是0。这一行字包含的信息量,比整个题的代码都大。
5.3 用“讲给别人听”的方式验证自己是否真懂
day10做完当天,我试着把三数之和的去重逻辑讲给一个不在状态的朋友听——当然他没听完就溜走了。但这个过程非常重要,当你能把“为什么是nums[left] == nums[left - 1]而不是nums[left] == nums[left + 1]”这件事说得头头是道时,说明你对这道题的理解已经可以迁移到其他双指针题上了。
如果身边没人听,就开个对话框自己给自己讲,或者在评论区写思路总结。上传平台打卡的本质也是这个:用输出倒逼输入。
5.4 坚持到day10之后,最难的是“F型学习“带来的疲惫感
最后说一点可能没人提过的事。hot100刷到第10天,刷题的新鲜感已经耗尽了,剩下的是机械重复感。这时候很容易产生“我会不会只是在假装努力”的自我怀疑。我的解法是:把十天前第一天写的代码翻出来看。我翻了一下day1做的题,代码虽然也能跑,但一个函数写了一长串重复判断,思路非常凌乱。和day10的代码一对比,肉眼可见地变清晰了。
这种对比给我带来的成就感,比任何打卡勋章都真实。算法学习本来就是一条钝感力拉满的长路,day10不过是一个小到不能再小的里程碑,但如果你能看到自己对比十天之前确实有变化,那这件事就可以继续做下去。hot100还剩下90天,慢慢来,比较快。