回溯算法入门:从递归树到剪枝优化实战指南

发布时间:2026/10/13 5:13:50
回溯算法入门:从递归树到剪枝优化实战指南 我大概是从前年才开始系统性刷算法的说实话前二十多天每天保持两到三个新知识点的节奏推进状态一直还算不错。但真正让我开始感觉有点吃力、甚至一度想绕过去的是到回溯算法这一天。为什么因为回溯这个玩意它不像二分法、双指针那样有个很明确的套路感它更像是递归的升维版本——一旦没想清楚递归树的长相写出来的代码就是一团浆糊能跑通也是瞎猫碰上死耗子。如果你也正在啃算法或者刷题刷到组合、排列、子集这一堆问题开始头大那这篇文章应该能帮你把回溯这根线彻底捋顺。我会从本质逻辑、模板框架、实操场景、剪枝细节再到排错经验完整走一遍。1. 回溯算法的本质与使用场景1.1 回溯本质上是借用了树形结构的深度优先搜索很多人第一次看回溯代码会觉得神乎其神明明没有明显的循环嵌套却能一次性把组合、全排列的问题全部列出来。但你把它的递归调用过程展开成图看就一目了然——每一次递归调用其实就是沿着树的某一条分支往下走了一层。整个回溯过程就是一棵决策树上的深度优先遍历。你手上有若干个可以选择的元素每一步选择一个选完以后剩下还有若干个可选。这个过程不断重复直到满足终止条件——比如组合里已经选了k个数。那这样走下来每一条从根到叶的路径就是一组结果。这就是为什么回溯算法基本都能用一棵多叉树来模拟根节点代表初始状态什么都还没选每个中间节点代表做了一个选择后当前的状态叶子节点代表已经到达终止条件的状态。我们做DFS遍历遍历到叶子就把路径加入结果集遍历完所有叶子所有可能的解就都拿出来了。我在实际刷题时经常跟朋友说一句话如果你能画出一棵递归树这个回溯题目就解决了百分之八十。画不出树硬写代码一定翻车。这是第一课。1.2 什么类型的问题适合用回溯来解决回溯的典型问题特征可以用一个词概括穷举所有可能。题面里出现所有组合所有排列所有子集所有方案是否存在一条路径这些关键词而且数据范围不大——一般n在10到20左右个别时候能到30——那大概率就是要用回溯。举几个最常见的类型组合类问题比如给定n个不重复数字返回所有长度为k的组合排列类问题给定一组数字返回所有全排列子集类问题给定一组数字返回所有子集搜索类问题比如棋盘上找路径、迷宫求解、单词搜索棋盘放置类问题典型的N皇后、数独求解、括号生成。这几个问题看起来各不相同但底层代码结构高度统一都是选择-递归-撤销的循环。所以我说回溯是套路的前提是你真正理解了那棵树。如果你不知道一个题目该不该用回溯还有一种判断方式题目要求的所有答案数量是不是全排列级别的爆炸量。如果是而且又没有明显的高效数学模型可以直接套公式那基本就是回溯的活。1.3 回溯的时间复杂度跟剪枝的关系说实话回溯本身效率并不高最坏情况下它是在做穷举时间复杂度是指数级的。比如子集问题的节点总数是2^n排列问题是n!。很多面试官喜欢问这个问题你这回溯解法复杂度是多少你要能答得上来而且关键是能说出为什么即便如此我们还是用回溯。因为这类问题往往在指数级搜索空间里剪枝之后实际访问的节点数远小于理论上限。而且如果题目本身就没有多项式解法那回溯就是一种逻辑上最自然、实现上最稳妥的暴力解。工程里我们通常建议先把可行的回溯版本跑通再考虑用状态压缩、记忆化等方式优化。这里也提醒一句回溯算法的复杂度分析和递归有点不一样不是你看到一个for循环就认为是O(n)而是要把递归树每个节点的处理时间加起来算。比如组合问题递归树的节点数接近C(n, k)这个组合数每个节点要做一次结果拷贝或路径维护所以整体通常是O(C(n, k) × k)。2. 回溯算法的核心模板与参数设计思路2.1 回溯三要素路径、选择列表、终止条件我刷了这么多回溯题之后发现所有问题都可以用这个框架往里套。所有回溯代码都逃不开三个核心变量路径通常叫path记录已经做出的选择也就是从根到当前节点的路径选择列表通常由参数start或used数组来表达当前节点还能选哪些元素终止条件到达叶子节点时路径长度已经满足要求或者所有元素都已处理完。理解了这三个东西对应到树上的位置你就明白为什么回溯函数总是长那个样子。路径对应递归函数里维护的那个数组变量选择列表对应for循环能遍历到的范围终止条件对应for循环开始之前的那个if判断。我见过有同学写回溯的时候非要在函数里再造一个全新的列表作为路径每层递归拷贝一遍代码写得极其痛苦。其实路径就全局维护一个进入递归前加入递归结束后移除这个思路要刻在脑子里。2.2 一套可以重复使用的回溯代码模板我直接把平时用得最顺手的模板放出来建议你存下来反复看几乎所有回溯题都是它的变体def backtrack(path, start): # 终止条件满足要求加入结果集 if len(path) k: result.append(path[:]) return # 遍历选择列表 for i in range(start, n): # 剪枝条件可选 if 需要剪枝: continue # 做选择 path.append(nums[i]) # 递归下一层 backtrack(path, i 1) # 撤销选择 path.pop()这里两个最容易写错的地方一个是path[:]而不是path另一个是递归传参是i 1还是i。path[:]是因为如果不拷贝一份后面pop操作会把已经加入结果集的内容改掉这是Python可变对象的经典坑。至于传参是i 1还是i取决于题目允不允许多次使用同一个元素。组合问题每个元素只能用一次所以传i 1如果是那些允许重复使用元素的问题比如某个组合总和的变体就需要传i让下一层还能选同一个元素。我建议你把这个模板背下来然后每做一道题先对照模板去分析差异在哪个环节而不是拿到题目就开始凭空写递归。这是适配合法、最快建立手感的方式。2.3 为什么必须撤销选择撤销这一步是回溯的灵魂。很多初学者第一次写完回溯会发现结果集里全是同一个数组的重复拷贝或者根本停不下来多半就是撤销环节出了问题。我习惯用一个比喻来理解撤销的必要性。你把递归过程想象成走迷宫你手里有一根绳子每走一个岔路口就做一个标记。如果你走到死胡同你要沿着绳子回到上一个路口重新选择另一条路。如果不把标记撤掉下一次从同一个路口出发时你的选择列表就会带着上一次的错误选择整个搜索空间就被污染了。代码里对应的就是path.pop()。进入递归前我们把元素加入path递归返回后我们要把path恢复到进入这个递归前的状态这样才能正确进入下一次for循环。一句话回溯里每个递归分支都是独立的探索路径路径恢复不是可有可无而是保证正确性的必要操作。3. 典型实操场景与解题拆解3.1 组合问题从组合总和看start参数的妙用先来看最经典的一类——组合问题。以从1到n中选k个数的所有组合为例。很多人第一眼看到这个题觉得很简单无外乎两层循环套一下但k是变长的时候你就必须用递归。注意这里的关键差异就在于start参数。组合问题强调无序性{1, 2}和{2, 1}是同一个组合所以我们需要限制每层只能从当前元素的下一个位置开始选避免回头选已经选过的元素这才保证了不会出现重复的组合。以组合总和III这个问题为例找出所有相加之和为n的k个数的组合每个数字只能使用一次。我当时的写法就是套上面的模板加一个当前sum参数不断累加然后在递归一开始判断如果sum超过了目标值就立刻return。这种提前返回其实也是一种剪枝只不过剪得比较自然。组合类问题基本上只要把start传对再加合适的剪枝条件就能AC。这里我想顺便说一个很多人写组合类题目容易搞混的细节start和i 1到底什么区别。举个具体例子nums [1,2,3,4]第一层选了2后start2表示下一层只能从索引2开始选也就是只能选3、4这样选出来的组合是{2,3}和{2,4}。如果你误把start忘了或者传成了start1那可能会产生{2,2}这种自选情况如果题目又说每个数只能用一次那答案就直接错了。3.2 全排列问题used数组与i从0开始的逻辑全排列跟组合最大的区别在于排列是有顺序的。{1, 2}和{2, 1}是两个不同的结果。这就导致我们不能再依赖start参数做限制因为每一层都可以从头开始选所有还没用过的元素。这个时候就需要一个额外的used数组或者叫visited来标记哪些元素已经在当前路径里被使用过。每一层的for循环从i0开始遍历所有元素如果used[i]是True就跳过否则就选它、标记used、递归、撤销标记。所以我一直说组合问题的核心是start排列问题的核心是used。这两个要分开记忆别混。很多题目看似是排列题但套用组合模板结果答案数量少了或者重复了排查半天发现是这里出了问题。还有一点需要注意如果数组里有重复数字而且题目要求返回不重复的全排列那么除了used数组还需要加一层去重逻辑通常是在同一层for循环里判断当前元素和上一个元素是否相同如果相同且上一个元素还没被使用过就跳过。这个去重逻辑我会在第4节详细展开。3.3 子集问题每个节点都收集结果子集问题和组合、排列有一个微妙的不同组合要的是恰好长度为k的路径子集要的是所有长度的路径。换句话说组合是在叶子节点才收集结果而子集是每到一个节点就把当前路径加入结果集。我见过不少同学写子集问题的时候专门把终止条件写成path长度等于n然后只收集最长的那个结果发现子集数量只有1个一脸懵。实际上子集问题里每个状态都是合法结果包括空集。所以正确的做法其实更简单——在递归函数的一开始就把path加入result不需要终止条件等start超过数组长度时自然返回。或者可以写一个终止条件判断start len(nums)然后return但在此之前先把当前path存一份。这个看起来好像只是收集时机不同但确实是子集类题目跟组合类的根本区别。记住这个点你再看LeetCode上的78题子集、90题子集II就不会再犯错。3.4 棋盘与路径类问题回溯在搜索领域的使用变体棋盘类问题跟前面的组合、排列不太一样它没有显式的选择列表数组而是通过坐标搜索来体现。比如N皇后问题、迷宫寻路、单词搜索。这个方向是回溯的一个大头分支它的特点是搜索空间不是显式的集合而是一个二维网格。以N皇后为例核心思路是按行放置皇后每一层递归处理一行在这行的每个列位置判断是否与之前已经放置的皇后冲突如果不冲突就放置并递归到下一行。冲突判断通常维护三个数组列冲突、主对角线冲突、副对角线冲突。这里主对角线的特征是row - col是固定的副对角线的特征是row col是固定的。这种题目看着难但它的回溯骨架仍然没变按行决策、逐列尝试、递归下一层、撤销放置。我练这种题目最大的感触是别慌先把二维模型映射到模板的三个要素上。路径就是当前已经放置的皇后坐标集合选择列表就是当前行所有安全的列位置终止条件就是已经放了n个皇后。4. 剪枝优化与去重策略实战4.1 排序后剪枝为什么先排序能让剪枝变得容易回溯如果没有任何剪枝那就是纯粹盲目的穷举题目数据稍大一点就直接超时。但剪枝不是靠灵感和运气最常见的套路就是先排序。举个例子组合总和问题里如果所有候选数字都是正数排序之后当某一层累加sum已经超过target那我们就可以直接中止这一层剩余的循环因为后面的元素只会更大。我用一个实际例子解释这个中止循环和continue的区别。假设nums [1,2,3,4,5]target 5当前path已经选了[1,2]sum3下一层从索引2开始选。如果选了3sum6已经超了那么后面选4、5肯定更超。所以此时正确做法是break跳出循环而不是continue。continue只是跳过3还会继续尝试4、5白白浪费计算。这就是排序带来的第一个优势只要判断当前元素就超了后面全都超。另外排序还能让重复元素相邻排列这样在上层去重的时候只需要和上一个元素比较而不需要维护一个哈希集合去记录本层已经用过的值。这也是去重问题里排序用得多的原因。4.2 同一树枝去重与同一层去重的辨析去重是回溯里面最容易让人绕晕的部分。以全排列II为例数组有重复数字但要求返回不重复的全排列。这里的关键是区分两个概念同一树枝上的去重和同一层循环里的去重。我举一个具体的例子nums [1, 1, 2]如果不管重复我们会有6个排列但实际不重复的只有3个。当你画出递归树时你会发现重复答案来自两种情况一种是在一条路径里重复选了同一个值的两个不同位置元素比如第一个1和第二个1另一种是在同一层的两个不同分支里第一个1和第二个1分别开头生成了完全相同的子树。解决办法是先排序让相同元素挨在一起。然后在for循环里写判断if i start and nums[i] nums[i - 1]: continue这个判断的意思是如果当前元素和上一个元素值相同而且上一个元素是同一层中已经处理过的那就跳过当前分支。这个写法对组合去重、子集去重同样适用。关键在于要理解条件里那个start是从哪来的——它正是本层循环的起始索引。有些同学学到这里会纠结used数组和这种判断到底用哪个。其实两个都对used数组的方式更通用尤其是全排列问题里必须用used标记路径元素而nums[i] nums[i - 1]的判断是更轻量的剪枝写法尤其适合组合和子集的去重。我建议你先掌握排序相邻判断再去理解used的方案会顺很多。4.3 剪枝不当引起的结果缺失问题剪枝的目的是减掉那些不可能产生合法结果的分支而不是什么分支都剪。我自己剪出过不少次局部正确但整体漏解的代码那个排查过程真的难受。典型的错误是终止条件写得太早。比如全排列里有些人不小心在path长度等于理想值之前就return了导致结果集全是半成品。还有一种是把剪枝条件写在了for循环外面本应该是在某个选择发生后判断是否超限结果变成了还没选就判断某些合法路径被直接ban掉。我的经验是先把完全没有剪枝的版本跑一遍确认结果正确再往上面加剪枝。这样如果剪完之后答案数量和标准答案对不上那一定是剪枝条件的问题不会跟基础逻辑混在一起。这个习惯帮我省了很多排查时间。5. 常见问题与排查技巧实录5.1 结果集里所有答案都长得一模一样这个问题几乎是每个学回溯的人都会遇到一次。你满心欢喜跑完代码结果result列表里面全是同一个数组的重复拷贝比如组合结果是[[1,2],[1,2],[1,3]]这种仔细看全部都是最后一组元素的模样。原因就是往result里添加的时候没有拷贝。path是同一个数组对象递归回溯过程中它不断被修改。如果path直接append进result那result里的多个元素其实引用的是同一个对象它们的值会跟着最后一次修改走。这就是我在前面强调path[:]的原因。用Python写的时候切片是拷贝如果你用别的语言自己注意深拷贝就行。这个坑我踩过一次之后如今每写一行result.append(path[:])都会感叹一次当初的愚蠢。5.2 递归调用栈溢出或者超时递归深度过大导致栈溢出通常是因为终止条件根本没有写到位。比如全排列问题里如果忘记判断path长度等于n函数就会一层一层往下递归永远到不了头栈很快被打爆。超时则是另外一回事。最典型的原因是剪枝条件缺失或不够强。我建议你一旦发现超时先去画递归树看看哪些节点其实根本不需要访问。如果一棵树有大量节点是必死节点却能一路递归到很深层才被拦截那说明剪枝的位置不对。越早判断越早return节省的计算量是几何级增长的。经验上是这样回溯题目出现的超时十有八九可以通过在递归入口处加一个尽早的判断解决掉而不是在for循环里慢慢continue。先把全局判断前置作为第一优先级。5.3 递归参数传递中的引用污染问题这个问题在写组合总和变体时特别典型。比如要返回所有可行组合的路径有人会在递归中新建列表传入下一层而不是用全局path加pop。理论上这样也可以但如果不小心把同一个列表传给了多个分支后一个分支的修改就会污染前一个分支已经加入result的结果。我的建议是坚持用全局path 进入递归前append 退出递归后pop这一套。原因很简单它把路径的维护逻辑集中在一处撤销可见复用安全。新建列表传入虽然看起来更函数式但一旦出现问题排查成本反而更高。另外有一点容易被忽略如果递归函数里出现了path直接赋值给另一个变量然后修改的情况也要小心那同样可能是浅拷贝的坑。多写一步切片或者copy不丢人。5.4 递归树画法与调试小技巧最后分享一个我调试回溯代码的独门方法。遇到回溯结果不对我从来不在代码里到处print看过程而是先在纸上画一棵小规模的递归树。比如n3的小数组把每一步的选择、路径内容、路径长度都列出来然后对照代码的每一步运行逻辑去走一遍。几乎所有的逻辑错误在走两三层树之后自己就暴露了。还有一种调试技巧是在递归函数顶部print当前path和start、i这类关键参数。小规模数据下print出来的调用序列基本就是树的前序遍历顺序你对照期望中的搜索顺序一眼就能发现哪里多走或少走了一条分支。调完记得删掉print就行。6. 回溯学习的进阶路径与个人建议6.1 从模板到变形我在哪个阶段开始有质变我的体会是回溯学习的第一个门槛是看懂模板第二个门槛是套用模板第三个门槛才是灵活变形。前两个阶段大概需要三到五天每天两三道题。第三个阶段需要更多场景的积累比如括号生成、复原IP地址、分割回文串这类偏字符串的问题它们的共同点是在一个线性序列上做切割决策。你练到一定量的时候会发现组合问题是在一个集合里挑元素排列问题是在一个集合里排顺序子集问题是组合的超集分割问题其实也是在选切割点只不过切割点之间的内容和原数组的连续片段有关。一旦你能把这些看似不同的问题都统一到决策树路径记录撤销的框架里那回溯这块就算真正拿下了。6.2 我踩过的几个让人印象深刻的坑想了一下最值得说给后来人的几个坑分别是第一在全排列的used去重里条件写成了used[i] False就continue结果把应该选的元素也跳过了第二组合求和问题里没有把target减去当前值导致sum参数越来越混乱第三N皇后问题里复制棋盘深浅拷贝搞错结果每一层的棋盘互相污染。这些坑说实话单看错误信息都是小问题但如果没有系统的排错思路每一个都可能耗费几天时间。所以我的建议是遇到问题先回归递归树让树的逻辑引领你排查而不是在代码里瞎试。人肉debug的尽头是画树画树的尽头是模板。6.3 后续学什么、怎么过渡到动态规划很多人在回溯学到某个阶段会开始接触动态规划然后被这两者的关系搞晕。其实回溯和动态规划存在一个天然的衔接场景回溯会做大量重复子问题的计算而带记忆化的递归一般叫备忘录递归就是在回溯基础上加一个cache来避免重复计算。一旦你看过一个带memo的回溯代码再往后学动态规划就会顺滑很多。所以我不建议把回溯和动态规划当成完全割裂的两个章节。回溯学好了你后面接触的状态转移、递归推导、子问题划分本质上都是同一套递归思维的延伸。这也是为什么我一直认为回溯是整个算法体系里承上启下的关键章节。从我自己这一天的学习体会来说回溯这个主题教给我的不只是怎么写递归更是一种面对复杂问题时画出决策树、逐步拆解、分支探索、及时回头的心智模型。你把它练熟之后再看很多实际问题第一反应不再是焦虑而是去画一棵候选树这种转变本身就很值。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询