
做算法题也好写业务逻辑也好回溯算法大概是大家最先接触到的“体面暴力”。它一句话就能讲清楚一条路走到黑不行就退回换一条。但真正写过递归搜索的人都知道回溯效率是个大问题——搜索空间一大机器根本扛不住。我第一次被回溯震撼到是写八皇后小工具刚开始没加剪枝四皇后已经让我觉得慢等跑到八皇后整个程序卡得风扇起飞屏幕鼠标都不怎么动。后来把几行剪枝逻辑一步步加进去同样的八皇后瞬间出结果搜索分支从千万级别掉到几万级别。从那以后我养成了一个习惯凡是回溯先问能不能剪枝。这篇文章把回溯剪枝的思路整理成一套可以直接上手的套路重点覆盖 N 皇后、数独、0-1 背包这几类典型场景后面还会单独讲 Alpha-Beta 剪枝——它在博弈树里解决最优选择问题时效率提升非常夸张。适合刚搞懂回溯但被性能卡住的人也适合想系统整理剪枝手法的同学。先说结论剪枝不是神秘优化它只是把“明知道没结果的路”提前排除把有限计算资源留给更可能出路的节点。1. 先看清回溯到底在搜索什么1.1 回溯是状态空间树上的深度优先遍历回溯算法本质上是在状态空间树上做深度优先遍历。你每做出一个选择就会走到一个更深的状态节点状态达到目标就记录成一个答案状态明显没有出路就回溯到上一层换一个选择。很多教材把它叫“试探与回溯”这个叫法很直观先试着往下走走不动就回头。举个最简单的全排列例子排列 [1, 2, 3]第一个位置选 1第二个位置只能在 {2, 3} 里选第三个位置剩下一个数字。整个过程形成一棵深度为 3 的树叶子就是 6 个排列。看这个例子觉得很简单但把数字换成 12 个树的分支数量就是 12!约 4.79 亿个叶子。这还只是全排列复杂问题的分支因子更高树的规模增长完全超出直觉。想减少不必要的搜索分支第一步就是意识到绝大多数分支不是来找答案的它们只是来凑数的。1.2 不剪枝的成本有多离谱以 N 皇后问题为例。最朴素的回溯写法是从第一行到最后一行每行在 n 个位置里尝试放皇后放下去之前检查是否和已有皇后冲突。如果不做任何提前检查每一行都有约 n 个选择整个搜索过程会展开成一棵近似 n 层的树节点数量接近 n^n。对八皇后来说n^n 是 1677 万次选择本地跑起来已经能明显感觉到延迟到十皇后n^n 是 100 亿这已经不是“慢一点点”的问题而是直接没法算。可实际上十皇后的解只有 724 个。也就是说绝大多数分支都在做无用功它们在已经发生冲突、进一步扩展完全不可能有效时仍然被算法老老实实地展开到了叶子。剪枝想解决的就是这件事不是减少解的个数而是减少那些注定没有解的分支的访问次数。想明白这一点你就知道为什么剪枝能带来这么夸张的效果。1.3 剪枝的本质提前说“不”把状态空间树当成一棵真实的树来看剪枝就是在一棵子树被完全展开之前先判断它有没有可能通向合法答案。如果判断出没有可能就直接返回不再进入它的任何一个子节点。这里的关键收益很直观在靠近根节点的地方砍掉一个分支省掉的不是一个节点而是一整棵子树。假设每个内部节点平均能长出 3 个子节点当前深度还有 5 层那么提前一层判断失败可以省下 3^5 243 个节点如果能提前两层省下 3^4 81 个节点合起来就是 324 个节点的工作量。搜索深度越深分支因子越大剪枝的收益就越夸张。这也是为什么同样一个算法加了几行剪枝条件后耗时曲线能从“指数爆炸”变成“温和增长”的原因。剪枝不是优化代码写法而是从算法结构上删掉整个计算子树。2. 剪枝策略盘点常用的六类手段2.1 可行性剪枝约束不满足立刻掉头适用范围最广的剪枝就是可行性剪枝也叫约束剪枝。它的逻辑一句话如果当前状态已经违反了问题的硬性约束那么不管后面怎么填都不可能合法直接返回。N 皇后问题里摆放皇后之前检查是否同列、是否在斜对角线上数独里填空之前检查行、列、宫是否重复图着色里检查相邻节点是否同色都是可行性剪枝。这类剪枝是最容易想到、也最容易写错的。容易写错的原因在于你检查的必须是“已经确定的事实”不能把未来可能的状态也当成事实。比如数独填某个格子你检查当前行有没有冲突这是对的如果你顺手检查了另一个空白格子的潜在候选数发现列表为空就剪掉这其实属于前瞻剪枝不是不可用但必须想清楚逻辑否则容易误剪。我见过不少人把这类判断混在一起最后解不出来还查不到原因。2.2 最优性剪枝拿当前最优解当标尺在有目标函数的优化问题里回溯要的不是“任意合法解”而是“目标函数最优的解”。最优性剪枝会在每一层维护当前已经找到的最好解对当前部分解做一个乐观估计即便把后面所有步骤都按最理想的情况推进最终结果最好也不过是某个值。如果这个乐观值都打不过当前最优解那这个分支无论如何都不可能给出更优答案可以整棵砍掉。0-1 背包问题是最经典的例子按单位价值排序把当前已经装入的价值加上后续物品能够贡献的最大价值上界如果上界小于当前最优解直接返回。很多教材把这套做法叫分支限界法本质上和回溯里的剪枝是一回事只是名字在优化领域更常用。最优性剪枝的难点在于找“乐观上界”。上界太紧剪枝效果最好但计算复杂上界太松剪枝约等于没剪边界判断成了摆设。2.3 对称性与重复排列剪枝只搜一个代表另一个非常实用但容易被忽略的是对称性剪枝。搜索树中大量节点从“答案”角度看完全等价只因为元素顺序或棋盘旋转等原因被重复展开。处理重复排列时如果输入数组里有重复数字可以用“排序 跳过同一层相同数字”的方法先把数组排序在递归的同一层里如果当前元素和前一个元素相同并且前一个元素还没被使用就不再处理当前元素。这个技巧在全排列、子集、组合问题里尤其常见。图着色问题还能利用颜色对称性第一个节点染颜色 1第二个节点染颜色 2如果出现某种颜色的使用情况和另一种颜色完全对称就可以避免重复搜索。使用对称性剪枝有一个前提就是你必须能证明被剪掉的节点不会包含合法解或更优解。对称性剪枝一旦证明不正确就会导致答案缺失而且缺失方式非常隐蔽不容易被测试发现。我对这类剪枝的态度是能用但别贪多每加一个条件都要写清楚证明理由。2.4 前瞻剪枝预判未来几步再往前走前瞻剪枝和可行性剪枝的区别在于可行性剪枝检查当前状态前瞻剪枝还要预判未来几步。典型场景是数独求解里的“最小剩余值”选择。数独里每次填数字前先扫描所有空格选择当前候选数最少的格子去填。候选数只有 2 个的格子比候选数有 6 个的格子更容易确定搜索分支也少。这本身不是直接剪枝但它通过改变搜索顺序让搜索树从一开始就偏向分支少的方向等价于在不损失正确性的前提下减少整棵树的规模。还有一类更强的前瞻剪枝在某个空格候选数为 0或某个未填格子所在行、列、宫已经没有可填数字时直接宣布当前局面死局。这类检查提前给递归判了死刑比单纯检查当前格子的冲突更狠代价是每次递归都要多花一些扫描时间。2.5 后继必要性剪枝只有一个选择就没有分叉这类剪枝建立在“某些选择是必然的”的基础上。如果一个状态中某个位置只有一个合法候选数那这一步就没有选择余地不需要再分叉。在约束满足问题里叫“单一性”在拼图问题里类似“某个空位只有一块积木能放”。实现上通常是在每次递归开始前做一轮传播检查把所有唯一候选都填掉直到没有新的唯一候选为止。这种做法的收益是很多分支在根节点附近就暴露出必死特征不用等到叶子节点才被拦截。要注意传播检查本身有代价如果每次递归都要扫描整个棋盘棋盘较大时扫描开销可能抵消剪枝收益。实际使用时要权衡检查频率与数据规模后面我会给出一个判断标准。2.6 启发式排序软剪枝才是放大器最后一种不算硬剪枝但效果和剪枝一样重要调整子节点的访问顺序。搜索树里有些分支能更早给出高质量解。以 0-1 背包为例把物品按单位价值从高到低排序可以让早期分支尽早接近最优解有了好的当前最优解后面的最优性剪枝才有力。Alpha-Beta 剪枝里也是如此节点排序越好能剪掉的无效子树越多。我把这种手段叫软剪枝它没有提前删分支但通过让“好分支”优先出现把真正的剪枝效果放大。回溯代码优化时排序代码通常只有几行带来的收益却经常是数量级的。我一般会把这个优化放在硬剪枝之后做因为如果硬剪枝条件没想清楚排序带来的效果很难量化硬剪枝稳了以后排序会让整体性能再上一个台阶。3. 三个经典问题里的剪枝落地方案3.1 N 皇后从二维数组到位运算剪枝写 N 皇后剪枝大多数人第一版是用数组记录哪些列、哪些对角线已经被占用。col、diag1、diag2 三个集合每次递归检查这几个集合。这样的代码可以工作但维护起来有不少细节。我自己更常用位运算版本用一个整数表示列占用用另外两个整数表示两条对角线占用。放第 row 行皇后时先把所有不可用位置合并成一个掩码剩下的 1 位就是可以放皇后的列。核心代码不到 20 行def solve_n_queens(n): res [] full (1 n) - 1 def dfs(cols, diag1, diag2, queens): if len(queens) n: res.append(queens[:]) return available full ~(cols | diag1 | diag2) while available: bit available -available col bit.bit_length() - 1 available - bit queens.append(col) dfs(cols | bit, (diag1 | bit) 1, (diag2 | bit) 1, queens) queens.pop() dfs(0, 0, 0, []) return res这段代码的剪枝逻辑藏在 available 的计算里。cols | diag1 | diag2 把所有被攻击的位置标记为 1取反后只剩可以落子的位置每次只在这些位置上继续搜索。整数位运算天然把“同列”和“两条对角线冲突”的可选分支全部排除比数组版本省下了大量判断。实测十皇后的搜索节点数大概只有数万个而不剪枝版本会以亿计算。觉得位运算不好读的话可以用 Python 的 bit_length 获取列号逻辑保持一致。这里的核心不是位运算本身而是它在展开分支之前就精确锁定了“还能走的位置”。3.2 数独候选数最少的格子优先数独求解是最典型的“约束越多搜索越窄”的问题。我的求解器只有一个核心技巧每次都先扫描全盘找到当前候选数最少的空格然后递归尝试它的每个候选数。这个技巧在文献里叫 MRV即优先填充剩余候选数最少的变量。做一个直观对比如果一个空格还剩 2 个候选另一个还剩 5 个候选先填前者失败后最多尝试 2 个分支而先填后者可能要试 5 个分支。多个空格叠加搜索树的宽度会小非常多。实现时每次找最小剩余候选格可以用一个小函数def find_mrv(board): best None best_candidates None for r in range(9): for c in range(9): if board[r][c] 0: cands get_candidates(board, r, c) if len(cands) 0: return -1, -1, [] # 死局 if best is None or len(cands) len(best_candidates): best, best_candidates (r, c), cands if len(cands) 1: return best[0], best[1], cands return best, best_candidates提前返回候选数为 0 或 1 的格子是一种有效剪枝候选数为 0 说明当前棋盘已经无解候选数为 1 说明这一步是强制选择不需要再比较其他格子。配上 get_candidates 里对行、列、宫的去重检查普通困难数独基本能在毫秒级解完。我自己踩过的坑是每次递归都重新扫描 81 个格子会增加常数开销但 9×9 的固定规模下这个开销可以接受如果要扩展到更大规格的数独就需要维护每行、每列、每宫的候选计数集合避免全盘扫描。3.3 0-1 背包分支限界的上界剪枝0-1 背包的暴力回溯会枚举每个物品放或不放2^N 次方组合N 稍大就爆炸。剪枝思路是先把物品按单位价值倒序排列递归时维护当前重量、当前价值、当前扫描到的物品下标。到某个物品时先算一个乐观上界即当前价值加上剩余物品最大可能贡献的价值。最常用的上界是“分数背包”的连续解剩余物品按性价比从高到低全部装入最后一个物品只装一部分。因为 0-1 背包要求整数选择而分数背包允许拆开所以分数背包的解一定不小于整数背包的任意解拿它做上界是安全的。核心逻辑可以写成这样def bound(idx, weight, value, items, capacity): if weight capacity: return 0 totalv value w weight for i in range(idx, len(items)): if w items[i].weight capacity: w items[i].weight totalv items[i].value else: totalv (capacity - w) * items[i].unit_value break return totalv def dfs(idx, weight, value): global best if idx len(items): best max(best, value) return if weight items[idx].weight capacity: dfs(idx 1, weight items[idx].weight, value items[idx].value) if bound(idx 1, weight, value, items, capacity) best: return dfs(idx 1, weight, value)注意细节在尝试“放”之前要检查不超过容量这是硬性约束剪枝在尝试“不放”之前用上界做最优性剪枝。两个剪枝方向覆盖了两种选择任何一条路都不可能突破上界搜索范围因此大幅缩小。我测过一个 30 件物品的普通随机数据暴力回溯节点数在千万级加了这个上界后降到几十万节点差别非常明显。如果物品数量更多还可以加记忆化或把容量维度的 DP 结合起来但那就是另一个话题了。3.4 组合去重排序加跳过同层重复元素处理带重复元素的组合、子集、全排列问题时最常见的错误是输出大量重复结果。解决方案是排序加同层去重。以全排列 [1, 1, 2] 为例如果不做任何处理第一个 1 和第二个 1 交换后生成的排列一模一样算法会重复枚举整棵相同子树。正确做法是在同一层递归中遇到和前一个元素相同且前一个元素还没被使用的情况时直接跳过for i in range(n): if used[i]: continue if i 0 and nums[i] nums[i-1] and not used[i-1]: continue used[i] True path.append(nums[i]) dfs(path) path.pop() used[i] False这里 used[i-1] 的判断是重点很多人容易写反。同层去重的思想并不复杂每个相同的值在一个位置只出现一次。它把同一层递归可能产生的重复子树压缩成一个代表从源头减少搜索分支。配合排序整体搜索树的高度不变但宽度被大幅压缩。这类剪枝的正确性证明也不难两个相同数字在前一个未被使用的状态下本质上没有区别第二个数字产生的子树一定包含在第一个产生的子树里所以可以砍掉。4. Alpha-Beta 剪枝博弈树里的最强加速4.1 从极小极大搜索说起如果你写过棋类 AI应该对极小极大搜索不陌生。下棋时我方希望最终评估值越高越好对手希望越低越好。所谓极小极大就是轮到己方时取所有子节点的最大值轮到对手时取其所有子节点的最小值。用这种递归方式遍历整棵博弈树能算出一个理论上最稳妥的走法问题是计算量太大。五子棋或者国际象棋的分支因子都在 30 以上深度每加一层节点数就要乘一次分支因子。深度 5 时可能已经是千万级节点再深就完全跑不动。Alpha-Beta 剪枝在这个基础上做了一个聪明的事情它不改变极小极大的计算结果只把那些绝对不可能影响最终最优选择的分支提前扔掉。也就是说它是一位不改变答案的剪枝这是它最迷人的地方。4.2 Alpha 和 Beta 分别是什么Alpha 和 Beta 是两个边界的名字理解它们比背代码更重要。Alpha 是当前路径上我方已经被保证能拿到的最大评估值它只会不断增大Beta 是当前路径上对手已经被保证能压到的最小评估值它只会不断减小。搜索过程中某个节点的值一旦导致 alpha 大于等于 beta就说明这个分支无论如何都不可能成为最终最优选择。比如我方已经有一个 alpha 5 的走法对手在另一个分支里回棋时我们扫到第一步就能让评估值降到 3那这个分支后面不管怎么走都不可能比 5 更好所以不再深入。对最大化节点来说一旦 alpha beta 就剪枝对最小化节点一旦 beta alpha 就剪枝。整个过程可以理解成两个人在不断收窄区间直到区间为空。4.3 实现一个带 Alpha-Beta 的搜索直接上代码比讲概念更实用。下面是最常见的递归实现INF 10**9 def alphabeta(state, depth, alpha, beta, maximizing): if depth 0 or state.is_terminal(): return evaluate(state) if maximizing: best -INF for action in state.legal_actions(): new_state state.apply(action) value alphabeta(new_state, depth - 1, alpha, beta, False) best max(best, value) alpha max(alpha, best) if alpha beta: break return best else: best INF for action in state.legal_actions(): new_state state.apply(action) value alphabeta(new_state, depth - 1, alpha, beta, True) best min(best, value) beta min(beta, best) if beta alpha: break return best调用入口传 alpha -INFbeta INF然后取返回值最大的走法即可。这个版本的返回值就是极小极大值剪枝只影响性能不影响正确性。关键点在于每层返回前都要更新边界最大化分支更新 alpha最小化分支更新 beta。很多人写错就是只更新了局部 best忘了把更新的边界传上去导致该剪的地方不敢剪。4.4 剪枝效果依赖节点排序Alpha-Beta 剪枝的效果有一个非常典型的规律如果子节点访问顺序很乱剪枝几乎不起作用如果每个节点都按对我方最有利的顺序优先展开剪枝效果可以达到最佳。理论上有一种理想排序状态能让搜索节点数从 O(b^d) 降到 O(b^(d/2))也就是说同样的深度计算量开个平方根。实际工程里做不到完美排序但靠着评估函数排序、启发式走法排序通常也能省掉 60% 以上的节点。这也是为什么做博弈树搜索时大家都会先做一个快速评分函数给着法排个序然后才递归而不是直接裸写 Alpha-Beta。排序本身要花钱但和它带来的剪枝收益比性价比非常高。对我自己来说这个案例最直接的启发是剪枝不是孤立的几行判断它和遍历顺序是一套组合拳。5. 常见问题与排查经验5.1 剪枝剪过头最危险的不完整性这是最典型的问题。剪枝条件稍微写错算法就会漏解。我自己经历过一次给组合总和问题加了一个“当前和已经大于 target 就返回”的判断原本很安全但后来数据里出现了 0 和负数这个判断就出错。因为后面还可能加上一个负数让和降回 target提前返回反而把合法解丢掉。任何剪枝都要先回答一个问题这个分支里还有没有可能有合法解或更优解如果有即便是很小的概率也不能剪。为了验证剪枝没有改变结果我习惯的做法是先跑一个小规模样例把不剪枝版本的结果和剪枝版本的结果做对比。如果有差异多半是剪枝条件不严密。这不是可选的步骤而是每一个剪枝改动都必须做的回归测试。许多人觉得剪枝嘛不就是要狠一点其实真正的大坑往往不是剪得少而是剪错方向。5.2 常见错误速查表把实战中容易踩的坑整理成一张表写代码和 review 时对照着看症状可能原因处理方式结果变少或缺失剪枝条件判断过于激进把仍有解分支剪掉回退该剪枝验证剪枝条件的安全性结果变多或重复对称剪枝去重条件写错或递归前后状态没恢复检查同层去重和 visited 标记确保恢复现场运行变慢每次递归都做高成本的前瞻检查记录扫描开销增加缓存或降低检查频率栈溢出搜索深度太大剪枝没能有效压缩深度检查边界条件用迭代加深替代纯递归Alpha-Beta 返回值异常边界参数没有在递归中正确更新在递归入出口分别打印 alpha、beta逐层核对这张表不是凭空列的每一种我都实际遇到过。尤其“递归前后状态没恢复”是回溯代码里最常见的老大难进入分支前设置的变量返回后没有还原导致下一个兄弟分支带着上一分支的状态继续搜索。排查时优先看所有修改过共享状态的代码行。5.3 判断剪枝收益的正确方法剪枝不是越快越好优化要看清收益。我评估一个剪枝手段会不会留下一般看两个指标一是剪枝后访问的节点数量变化二是总体运行时间变化。有时节点数量下降一半但为了维护剪枝所需的额外数据结构整体时间反而上升。这就需要用统计计数器在代码里埋点进入递归时 count 1剪枝返回时在剪枝点再加一个标记。跑同一组测试用例记录三个数总调用次数、剪枝前调用次数、剪枝后调用次数。如果剪枝判断本身过于昂贵可以把复杂的检查放在更容易触发的位置先做便宜快速的硬约束剪枝再做昂贵的前瞻剪枝和上界估计。排序、位运算、候选集合维护这些技巧都应当放在“确定剪枝条件正确”之后再上否则很难定位性能瓶颈到底出在哪一步。5.4 关于“非结构化剪枝”等术语的澄清最后提一个容易混淆的点。剪枝这个词在深度学习领域也常被用来指“模型剪枝”包括结构化剪枝和非结构化剪枝对应的是把神经网络的某些权重置为零或删除整层整通道目的是压缩模型、加速推理。它和本文讨论的回溯搜索剪枝虽然在中文里都叫剪枝但完全是两码事。搜索领域的剪枝砍的是状态空间树里的分支属于算法正确性框架内的优化模型剪枝砍的是神经网络参数属于模型压缩技术。如果你在搜“剪枝算法”时看到两类完全不同的文章不用惊讶先分辨讨论的是搜索树还是神经网络图再决定参考哪套方法。回溯搜索的“剪枝”永远要保证答案不变而模型剪枝牺牲的往往是模型精度两者目标不同判断标准也不同。我自己的习惯是剪枝代码永远最后写。先把暴力回溯跑通保证结果正确再把剪枝一条条加进去每加一条就跑一次结果对比。这不是保守而是剪枝写多了就会发现几乎所有隐蔽 bug 都出现在“想当然地认为某类分支不可能出解”的时候。如果你想把搜索性能再压一压可以继续探索记忆化搜索、启发式排序和双向 BFS但在那之前把剪枝这几个基础手段用好已经足够让大多数回溯问题从“跑不动”变成“刚刚好”。