97天刷完LeetCode经典150:算法模板与边界条件心法 day972月26日。我在 LeetCode 面试经典 150 这套题单上已经实打实泡了 97 天。如果只看数字97/150 的进度不算慢但真正让我感觉到质变的不是又做完了多少道题而是从第 60 天左右开始我忽然能看见这些题之间的骨架了。同一个模式的三四道题摆在面前以前觉得是三个解法现在知道它们背后是同一块模板。这篇文章不是打卡炫耀帖我是想把这三周以来最有效的方法、几个反复出现的题型规律以及那些翻车到怀疑人生的边界条件原原本本整理出来。适合谁看正在刷这套题单准备算法面试的同行以及刷了七八十道但总觉得没手感的人。新手也能看不过建议先刷几十道题再读体会会更具体。1. 为什么面试经典 150值得用三个月去啃1.1 这份题单的真正价值知识点覆盖密度带来的体系感我算过一笔账LeetCode 全站两千多道题150 道只是很小一块。但面试经典 150的价值恰恰不在体量而在它把算法面试最高频的知识点拆成了十几个大主题每个主题里安排了从易到难的梯度题。数组、字符串、双指针、滑动窗口、哈希表、栈、链表、二叉树、图、前缀和、动态规划、贪心、回溯、分治、位运算、堆——每个主题不是只给你一两道题而是用三到十道题把知识点从基础到变形考到透。这和很多博主推荐的按 tag 刷高频不太一样。按 tag 刷容易一天到晚做同一类题换个马甲就认不出来。而 150 这种主题内梯度安排逼着你去掌握从暴力解到最优解的完整思考链。我第 80 天复盘时发现一个规律这套题单里大概三成题目你面试时原题命中的概率不高但它训练的那种把问题拆成已掌握模板的能力反而最值钱。比如一道题表面是找最长无重复字符子串实际训练的是对窗口边界和哈希表状态的精确控制表面是实现 LRU 缓存实际训练的是多数据结构组合使用。这才是面试官真正在意的底层能力。1.2 我的 97 天三段式节奏先扫全貌再按主题回刷最后乱序混合整个 97 天我切成三段节奏完全不同。第一阶段前 30 天顺着题单顺序从前往后刷。心态是先见题型每天两到三道题保量。说实话效果一般今天刚搞懂栈明天跳到树知识点连不成线。这个阶段唯一的价值是摸了一遍全貌认清哪些主题是自己彻底不熟的盲区。第二阶段第 31-70 天是我整个刷题过程里最正确的一个决定把题单打散按主题重新组织。比如连续三天全部做二叉树把前序、中序、后序、层序、最近公共祖先、路径总和放在一起刷。这个阶段做题速度明显降了但理解深度上去了。同一主题的题放一起你很容易看到共享的模板代码也容易发现上一道题漏了这个判断这道又漏了这种重复错误。第三阶段第 71 天到现在进入乱序混合状态。每天从已刷过的题里随机抽 5 到 8 道模拟真实面试的节奏。这个阶段再反复出错的点才是真正的知识漏洞——因为去掉了这题我知道是二叉树这种线索纯粹靠题目信息判断它属于哪个知识块。我在第 86 天总结出的求个数、最大值、最小值先想 DP 再想贪心的判断流程就是从这阶段的错误里淬出来的。2. 刷到中期我才真正读懂的题型规律2.1 双指针与滑动窗口模板人人都懂细节才是分水岭双指针和滑动窗口是经典 150 里的高频区。这东西看着简单——两个指针走走走但真要独立写对比动态规划还容易翻车。先看模板以滑动窗口求满足条件的最长/最短子数组为例def solve(nums, k): left 0 cur 0 ans 0 for right in range(len(nums)): cur nums[right] while cur k: # 不满足条件时收缩左边界 cur - nums[left] left 1 ans max(ans, right - left 1) return ans模板谁都会背陷阱全在细节里。第一个坑是 while 条件方向while cur k还是while cur k取决于题目条件是小于等于还是小于。第二个坑是记录答案的时机有的题答案是窗口长度必须收缩后再更新有的题答案是窗口的某种性质得在收缩前记录。顺序反了答案必错。第三个坑最容易忽略右边界的扩展逻辑和左边界收缩逻辑的先后关系取决于你维护的是以 right 结尾的合法窗口还是以 right 扩展后的合法窗口。我实际踩过的例子是滑动窗口最大值那道变式。用单调队列存下标时队头元素如果已经滑出窗口需要先判断queue[0] left_bound再弹出。我连续两次漏掉这个判断导致队头存着一个过期下标取最值时直接引用了窗口外的索引。本地测试不容易触发因为随机数据里过期元素大概率被更大的值挤掉但面试官只要扫一眼代码立刻就会指出。这是典型的写模板却不理解模板里每个判断来历的案例。2.2 动态规划把背状态转移方程换成暴力递归缓存的推导路径关于动态规划我第 50 天才真正想明白一件事不要一上来就背状态转移方程而是要走先写暴力递归再改记忆化最后改递推这条路径。大部分 DP 题的核心难点不是状态多难定义而是你根本不知道状态从哪来。如果你把 DP 当成暴力搜索加缓存来理解一切就通了。比如最简单的一维计数题你先写出f(n) f(n-1) f(n-2)的递归——这几乎就是暴力枚举然后发现同一个n被反复计算加一个 memo 数组就变成记忆化搜索最后观察依赖方向发现f(n)只依赖前两个值连数组都不用两个变量滚动即可。有个关键认知帮了大忙记忆化搜索和递推 DP 本质是同一个思维只是实现方式不同。你写递归函数时的参数就是 DP 状态函数里 base case、选择、递归调用的过程就是状态转移。意识到这一点后DP 的入门门槛直接降了一半。我第 73 天做一道完全背包变式题先写暴力递归再改 memo 缓存最后推成二维 DP前后花了 40 分钟。想清楚之后后面同类型的题基本是套模板。相比背下来的转移方程这个方式最大的好处是题目再怎么变形你都能从暴力搜索的视角重新推出来。2.3 二叉树与图遍历模板已经够用难的是选对搜索方式二叉树遍历是很多题的基础。迭代写法说白了就是模拟递归栈。中序模板长这样def inorder(root): res [] stack [] node root while stack or node: while node: stack.append(node) node node.left node stack.pop() res.append(node.val) node node.right return res背模板容易关键是理解为什么while node:要一路往左压栈——因为中序要求左中右第一次经过节点时还不能输出必须等左子树全访问完从栈里弹出来才轮到它。后序更麻烦一点需要标记右子树是否访问过或者用反前序遍历再反转结果的技巧。图的部分经典 150 里题量不算多但环检测、拓扑排序爱考。我刷图时犯过一个认知错误以为递归 DFS 够用实际上很多求最短变式的题必须上 BFS。DFS 和 BFS 的根本差异在于顺序DFS 走直线适合可达性判断、路径枚举、连通块计数BFS 一层一层扩散适合最短距离、最小步数。拿不准的时候先问自己答案需要什么性质——是否存在一条路径优先 DFS最短路径需要几步优先 BFS。后面大量刷题验证下来这个判断标准几乎没失灵过。3. Day97 实战复盘一道最长无重复子串的三级跳3.1 暴力解的边界先把语言表达转换成程序约束第 97 天做的一道题很适合拿来完整复盘。题目描述抽象成一句话给定一个字符串找出不含重复字符的最长子串长度。比如abcabcbb答案是 3。我拿到题的第一步不是想最优解而是先确认暴力解。枚举所有起终点判断每个子串是否无重复字符取最大长度def length_of_longest_substring(s: str) - int: n len(s) ans 0 for i in range(n): for j in range(i, n): if len(set(s[i:j1])) j - i 1: ans max(ans, j - i 1) return ans这个 O(n³) 的解简直离谱但它有一个隐藏价值它把枚举所有子串的语义表达得极其清楚。写完你立刻意识到我们做了大量重复工作——同一个区间被反复切片、反复建集合。暴力解的意义就是让你看见重复在哪里才有优化的方向。3.2 从优化到最优哈希表记录位置的滑动窗口暴力解之后有两个优化方向。固定左端点不断右扩右端点用哈希集合维护当前窗口内字符。每加入一个新字符如果发现它在窗口里就说明以当前左端点开头的最长无重复子串到头了。这一步把重新判断整个子串是否重复的 O(子串长度) 去掉整体降到 O(n²)。再往前一步问一个问题右指针遇到重复字符时左指针为什么只能前进一格如果s[right]已经在窗口里说明窗口中存在一个位置poss[pos] s[right]。那左指针可以直接跳到pos 1跳过中间这些不可能形成更长答案的区间。这就是滑动窗口加哈希表记录最近位置的最终形态def length_of_longest_substring(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这里有个极容易被忽略的细节为什么判断条件要加pos[ch] left因为哈希表里存的可能是很早以前的下标如果它已经滑出当前窗口[left, right]这个重复不会影响窗口合法性左指针不需要动。不加这个判断left会被一个过期位置往回拖或者保持不变答案直接错。我第 40 天第一次写这题时就漏了它用了一个过期下标窗口左边界算错。后来我给自己立了条规矩凡是哈希表记录最近位置的滑动窗口题先想清楚什么情况下这个位置会过期。3.3 提交前的三个自检点空输入、全重复、无重复代码写完不急着提交先在脑子里过三个用例。第一个是空输入。字符串为空时pos为空循环不执行ans初始 0返回 0。正确。第二个是全部字符都不同。比如abcde每次ch not in posleft始终为 0ans最终等于 5。正确。第三个是全部字符相同比如bbbb。第一次bleft 0第二次遇到bpos[b] 0 0于是left 1此时ans right - left 1 1之后每次循环都更新left为当前位置答案一直是 1。正确。这三个用例覆盖了最常见的边界空输入、无重复、全重复。很多面试翻车不是主体逻辑错而是边界输入没考虑。养成这个自检习惯后我提交的一次通过率明显变高。4. 走完 97 天真正改变我刷题效率的三个习惯4.1 打散题单按主题回刷让模板自己现身前 30 天顺题单刷最大的毛病是知识散装。后来改按主题回刷最少连续三天只碰一个主题。这里有一个很直观的好处你能看见模板。链表题无论反转、判环、找交点核心操作就是双指针走位加节点指针重连。当连续三天都在摆弄链表你自然会归纳出dummy node处理头节点变化、fast/slow处理追赶问题、prev/cur/next处理反转问题。你发现的模板越多后面做题越快因为新题在你眼里不再是孤立题目而是见过无数次的旧骨架。提示按主题回刷时刷题记录别只打勾。每道题记一行解法关键词比如单调栈下一个更大元素或快慢指针环入口。后面回看时这些关键词就是你自己的索引系统。4.2 错题本只记三行作用不是记录而是出题错题本几乎人人都有但九成的人建完就吃灰。我前几周也是这样后来琢磨出一个能坚持的方法错题本不是用来记录而是用来出题。每次做错或卡超过 20 分钟的题我只记三行问题是什么一句话概括核心问题我卡在哪是没想到思路还是某个操作细节写错正确突破口是什么一个词或一句话例如枚举子集时固定左边界哈希判重这三行不是复习素材是复述题卡。隔三天我合上本子只看着问题是什么在脑子里从头重建完整解法。不是回忆答案是重新推导。推导不流畅就说明这道题还不是你的回去重做。这个方法本质是费曼学习法用在错题上但效果比抄一遍题解强太多——抄题解的时候手在动、脑子没动重建解法的时候每一步都得想清楚为什么。4.3 每天花 15 分钟讲题把思路输出练成条件反射第 60 天左右我突然意识到一个尴尬的事实刷题是输入面试是输出。做题时你对着编辑器安静思考面试现场却要求你把思考过程口述出来。这个能力不练到了场上很吃亏。就算脑里有正确答案讲得混乱面试官很难确认你是真会还是背过。我的碎片化训练法很简单每天挑一道当天做过的题用讲题模式过一遍。打开手机录音给自己三分钟把思路讲清楚——从问题分析、数据结构选择、算法流程到边界条件。录完自己听一遍重点盯两件事有没有频繁出现呃然后然后这种口头禅讲核心优化步骤时有没有解释明白为什么这样做是安全的。一天 10 到 15 分钟坚持两周后我发现自己手写题解时的思维链条也清晰了。面试最忌讳只蹦结论不讲过程。不是不能接受你慢慢想是不能接受你想哪说哪、毫无逻辑。这个习惯练到最后你在模拟场景里基本不会冷场。5. 那些我踩过的坑希望你提前避开5.1 打卡天数绑架了刷题质量刷题打卡有正向激励但也容易扭曲判断。第 25 到 35 天那段时间我每天的目标变成了今天必须刷够 3 道新题而不是今天吃透一类题。结果刷了很多题题目之间毫无关联过两周回头做当初的题跟第一次见没区别。问题根源是产出指标设置错了——只有刷题数没有解决旧问题的指标。于是人会下意识回避硬骨头专挑软柿子。我现在的产出指标是今天有没有解决一个以前不会的问题。新题数量不重要重要的是知识库里多了一个可复用的模式或一个边界处理经验。调整后焦虑明显减少哪怕一天只做一道题只要它是真卡了我很久的题今天的产出就是正向的。面试最终看你会不会不看你打卡多少天。5.2 跳过暴力解直接背最优解的代价有一段时间我只看题解里的最优解看懂了就觉得自己会了。直到一次模拟面试把我打回原形一道回溯变式题我卡在一个特别基础的地方——没法确认自己的递归搜索树是完整且不重不漏的。面试官追问 memo 的 key 包含哪些参数我当场答不上来。因为我背的是最优解的最后形态没见过它从暴力解一步步演化来的过程。从那以后我给自己立了规矩每道题先写暴力解再写优化解哪怕暴力解时间复杂度夸张也先跑通小数据测试。有两个直接好处。一是面试被问有没有更优解法时不慌因为你知道暴力解的边界在哪优化是从哪里开刀的。二是很多最优解本质就是暴力解去重、剪枝、降维之后的长相走一遍完整推演路径远比背最后形态扎实。5.3 一次二分查找边界翻车的完整复盘最后一个案例第 89 天做的二分查找变式在已排序、可能包含重复元素的数组中找目标值第一次出现的位置。主体逻辑很快写出来了def first_position(nums, target): left, right 0, len(nums) - 1 while left right: mid (left right) // 2 if nums[mid] target: left mid 1 else: right mid return left if nums[left] target else -1看着没问题当数组为空时left, right 0, -1while left right不执行但最后一行访问了nums[0]——空数组直接抛 IndexError。这就是典型的主体逻辑全对边界一跑就炸。修复只需要一行def first_position(nums, target): if not nums: return -1 left, right 0, len(nums) - 1 while left right: mid (left right) // 2 if nums[mid] target: left mid 1 else: right mid return left if nums[left] target else -1我把这类问题记成一条教训二分查找开头必须先判空。后来凡遇二分必先检查输入判空。这个案例引出一个更通用的习惯代码写完按空输入、单元素、全相同、无目标值、目标值在两端五类输入过一遍比任何花哨调试技巧都靠谱。面试官看的不仅是你会不会写更是你写出的代码经不经得起边界考验——这是专业习惯而不只是技巧。最后再分享一点第 97 天之后的体会。随着题越刷越多我发现真正拉开差距的往往不是会不会做这种 0 和 1 的区别而是做得多顺和讲得多清楚。我现在不再追求把 150 这个数字走完而是把每一类题里的高频变式练到条件反射。这个阶段我刻意放慢新题速度把时间留给复述和回刷。后续我会在这套题单的基础上把周赛里遇到的高质量题补充进来但方法论不变。如果你也在刷这套经典 150我的建议很直接别被打卡数字绑架把每道题的为什么和边界在哪真正想透。这个功夫下了面试那天的回报会让你觉得 97 天没有白熬。