拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java回溯算法框架详解:从递归模板到剪枝实战,彻底搞定组合排列与N皇后

1. 回溯算法不是玄学是一套可以背下来的框架回溯算法刷了忘、忘了刷是很多Java开发者的真实写照。洛谷、力扣上的全排列、组合总和、N皇后每道题看着答案都能看懂自己一写就卡壳。问题出在哪多半是把回溯当成了一道道孤立的题而没有把它当成一套有固定套路、可以套模板的解题框架。说白了回溯算法就是一个带撤销功能的暴力搜索。它做的事情非常朴素把每一步的选择画成一棵决策树沿着一条路走到黑发现走不通就掉头回来换一条路继续走。这个“掉头回来”的过程就是所谓的“回溯”。和普通暴力的区别在于回溯在每一步都尝试所有可能的分支所以它能覆盖所有解但同时又通过剪枝省掉大量没必要的搜索。拿我自己的经历来说最开始刷回溯题的时候我也是一道题一道题地背什么“全排列要swap”、“组合题要startIndex”、“子集题要控制起始位置”……背得头晕。后来把十几道题放在一起对比才发现它们本质上就是同一个骨架在变化。这篇文章就是把我总结的这套Java回溯框架完整拆开从模板代码到剪枝技巧再到两类最经典的题目推演一次说清楚。适合谁看刚接触回溯、想系统梳理的初学者以及面试前想快速形成肌肉记忆的求职者。花半小时把框架理解透胜过盲目刷二十道题。2. 为什么回溯能统一这么多题核心是决策树思维很多教材喜欢上来就讲“回溯是一种深度优先搜索”这话对初学者友好度为零。换个说法大家就懂了回溯就是在一棵树上做深度优先遍历树的每个节点代表一个“当前状态”每个分支代表“一种新的选择”叶子节点代表“一个完整结果”。用生活中的例子类比一下。想象你在玩迷宫游戏面前有三个岔路口。你选了左边那条走了一段发现是死胡同于是退回到岔路口改走中间那条。退回岔路口的动作就是“回溯”每次在岔路口做的选择就是“分支”。如果迷宫的岔路特别多你又不愿意重复走同一条路那你就需要一个系统性的办法来确保每条路都试过——这就是回溯算法。那“决策树”到底长什么样以经典的“全排列”为例。给定数组[1,2,3]你要列出所有排列第一层你可以选1、2、3当第一个数分出三个分支第二层在选了1的基础上剩下2和3可以选分出两个分支第三层只剩一个数可选直接落到叶子。整棵树的路径就是结果集[1,2,3]、[1,3,2]、[2,1,3]……一共6个排列。回溯算法做的事情就是依次深度遍历这棵树的每条根到叶子的路径路径上的节点序列收集起来就是一组答案。理解了决策树你就拿到了回溯的“世界观”。剩下的问题只有一个在代码里怎么优雅地完成“往下走一步”和“掉头回来”这两个动作答案就是那个被无数人背诵的递归模板。再说深一层为什么要用递归而不是手动维护栈因为决策树天然是递归结构每个节点的处理逻辑完全相同只是数据范围在缩小。用递归可以把“进入下一层”和“回到上一层”这两个动作交给函数调用栈去管理你只需要在递归函数的开头处理“当前层要做什么”在递归返回后处理“撤销当前层的影响”就够了。手动维护栈当然可行但你要同时管理状态栈、选择列表、撤销列表代码量至少翻一倍还容易出bug。3. 一套通用框架模板循环、递归、撤销缺一不可我在实际刷题中反复打磨后把回溯算法收敛成了下面这个Java模板。它不敢说是最简洁的写法但一定是最容易理解和套用的写法。先看代码再一行一行拆。public void backtrack(路径 选择列表) { if (满足结束条件) { 结果集合.add(路径); return; } for (选择 : 选择列表) { // 1. 做选择把当前选择加入路径并更新状态 路径.add(选择); updateState(选择); // 2. 递归进入决策树的下一层 backtrack(路径 选择列表); // 3. 撤销选择把刚才加入路径的节点移除恢复状态 revertState(选择); 路径.remove(路径.size() - 1); } }这个模板的精髓在于三个动作的对称性你做了一次什么选择递归回来之后必须原样撤销。路径上加了什么就得在递归返回后把它移除状态上改了什么就得在递归返回后改回去。这个“对称性”是回溯算法正确性的根基也是新人最容易漏的一步。具体到每部分的写法我习惯把模板固定成四块终止条件什么时候算找到了一组答案。比如全排列中路径长度等于数组长度组合总和中路径和等于目标值。终止条件写对了答案就不会重复或漏掉。for循环负责枚举当前层的所有可能选择。不同题目的区别主要就体现在这个循环的起点和终点怎么定。剪枝条件在循环体内、递归之前先把明显不合法或已经用过的选择跳过。这步是优化性能的关键后面单独展开。递归与撤销进入下一层回来后撤销选择保持当前层的状态和进入时一模一样。记住一句话递归之前是什么样递归返回之后还得是什么样。为什么“撤销”这么重要因为你用的是同一个路径集合在收集结果。如果递归返回后不把刚才添加的节点弹出去下一轮for循环再添加新节点时路径里的残留数据就会导致结果错乱。这是新手写回溯最常见的bug没有之一。再解释一个高频困惑参数里的“选择列表”到底该怎么传经验是尽量把“还能选什么”的信息通过参数传下去而不是在递归函数内部用全局变量硬扛。常见做法有几种传一个visited数组标记已用元素传一个startIndex表示本轮从哪个位置开始选传一个remainTarget表示还剩多少目标值需要凑。无论哪种本质都是在告诉下一层递归你还有哪些路可以走。4. 组合与排列用两种startIndex写法区分“顺序无关”和“顺序有关”如果你理解了上面的模板接下来要面对的分岔路就是组合和排列的区别。很多初学者在这道坎上栽过跟头同样是选元素为什么组合题要传startIndex排列题却要传visited数组原因就一句话——组合不区分顺序排列区分顺序。我以LeetCode 77“组合”和LeetCode 46“全排列”为例把两种写法放在一起对比看完你就能彻底分清。4.1 组合题startIndex控制前进方向组合题的典型描述是给定 n 和 k返回 1 到 n 中所有可能的 k 个数的组合。比如 n4, k2结果是[1,2]、[1,3]、[1,4]、[2,3]、[2,4]、[3,4]。注意[2,1]不会出现在结果里因为它和[1,2]被视为同一个组合。为什么要传startIndex因为在组合里一旦选了2下一层就不能再选1了否则会产生重复。startIndex的作用就是告诉下一层递归“你只能从当前位置往后取前面已经考虑过的元素不要再看了。”这等于在决策树上砍掉了一半的分支避免了重复组合的产生。public ListListInteger combine(int n, int k) { ListListInteger result new ArrayList(); DequeInteger path new ArrayDeque(); dfs(n, k, 1, path, result); return result; } private void dfs(int n, int k, int startIndex, DequeInteger path, ListListInteger result) { if (path.size() k) { result.add(new ArrayList(path)); return; } for (int i startIndex; i n; i) { path.addLast(i); // 做选择 dfs(n, k, i 1, path, result); // 下一层从 i1 开始 path.removeLast(); // 撤销选择 } }注意这里我用的是new ArrayList(path)而不是直接result.add(path)。因为path在整个递归过程中是同一个对象递归返回后会不断被修改直接添加会得到一堆相同的空列表或最终状态。拷贝一份再添加是回溯输出结果时最容易被忽略的细节。4.2 排列题visited数组标记元素是否用过再看全排列。给定[1,2,3]返回所有排列。排列和组合最大的不同是顺序[1,2,3]和[2,1,3]是两种不同的排列。这意味着每一层递归都能使用之前选过的元素唯一要保证的是同一个元素在一次排列中不重复出现。所以不能用startIndex而要用visited数组来标记“当前路径上哪些元素已经用过了”。public ListListInteger permute(int[] nums) { ListListInteger result new ArrayList(); DequeInteger path new ArrayDeque(); boolean[] visited new boolean[nums.length]; dfs(nums, visited, path, result); return result; } private void dfs(int[] nums, boolean[] visited, DequeInteger path, ListListInteger result) { if (path.size() nums.length) { result.add(new ArrayList(path)); return; } for (int i 0; i nums.length; i) { if (visited[i]) { continue; // 剪枝跳过已经用过的元素 } visited[i] true; path.addLast(nums[i]); dfs(nums, visited, path, result); path.removeLast(); visited[i] false; // 撤销状态和做选择时的操作完全对称 } }跟着代码走一遍就会发现visited数组就是当前节点上的“状态标记”进入递归前把它设为true递归返回后立刻改回false。这个状态只有当前路径有效一旦回到上层就必须清零否则会影响后续分支的选择。理解了visited和startIndex的使用场景组合和排列这两大类问题的骨架你就掌握了。4.3 组合总和当终止条件从“等长”变成“等和”组合问题的另一个常见变形是“组合总和”给定一个无重复元素的数组candidates和一个目标数target找出candidates中所有可以使数字和为target的组合。这道题最大的特点是没有固定长度终止条件取决于“路径和是否等于目标值”。我习惯在参数里维护一个remain变量表示“离目标还差多少”。每选一个数就用remain减去它。当remain等于0时说明当前路径已经凑够了加入结果当remain小于0时说明当前路径超标了直接剪枝返回。private void dfs(int[] candidates, int startIndex, int remain, DequeInteger path, ListListInteger result) { if (remain 0) { result.add(new ArrayList(path)); return; } for (int i startIndex; i candidates.length; i) { if (candidates[i] remain) { continue; // 剪枝当前元素已经超过剩余目标值 } path.addLast(candidates[i]); dfs(candidates, i, remain - candidates[i], path, result); // 注意这里传的是 i不是 i1 path.removeLast(); } }注意一个细节组合总和允许同一个元素重复使用所以递归时传的是i而不是i1。这就是“可重复选择”和“不可重复选择”在代码上的唯一区别。面试时考官很喜欢在这个基础上再挖一层如果candidates里有重复元素并且要求结果不能有重复组合怎么做答案也不难在for循环里判断如果当前元素和上一个元素相同就跳过本次实现“同一层去重”。这种小变体理解了原理之后改起来都是两三行的事。5. N皇后与剪枝从暴力遍历到高效求解如果只聊组合和排列回溯的威力还体现得不够充分。真正让回溯大放异彩的场景是N皇后这类“看起来很复杂代码却很规整”的棋盘问题。这也是面试中常被用来考察候选人是否能将框架活学活用的题目。5.1 把N皇后问题翻译成决策树N皇后问题的描述很简洁在N×N的棋盘上放置N个皇后使得任意两个皇后不能处于同一行、同一列或同一对角线上。初看似乎无从下手但只要按回溯的思路来拆就清晰了决策树的层代表棋盘的行。每一层递归处理一行共N层。每层的分支代表在当前行的哪一个列位置放皇后共N个选择。走到最后一层说明N行都放完了记录当前棋盘。这么一翻译N皇后问题和全排列在结构上就通了。唯一的不同是选列的时候多了约束校验当前列、两个对角线都不能已经有皇后。用一个二维字符数组表示棋盘或者更聪明一点用列、主对角线、副对角线三个集合来加速判断。主对角线和副对角线的规律先记住单元格(i, j)在主对角线上的判断条件是i - j的值是常数在副对角线上是i j的值是常数。这个规律在校验时非常管用比每次都在棋盘上逐格扫描要快得多。5.2 完整代码与逐段原理解读我写过一个用集合做校验的Java版本实测运行效率比二维数组扫描快很多。代码结构完全符合回溯模板大家可以直接拿来对比理解。public ListListString solveNQueens(int n) { ListListString result new ArrayList(); // 用三个集合分别记录已占用的列、主对角线、副对角线 SetInteger cols new HashSet(); SetInteger diag1 new HashSet(); // i - j SetInteger diag2 new HashSet(); // i j char[][] board new char[n][n]; for (char[] row : board) { Arrays.fill(row, .); } backtrack(n, 0, cols, diag1, diag2, board, result); return result; } private void backtrack(int n, int row, SetInteger cols, SetInteger diag1, SetInteger diag2, char[][] board, ListListString result) { if (row n) { // 找到一组解把字符数组转成字符串列表 ListString snapshot new ArrayList(); for (char[] r : board) { snapshot.add(new String(r)); } result.add(snapshot); return; } for (int col 0; col n; col) { int d1 row - col; int d2 row col; // 剪枝三个方向上都不能有冲突 if (cols.contains(col) || diag1.contains(d1) || diag2.contains(d2)) { continue; } // 做选择 board[row][col] Q; cols.add(col); diag1.add(d1); diag2.add(d2); // 递归下一层下一行 backtrack(n, row 1, cols, diag1, diag2, board, result); // 撤销选择 board[row][col] .; cols.remove(col); diag1.remove(d1); diag2.remove(d2); } }每次递归只处理一行所以天然不存在“同一行放两个皇后”的问题。真正需要校验的只有列、主对角线、副对角线这三个维度用三个Set存常量值O(1)时间就能完成剪枝判断。对角线判断可能有些抽象我在注释里已经写了主对角线上的所有点row - col的值相同副对角线上的所有点row col的值相同。把这个数学规律用上判断冲突就变得非常简单。一个容易踩的坑是撤销时集合的remove操作必须确保当前元素确实在里面才能移除。这里之所以安全是因为我们进入递归前才add递归返回后立刻remove同一时刻集合里的元素必定包含本次添加的那个值不会出现误删。如果你在代码里同时用了全局Set又用了局部变量就要格外小心生命周期的问题。5.3 怎么剪枝剪枝的核心是“提前止损”回溯算法在没有剪枝的情况下本质是穷举。N皇后问题如果不剪枝8皇后需要遍历8^8约1677万种情况勉强还能跑到12皇后就是12^12约8.9万亿种直接爆炸。但加了“冲突即跳过”的剪枝后实际搜索空间会大幅缩小。比如8皇后实际只搜索约15720个节点差距是数量级的。剪枝的思路用一个词概括就是“提前止损”在做选择之前先判断这条路是否已经注定失败如果注定失败直接跳过不再浪费递归调用。组合总和里判断candidates[i] remain是剪枝全排列里判断visited[i]是剪枝N皇后里判断三个方向的冲突也是剪枝。什么时候剪枝能大幅优化性能有两个典型信号一个是不合法的分支能快速判断出来判断成本远低于走完整个分支的成本另一个是决策树非常深大量分支在中途就会“夭折”。如果你发现自己的回溯代码跑得很慢第一反应不该是换一种算法而应该先看剪枝条件是否充分、是否把剪枝判断放在了递归调用之前。6. 常见问题与调试技巧我踩过最典型的几个坑写回溯代码框架背熟不难真正烦人的是一堆隐蔽的细节bug。我把自己在实际编码中踩过、也在面试中帮别人排查过的几个高频问题整理成了一张速查表每个问题都附上了解决思路按图索骥能省不少排查时间。常见问题典型表现解决思路结果全是空列表输出的每一条路径内容都一样且为空大概率是忘记拷贝路径对象直接用同一个引用加入了结果集改为new ArrayList(path)结果缺少部分组合比如组合题漏掉了[1,3]这种跨位置的组合检查startIndex是否写成了从0开始或者递归时传成了i而没传i1结果大量重复排列题出现了[1,2,3]和[2,1,3]之外的重复项检查visited数组在撤销时是否被正确置回false或者组合题中是否错误使用了visited栈溢出大数量级输入直接抛StackOverflowError检查终止条件是否会在某些分支上永远无法满足递归深度是否失控性能极差n稍大一点就卡死优先检查剪枝条件把剪枝判断前移到循环内递归调用之前有几个调试技巧值得单独说一说。回溯代码是我见过的算法题里最适合“打印大法”的类型没有之一。因为决策树的路径是清晰的、可跟踪的你完全可以在进入递归和退出递归时分别打印一行日志把当前层、当前选择、路径内容打出来错误一目了然。我常用的调试模板长这样System.out.println(进入递归路径 path , 已选列 cols); // 执行递归... System.out.println(退出递归路径 path , 已选列 cols);如果进入和退出的路径不一致说明撤销逻辑有问题如果该进入的分支没有进入说明剪枝条件写得太严了如果某层循环里少了一个选择说明for循环的起止范围写错了。这种逐层跟踪的方式比在网上看别人的答案猜半天要靠谱得多。还有一个小技巧是先写一个不带剪枝、纯暴力的版本确保结果正确之后再逐步加上剪枝逻辑观察性能变化。这样至少能区分“算法逻辑本身错了”和“剪枝条件写错了”两种不同的bug来源。千万别一上来就追求极致剪枝结果剪枝剪多了连正确答案都被剪掉了排查起来反而更痛苦。7. 扩展思路从回溯框架到DFS、BFS与动态规划的交叉学到这你已经掌握回溯算法的骨架也清楚它和组合、排列、棋盘类题目的对应关系。接下来值得花点时间做的事情是把回溯放到更大的算法视图里看搞清楚它和深度优先搜索DFS、广度优先搜索BFS、动态规划DP之间的区别和联系。回溯本质上就是带状态撤销的深度优先搜索。DFS强调的是“从起点出发沿着一条路探索到底再换路”的遍历方式回溯在DFS的基础上额外要求“在返回时恢复现场”。所以我们可以把回溯理解为DFS的一个特化版本专门用于搜索所有可行解的场景。那回溯里“回溯”这个动作和动态规划的“状态转移”又有什么区别简单来说动态规划要求问题具有重叠子结构和最优子结构可以用一个状态数组把中间结果缓存起来避免重复计算回溯则更“老实”几乎所有可行解都尝试一遍不去做状态归并。这两者有时候能互相改写——比如“凑零钱”题目既可以用回溯穷举所有方案也可以用动态规划求最优解。面试中如果遇到这种题要先判断题目是“求所有解”还是“求最优解”。求所有解优先回溯求最优解优先动态规划。从框架的角度做一个小总结回溯和DFS都需要写出递归函数DFS常用来搜索或遍历图结构回溯常用来生成所有候选解。两者之间的桥梁是“状态”这个概念DFS的状态是“当前节点”回溯的状态是“当前路径和约束条件”。你只要把状态定义清楚递归函数怎么写都会很顺。如果你刷题进入后期阶段建议试着用回溯去解决一些更复杂的题目比如数独求解、括号生成、单词搜索。它们的共性依然是“选择—递归—撤销”三步曲差别只在于约束条件和求解目标。框架一旦形成肌肉记忆看到新题的第一反应就会变成“这道题的状态是什么、选择是什么、剪枝条件是什么”而不是“这题该用哪种算法”。8. 我个人在实际练习中的体会回溯框架的正确打开方式文章写到这正文的主要内容就讲完了。最后分享一点我在刷题和带团队新人时积累的私人经验希望对读者有帮助。第一回溯题不能只靠看一定要动手写框架。我见过不少人把力扣评论区的高票答案背得滚瓜烂熟一到面试官手写代码就露馅。原因很简单回溯代码的细节太多——路径拷贝、撤销时机、startIndex传参——这些东西只有自己动手写错几回才能真正长在脑子里。我自己的建议是把文中的模板代码、组合题、排列题、N皇后题各手写一遍写完之后关掉IDE白纸上再默写一遍。两遍写下来框架基本就牢固了。第二面试时不要闷头写代码先把“选择、递归、撤销”三件事口头说清楚。回溯是面试官非常喜欢考察的题型因为它能很好地反映候选人的递归思维和状态管理能力。你如果在动手之前能清晰地说出“终止条件是什么、每一层的选择列表是什么、递归返回后要恢复什么状态”面试官对你的评价会远高于一个闷头写出正确答案的候选人。我甚至觉得能把“为什么撤销”解释明白比把代码写对更关键。第三从一个特定角度使用框架很重要想清楚再写写完再想剪枝。回溯是先暴力后优化的典型。不要一上来就试图写一个“完美剪枝”的版本先把暴力的正确版本跑通再逐个条件加上去一边加一边确认结果没有变化。这种做法在开发真实项目时也适用——先保证功能正确再考虑性能优化。题海战术不是目的学会一套框架并且能灵活变形才是最高效的学习路径。希望这篇回溯算法框架的梳理能帮你把零散的知识点串起来。框架记熟之后你再去刷题会发现很多题在第一次读完题目的时候就已经能大概猜到解法的轮廓了。那才是真正的“通了”。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门