LeetCode Hot100 11-20题刷题复盘:回溯、剪枝与哈希建模是关键

📅 发布时间:2026/9/24 22:33:10
LeetCode Hot100 11-20题刷题复盘:回溯、剪枝与哈希建模是关键
不知道你有没有类似的感受hot100 刷到前 10 题的时候一切都还挺友好哈希、双指针、链表基础靠直觉能撑住。可一旦进入第 11 题之后难度仿佛突然跳了一个台阶递归、回溯、优先级队列轮着来第一次刷的时候我在括号生成那道题上卡了整整一晚上。后来二刷、三刷我慢慢意识到hot100(11-20) 就是一个刷题分水岭这一批题不是为了让你记住答案而是逼你建立“递归的肌肉记忆”和“调参拆解法”的思维习惯。这篇文章不是把十道题从头到尾贴一遍代码而是想把我刷完这十道题之后的复盘经验写清楚这一批题真正在考什么、哪些题适合放在一起练、哪些看似不相关的题其实共用同一套思路、以及我实际调试中反复踩过的几个坑。正在刷 hot100 的朋友可以拿它当参考已经刷完一遍但觉得不扎实的也可以对照看看这十道题是不是真的吸收了。1. 这批题为什么叫“分水岭”而不是“又一组题”1.1 我刷到的 11-20 是哪十道先说清楚一个前提LeetCode 热题 100 这个列表在不同平台、不同时期内部顺序会有一点调整但收录的题目基本是固定的。我这里说的“11-20”是按照我实际刷题所用的常用版本顺序展开的。你手上列表如果有个别题对不上不影响整体参考每道题本身都是面试和笔试的高频题。序号题目核心考点难度直觉11括号生成回溯、DFS、剪枝中等但第一个让我真正写递归12合并K个升序链表链表、优先队列、分治中等偏上13下一个排列数组规律、双指针中等容易“背了忘”14电话号码的字母组合哈希映射、回溯中等偏简单15四数之和排序、双指针、剪枝中等16组合总和回溯、排序剪枝中等17全排列回溯、used数组、交换法中等18旋转图像矩阵规律、坐标变换中等19字母异位词分组哈希、字符串处理中等20最长连续序列哈希集合、去重优化中等偏简单看到这个表你应该能发现一个明显信号这一批题里递归回溯占了四道括号生成、电话号码的字母组合、组合总和、全排列再加上合并K个升序链表这种“用堆但本质是反复取最小值”的问题整个批次的思维模型跟前 10 题完全不同了。1.2 前 10 题靠“套路”11-20 靠“建模”前 10 题里两数之和可以用哈希表缓存三数之和可以用排序加双指针有效括号用一个栈就能过。这些题的共同点是题面已经把“数据结构”写在脸上你只要判断该用哪一个容器。到了 11-20题面不会直接告诉你“请用回溯”。括号生成问的是组合方式电话号码的字母组合问的是映射组合全排列问的是排列方式四数之和问的是求和结果。你要先把它识别成“搜索问题”才能想到递归。这一步识别和建模不是背模板能解决的。所以我特别不建议把这十道题按数字顺序一个一个硬刷。更好的方式是像我下面这样按“主题家族”打包处理一口气把同类的思路打通。2. 回溯第一课括号生成和电话号码的字母组合放在一起练2.1 括号生成先别急着写代码把“合法括号”的条件想明白括号生成的题面很简单给定 n 代表括号对数生成所有可能的有效括号组合。比如 n2 的时候结果是[(()), ()()]而)(这种无效组合不能出现。我第一次写这题时第一反应是用全排列的思路枚举所有左右括号的排列再把合法的筛出来。这个方向不是完全错但效率太低。正确的姿势是把“合法”这个约束直接写进递归过程里每一步只生成可能合法的下一个字符。具体来说递归函数里记录两个数字已经放了几个左括号left、已经放了几个右括号right。每一步有两个分支如果left n可以放一个左括号如果right left可以放一个右括号因为右括号数量不能超过左括号数量否则这个括号序列永远无法闭合。等left n且right n说明长度到 2n递归结束把当前路径加入结果。def generateParenthesis(n): res [] def backtrack(left, right, path): if len(path) 2 * n: res.append(.join(path)) return if left n: path.append(() backtrack(left 1, right, path) path.pop() if right left: path.append()) backtrack(left, right 1, path) path.pop() backtrack(0, 0, []) return res这段代码最有价值的细节是right left这个剪枝条件。很多人背回溯模板时会忽略这类“由题目约束产生的剪枝”导致结果差很多。括号生成这题剪枝条件就是构成“合法性”的核心而不是单纯的性能优化。2.2 电话号码的字母组合从“选左还是选右”变成“从列表里选一个”电话号码的字母组合输入一串数字比如23每个数字对应几个字母要求返回所有可能的字母组合。这一题比括号生成好理解但很多人在从“字符串拼接路径”切到“数组收集路径”时反而出问题。核心思路很简单预处理一张数字到字母的映射表然后递归函数的参数里记录当前处理到第几位数字。每次递归从映射表里取出当前位对应的所有字母逐个选择进入下一层然后撤销选择。def letterCombinations(digits): if not digits: return [] mapping { 2: abc, 3: def, 4: ghi, 5: jkl, 6: mno, 7: pqrs, 8: tuv, 9: wxyz } res [] def backtrack(index, path): if index len(digits): res.append(.join(path)) return for ch in mapping[digits[index]]: path.append(ch) backtrack(index 1, path) path.pop() backtrack(0, []) return res这里值得留意的是空输入digits 的情况按题意应该返回[]而不是[]。这个边界在真实面试中很容易被忽略一旦没处理所有后续测试用例都会错。把这两道题放在同一天刷你就能明白回溯的本质它就是在“尝试—撤销—再尝试”的过程中走完一棵决策树。括号生成每一步选择的是放置哪个括号电话号码生成每一步选择的是当前位置填哪个字母不同的只是“选择列表怎么构造”。3. 组合总和和全排列最值得反复琢磨的两个回溯变体3.1 组合总和同一个元素可以重复选反而更容易出重复组合组合总和这题给定一组候选数字candidates和一个目标值target要求从候选数字中找所有和为 target 的组合同一个数字可以无限重复使用。这道题和全排列长得不一样但它才是让我真正理解“回溯起点参数”的关键。很多人在写这道题时递归函数里只传入target和path没有传入“当前可以从哪个下标开始选”结果会得到一堆重复组合比如[2,2,3]和[2,3,2]被当成两个结果。解决办法是递归函数里多一个参数startIndex表示本轮从candidates[startIndex]开始考虑。这样同一层内不会回头去取之前已经取过的数组合自然就不会乱。因为同一个元素可以重复用进入下一层时传的是i而不是i1。def combinationSum(candidates, target): res [] candidates.sort() def backtrack(start, remaining, path): if remaining 0: res.append(path[:]) return for i in range(start, len(candidates)): if candidates[i] remaining: break path.append(candidates[i]) backtrack(i, remaining - candidates[i], path) path.pop() backtrack(0, target, []) return res排序后的if candidates[i] remaining: break是个很好的剪枝。因为数组已经升序当前数如果已经大于剩余目标后面的数一定更大没有再循环的必要。这个剪枝不要写成continue因为一旦排序过后面所有数都大于等于当前数直接退出循环就够了。3.2 全排列used 数组和 startIndex 到底选哪个全排列这题输入一组不含重复数字的数组返回所有可能的排列。如果你刚刷完组合总和很容易惯性思维地往递归里传入一个startIndex然后发现结果只能生成固定顺序的组合根本生成不出所有排列。原因很简单排列允许同一数字出现在不同位置所以每一层递归都必须能重新选择之前已经选过的数字。这时候需要的是used数组标记哪个数字已经被当前路径用过了。def permute(nums): used [False] * len(nums) res [] def backtrack(path): if len(path) len(nums): res.append(path[:]) return for i in range(len(nums)): if used[i]: continue used[i] True path.append(nums[i]) backtrack(path) path.pop() used[i] False backtrack([]) return resused[i]的回归必须在递归后将状态恢复否则下一轮分支会错误地认为某个数字已经被使用。这个“状态恢复”动作是所有回溯题里最容易遗漏的。很多人写递归时只记得path.pop()却忘了恢复used结果整个分支树全部乱掉。这是三道回溯题之后我的核心体会组合类问题用startIndex排列类问题用used数组。判断的标准很简单就看“当前这一层能不能重新选择之前已经选过的元素”。如果能用 used如果不能用 startIndex。这个判断想明白了你以后遇到任何回溯题起码不会被卡在入口。4. 下一个排列和旋转图像“规律题”不能死记要自己推一遍4.1 下一个排列从右往左找是唯一的记忆锚点下一排列这题输入一个整数数组把数组重新排列成“按字典序的下一个更大的排列”如果不存在就排成最小的那个。经典例子是[1,2,3]-[1,3,2][3,2,1]-[1,2,3]。这道题网上有无数个口诀版本“从右往左找升序对”“找第一个比它大的数”“交换”“反转”。但我敢说只背口诀的人一周后基本全忘。真正要理解的是字典序排列是一个树状结构某个排列的“下一个”在算法上等价于找到能让变大幅度最小的那次调整。推一次过程你就明白了拿[1,5,8,4,7,6,5,3,1]举例从右往左找到第一个相邻升序对(4,7)也就是nums[i]4这个位置就是需要调整的地方再从右往左找第一个大于4的数是5交换4和5交换后i右边的后缀是降序排列的反转后变成升序这样刚好是“最小的下一个排列”。这个算法的最妙之处在于情况 2 和情况 3 本质上都是“维护一个从右往左递减的序列”找升序对的过程就是在找这个递减序列的起点。只要你能亲手用纸笔推一遍[1,5,8,4,7,6,5,3,1]这道题就不再是玄学。4.2 旋转图像转置加翻转比坐标公式好记一百倍旋转图像这题给一个 n×n 矩阵要求顺时针旋转 90 度而且必须原地修改。最直接的做法是把矩阵先转置再左右镜像两步操作后就是顺时针旋转 90 度的效果。以[[1,2,3],[4,5,6],[7,8,9]]为例转置后变成[[1,4,7],[2,5,8],[3,6,9]]再对每一行做左右翻转变成[[7,4,1],[8,5,2],[9,6,3]]。这个结果你拿支笔验证一下就是原矩阵顺时针旋转 90 度。def rotate(matrix): n len(matrix) for i in range(n): for j in range(i 1, n): matrix[i][j], matrix[j][i] matrix[j][i], matrix[i][j] for row in matrix: row.reverse()这个解法的价值在于它不需要硬记(i,j) - (j, n-1-i)这样的坐标变换公式。面试时你说“先转置再翻转”大多数人能立刻跟上思路如果直接背公式很容易在边界条件上翻车而且面试官追问为什么时也容易露怯。这两道题放在一起是因为它们都有一个共同特点解法其实是“观察出规律”不是“设计出算法”。下一个排列是在观察字典序结构旋转图像是在观察矩阵坐标变换。面对这类题最佳策略就是拿小例子亲自手推推三遍你就会形成自己的肌肉记忆。5. 哈希表不只是缓存字母异位词分组和最长连续序列5.1 字母异位词分组排序做键还是计数做键字母异位词分组给一组字符串把字母异位词分到同一组。比如eat、tea、ate是一组因为它们字母出现次数完全相同。最容易想到的方案是把每个字符串的字符排序后作为哈希表的键。eat排序后是aettea排序后也是aet它们自然归到同一个 key 下面。这个方案简单有效面试时完全够用。如果字符串特别长或者面试官要求优化可以改成“统计每个字符出现的次数把计数结果作为键”。比如eat的计数序列是a1e1t1tea的计数序列也是a1e1t1同样能归组。这个方案的优点是不需要排序时间消耗更低缺点是键的构造更麻烦而且如果实现时直接拼接“字符数量”遇到多位数数量时要小心歧义。这一题最值得提炼的是“哈希的键是可以自定义的”。很多刷题的人只会用“值作为键”遇到复杂需求就想不到怎么自定义一个键来聚合信息。异位词组和下面这道最长连续序列都是在逼你去想“什么键最能代表这类对象的本质特征”。5.2 最长连续序列用集合去重然后只从起点开始找最长连续序列给一个无序数组[100,4,200,1,3,2]要求找出数字连续的最长序列长度答案是[1,2,3,4]长度为 4。时间复杂度要求 O(n)。看到无序数组很多人第一反应是排序但排序复杂度是 O(n log n)不满足要求。正确的解法是用哈希集合存储所有数字然后遍历数组里的每个数字只有当num - 1不在集合中时才以num为起点向后扩展统计长度。为什么一定要判断num - 1不在集合中因为num如果是某个连续序列的中间元素从它开始往后数统计出的长度一定不是这个序列的最大长度。比如数组里有2,3,4从 3 开始数只会得到 3而从 2 开始数会得到 3。通过“只从起点开始”能保证每个连续序列只被统计一次所以整体复杂度可以做到 O(n)。这题我一开始很容易写成两层循环先判断起点再用 while 往后遍历。后来发现内层 while 虽然看起来是嵌套但因为每个数字最多只会被遍历一次所以均摊复杂度仍然是 O(n)。这个“均摊”的复杂度分析也是面试官非常喜欢追问的点。6. 合并K个升序链表面试时两种方案都能拿出来6.1 优先队列方案每次取当前最小的节点合并K个升序链表输入是一个链表数组每条链表都已经升序要求合并成一条升序链表。最直觉的做法是每次从 K 个链表的表头里找最小的节点取出来接到结果链表末尾然后移动对应链表的指针。如果用暴力线性查找最小值总体复杂度是 O(NK)N 是所有节点总数K 是链表数量。当 K 很大时这个复杂度不好看。优先队列能把“找最小值”降到 O(log K)整体复杂度变成 O(N log K)。思路是把每条链表的当前表头放入一个小根堆然后循环弹出堆顶节点接入结果链表如果这个节点还有下一个节点就继续把下一个节点压入堆中。import heapq def mergeKLists(lists): heap [] for i, node in enumerate(lists): if node: heapq.heappush(heap, (node.val, i, node)) dummy ListNode(0) cur dummy while heap: val, i, node heapq.heappop(heap) cur.next node cur cur.next if node.next: heapq.heappush(heap, (node.val, i, node.next)) return dummy.next这里有一个小坑Python 的heapq在比较元组时如果第一个元素val相同会继续比较第二个元素。如果第二个元素传的是节点对象而节点对象没有实现比较方法会直接报错。所以我在元组里加了一个i作为索引保证即使 val 相同元组也能正常比较。这个细节不处理LeetCode 上很容易踩到 TypeError。6.2 分治方案像归并排序一样两两合并除了优先队列这题还能用分治。把 K 条链表看作数组每次对半拆分拆到只剩一条然后两两递归合并这个过程很像归并排序。分治方案的复杂度同样是 O(N log K)但没有堆结构代码反而更接近普通链表合并题。面试时如果能同时说出优先队列和分治两种方案说明你不是只会背一种模板而是理解两种算法的核心差异一个是用“动态取最小值”的思想一个是用“分而治之”的思想。我个人更推荐优先队列方案因为它的直觉更强把 K 条链表看成 K 条流水线每次从流水线出口取一个最小件。面试讲起来也顺畅。7. 刷了三遍之后我把这些坑列成了清单7.1 回溯忘记恢复状态这是错误率最高的点前十题大多不需要“撤销”这步操作所以很多人在 11-20 会遇到一个奇怪现象单独看递归逻辑没问题但结果全是重复的。问题基本出在忘记path.pop()或者忘记恢复used[i]。写回溯递归时我现在的习惯是先写“进入递归”再立刻写“撤销代码”让这两行相邻。这样如果path.pop()被遗漏至少会显得非常违和。括号生成、电话号码、组合总和、全排列四道题每道都值得用这个顺序写一遍。7.2 四数之和里最容易漏的是“排序后跳过重复”和“三个指针同时移动”四数之和这题本质是在三数之和外面再套一层循环。外层固定第一个数nums[i]内层固定第二个数nums[j]然后双指针找剩下两个数。我第二次刷四数之和时在双指针找到一组结果后只移动了左指针忘了同时移动右指针结果下一轮循环里左右指针指向同一个位置直接死循环。更准确的写法是找到一组满足条件的组合后左指针向右移动右指针向左移动然后分别跳过重复值再继续下一轮判断。这类双指针边界问题最权威的验证方式就是拿一个重复元素较多的数组比如[1,1,2,2,-1,-1,0,0]手跑一遍。7.3 空输入边界不是所有题目都想当然地返回相同结果合并K个升序链表里如果传入的lists是空列表或者里面全是空链表优先队列初始就是空的循环不会执行返回的是空链表这个逻辑没问题。电话号码的字母组合里如果digits是空字符串返回[]而不是[]。这个我就在面试模拟中被问过一次当时第一反应是返回[]但题意是“没有任何字母可以组合”所以应该返回空列表。下一个排列里如果是纯降序数组比如[3,2,1]你的查找升序对循环会从右往左一直找到i 0都没找到此时要把整个数组反转成最小的排列。这个反转条件决定了代码最后一段是if i 0包住交换逻辑还是无脑在循环外直接反转。写错会造成越界尤其要注意。7.4 复杂度分析不要只背结论要会解释“均摊”和“剪枝”面试官很容易追问最长连续序列为什么是 O(n)。你只说“因为用了哈希集合”是不够的要说清楚每个数字进入集合后只有作为起点时才会触发 while 循环而 while 循环里的每个数字在整个算法中只会被访问一次所以总共 O(n)。回溯题的复杂度分析更灵活。括号生成的最终状态数量是卡特兰数所以结果集大小就是 O(4^n / sqrt(n))。但实际递归中每一层的分支数不是固定的受剪枝影响很大。回答复杂度时我的建议是先把“最坏情况下”的状态数说清楚再补充剪枝让实际运行远小于最坏界。面试官不喜欢只会背死公式的人。7.5 二刷三刷的正确节奏这批题我第一次刷完花了大约四天很多题目只是“抄会了”不是“想会了”。第二次重新刷时我做了一个调整把括号生成、电话号码字母组合、组合总和、全排列放到同一个晚上连续刷。这个做法的效果立竿见影前三题还在回忆递归结构到第四题时backtrack函数几乎是条件反射般写出来的。所以我想推荐给同样在刷 hot100 的朋友不要按题号顺序平铺推进尝试按“主题批次”批量刷。11-20 里的四个回溯题完全可以当成一个强化训练单元两个哈希题放一起下一个排列和旋转图像放一起。这样同一批知识被你连续刺激四遍记忆留存率远高于每天一道的刷法。最后再分享一个小习惯。我刷完一批题后会把做错的题抄进一个“错误模式”清单不看题目只看原因。比如“组合总和忘记传 startIndex”“全排列忘记恢复 used”“旋转图像把 i 和 j 范围写成 0..n”。这个清单比任何题解都值钱因为它是你真实的思维盲区。等你积累到第二轮你会发现真正反复踩的坑其实就那么几个提前规避掉面试时的调试效率会高一大截。