
回溯算法写多了你会发现它其实就那几板斧画决策树、定参数、想清楚什么时候递归、什么时候停止。基础篇已经把组合问题和切割问题扫了一遍这一篇继续往后走重点啃两个高频题组合总和与复原IP地址。前者代表“同一元素可重复使用”的回溯后者代表“字符串切割”的回溯两个题刷明白回溯的参数设计和剪枝思路基本就能过关了。我把这两个题放到同一篇里讲是因为它们恰好踩中了回溯算法最容易出问题的两个点一是元素可重复使用时如何控制 startIndex二是递归过程中如何判断局部结果是否合法。很多同学单独刷组合总和觉得“也就那样”单独刷复原IP地址觉得“边界条件好多”但放到一起对比过后才能真正理解回溯里那套“状态恢复”的穷举机制并不是针对某个题型的特殊技巧而是同一个模型在不同约束下的自然延伸。这篇文章默认你已经知道递归和基础回溯是怎么回事不会从头硬解释什么是递归。我会从决策树的角度把模板再捋一遍然后分别拆解两个题从暴力思路到剪枝优化的完整推导最后把高频踩坑点和调试心得整理成速查表。无论你现在是刚学完回溯准备刷题还是刷过一遍想回头看细节这篇都值得你沉下心过一遍。1. 回溯算法的核心套路先把共性讲透1.1 回溯就是穷举一棵决策树很多人觉得回溯难是因为把回溯理解成了某个固定 API 或者某种神奇魔法。实际上回溯干的事情非常简单在一个多步决策问题中每一步都有若干个选项你先选一个走下去发现走不通就退回一步选另一个直到把所有可能路径全部走完。用生活里的事类比一下就是标准的“迷宫出口”问题。你在每个路口面临前进方向的选择每个方向都会通向新的路口和新的选择。回溯跟深度优先搜索DFS是同构的但会更强调“回到上一步时要把现场恢复干净”。从实现层面看回溯的过程天然可以用一棵树来描述每个节点代表一次决策每条边代表一个具体选择。对组合类问题来说这棵树的叶子节点往往是某些满足条件的组合对切割类问题来说叶子节点则是一组切割点形成的分段结果。理解这棵树是一切的基础。你会发现组合总和和复原IP地址虽然表面上一个在算数字、一个在切字符串但代码骨架几乎一模一样都是用一个 path 记录当前路径递归进入下一层递归返回后把 path 弹出来恢复原状。1.2 一个能套用大部分回溯题的模板先把我自己一直在用的模板贴出来后面两个题都要按照这个骨架来套。def backtrack(路径, 选择列表, 起始位置): if 满足终止条件: 结果.append(路径[:]) return for 选择 in 从起始位置开始的可选范围: if 不满足剪枝条件: continue # 或者 break 路径.append(选择) backtrack(路径, 新的选择列表, 新的起始位置) 路径.pop() # 撤销选择恢复状态这个模板有几个容易写错的地方我先强调三个第一路径为什么要用 path[:] 拷贝再塞进结果列表因为 path 在后续递归里还会被修改如果你直接 append(path)最后结果里存的是同一个列表的引用回溯恢复状态时会把已经保存的结果也改掉。这个 Bug 在组合总和、复原IP地址这类需要多次 append 结果的题目里几乎必踩一次。第二终止条件的判断位置决定了你是在收集叶子节点还是在收集中间节点。组合总和是到 target 才收集所以终止条件写在最上面复原IP地址是先判断已经分了几段段数达到 4 就做最终判断本质是在叶子节点做合法性检查。第三递归能不能剪枝取决于当前状态是否能预测后续状态。组合总和排序后可以预测“后面的数更大加起来一定超”复原IP地址可以根据剩余字符串长度预测“剩下这些字符无论如何也凑不出 4 段”这都属于利用已知信息提前终止而不是等递归到终点再发现不合法。有了这个模板打底下面两个题就只是往模板里填具体约束的事。2. 组合总和可重复选择问题的关键处理2.1 题目定位与暴搜思路组合总和的题目描述是这样给你一个无重复元素的整数数组candidates和一个目标整数target找出candidates中可以使数字和为目标数target的所有不同组合candidates中的同一个数字可以无限制重复被选取。第一次看到这个题直觉反应就是三层循环套无穷层因为每个数字可以被选任意多次嵌套层数根本不固定。这时候就能体会到回溯的价值它可以用递归天然地模拟“不知道多少层”的循环。先别管任何优化暴力回溯的思路很直接每一步从数组中选一个数加到当前累计和total上。当total target把当前路径记入结果。当total target说明这条分支没戏了回退。选择时从某个起始位置开始避免组合出现顺序不同的重复项。这里马上出现一个经典问题起始位置到底怎么定如果不加限制每一步都从数组索引 0 开始那对于target 8, candidates [2, 3, 5]来说(2, 3, 3)和(3, 2, 3)都会被搜到。题目要求的是“不同组合”组合里元素的顺序没有意义所以这种重复必须想办法去掉。你可能会想那先排序不就行了排序确实能解决一部分顺序问题但如果不控制起始位置排序后依然可能搜出(2, 2, 2, 2)之后再搜出(2, 2, 2, 2)的某个内部排列吗不会。真正限制重复的手段不是排序而是在递归时把下一层的搜索起点设为当前选择的索引位置使后面的选择永远不回头往前找。2.2 参数设计为什么 startIndex 不 1这是组合总和最容易和组合总和II搞混的地方。在“组合”基础题里每个元素只能使用一次所以递归调用要写成backtrack(i 1, ...)下一层从当前元素的下一个位置开始选。而在组合总和里每个元素可以无限重复所以递归调用要写成backtrack(i, ...)下一层仍然可以从当前元素开始选这样才能实现“同一个数连续使用多次”。不要小看这个i和i 1的差别。我见过不少同学把组合总和的递归改成i 1结果[2]这种用例能过一旦target较大需要重复选同一个数时比如candidates [2], target 8正确答案应该是[[2, 2, 2, 2]]用i 1的话第二层直接超出索引范围什么结果都搜不出来。反过来组合总和II每个数只能用一次如果写成i就会把同一个元素用多次导致结果大量重复或者干脆超出候选集限制。判断你自己写没写对不需要背结论只需要想一层逻辑如果我选了这个数下一层还能不能选同一个数能选就传i不能就传i 1。关于排序其实不排序回溯也能拿到正确答案因为startIndex已经保证了组合不重复。但排序有个巨大好处是可以配合后续剪枝直接提前终止。无序数组里你遇到一个数加起来超了 target你没法保证后面的数不会更小所以只能 continue 继续遍历而有序数组里一旦某个数超了后面的数只会更大可以直接 break。这个差距在数据规模大的时候非常明显。2.3 剪枝的核心排序 break先看我常用的实现版本class Solution: def combinationSum(self, candidates: List[int], target: int) - List[List[int]]: candidates.sort() result [] path [] def backtrack(startIndex: int, total: int): if total target: result.append(path[:]) return for i in range(startIndex, len(candidates)): # 剪枝当前数比剩余空间还大后面更大直接断掉 if total candidates[i] target: break path.append(candidates[i]) backtrack(i, total candidates[i]) path.pop() backtrack(0, 0) return result这段代码其实已经把回溯题目的“暴力性”藏得很深了。不剪枝的做法是在total target时 return剪枝的做法是在进入循环之前就判断当前选择是否合理。我在实际刷题的过程中喜欢把剪枝条件放在循环内的第一行也可以放在循环外面用 if 判断之后 break。两者的效率相同但break 有一个前提数组必须已经有序。如果题目一开始没说明有序或者你忘了 sort那这里可能就会漏解明明后面还有更小的数能补上总和你却在当前这个大数这里直接停了。所以组合总和的最优解是明确的排序让数值从小到大排列。循环内total candidates[i] target时直接 break。递归传i允许当前元素重复选。终止条件写total target并把 path 拷贝进结果。这套组合拳打下来复杂度也从指数级垃圾遍历变成了带剪枝的指数遍历实际运行速度会有肉眼可见的提升。3. 复原IP地址切割问题的细节地狱3.1 题目定位与合法性判断复原IP地址题目描述给定一个只包含数字的字符串s复原它并返回所有可能的 IP 地址格式。有效 IP 地址恰好由四个整数组成每个整数位于 0 到 255 之间且不能含有前导零。这个题翻译成人话就是在s中插入三个点把字符串切成四段每一段都满足0 段值 255并且如果段长度大于 1不能以0开头。为什么说它是切割问题因为本质上是选择三个分割点。字符串长度为n第一段可以从索引 0 开始取长度为 1、2 或 3 的子串第二段接在第一段之后继续选长度以此类推。这就是一个典型的决策树每层决定当前段取多长共四层。我们首先要解决的是“什么才算合法”。很多同学在网上看题解发现别人写了个is_valid函数于是自己也想写一个但容易漏条件。我把它拆成三个必须同时满足的条件子串非空。子串不含前导零长度大于 1 时第一位不能是0。子串转成数字后必须在 0 到 255 之间。这三个条件之间是有联系的。比如01长度为 2 且第一位是 0就算转成数字是 1也不能作为 IP 段。比如256长度是 3 但数字大于 255同样不行。比如空字符串转数字会报错必须提前过滤。我建议把合法性判断独立成一个函数不要写在大回溯里。因为回溯过程中你会在多个地方调用它抽出来能减少重复代码也让主流程更清晰。3.2 切割回溯的实现与剩余长度剪枝切割回溯跟组合回溯在形状上有个显著区别组合问题是在“元素集合”里挑选子集切割问题是在“字符串位置”上做划分。所以组合的 path 记录的是选中的数字切割的 path 记录的是切出来的字符串片段。用“段数”来当终止条件是自然的最多切 4 段切满 4 段并且字符串正好用完就收集结果切满 4 段但还剩字符就说明这条路不可能成功直接回退。直接看代码class Solution: def restoreIpAddresses(self, s: str) - List[str]: if len(s) 4 or len(s) 12: return [] result [] path [] def is_valid(segment: str) - bool: if not segment: return False if len(segment) 1 and segment[0] 0: return False return int(segment) 255 def backtrack(start: int, segments: int): # 已经切了4段 if segments 4: if start len(s): result.append(..join(path)) return # 剩余字符数不够或太多 remaining len(s) - start if remaining (4 - segments): return if remaining (4 - segments) * 3: return for length in range(1, 4): if start length len(s): break segment s[start:start length] if is_valid(segment): path.append(segment) backtrack(start length, segments 1) path.pop() backtrack(0, 0) return result这段代码里的两个剪枝条件非常关键也是我建议每个刷这题的人都要想明白的点。第一个剪枝remaining 4 - segments。意思是剩下的字符连最基本的“每段至少一个字符”都满足不了那这条路径不可能凑满 4 段直接返回。第二个剪枝remaining (4 - segments) * 3。意思是剩下的字符太多了就算每段都取最长的 3 位也无法在剩余的段数内消耗完直接返回。这两个剪枝组合起来实际上是在说当前状态还有没有可能通过合法的路径走到终点如果能提前判断“不可能”就别浪费递归栈去试了。你可能会问这个剪枝不写行不行行不写也能 AC反正每段只取 1-3 位递归层数很少。但写了之后很多无效分支在第一层就被砍掉了边界情况处理起来也更安全。我个人的习惯是能证明不可能的分支一律剪掉这是回溯题优化的第一优先级。另外还有一个细节segments 4时我用的不是pointNum 3的写法。两种写法等价但“segments 4”配合start len(s)更直观有效避免了切割到最后一段还要剩几个字符的尴尬判断。4. 把两个题放在一起看异同对比与总结4.1 一张表说清参数和剪枝差异单独刷题的时候其实很难看清全貌两个题都写完再对照一下参数设计很多之前模糊的地方会瞬间清晰。我把它们放在一张表里对比对比维度组合总和复原IP地址问题本质从数组中选数字求和在字符串中切出四个合法字段可重复选择是递归传i否每段是互不重叠的子串递归传start length路径单位数字组合字符串片段终止条件total target切满 4 段且消费完字符串剪枝方式排序后total candidates[i] target直接 break根据剩余字符串长度判断是否可能凑满段数状态恢复path.pop()path.pop()主要边界组合去重、元素无限重复前导零、0-255 范围、剩余长度这张表的信息量其实很大。你看“路径单位”那一行组合总和里 path 存的是数最终结果就是这些数的组合复原IP地址里 path 存的是字符串片段最终结果要把它们用点连接起来。两边的数据结构不同但状态恢复手段完全一样都是 pop。再看“剪枝方式”组合总和的剪枝是靠数值排序带来的单调性复原IP地址的剪枝是靠位置关系带来的长度约束。这告诉了我们一个道理剪枝本质上是在利用问题的已知规律提前排除不可能分支每一种剪枝都对应一种题目特有的性质。4.2 从这两个题得出的回溯方法论刷完这两个题有两个方法论层面的东西我想重点说。第一回溯题的第一步不是写代码是画数。拿组合总和来说如果target 8, candidates [2, 3, 5]你画一下以 2 开头的分支2 - 2 - 2 - 2 是合法答案2 - 5 也合法5 - 2 这种分支因为startIndex的限制根本不会出现。画完这棵树你就知道递归的起点传递是i还是i 1了。复原IP地址同理你画一下字符串25525511135的切割树看看第一段取2、25、255时的分支情况马上就能理解为什么递归函数需要传当前位置和段数。第二判断终止条件和剪枝条件时先问自己“我现在在第几层”。组合总和没有固定层数所以终止条件是“总和等于 target”复原IP地址有固定层数所以终止条件是“段数已经达到 4”。这个差异是题目形态决定的不是因为模板不同。能快速判断出“这个题是可变层数还是固定层数”就能少走很多弯路。从这里再往后延伸就是很多变体题的解题钥匙组合总和II去重要看层内重复分割回文串要看子串是否回文N皇后要看对角线是否冲突。它们全部都是“在回溯模板上增加不同类型的约束条件”而已。5. 刷题过程中踩过的坑与排查技巧5.1 常见错误速查表这两个题我刷了很多遍身边同学也常在同样的地方翻车。整理一个速查表建议直接收藏错误现象可能原因解决办法组合总和结果里出现[2,2,2,2]没有但出现[2,3,3]和[3,3,2]重复递归时没有传startIndex或者传成了 0每层循环从startIndex开始递归传i组合总和结果里每个数只用了一次漏解递归传了i 1导致不能重复选同一个数根据“下一层能不能继续选当前数”决定传i还是i 1结果列表里的组合被后续修改掉了直接result.append(path)没有拷贝使用path[:]或copy.copy(path)组合总和超时严重没做剪枝total target后才退出排序后total candidates[i] target直接 break复原IP地址漏掉0这种合法段用int(segment) 0判断条件不合理分段判断前导零然后再转数字范围复原IP地址把255也算成非法int(segment) 255写成了 255注意边界255 合法复原IP地址把01算成合法没判断前导零长度大于 1 且第一位为 0 直接非法复原IP地址切到一半就 index out of range切子串时start length越界循环里先判断if start length len(s): break复原IP地址结果集出现1.1.1.1.这种结尾带点join 时把点加到了每段后面最后..join(path)统一拼点这张表不用刻意背刷题遇到问题回来查阅一下就够用了。不过我想强调其中一条因为它是我见过最多人犯的错也是面试里最容易暴雷的result.append(path)不拷贝列表。很多同学在写回溯模板时都会在收集结果那里直接写result.append(path)。乍一看没问题因为 print 出来是符合预期的。但当你回溯进入更深层然后返回时path 里面的元素会一个个 pop 掉最后你存进 result 的所有引用都指向同一个已经被清空的列表。最终的输出就会是[[], [], []]这种诡异结果。这个坑不是只有组合总才有。复原IP地址、分割回文串、全排列、子集凡是回溯题都用同一个套路所以务必养成固定习惯收集结果一律path[:]。5.2 三个调试心得分享最后分享三个我实际操作下来非常有效的调试习惯比单纯看题解要有用得多。第一个心得打印“进入递归”和“退出递归”的日志。遇到回溯题目逻辑绕不清的时候我会写一个临时函数打印当前层数、当前 path、当前 startIndex 或剩余字符串。以复原IP地址为例在backtrack的第一行打印print(fstart{start}, segments{segments}, path{path})这样递归树会清清楚楚显示在哪一层、在哪个起点分出哪些分支。排查那种“为什么这个组合没有答案”的问题一眼就能看出是分支条件写错了还是终止条件漏了。这个方法尤其适合“两个题一起刷”的场景因为对比日志能很快发现组合总和里递归传i会重复选择自己复原IP地址里递归传start length会跳着切字符串原因非常直观。第二个心得小样本暴力验证。组合总和可以用target 8, candidates [2, 3, 5]这种小数据手跑一遍复原IP地址可以用25525511135这种标准例子跑一遍。跑通之后把结果和题解对比重点看数量级。如果结果数量少了往往是剪枝太狠如果多了往往是合法性判断太松。比如复原IP地址把25525511135跑完应该是 2 个结果255.255.11.135和255.255.111.35如果你跑出来 3 个以上那说明011这种非法段也被你放进去了。第三个心得把剪枝单独写成一行注释。很多题解会把剪枝条件藏在循环里初学者看半天也不知道这行为什么存在。我在自己代码里有一个习惯任何剪枝条件都要在旁边注释“为什么这样才能剪”比如# 排序之后当前总和已经超过 target后面的数只会更大没必要继续遍历 if total candidates[i] target: break这个习惯不仅帮你巩固对题目的理解而且在面试写代码时能清晰地跟面试官讲思路。面试官最喜欢看到的一个信号就是候选人不只是在背模板还能解释每一步剪枝背后的数学或逻辑依据。最后说一点我个人刷题的体会这两个题放在一起刷效率远远高于分开刷。组合总和让你理解“可重复选择时用 i 不用 i1”复原IP地址让你理解“切割时段的终点就是下一段的起点”。把它们连成一条线你脑子里就自动生成了回溯算法的地图组合类、切割类、子集类、排列类分别对应什么约束、什么参数、什么剪枝。我自己在刷完这两个题之后再去补组合总和II、分割回文串、全排列这些变体时感觉就像在看同一个模板换皮。如果你也觉得回溯题乱糟糟的不妨按这个顺序先老老实实画决策树再写最朴素的回溯最后再想剪枝。整套流程走完再回头看这两个题你会觉得回溯就是这个世界上逻辑最清晰的穷举方式没有之一。