LeetCode热题100第三期:中等难度五大算法模型实战拆解
老实说刷LeetCode热题100这件事我前后经历过两个截然不同的阶段。第一阶段是“收藏即学会”看到题单的一瞬间觉得也就100道题安排好周末突击一下结果真坐下来刷了三道就翻车了。第二阶段才是真正把这100道题一题一题过完才发现这些题并不是简单的“高频题汇总”它其实是一套经过市场检验的算法模型浓缩包覆盖了面试中最常见的几大类考察方向。尤其刷到中段的时候题目难度整体开始爬坡这里想跟大家聊聊我刷到第三期的一些真实体会以及几道典型题目的完整思考过程。这一篇算是“LeetCode热题100三”的随记前两期我分别整理了数组、链表、二叉树这些相对基础的内容这一期会集中攻坚一批中等难度的题重点放在五个高频算法模块二分查找、滑动窗口、双指针、动态规划和单调栈。如果你正在准备大厂面试或者刷题遇到了瓶颈期老是在“看了题解恍然大悟、合上题解自己不会写”之间反复横跳那这篇文章应该能帮你在思路上理出一些头绪。1. 热题100的真正价值别把刷题做成体面地浪费时间1.1 热题100和“题海战术”的本质区别我以前也干过傻事买了三四本算法书注册了四五个刷题平台收藏了一堆所谓的“刷题清单”结果几个月过去连清晰的解题框架都没建立起来。后来逼着自己把LeetCode热题100当成主战场才发现这套题单的精妙之处在于它把算法题的考法收敛成有限的几十种模型。比如很多题目表面看起来风马牛不相及但核心解法可能都是二分查找或滑动窗口。热题100的题目选取本身就带着“按模型选样”的味道你做一道题其实是在掌握一类题的解法骨架。这一点跟我一开始“追求AC数量”的认知完全不同——数量不重要真正重要的是能不能从每题中提取出可复用的思路模式。我现在的理解是一个适合面试冲刺的人刷题列表根本不用贪多热题100里的题目足够覆盖大部分常见考点。如果你能把里面的题吃透到“看穿本质”的程度通过算法面试基本是够用的。这也是这一期博客想传递的核心我不打算给你简单列一遍答案而是想把每一类题目的拆解逻辑和常见陷阱讲明白。1.2 第三期为什么选择“中等难度”作为重点把热题100刷到中段你会明显感觉到难度梯度变了。前面的简单题练的是“会不会用API、知不知道某个函数”中段的中等题开始真正考“你能不能把问题转化为一个已知的算法模型”。以我自己的刷题体验来说这段时间最容易崩心态题目看起来都认识代码写起来总是差那么一点边界一多就容易越写越乱。我选择在这个进度节点写第三期还有一个原因是热题100的中等题是最有价值的能力分水岭。面试官很少直接考超难题基本都是中等题或者简单题的变种。你把中等题攻克下来等于掌握了80%面试场上的主动权。后面刷困难题时也都是在中等题的框架上做组合叠加。2. 本期题型分布与刷题路线2.1 核心算法模块和典型题配对我把这一期拆解的题目对应到具体的算法模块上整理了一个速查表。这张表不只是“题目名-解法”的简单对应更重要的价值在于每个模块需要掌握的关键能力点算法模块典型题目示例核心考察点需要重点掌握的能力二分查找爱吃香蕉的狒狒在单调关系上找边界把“最小速度、最少天数”等描述转成二分条件滑动窗口无重复字符的最长子串窗口扩张与收缩的时机理解什么时候收缩、什么时候更新答案双指针三数之和有序数组上的相向遍历去重逻辑的完整覆盖不漏不重动态规划打家劫舍状态定义与滚动优化把“选或不选”的决策变成递推公式单调栈每日温度用栈维护最近更大值掌握“栈内单调”带来的快速求解能力这五个模块不是随随便便凑到一起的它们几乎是面试中出镜率最高的五个中等难度模型。你会发现很多中等题看上去复杂但大类上总能归到这些模型上。学习的时候以模块为单位比一题一题孤立地刷效率高得多。2.2 刷题顺序的建议个人经验是先啃双指针和滑动窗口因为它们对思维的训练比较直接代码量也不大容易建立信心。二分查找可以放在第二位因为它的思维模式比较固定只要记住一套模板就能举一反三。动态规划建议放第三位——这类题不是能短时间突击出来的需要提前积累状态转移的感觉建议配合官方题解多做几组不同的状态定义。单调栈放到最后因为它的应用场景需要一定的积累才能识别出来。我每期刷题都会给自己定一个小目标至少完成五道不同模块的题目并输出完整的推导笔记。你可以把这个习惯当作复盘的锚点。因为只有写得出推导过程才说明你真的掌握了这道题而不是背下了答案。3. 重点题型拆解与代码实现3.1 爱吃香蕉的狒狒把“求最小速度”翻译成二分查找这是热词里点名出现的一道题LeetCode 875。题目描写得挺有意思狒狒要在 h 小时内吃掉所有香蕉每小时只能选一堆吃问最小的速度 k 是多少。很多人第一次看到可能会想这不就直接算总数除以小时数但如果仔细看题你会发现猴子在同一堆吃完后这一个小时内就不能再吃另一堆了所以不是简单的除法关系。解题的关键在于发现一个单调性速度 k 越大吃完所需的总时间越小k 越小总时间越大。这个单调关系存在就把“求最小可行速度”成功转化成了二分查找问题。我们只需要在一个确定的速度区间里找到第一个可以让总耗时 ≤ h 的速度即可。我的代码实现如下class Solution: def minEatingSpeed(self, piles: List[int], h: int) - int: left, right 1, max(piles) # 在 [left, right) 中查找第一个满足条件的速度 while left right: mid (left right) // 2 # 当前速度下吃完所有香蕉需要的时间 total_time sum((p mid - 1) // mid for p in piles) if total_time h: right mid else: left mid 1 return left这里有三个细节值得展开说说。第一初始右边界为什么取max(piles)因为当速度等于最多那堆香蕉的数量时无论哪一堆都能在一个小时内吃完此时总耗时就是堆数。而题目给的 h 通常大于等于堆数所以这个速度一定是可行解。知道了可行上界我们只需在上界之下寻找更小的可行值这正是二分查找的适用场景。第二(p mid - 1) // mid是一个向上取整的小技巧。有些同学习惯先p // mid再判断余数其实用(p mid - 1) // mid一行就能完成同样的事情简洁且不容易漏掉余数不为零的情况。第三这道题里的边界判定很隐蔽容易出错的是total_time h时不能直接返回 mid。因为可能存在更小的速度也能刚好达到同样耗时需要在左半区继续搜索。所以我用了if total_time h收紧右边界保留继续向左寻找的空间。这个“等号归到可行解继续向左找更小值”的思路是很多二分题通用的一种模式。实际面试中如果你能顺手说出“这个 ≤ 条件为什么不能改成 ”面试官会对你理解深度多几分认可。第二题也是二分力扣周赛里出现频率也很高我把这题的模板背熟之后再做其他“最小化最大值”的题目会顺手很多。3.2 无重复字符的最长子串滑动窗口的入坑与出坑字符串相关的题目在热题100里占了不小比重而不带重复字符的最长子串可以说是滑动窗口的入门题。题目要求给定一个字符串找出其中不含重复字符的最长子串长度。如果用暴力解法每个子串都检查一遍复杂度是O(n²)数据量稍大就会超时。滑动窗口的巧妙之处在于它把每个字符“进入窗口”和“离开窗口”的记录控制在常数次。我的标准写法有两种这里先给出用字典存储索引的写法我认为是最容易讲清楚的版本class Solution: def lengthOfLongestSubstring(self, s: str) - int: pos {} left 0 ans 0 for right, ch in enumerate(s): # 如果当前字符之前出现过且还在窗口内就更新左边界 if ch in pos and pos[ch] left: left pos[ch] 1 pos[ch] right ans max(ans, right - left 1) return ans这里面最关键的状态是left的更新。举个例子字符串abcabcbb当右指针走到第二个a时发现pos[a]是 0但左边界已经走到了 1说明第一个a早已被移出窗口所以不应该把左边界改到 1 之后。加上pos[ch] left这个判断就是为了确保我们仅参考“当前窗口内”上一次出现的位置。还有一点容易被忽略更新pos[ch]的时机必须在答案更新之前还是之后其实都可以但建议统一成“先更新窗口再更新答案”的顺序。这样逻辑更一致也方便别人阅读你的代码。如果你用set版本也就是window里存字符遇到重复字符就不断从左边弹出也能解决问题不过时间复杂度更不稳定——某些情况下同一个字符会被连续弹出多次虽然整体仍是O(n)但代码上会比字典版本多一些判断。我的建议是面试时优先用字典版本因为它刚好体现了“用空间换时间”的权衡思路。3.3 三数之和双指针的去重陷阱三数之和应该是我见过翻车率最高的一道热门题。题目要求找出数组中所有和为0的三元组且不能重复。最容易想到的三层循环在LeetCode上会直接超时而且还要额外处理去重问题复杂度高得离谱。正解是排序加双指针。先把数组排序然后固定一个数nums[i]再用双指针在剩余区间里找两个数使它们相加等于-nums[i]。排序的价值在于一是让双指针的移动有方向性二是让重复元素都挨在一起方便去重。class Solution: def threeSum(self, nums: List[int]) - List[List[int]]: nums.sort() n len(nums) res [] for i in range(n - 2): # 剪枝如果第一个数已经大于0后面全是正数不可能凑出0 if nums[i] 0: break # 跳过重复的固定元素 if i 0 and nums[i] nums[i - 1]: continue target -nums[i] left, right i 1, n - 1 while left right: s nums[left] nums[right] if s target: res.append([nums[i], nums[left], nums[right]]) # 去重跳过所有和当前左指针相同的元素 while left right and nums[left] nums[left 1]: left 1 while left right and nums[right] nums[right - 1]: right - 1 left 1 right - 1 elif s target: left 1 else: right - 1 return res这道题最大的坑不在双指针的移动本身而在去重逻辑。很多同学的代码明明数组排序了返回结果里还是出现重复三元组原因就是把“跳过重复元素”写在了错误的位置。记住一个原则找到一组答案之后立刻原地去重然后才移动双指针固定元素的去重必须在进入双指针之前完成。这两个去重逻辑的分工不同混在一起就会出现要么漏解、要么重复的诡异情况。另一个容易错的是越界访问。处理完内部去重之后left 1和right - 1是必须的否则会转进死循环。而且去重时while left right的条件一定要写在前面防止left 1越界。我还想提醒一件事这道题有时候被用作面试热身面试官会追问各种变体比如“如果改成四数之和呢”你只要把递归或固定双数的思路讲出来基本就是满分回答。3.4 打家劫舍动态规划初体验动态规划这模块在水面下藏了很多坑。很多人对DP的第一反应就是“状态转移方程背下来”这其实是最低效的学习路径。打家劫舍是一道经典的入门DP题一排房子每个房子有不同金额的现金相邻的房子不能同时偷问最多能偷多少。解题思路的核心不是“偷或者不偷”这个二元选择而是在每个位置上记录“偷到当前位置时能获得的最大金额”。定义dp[i]为前 i 个房子能偷到的最大金额那么转移方程就出来了dp[i] max(dp[i-1], dp[i-2] nums[i])这个方程的含义很直白第 i 个房子不偷那就延续前 i-1 个房子的最优结果第 i 个房子偷就必须跳过第 i-1 个房子所以加上前 i-2 个房子的最优结果。这种“由局部最优推导全局最优”的思路正是动态规划区别于贪心的关键点。可以进一步用滚动数组优化空间class Solution: def rob(self, nums: List[int]) - int: prev 0 # 相当于 dp[i-2] cur 0 # 相当于 dp[i-1] for num in nums: # 新值等于 max(不偷当前房子, 偷当前房子) prev, cur cur, max(cur, prev num) return cur这里的写法比维护完整 dp 数组更节省空间而且代码看起来清爽。面试时如果你能主动写出这种滚动数组版本通常会给面试官留下好印象——说明你思考过空间复杂度优化。不过我得提醒一句千万别在没有完全理解 dp 数组含义的情况下背这种滚动数组写法。因为一旦题目稍作改动比如要求输出具体偷了哪些房子只靠滚动数组就无从下手了。正确学习路数是先用完整的dp[i]数组把推导逻辑写明白熟练后再做空间优化。热题100里还有不少DP变体比如编辑距离、背包类型的题底层都是用类似的状态定义方式。你把这个“选或不选”的思维模式吃透后面很多DP题都会顺手很多。3.5 每日温度单调栈的直观理解单调栈是我刷题时觉得最“反直觉”的模块之一。每日温度的题面很生活化给定每天的温度列表要求返回每个位置需要等多少天才能等到更高温度没有就填0。暴力解对每一天往后扫一遍遇到更高温度就停时间复杂度O(n²)遇到几万天的数据直接就废了。单调栈的解法并不复杂但需要理解它到底维护了什么一个从栈底到栈顶递减的温度索引栈。遍历每一天的温温度时只要当前温度大于栈顶那一天的温度就说明栈顶那天等到了第一个更高的温度距离就是当前位置减栈顶索引然后弹出栈顶继续比较新的栈顶。直到当前温度不大于栈顶温度把当前索引入栈。class Solution: def dailyTemperatures(self, temperatures: List[int]) - List[int]: n len(temperatures) ans [0] * n stack [] for i in range(n): while stack and temperatures[i] temperatures[stack[-1]]: idx stack.pop() ans[idx] i - idx stack.append(i) return ans这个解法的精妙之处在于每个索引最多入栈一次、出栈一次所以整体复杂度是O(n)。栈里剩下的那些索引后面再也没有出现更高温度了保持初始值为0即可。很多人第一次接触单调栈会觉得抽象为什么栈底到栈顶是递减的你可以想象成一座“候选者”队伍每个新来的人都会先看看队伍末尾的人有没有被自己“终结”如果有就把对方弹出自己继续往前看看能不能终结更多人。最终留下的都是一批“尚未被终结”的候选者队列保持着某种递减的性质。这个过程不需要回头扫描数组相当于把所有未来信息通过栈的弹出机制提前结算了。热题100里面还有一题“接雨水”也用了单调栈的思想理解了每日温度之后接雨水的思路会好啃很多。所以我一直建议把单调栈题目当成一个组合包来刷效果比单题突击好得多。4. 刷题过程中的常见问题与排查技巧4.1 代码超时从O(n²)到O(n)的优化路径我在给朋友做代码review时发现新手最常见的超时原因就是随意嵌套循环。如果你写完一版代码后自己都觉得“这也能过”那大概率过不了。LeetCode的隐藏用例往往卡在边界数据上比如10的5次方级别的输入如果算法是O(n²)基本必挂。一个实用的自检方法是先看题目给出的数据范围粗略估计能接受的复杂度。比如数组长度到了10⁵算法的整体循环次数就不该超过10⁶~10⁷。一旦发现嵌套循环套用了两三层就必须停下来思考有没有更优的解法。这也是热题100这类经典题单训练价值的一部分——大部分题的线性解或O(n log n)解都不是天然蹦出来的而是被复杂度给逼着优化出来的。超时问题排查时我还习惯在本地用最大规模的数据跑一次观察耗时。不要只靠LeetCode的判题结果本地能测的数据规模往往比平台上的可见用例更大能提前暴露性能瓶颈。4.2 边界条件和索引越界最容易翻车的细节很多题目的求解框架完全正确却因为漏掉了边界条件导致大量答案错误。最典型的几个坑包括数组为空时直接访问nums[0]、字符串为空时直接取第一个字符、循环里left 1越界访问、以及二分查找时区间开闭混淆。我的习惯是写完主逻辑之后先手动跑三个用例——空的、只有一个元素的、最大输入规模的。这三个用例能覆盖大部分边界错误。热题100里的很多中等题之所以让人“感觉会写却老AC不了”往往就是边界细节拖了后腿而不是思路没想清楚。还有一点可以分享调试的时候不要靠眼睛硬看直接在关键中间变量处打印。比如滑动窗口里的left和right二分查找里的mid看这些值是否按照预期移动往往一眼就能定位到问题。4.3 题解看得懂、代码写不出输出倒逼输入“看了题解恍然大悟合上题解大脑空空”是刷题人群中最普遍的问题。我的破局技巧是看完题解后不要马上去看参考代码先在草稿纸上把思路复述一遍包括算法的每一步在做什么、为什么这样做、终止条件是什么。然后合上笔记凭记忆和理解自己写一遍。写不出来的地方就是你的知识盲区这时候再去对照代码把盲区逐个补上。这个方法看起来简单但效果远好于反复阅读别人的代码。它把“被动输入”变成了“主动输出”强制大脑完成一轮真正的编码实践。5. 从“会做一道题”到“会做一类题”的破局方法5.1 建立“模型库”而不是“题目库”刷到第三期我最大的认知升级是不要按题目记忆要按模型记忆。比如看到“求最大/最小的可行值”马上考虑二分查找看到“连续子数组/子串的最值”马上考虑滑动窗口或前缀和看到“相邻两个不能同时选”的字眼条件反射地考虑动态规划。我整理模型库时用了很朴素的方法每道题写完题解后在文件开头标注三个东西——“这道题属于哪个算法模型”、“关键的状态/判定条件是什么”、“如果有变体题变体会怎么改”。这个“模型标签”会在你刷题量积累到一定数量后产生质变你会惊奇地发现很多新题只是旧模型的马甲。5.2 刷题复盘怎么写才有价值我见过一些人的刷题笔记就是把别人的题解复制一遍贴进备忘录然后心安理得地收藏。这样做除了制造“我正在努力”的错觉对能力提升没有实质帮助。真正有用的复盘应该包含四个部分第一这题的“题眼”在哪里第二我最开始是怎么想的哪个环节卡住了第三正确思路的关键转折点是什么第四如果要改条件这道题会变成什么新题。这四个部分缺一不可。第一个帮你识别题目类型第二个帮你暴露思维习惯上的短板第三个帮你建立新的思维路径第四个帮你把知识泛化到变体中。每次刷完一道题花十五分钟写这些内容比花两个钟头看下一个题解更划算。5.3 周赛与热题100如何配合最近LeetCode周赛出了不少新题比如周赛430期的题面就保持了近几期的高质量很适合当作热题100之外的“验货场”。很多人纠结要不要刷周赛我的建议是周赛是检验手段不是学习手段。基础不牢的时候参加周赛大概率被虐得体无完肤还会打击自信。先把热题100的中等题吃透再用周赛检验自己在一小时内的真题应变能力这个顺序比较合理。参加周赛还有一个好处你会在有限时间内被迫快速判断题目的模型归属而不是像平时刷题一样慢慢琢磨。这种“快准狠”的识别能力恰恰是面试中最需要的实战能力。6. 复盘这一期聊点实在的把这三期刷下来我对热题100的感受越来越清晰它不是一道死板的题目清单而是一套浓缩的算法面试训练方案。有人刷题喜欢追求速度一天十几道结果过两天全忘光有人刷题喜欢抄答案刷完一百题还是不会变通。这两种模式我都经历过也都踩过坑。我的真实体会是刷题的本质是训练识别模式的速度和拆解问题的能力。热题100把最基本的算法模型筛出来摆在你面前你要做的不是背下每道题的答案而是通过一组组题目把模型的肌肉记忆刻进脑子里。等你在面试现场看到一道从未见过的新题脑中能迅速浮现“这题有点像二分、那部分需要滑动窗口维护”那么这个题单就真正发挥出它的价值了。如果你正好刷到了中段这个坎别急着追求数量静下心来把每道题的“为什么”搞清楚。宁可一道题钻研三个小时也不要三分钟翻完一道题的答案。你要相信真正影响你面试表现的从来都不是你做过多少题而是你通关了多少种思维模型。