ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++回溯算法从入门到精通:模板、剪枝与经典例题详解

C++回溯算法从入门到精通:模板、剪枝与经典例题详解 回溯算法这个名字第一次听起来有点玄乎好像是什么高深莫测的技巧但说白了它就是“暴力枚举”的优雅写法——把所有的可能路径都走一遍走到死路就回头换条路再走。我学C的过程中递归和回溯是绕不过去的坎尤其是刷题遇到排列、组合、子集、数独、八皇后这类问题脑子里没有一棵清晰的“决策树”的话写代码就是一团乱麻。这篇笔记就是我自己从“看到回溯题就懵”到“能闭眼写出套路”的完整记录适合刚学完递归、想啃算法题的C初学者也适合面试前突击复习回溯模板的朋友。回溯算法的核心应用场景非常明确在有限集合上做决策穷举所有可行解。组合总数、全排列、分割字符串、棋盘填数、路径搜索全部是它的主场。很多初学者觉得这类题难其实难的不是思路而是“怎么把回溯的代码模板套上去”这个基本功。我接下来会从解法本质、标准模板、经典例题、剪枝优化、常见bug几个维度把这块硬骨头拆开嚼碎。1. 回溯到底在解什么题1.1 回溯问题的三个特征先问个最简单的问题什么样的题目你会想到用回溯我自己的判断标准是三条同时满足两条以上基本就是回溯题需要枚举所有解不是找到一个就行而是把所有符合条件的方案全部列出来。解的构成存在“选择序列”比如全排列每一层选一个数选完就不能再选比如括号生成每一层决定是放左括号还是右括号。搜索过程中有约束条件约束可以在递归过程中实时检查提前掐掉不满足的分支。举个例子“给定一个无重复数字的数组返回所有可能的全排列”。这道题要求你列出全部排列方式本质就是在每一层“还没被用过的数字”里挑一个递归到下一层直到所有数字都用完。”这就是典型的决策树搜索树的每一层是一个选择位置树的每一条从根到叶子的路径就是一个排列结果。再举一个场景很多人在LeetCode上刷过“组合总和”给定一个数组和一个目标数找出所有和为目标数的组合。这里每一层要决策“当前数字选不选、选几次”决策序列从头到尾记录下来满足和等于目标就输出。小时候玩迷宫也是这样——每个岔路口就是一条回溯分支走不通就一路退回到最近的岔路口换一条路继续试。1.2 回溯、递归、DFS三者什么关系这个问题我在学的时候纠结了很久反复看别人的博客才彻底理清。递归是一种函数调用自身的编码方式是“工具”DFS深度优先搜索是一种遍历树或图的策略是“方法论”而回溯是带着状态记录和状态撤销的DFS通常也用递归实现。换句话说回溯 递归这种工具 DFS这种遍历策略 状态管理做出选择、撤销选择。更直白地说回溯和DFS的区别在于DFS只负责“访问”回溯负责“访问后还要恢复现场”。这就好比逛商场DFS是从一楼走到五楼每个店铺都进去看一眼而回溯是每进一个店铺都要在自己的购物清单上记录一下出来时把清单恢复原样再换一家店。恢复现场这一步就是回溯的灵魂也是新手最容易丢的代码。1.3 回溯 vs 动态规划很多题看起来既像回溯又能用动态规划做比如“不同路径”这种计数类问题。这里有个简单的判断逻辑如果题目要求输出所有具体方案多半是回溯如果只问方案数量、最优值动态规划往往更合适。动态规划的核心是重叠子问题 状态转移它保存的是“从某状态出发的最优解/方案数”同一状态只算一次回溯则是一路走到黑把所有路线都画出来。回溯的复杂度通常非常高指数级甚至阶乘级所以能用DP压掉状态重复计算的题目就不要硬用回溯去裸搜。反过来如果DP状态不好定义、转移复杂而数据规模又不大比如n15那回溯加剪枝往往是最容易写对的选择。2. 回溯的标准代码模板直接背2.1 C里最顺手的回溯框架我在本地测试过很多遍下面这个模板是我觉得C里最顺手、最不容易出错的版本。#include vector using namespace std; class Solution { private: vectorvectorint result; // 全局结果集 vectorint path; // 当前路径 void backtrack(vectorint nums, vectorbool used) { // 1. 终止条件路径长度等于选择池规模记录一组完整解 if (path.size() nums.size()) { result.push_back(path); return; } // 2. 遍历当前层的可选列表 for (int i 0; i nums.size(); i) { if (used[i]) continue; // 已被使用跳过 // 3. 做出选择 used[i] true; path.push_back(nums[i]); // 4. 深入下一层 backtrack(nums, used); // 5. 撤销选择 used[i] false; path.pop_back(); } } public: vectorvectorint permute(vectorint nums) { result.clear(); path.clear(); vectorbool used(nums.size(), false); backtrack(nums, used); return result; } };这段代码就是全排列的标准写法它包含回溯解题的三件套结果收集点递归到叶子节点时把当前路径拷贝进结果集。可选列表每一层通过for循环枚举可做的选择used数组标记哪些元素已经用过。撤销操作递归返回后把刚标记的状态复位、把刚加入路径的元素弹出。每次递归进入新的一层相当于在决策树上往下走一个节点每次for循环相当于站在当前节点上挑一条没走过的岔路而递归里的return回来又相当于沿着原路退回上一个节点。整个流程就像在迷宫里做标记往前走、再擦掉标记走另一条路非常直观。2.2 模板里为什么要有“撤销”这一步这一步是无数人写回溯时最容易漏掉的地方。画个递归调用栈想一下如果我在第一层选了数字1把这层used[0]置为truepath加入1然后递归进入第二层。第二层跑完所有分支后回到第一层如果此时不把used[0]还原成false、不把path里的1弹出来那接下来第一层尝试选数字2的时候used[1]还是false没问题但path里残留的那个1会混进以2开头的排列里结果就全乱了。打个比方这就相当于你走进一家店在里面做了个“已进入”标记逛完出来必须把标记擦掉不然下一家店一开始就看到“已进入”的标记以为有人在里面就不让你进了。整个搜索空间被错误的残留状态污染轻则结果重复重则直接漏解。而且撤销操作有一个隐含的伴随关系每一次递归调用返回后紧接着就是撤销。有没有发现撤销代码和选择代码在结构上完全对称push_back对应pop_backused[i]true对应used[i]false。记住这种对称性写代码时就能自动检查选了啥就要撤销啥漏了任何一个都不是合格的回溯。2.3 path和result的内存细节模板里有两个vector一个叫path存当前路径一个叫result存所有完整解。这里有个C新手必经的教训如果你直接把path放进result会发生什么result.push_back(path); // 拷贝还是引用这行代码比较微妙。path是局部变量但result是类的成员。push_back(path)会把当前的path里面所有元素拷贝一份放进result所以即使后面path变了result里存的依然是一份独立的旧快照不会受影响。C的vector拷贝是深拷贝这一点天然帮我们固化了现场。但如果在更底层的写法里你用指针、引用或者手写的固定数组去存结果就很容易存出一堆“共享同一块内存”的假解——等递归结束、path回溯成空你会发现result里全是空数组或全部变成最后一种排列。这种坑在写OJ题、面试手撕代码时特别常见。我的建议是结果容器里存值别存引用path尽量用vector或string这种自带深拷贝的容器别用裸指针。3. 用三道经典题把套路焊死3.1 全排列先吃透最基本的形态题目长这样给定一个不含重复数字的数组nums返回所有可能的全排列。这道题略过那些超纲的优化思路直接用模板就能过。核心只有三处要注意第一使用used数组记录哪个元素已经选过第二递归的终止条件是path.size()nums.size()第三每层for循环从0开始扫配合used数组过滤掉已选元素。这三条组合在一起就能保证每个排列里每个元素恰好出现一次。你可能会问为什么组合类题目大多是“从i开始扫”而排列类要从0开始扫因为排列中每个位置都能放任意一个未被使用的元素所以每一层的候选集合永远是“全量减已用”而组合题不允许回头选之前的元素所以用startIndex控制起始位置防止出现[1,2]和[2,1]这种重复。搞清楚这个区别排列和组合就分清楚了。全排列的时间复杂度是O(n!)n10时已经是300多万个排列n12就是4亿多个这题目能做全排列的数组规模一般都很小。面试时如果题目没给数据范围你最好先跟面试官确认一下n的量级超出12左右就要考虑别的思路了。3.2 组合总和带约束和剪枝的经典形态组合总和这题是这样给你一个无重复元素的整数数组candidates和一个目标数target找出所有“使数字和等于target”的组合同一个数字可以无限次重复使用。这题比全排列多了一个约束和等于target和一个特性元素可重复使用。设计递归的时候每一层仍然遍历候选数字但有两个关键不同用startIndex而不是used数组因为组合不考虑顺序[2,2,3]和[3,2,2]是同一组必须用startIndex控制每一层只能从当前位置往后选避免回头产生重复组合。同一个数字可以重复用所以递归传入的startIndex不一定是i1而是i本身表示本轮选择了candidates[i]之后下一轮还能继续选它。直接上代码class Solution { private: vectorvectorint result; vectorint path; void dfs(vectorint candidates, int target, int startIndex, int sum) { if (sum target) { result.push_back(path); return; } if (sum target) { return; } for (int i startIndex; i candidates.size(); i) { path.push_back(candidates[i]); dfs(candidates, target, i, sum candidates[i]); // 注意传i可重复选 path.pop_back(); } } public: vectorvectorint combinationSum(vectorint candidates, int target) { result.clear(); path.clear(); dfs(candidates, target, 0, 0); return result; } };这里有个细节很值得说sum用了值传递每次递归自己加自己的所以撤销的时候不需要做sum-操作而path是成员变量递归返回后必须手动pop。我的经验是能用局部变量传递的状态就不要用成员变量能少写一个撤销就少一个出错点。但path不得不放成员变量因为要记录整个递归链上的选择序列每次都传vector副本开销太大。当然这个版本的剪枝很粗糙sum target是递归进去之后才发现的属于“延迟剪枝”。更优的做法是在for循环里先判断sum candidates[i] target就continue这样能少进一层递归。3.3 N皇后把每行当作一层决策N皇后是一道把回溯思想展现得最完整的题。题目要求在n×n的棋盘上放n个皇后任意两个皇后不能在同一行、同一列、同一条对角线上返回所有合法摆法。这种题的决策树怎么建很简单每一行就是一层递归每一行的N个位置就是该层的候选列表。放好第0行的皇后递归第1行在这一行里找所有不冲突的位置放第二个皇后直到放完最后一行就是一个合法解。判断冲突是这道题的编码难点。不用搞复杂的数学直接用三个布尔数组就能搞定col[j]表示第j列是否已有皇后。diag1[i j]表示左上到右下方向。同一方向的格子行号加列号是常数。diag2[i - j n - 1]表示右上到左下方向。同一方向的格子行号减列号是常数加个偏移防止负数下标。class Solution { private: vectorvectorstring result; vectorstring board; void dfs(int row, int n, vectorbool col, vectorbool diag1, vectorbool diag2) { if (row n) { result.push_back(board); return; } for (int j 0; j n; j) { if (col[j] || diag1[row j] || diag2[row - j n - 1]) { continue; } board[row][j] Q; col[j] diag1[row j] diag2[row - j n - 1] true; dfs(row 1, n, col, diag1, diag2); board[row][j] .; col[j] diag1[row j] diag2[row - j n - 1] false; } } public: vectorvectorstring solveNQueens(int n) { board.assign(n, string(n, .)); vectorbool col(n, false); vectorbool diag1(2 * n, false); vectorbool diag2(2 * n, false); dfs(0, n, col, diag1, diag2); return result; } };N皇后这题的剪枝其实就藏在判断条件里行天然的每一层只能放一个皇后这是递归结构自带的剪枝列、对角线冲突则通过三个布尔数组实时拦截。这样绝大部分非法分支还没深入就被砍掉了n8时跑起来非常快。双对角线索引的推导值得多花两分钟理解因为这是N皇后最容易写错的地方。ij能作为同一对角线标记本质是“斜率为-1的直线方程”。i-j能作为另一方向的标记本质是“斜率为1的直线方程”。画一条棋盘对角线标上所有格子的ij你会发现同一斜线上的值一模一样。搞懂这个以后做八皇后变形题比如“主对角线也可以放皇后”你也能快速想出对应的数组映射方案。4. 剪枝才是回溯的灵魂4.1 为什么必须剪枝很多人写回溯第一个版本能过小样例一上大数据就超时问题几乎都出在剪枝不够。回溯本身就是暴力枚举它最坏情况下的复杂度是指数级或阶乘级。如果不剪枝n20的组合枚举有2的20次方约100万种还能扛一扛n30就是10亿量级已经不可能跑完。所以在每个递归分支上能提前拦截就提前拦截把“注定没结果的路”尽早断掉。剪枝的思路本质上只有一个在进入某一层选择之前判断这个方向继续走下去还有没有可能得到合法解如果不可能直接跳过。判断的依据来源很杂可以是数值范围、剩余容量、剩余元素数量、是否已经重复等等。下面列几个最常见的剪枝套路。4.2 排序 跳过相邻重复元素这是组合总和II、子集II这类“有重复元素但要求结果不重”的标准剪枝写法。题目给了含重复元素的数组如果不做处理最后会得到一堆重复的组合。很多人第一反应是用set去重能过但效率很差。正确做法是先排序让相同元素靠在一起在for循环里如果发现i startIndex candidates[i] candidates[i - 1]说明这个数字是当前层的第一个出现位置是合法的而如果它跟前一个元素值相同且前一个没被选过那这一支肯定跟前面某支重复剪掉。我第一次看到这个判断条件时也绕了很久。后来自己手动画递归树才明白同一层挑选重复元素会产生完全相同的子树因为可选的剩余集合一模一样。但同一路径上不同层选相同元素比如[1,1,2]里两个1都在解里那是合法的因为它们是不同位置的元素。这个边界靠i startIndex来卡——第一轮i等于startIndex时即使值相同也是合法起点后续位置值相同则跳过。4.3 提前计算剩余可行空间还有一个非常实用的剪枝技巧拿组合总和那道题来说如果数组已经从小到大排好序并且路径里的sum加上当前元素已经超过target那么后面更大的元素也全部超过可以直接break连循环都不用继续了。for (int i startIndex; i candidates.size(); i) { if (sum candidates[i] target) break; // 排序后可break path.push_back(candidates[i]); dfs(candidates, target, i, sum candidates[i]); path.pop_back(); }这比单纯递归进去再return省太多时间——省掉的不是一层递归而是整棵以该节点为根的子树。很多组合类题只要加了排序break这行代码运行时间能降一个数量级。4.4 剩余元素个数剪枝还有一类题目比如“组合”问题从n个数里选k个当你已经选了len个元素、而len (n - startIndex) k时就算把后面所有元素都选上也不够k个直接return。这个判断通常写在递归入口或者for循环前面if (path.size() (n - startIndex 1) k) return;这个剪枝在构造组合、赛跑选手中很常用。说白了就是提前判断“库存够不够”不够就别往下走了。速度提升虽然没有排序break那么夸张但也是标准动作尤其当n和k差很大时效果很明显。4.5 剪枝与回溯的平衡有个误区我要特别提一下很多人追求极致的剪枝把所有条件一股脑塞进一个复杂的判断式子结果代码难看不说还容易写错边界。我的经验是先写朴素回溯确认逻辑正确、结果无误再逐条加剪枝每加一条都跑一遍测试。这样你能清楚看到每条剪枝到底优化了什么万一结果不对也容易定位是哪条剪枝把合法分支误杀了。剪枝的本质是“用判断换来递归量的减少”但判断本身也有开销。当数据规模很小时复杂的剪枝可能比朴素版本还慢所以不要盲目堆剪枝。判断语句的复杂度应该保持O(1)那种在循环里套排序、套哈希计算的剪枝往往得不偿失。5. 常见问题与调试心得实录5.1 忘记撤销导致“路径脏了”这是回溯的第一大bug。症状是结果莫名重复、或者结果看起来完整但中间夹杂着错误的路径。最典型的翻车现场是这样的你把path.push_back(nums[i])和path.pop_back()写对了却把used[i] true和used[i] false忘了配平。我自己排查这种bug的经验很简单在递归函数的入口和出口各加一行打印打印path内容和used数组状态。递归进入时打印一次递归返回前再打印一次。如果看到的状态跟手推的不一致就说明撤销环节出问题了。比如进入第二层时used状态应该是“已选[某元素]”退出后立刻打印却发现used状态带着上一层的残留那就能精准定位是哪个变量没还原。5.2 path传递时用引用还是拷贝这个问题几乎每本算法书上都会提但实战中很多人还是踩坑。如果递归函数签名写的是void backtrack(vectorint nums, vectorint path)注意path是值传递每次递归都会自动拷贝一份那么你就不需要在回溯时手动pop因为进入下一层时新的path是独立副本本层的path没受影响。这种写法代码简单但代价是每一层递归都要做一次O(n)的vector拷贝对全排列这种n!级别的搜索来说拷贝开销相当可观。我建议统一用成员变量或引用保存path递归深层共用同一块内存通过push/pop配平来维护状态。这是性能最优的写法也是标准解法里最常见的写法。唯一需要注意的是把path放进result时要确保push_back(path)发生在一个正确的“完整状态”节点上不要推到一半就把半截路径存进去。5.3 startIndex 和 used 用混了组合题用startIndex排列题用used数组这个区分讲起来容易但混合题型一出来就容易乱。比如“子集”问题需要枚举所有长度的子集用startIndex控制不回头即可“全排列”则必须允许每层从整个数组里挑选用startIndex就限制了排列的灵活性会漏掉大量解。有一种题会同时用到两者比如“给定数组有重复元素返回所有不重复的全排列”。这种题除了used数组标记使用状态还要配合排序和“同层去重”判断。也就是说排列做位置去重靠used做值去重靠排序跳过重复元素两个机制是互相配合的不是互相替代的。做题时一旦卡壳先把问题归类到“组合/排列/子集/棋盘”再对应套用startIndex还是used思路就顺了。5.4 边界条件测试清单回溯题目调试不能只盯着样例过没过还得额外测几类边界空数组输入。比如permute([])你期望结果是一个空列表而不是什么都没有。单元素数组。路径长度等于规模的条件要能正常触发。全重复元素数组。去重剪枝要保证不返回任何重复解。目标值小于数组最小值或目标值等于0这类特殊情况。超大n加上严格约束条件确保递归深度没有爆栈剪枝没有漏判。我一般会先在本地把这几类情况全部跑一遍全部正常后再提交。养成这类测试习惯后OJ提交被WA的概率会低很多。5.5 用打印日志手推递归过程新手调试回溯最痛苦的是“不知道递归跳到哪一层了”。我的土办法是维护一个depth参数打印日志时前面补空格void backtrack(vectorint nums, vectorint path, int depth) { string indent(depth * 2, ); cout indent enter: depth depth; for (int x : path) cout x ; cout endl; // ... 递归主体 ... cout indent exit: depth depth; for (int x : path) cout x ; cout endl; }这样打印出来的缩进树一眼就能看明白哪个分支进入深、哪个分支提前返回、哪个位置的path内容异常。我调试过的回溯题里八九成都能靠这个办法十分钟内定位问题。等代码跑通了再把打印删掉提交即可。6. 从模板到变体我的扩展心得回溯算法学到这里框架我是滚瓜烂熟了但真正让我把它融会贯通的是几次主动做“变体练习”。第一种变体是考“解的数量”不给全部解而只统计解个数这时不需要额外申请result数组直接在终止条件里让计数器加一能省大量内存。第二种变体是考“是否存在解”比如数独的判空找到一个合法解就提前终止全部递归这种场景要在递归函数返回值里带一个bool找到解后一路return true层层退出避免无谓搜索。第三种变体是“带约束的状态压缩”。当我做数据规模稍大的题目时发现bool数组还可以进一步改造成整数的位运算状态用int变量里每一位表示某个元素是否已选既省空间又能通过按位与快速判断。比如全排列在n20时(used i) 1这种写法比vector 更快更简洁而且撤销时直接used ^ (1 i)就行一行完成“取反还原”非常香。这些变体练习做完我最大的感受是回溯的模板是“骨”剪枝是“肉”而灵活的状态管理是“神经系统”。骨架不变你往上面接不同的状态、不同的剪枝、不同的终止条件就能应对五花八门的搜索题。面试前突击时与其盲目刷几十道题不如把全排列、组合总和、N皇后这三道题每个变体都吃透基本能涵盖80%的回溯考点。最后再分享一个小技巧写完回溯代码后我一般都会手动运行一次小规模的测试然后在纸上画出完整的递归树把每一步的path和选择都标出来跟代码输出对比。这个习惯看起来土但比任何调试器都好用因为它逼你把整个搜索过程在脑子里走了一遍而不是依赖工具去猜。坚持几道题后你对“每一层选什么、什么时候该剪、什么时候该记录”会形成直觉再到面试手撕代码时肌肉记忆就能帮你直接写出正确的回溯框架。
返回列表