
做算法题或者写搜索类程序的人应该都经历过这种崩溃回溯算法写出来逻辑全对样例也能过一提交就是超时。问题就出在搜索分支太多把一整棵搜索树硬生生走到底。我最早对剪枝产生深刻体感是在刷N皇后问题的时候——8皇后如果按最粗暴的方式枚举是8^8量级的候选组合而加了约束剪枝之后整个搜索过程只需要几千次递归调用这种量级差距用“拯救”来形容毫不夸张。这篇文章我会把回溯算法的剪枝思路完整拆开讲从搜索树为什么会爆炸讲起到四类核心剪枝方法再亲手带你用N皇后、0-1背包、数独和Alpha-Beta博弈搜索四个经典场景把代码写出来。适合正在刷LeetCode回溯题、写搜索类项目、或者做博弈AI的同学参考看完你至少能少走三个月弯路。1. 回溯算法的困境搜索分支为什么会爆炸1.1 回溯的本质是带后悔药的DFS回溯算法本质上就是深度优先搜索DFS加上状态恢复。你沿着一条路往下试探发现走不通就退回来恢复现场再选另一条路。这个“退回来”的操作在代码里表现为递归返回后撤销对状态变量的修改。举个例子求一个数组的全排列最直觉的写法就是这样的def permute(nums): result [] path [] used [False] * len(nums) def dfs(): if len(path) len(nums): result.append(path[:]) return for i in range(len(nums)): if used[i]: continue used[i] True path.append(nums[i]) dfs() path.pop() used[i] False dfs() return result这个写法是教科书级的但也是理解剪枝最好的起点。当len(path) len(nums)时我们得到一个完整排列每次循环选一个没用过的数递归下去。没有做任何“提前判断”的工作所有分支都会被探索一遍。这里的核心问题是如果输入有10个数全排列是10! 3,628,800个结果搜索树的节点数大约是10!再加上中间路径的节点数整体规模直接奔着千万级去了。一旦输入数量到1515!已经超过1.3万亿任何一个普通程序都不可能枚举完。1.2 搜索树的膨胀速度远超直觉很多人对搜索树规模没有概念我习惯用一个简单公式来估算搜索树的节点数约等于“每一层的分支数的连乘”也就是分支因子depth次方。分支因子为n、深度为m的搜索树叶子节点数是n^m。拿八皇后举例如果不加任何约束每一行有8个列位置可选放8行那就是8^8 16,777,216个叶子节点。看着不算特别大但如果是12皇后12^12约等于8.9万亿瞬间爆炸。现实中的搜索问题比如数独、排班调度、路径规划分支因子和深度都比这个夸张完全枚举在物理上是不可能的。这就是为什么剪枝不是优化手段而是回溯算法的必需品。一个没有剪枝的回溯算法在绝大多数真实场景下只是把“超时”变成了“MLE”或者“死循环”的另一种写法。1.3 先分清两种“剪枝”概念现在网络上搜“剪枝算法”经常能看到两种完全不同的东西。一个是深度学习里的模型剪枝分成结构化剪枝和非结构化剪枝指的是把神经网络里不重要的权重、通道、层去掉压缩模型体积。另一个就是我们这篇文章聊的搜索剪枝指在回溯搜索过程中提前终止对不可能产生解的分支的探索。两者的英文都是pruning但底层逻辑完全不同。模型剪枝砍的是参数搜索剪枝砍的是搜索树的子树。这篇文章只讲后者别再混淆了。2. 剪枝的本质与四类核心方法2.1 可行性剪枝不满足约束的分支直接砍掉可行性剪枝是最基础也最常用的一类。它的逻辑非常简单在递归的任意一层如果当前的局部选择已经违反了题目的约束条件那这个分支无论怎么往下走都不可能得到一个合法解直接return。以N皇后为例题目约束是“任意两个皇后不能在同一行、同一列、同一条对角线上”。我们按行放置皇后每行放一个那么行约束天然满足。剩下的就是列冲突和两条对角线冲突。def solve_n_queens(n): cols [False] * n diag1 [False] * (2 * n - 1) # r c diag2 [False] * (2 * n - 1) # r - c n - 1 board [-1] * n result [] def backtrack(r): if r n: result.append(board[:]) return for c in range(n): if cols[c] or diag1[r c] or diag2[r - c n - 1]: continue board[r] c cols[c] diag1[r c] diag2[r - c n - 1] True backtrack(r 1) cols[c] diag1[r c] diag2[r - c n - 1] False board[r] -1 backtrack(0) return result每走一步之前先检查列和两条对角线是否已经被占用。注意对角线冲突的两种表示方式r c恒定表示“/”方向的对角线r - c n - 1恒定表示“\”方向的对角线。这个偏移量n - 1是为了把负的下标转为非负索引避免数组越界。这道题里“行列冲突”就是约束条件。只要当前格子已经在某一列被占用后面的行无论再怎么放这个解都不会合法完全没必要往下走。这一个剪枝直接把8皇后的搜索从1677万个叶子节点砍到只有大约2000次递归调用。记住可行性剪枝是“保底”的任何回溯题至少要做到这一步。2.2 最优性剪枝用上下界干掉注定吃亏的路径可行性剪枝只管“合法不合法”但很多问题的目标是求“最优解”比如最大收益、最小代价。这类问题里即便一个分支能走到合法解也不代表它值得走——因为已经有一个更优的解在手了。最优性剪枝的核心思路是计算当前路径的“理论上限”如果是求最大值或“理论下限”如果是求最小值跟当前已知最优解比较。如果上限也不可能超过已知最优解那这个分支可以直接剪掉。以0-1背包为例经典回溯解法是这样def knapsack_max_value(values, weights, capacity): n len(values) items list(zip(values, weights)) items.sort(keylambda x: x[0] / x[1], reverseTrue) v [x[0] for x in items] w [x[1] for x in items] best 0 def bound(i, cur_w, cur_v): if cur_w capacity: return 0 total_v cur_v remaining capacity - cur_w j i while j n and w[j] remaining: remaining - w[j] total_v v[j] j 1 if j n: total_v v[j] * remaining / w[j] return total_v def backtrack(i, cur_w, cur_v): nonlocal best if i n: best max(best, cur_v) return if cur_w w[i] capacity: backtrack(i 1, cur_w w[i], cur_v v[i]) if bound(i 1, cur_w, cur_v) best: backtrack(i 1, cur_w, cur_v) backtrack(0, 0, 0) return best这里最关键的代码是bound函数。它的含义是假设当前已经选了i之前的物品剩余容量还能装多少价值。为了估算上限用贪心的方式把剩余物品按单位价值从高到低“全部塞进去”——哪怕某个物品只能分割一部分也用分数填满。这个分数装填的结果就是当前路径的价值理论上限。如果这个理论上限都不超过已经找到的best那这条路径继续往下走即使走得再深也不可能刷新最优值果断剪掉。留意排序的细节。我把所有物品按单位价值从高到低排序这样贪心放松的上界会更紧剪枝效率更高。如果排序不处理贪心上界可能松弛得厉害剪枝效果大打折扣。这个排序值得反复体会它是整个最优性剪枝的隐形功臣。2.3 重复状态剪枝别让同一个状态被算三遍第三类剪枝比较隐蔽但也非常常见。很多搜索问题会在不同路径上抵达相同的“状态”——比如相同的剩余物品集合、相同的剩余容量、相同的棋盘局面。如果每次都从头搜索等于反复计算同一个子问题。最直接的解决办法是哈希去重或记忆化搜索。用一个字典记录“状态 - 已计算的最优结果”递归时先查表命中就直接返回。from functools import lru_cache def knapsack_with_memo(values, weights, capacity): n len(values) lru_cache(None) def dfs(i, cap_left): if i n: return 0 # 不选当前物品 ans dfs(i 1, cap_left) # 选当前物品 if weights[i] cap_left: ans max(ans, values[i] dfs(i 1, cap_left - weights[i])) return ans return dfs(0, capacity)状态是(i, cap_left)表示已经处理到第i个物品、剩余容量为cap_left时的最大价值。因为在每个状态下未来的决策完全由这两个参数决定所以计算结果可以安全缓存。原来需要重复计算大量子问题现在每个状态只算一遍。不过要注意记忆化适合子问题重叠度高的场景。如果每个状态只出现一次比如前面那种已经做了排序和上界剪枝的回溯版本记忆化的收获就不大。两类写法有各自的适用场景不是用得多就好而是合适就好。2.4 搜索顺序优化先搜哪个分支影响超乎想象这一条常被忽略但效果往往比花哨的剪枝函数更猛。搜索顺序决定了“找到好解的速度”和“剪枝条件生效的速度”。求最大值的问题先探索大概率产生更优解的分支就能尽早抬高best让后续分支更容易被上界剪枝砍掉。求可行解的问题先探索约束最强的分支能更快触发冲突提前剪掉大量分支。在N皇后问题里列冲突最少的行先放明显减少尝试次数在数独问题里找候选数最少的空格先填这就是著名的MRV启发式Minimum Remaining Values。一个简单的排序经常能把搜索树缩小几个数量级。搜索顺序优化的本质是剪枝条件只有在信息充分的情况下才有效。你早一点拿到一个较好的可行解后续的bound best判断就会更频繁地为真剪枝也就更狠。我做过一个实验同一个0-1背包数据集物品按单位价值降序排列后的回溯耗时只有乱序排列的1/10就是这么离谱。3. 实战拆解三个经典问题的剪枝套路3.1 N皇后从暴力到位运算加速的完整演化N皇后是最适合练习可行性剪枝的题目。上面给的版本用了三个布尔数组已经把搜索规模压到很小。但如果你想要更极致可以用位运算替代布尔数组同时能进一步压缩常系数。位运算的核心思路是用三个整数分别表示列、主对角线、副对角线的占用状态每一位代表一列的位置def solve_n_queens_bit(n): result [] board [-1] * n def backtrack(r, cols, diag1, diag2): if r n: result.append(board[:]) return available ((1 n) - 1) ~(cols | diag1 | diag2) while available: p available -available c p.bit_length() - 1 board[r] c backtrack(r 1, cols | p, (diag1 | p) 1, (diag2 | p) 1) board[r] -1 available available - 1 backtrack(0, 0, 0, 0) return result说明一下位运算的机理cols的二进制第i位为1表示第i列已被占用diag1的二进制第i位为1表示“rci”这条主对角线被占用diag2的二进制第i位为1表示“r-cn-1i”这条副对角线被占用。available的值是当前行所有可放位置组成的位掩码拿到最低位的1p available -available就对应一个可放置的列。进入下一层递归时主对角线掩码要左移一位副对角线掩码要右移一位因为随着行号增加对角线冲突的编号恰好平移一位。这是位运算回溯的经典代码网上很多版本都是这个套路建议自己手写两遍彻底吃透。性能上位运算版本比布尔数组版本大约再快3到5倍在大N比如N20时可以显著感受差距。实际测试中用布尔数组版本的8皇后递归调用约2000次12皇后大约几十万次到了16皇后布尔版本已经需要一段时间的等待而位运算版本仍然能快速返回。所以如果你要挑战大N位运算不是炫技而是实实在在的工程需要。3.2 0-1背包上界函数和搜索顺序如何协同发力0-1背包用回溯上界剪枝是理解最优性剪枝最好的例子。前面代码里已经出现过核心逻辑这里再拆解一遍执行流程顺带讲清楚上界函数是怎么和搜索顺序协同的。假设数据是这样容量10三个物品价值5重量3价值7重量4价值3重量2。先按单位价值排序三个物品单位价值分别是1.67、1.75、1.5排序后顺序是物品27/41.75、物品15/31.67、物品33/21.5。搜索从第0个物品开始两条分支选与不选。选则有价值7不选则为0。每条分支继续向下每一步都调用bound估算剩余容量的价值上限。关键点在于一旦在某个分支上找到一个较好的可行解比如“选物品2和物品3”总价值10best就被抬到10。后续不选物品2的分支即使把剩余容量全塞满上界也只有7低于10直接不再深入。为了让这个“较早发现好解”的概率最大化我们把价值高、重量轻的物品放在前面优先搜索。这是搜索顺序优化和最优性剪枝的组合拳两者缺一不可。如果顺序乱排前期找到的best太小上界剪枝很难生效搜索就退化成暴力枚举。实测中同样100个物品的数据集排序后剪枝的回溯平均在几毫秒内完成不排序直接上回溯可能几秒都跑不完。所以我每次写回溯题第一件事就是看能不能通过排序让“好分支”先被探索。这个上界函数还有改进空间。比如可以加一个“当前已选重量剩余最小重量”判断剩余物品全部塞进去也装不满就不行或者用动态规划预处理求剩余物品子集的精确上界。但作为回溯剪枝贪心放松的上界已经足够日常使用了太复杂的上界计算会喧宾夺主反而拖慢整体速度。3.3 数独求解MRV启发式让搜索量断崖式下降数独是另一个极好的剪枝练习场。暴力回溯填数独的写法很简单从左到右从上到下找到空格就试着填1到9填完检查行、列、九宫格是否冲突。但这种写法在比较难的数独谜题上会非常慢因为搜索顺序完全由棋盘布局决定而没有任何智能。真正提升数独求解性能的关键是MRV启发式每次选择候选数字最少的空格来填。候选数字少意味着分支因子小搜索树整体就小更重要的是候选数为0时立刻就能判断这条路径失败不用再往下白白探索。def solve_sudoku(board): def candidates(r, c): used set(board[r]) | {board[i][c] for i in range(9)} br, bc r // 3 * 3, c // 3 * 3 for i in range(br, br 3): for j in range(bc, bc 3): used.add(board[i][j]) return [d for d in range(1, 10) if d not in used] def find_next(): best_r best_c -1 best_len 10 for r in range(9): for c in range(9): if board[r][c] 0: cands candidates(r, c) if len(cands) best_len: best_len, best_r, best_c len(cands), r, c if best_len 0: return best_r, best_c return best_r, best_c def backtrack(): r, c find_next() if r -1: return True for d in candidates(r, c): board[r][c] d if backtrack(): return True board[r][c] 0 return False backtrack() return board这段代码里find_next每次扫描整个棋盘找到当前候选数最少的格子。注意候选数是在递归内部动态计算的——因为你每填一个数会影响同行、同列、同九宫格其他格子的候选数集合。“动态更新选择最小候选集”这就是MRV启发式的完整闭环。可以用一个生活化类比来理解做抉择时哪个选项最受限就先决定它如果最受限的地方都没有可行选项整个方案必然失败趁早止损比在宽松的地方纠结一千次都划算。实测中MRV版本对绝大多数标准9x9数独都能在毫秒级完成求解而固定顺序扫描的回溯在“困难”级别谜题上可能要十几秒甚至分钟级。如果你有写数独游戏或者数独解题器的需求这个优化是必须的。4. Alpha-Beta剪枝博弈树上的最优选择搜索4.1 极大极小值搜索是怎么工作的下棋类AI是回溯搜索最经典的应用场景。假设你执黑棋对方执白棋你希望最终局面对自己最有利对方希望对他最有利。那在搜索时就轮流以“己方取最大值、对方取最小值”的视角来评估局面。这就是极大极小值搜索Minimax。搜索树的每一层代表一步棋偶数层是己方走棋选择评估值最大的分支奇数层是对方走棋选择评估值最小的分支。叶子节点的评估值由局面评估函数给出正的表示对己方有利负的表示对对方有利。举个例子深度为4的搜索可以这样理解你走一步对方走一步你再走一步对方再走一步到达叶子局面然后从叶子开始往上回溯。对方会选那些让评估值最小的走法而你会选让评估值最大的走法。这个交替取最大/取最小的过程保证了最终的评估值对应双方都做出最优选择之后的局面优劣。4.2 Alpha-Beta剪枝的递归结构Alpha-Beta剪枝是对Minimax搜索的优化原理是维护两个界限alpha表示“我方已找到的最佳选择的下界”beta表示“对方已找到的最佳选择的上界”。在搜索过程中一旦发现某个分支已经不可能改变最终的alpha或beta就停止搜索剩余子节点。def alphabeta(node, depth, alpha, beta, maximizing): if depth 0 or node.is_terminal(): return node.evaluate() if maximizing: value float(-inf) for child in node.get_ordered_children(): value max(value, alphabeta(child, depth - 1, alpha, beta, False)) alpha max(alpha, value) if beta alpha: break return value else: value float(inf) for child in node.get_ordered_children(): value min(value, alphabeta(child, depth - 1, alpha, beta, True)) beta min(beta, value) if beta alpha: break return value核心就一句话if beta alpha: break。当对方所说的上界已经不超过我方所说的下界时这个节点后面的分支再怎么搜也没有意义。对极大层来说一旦某个子节点的值让alpha超过了beta说明这个节点的最终值不可能对整体决策产生影响了对极小层同理。需要注意Alpha-Beta剪枝必须在Minimax的递归框架内使用。它不是独立于Minimax的另一种算法而是Minimax的剪枝加速版。在不改变结果的前提下它能极大减少搜索量。理想情况下Alpha-Beta可以把搜索树从O(b^d)降到O(b^(d/2))这个差别是巨大的——同样是每层35个分支、深度8层的国际象棋搜索暴力搜是35的8次方而Alpha-Beta大约只需35的4次方量级。4.3 走法排序对剪枝效果的显著影响Alpha-Beta剪枝的效果高度依赖节点的搜索顺序。如果最大值层最先遇到足够大的值最小编层最先遇到足够小的值剪枝窗口会快速收窄剪掉的子树数量会大幅增加。反过来如果顺序很差alpha和beta迟迟无法收紧几乎砍不掉什么分支。所以工业级的博弈引擎几乎都会做走法排序先用上一迭代的置换表记录类似重复状态剪枝优先搜索再对剩余走法做静态启发式评估——比如吃子棋局中优先走价值高的吃子棋类中优先走中心控制较强的位置。我见过一些对战程序只是改了走法排序相同的搜索深度下棋力就能明显上升因为同样的时间预算内它能“看得更远”。还要提醒一个实战中容易忽略的细节Alpha-Beta剪枝要求博弈树的评估是“全局一致”的。也就是说同一个局面不论在搜索树的哪个位置出现它的评估值应该一样。如果评估函数带了影响搜索方向的随机性或者开局库、残局表这些外部知识没有被正确处理剪枝结果就不可靠甚至可能剪掉最优解。5. 常见问题与排查技巧实录5.1 剪枝把正确答案也剪掉了怎么办剪枝过头是最容易踩的坑。我自己最早写0-1背包时上界函数里忘了对“选”分支做容量检查结果某个分支的bound过小把明明能刷新best的路径给截断了。还有一个典型错误是可行性剪枝的约束条件写错比如N皇后判断对角线冲突时下标搞混导致某些合法摆法被直接跳过。排查思路其实很简单先用一个明确的小测试用例跑一遍确认答案正确再手动模拟搜索树人工确认每一个剪枝条件是否成立。如果没有现成的小用例就自己构造保证答案一眼能算出来。正确性永远是第一位的剪枝效率只是优化不能以牺牲正确性为代价。5.2 为什么剪了还是很慢如果剪枝条件写对了但还是慢通常有两个原因。第一缺少搜索顺序优化。不管什么剪枝都是在已有信息基础上做判断——你越早拿到一个较好的可行解剪枝条件越容易快速生效。第二剪枝条件本身的强度不够。比如上界函数给了个非常放松的估算上限那么几乎所有分支都无法被剪掉等于没剪。我一般会做一件事在回溯递归入口加一个计数器统计进入递归的次数然后把有剪枝和无剪枝版本的计数对比。如果剪枝版本递归次数只少了一点点说明你的剪枝条件太弱如果少了几个数量级说明剪枝是有效的。这个数字是最直观的健康指标比猜优化效果靠谱得多。5.3 避坑速查表问题症状排查方向剪枝条件过强答案缺失或错误用小用例人工模拟搜索树检查剪枝条件是否过强剪枝条件过弱运行时间与暴力枚举相当提高上界/下界精度或检查可行性剪枝是否漏写搜索顺序差剪枝不生效best长期很低调整物品/走法/格子的排序策略优先探索高质量分支状态恢复遗漏结果错乱、分支互相污染检查递归里所有修改过的状态是否在回溯后还原记忆化key设计错缓存命中率低或结果错误确保状态key包含所有影响后续决策的变量边界条件写错数组越界或结果偏移重点检查偏移量计算、容量、剩余值等上下限提示剪枝是“用对问题的理解换性能”不是无脑砍分支。写剪枝前先把问题的约束和最优化目标用一句话写清楚再动手。结尾根据我实际做搜索类题目的经验回溯剪枝想写好核心就三件事先保证可行性剪枝把非法分支挡掉再设计一个足够紧的最优性剪枝条件最后用搜索顺序优化让剪枝条件尽早生效。这三个环节缺一个性能都会差很远。我踩过最深的坑是只盯着剪枝函数本身忽略了搜索顺序——后来发现调整顺序的收益常常比优化剪枝函数还大。最后再分享一个小技巧做任何回溯题先把递归函数的参数想成“状态的完整描述”再想“什么情况下这个状态不可能得到更好解”剪枝条件自然就写出来了。多练几个经典题之后你会发现所有剪枝代码背后都是一套统一的思维模式看透了就能举一反三。