做算法题也好,写业务逻辑也好,回溯算法大概是大家最先接触到的“体面暴力”。它一句话就能讲清楚:一条路走到黑,不行就退回换一条。但真正写过递归搜索的人都知道,回溯效率是个大问题——搜索空间一大,机器根本扛不住。我第一次被回溯震撼到是写八皇后小工具,刚开始没加剪枝,四皇后已经让我觉得慢,等跑到八皇后,整个程序卡得风扇起飞,屏幕鼠标都不怎么动。后来把几行剪枝逻辑一步步加进去,同样的八皇后瞬间出结果,搜索分支从千万级别掉到几万级别。从那以后我养成了一个习惯:凡是回溯,先问能不能剪枝。
这篇文章把回溯剪枝的思路整理成一套可以直接上手的套路,重点覆盖 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 = -INF,beta = 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;但在那之前,把剪枝这几个基础手段用好,已经足够让大多数回溯问题从“跑不动”变成“刚刚好”。