很多程序员第一次接触"搜索",是在 LeetCode 上的全排列、组合、子集、N 皇后、数独、单词搜索这些题里。这些题做多了以后,我们很容易形成一套肌肉记忆:递归、选择、撤销选择、剪枝。
但搜索算法真正重要的地方,并不在递归本身。
递归只是遍历方式。搜索真正关心的是:如何把一个巨大的候选空间组织成一棵隐式的树;如何在树还没有完全展开时,就证明某些分支不可能产生答案;如果问题要求最优解,又如何证明某些分支不可能比当前答案更好。
换句话说,搜索算法的核心不是"把所有可能性试一遍",而是"尽早证明哪些可能性不必再试"。
这篇文章讨论的不是回溯模板,而是模板背后的结构:解向量、解空间树、部分解、死节点、限界函数,以及一个最重要的问题:剪枝为什么不会漏掉正确答案。
从暴力枚举到隐式解空间树
很多组合问题看起来都绕不开穷举。
比如图着色问题中,给定一个无向图 \(G=(V,E)\),如果每个顶点都可以染成 \(1,2,3\) 三种颜色之一,那么对 \(n\) 个顶点来说,最朴素的候选空间大小是:
\[3^n \]
其中大多数染色方案并不合法,因为相邻顶点可能颜色相同。
如果完全无组织地枚举,搜索很快会失控。搜索算法所做的第一件事,就是把这些候选方案组织起来。很多搜索问题都可以写成一个解向量:
\[X=(x_1,x_2,\dots,x_n) \]
其中每个分量 \(x_i\) 来自一个有限集合 \(S_i\)。完整搜索空间就是:
\[S_1 \times S_2 \times \cdots \times S_n \]
这句话看起来有些数学化,但它其实就是我们平时写搜索代码时面对的对象。
例如:
- 全排列问题中,\(x_i\) 表示第 \(i\) 个位置放哪个元素;
- N 皇后问题中,\(x_i\) 可以表示第 \(i\) 行皇后所在的列;
- 3-coloring 中,\(x_i\) 表示第 \(i\) 个顶点的颜色;
- 0/1 背包中,\(x_i \in {0,1}\) 表示是否选择第 \(i\) 个物品。
一旦把问题写成连续决策,它就自然形成了一棵树:
- 根节点表示空向量 \(()\);
- 第 \(k\) 层节点表示一个长度为 \(k\) 的前缀 \((x_1,\dots,x_k)\);
- 从一个节点扩展子节点,就是给 \(x_{k+1}\) 选择一个候选值;
- 叶子节点对应完整解向量。
这棵树通常不会被显式构造出来。它存在于递归调用栈、队列、优先队列、状态变量或搜索框架里。因此它也常被称为隐式解空间树。从这个角度看,搜索代码并不是"递归魔法"。它只是用某种顺序遍历这棵隐式树。
一个节点代表的不是一个答案,而是一整棵子树
真正决定搜索效率的,往往不是完整解,而是部分解。一个长度为 \(k\) 的前缀:
\[(x_1,x_2,\dots,x_k) \]
代表的不只是当前这 \(k\) 个选择,而是它下面所有可能的补全。也就是说,一个搜索节点代表的是一整棵子树。例如在 N 皇后中,前 \(k\) 行皇后的列号确定以后,这个节点下面包含了所有可能的后 \(n-k\) 行摆法。剪掉一个节点,等于同时放弃它下面的所有补全。
这件事很危险,也很强大。如果剪错了,就可能漏掉正确答案。如果剪得安全,一次判断就能砍掉指数级数量的候选方案。
所以每个安全剪枝背后,都应该有一个证明。
在回溯法中,我们通常证明:
\[\text{当前部分解违反约束} \Rightarrow \text{任意扩展都不可能合法} \]
在分支限界法中,我们通常证明:
\[\text{当前子树的最好可能性不优于已知答案} \Rightarrow \text{任意扩展都不可能成为最优解} \]
如果一个判断只能说"这样大概率不会错",那它是启发式;如果它能说"这样一定不会漏解",它才是精确搜索中的安全剪枝。
这也是搜索算法最重要的思想:剪枝不是技巧,而是一种证明。

部分解、死节点和 promising node
在解空间树中,一个节点通常可以被分成几类。
-
第一类是完整解。它已经给所有变量赋值,并且满足问题约束。
-
第二类是仍有希望的部分解。它还不是完整解,但目前没有足够理由否定它。
-
第三类是死节点。它已经不可能扩展成合法解,或者不可能扩展成更优解。
当然,"当前没有违反约束"不一定等于"未来一定可以扩展成完整解"。例如图着色中,一个部分染色可能暂时没有任何已染色边冲突,但某个未染色顶点的所有颜色都已经被邻居占用。此时它虽然没有出现显式冲突,却已经不可能扩展成完整合法染色。没有被剪掉的节点,只表示"暂时不能证明它失败";被剪掉的节点,则必须能证明"它一定失败"。
回溯法中的 is_valid、is_promising、check 这些函数,本质上都是在回答同一个问题:这个节点下面,还有没有继续搜索的必要?
回溯法:用约束剪掉不可能的解
回溯法通常以深度优先方式遍历解空间树。它从空向量开始,依次给 \(x_1,x_2,\dots\) 赋值。如果当前前缀仍然有希望,就继续深入;如果当前前缀已经被证明不可能成功,就返回上一层,尝试下一个候选值。
程序员熟悉的结构大概是:
text
search(k):
if current state is a final solution:
record it
return
for x in candidates of next decision:
choose x
if current state is still promising:
search(k + 1)
undo x
这里最重要的不是递归,而是 promising 判断。如果没有这个判断,搜索就退化成完整枚举。promising 越能尽早发现失败,搜索树就越小。
搜索顺序不是实现细节
从解空间树的角度看,候选值顺序决定了遍历顺序,变量顺序决定了树的形态。
这会带来几个直接后果:
- 变量顺序会改变剪枝发生的早晚;
- 候选值顺序会影响第一个解出现的位置;
- 剪枝判断越早发生,被砍掉的子树越大;
- 同一个问题换一种状态表示,可能得到完全不同规模的搜索树。
所以,成熟的搜索算法设计并不是"套模板",而是先设计一个好的状态空间,再决定如何遍历它。
N 皇后:状态设计本身就是剪枝
N 皇后是最经典的回溯问题之一。最直接但很糟糕的建模方式,是逐格决定棋盘上每个位置是否放皇后。对于一个 \(n \times n\) 的棋盘,这相当于面对接近 \(2^{n^2}\) 的候选空间。
但我们知道,每一行最终必须恰好放一个皇后。于是可以改用一维向量:
\[x_i = \text{第 } i \text{ 行皇后所在的列} \]
这样搜索空间立刻从棋盘格子的子集,变成每行列号的组合,规模变成 \( n^n \)
如果进一步意识到每一列也最多只能放一个皇后,那么还可以把状态空间限制为列的排列,规模进一步变成 \( n! \)
这三个状态空间描述的是同一个问题,但搜索规模完全不同。这说明一个很重要的事实:剪枝不只发生在 if 语句里,也发生在状态定义里。

在一维表示中,对于任意两行 \(i\) 和 \(j\),两个皇后冲突当且仅当 \( x_i = x_j \) 或者 \( |x_i-x_j|=|i-j| \)。前者表示同列冲突,后者表示对角线冲突。
当我们只放了前 \(k\) 行皇后时,只要前 \(k\) 行内部已经冲突,就没有必要再考虑第 \(k+1\) 行及之后的任何放法。因为后续选择无法改变已经放下的皇后之间的冲突。
这就是一个典型的回溯剪枝证明:
\[\text{已有皇后互相攻击} \Rightarrow \text{任意扩展都不可能合法} \]
N 皇后的重点是理解:好的状态设计本身就在提前编码约束。
图着色:从冲突检查到更强的提前失败检测
再看 3-coloring。给定无向图 \(G=(V,E)\),要给每个顶点染三种颜色之一,使得任意相邻顶点颜色不同。完整解可以表示为:
\[X=(x_1,x_2,\dots,x_n), \quad x_i \in {1,2,3} \]
没有剪枝时,候选解数量是 \( 3^n \)。
最基本的回溯做法是按顺序给顶点染色。对于一个部分染色,只要某两个已经染色的相邻顶点颜色相同,就可以立刻剪枝:
\[\exists (u,v)\in E,\ x_u=x_v \Rightarrow \text{任意扩展都不可能合法} \]
因为后续给其他顶点染色,无法修复已经发生的边冲突。
更强的做法是维护每个未染色顶点仍然可用的颜色集合。给某个顶点染色后,它的邻居就不能再使用这个颜色。如果某个未染色顶点的颜色集合变为空,那么即使当前没有任何已染色边冲突,这个节点也已经不可能扩展成完整解。这叫 forward checking,可以理解为一种更早的失败检测。
例如,某个未染顶点 \(v\) 的三个邻居已经分别使用了颜色 \(1,2,3\)。此时 \(v\) 已经没有任何合法颜色可选。即使当前染色方案还没有直接冲突,也应该立即剪掉。越强的必要条件,越可能提前发现死节点,节省无用的搜索。
变量选择顺序也很重要。如果总是按编号给顶点染色,可能很晚才遇到真正困难的顶点。更好的策略是优先选择可选颜色最少的顶点,也就是常说的 MRV,minimum remaining values。
直觉很简单:越难安排的变量,越应该早点处理。因为如果它注定失败,越早失败,剪掉的子树越大。这就是为什么搜索顺序不只是实现细节。它直接影响剪枝发生的时机。
从可行性剪枝到最优性剪枝
回溯法擅长处理约束满足问题,例如 N 皇后、数独、图着色。它关心的主要问题是:这个部分解还能不能扩展成合法解?
但很多问题不只是要找一个合法解,而是要找最优解。例如 0/1 背包中,合法解有很多个。我们真正关心的是:在容量限制内,总价值最大的是哪一个?这时,仅仅判断一个节点是否合法还不够。一个部分解即使合法,也可能没有继续搜索的价值,因为它下面无论怎么扩展,都不可能超过当前已知最优解。
这就是分支限界法的入口。分支限界法也是一种有组织的穷举。它和回溯法一样在解空间树上搜索,但它多了一个关键问题:这个节点所在的子树,理论上最多能产生多好的解?
对于最大化问题,我们通常给每个节点计算一个上界 upper bound,记作 \(ub\)。它表示:从这个部分解继续扩展,理论上最多能达到多好的目标值。
如果当前已经有一个可行解,目标值为 \(best\),并且某个节点满足:
\[ub \le best \]
那么这个节点下面不可能产生更好的解,可以安全剪掉。
对于最小化问题,逻辑类似,只不过使用的是下界 lower bound。如果某个节点的下界已经不小于当前已知最优值,也可以剪掉。
因此,回溯法和分支限界法的区别可以概括为:
\[\text{回溯法:证明这里不可能有合法解} \]
\[\text{分支限界法:证明这里不可能有更优解} \]
bound 不是猜测,而是正确性的一部分
bound 很像启发式评分,但二者不能混为一谈。
如果某个函数只是用来决定"先搜索哪个节点",那它可以是启发式的。它估得准,算法就快一些;估得不准,算法就慢一些。
但如果某个函数被用来剪枝,为了保证不漏掉最优解,它就必须满足严格的上界或下界性质。
对于最大化问题,如果我们用 \(ub(s)\) 剪枝,就必须保证:
\[ub(s) \ge \max_{y \in completion(s)} value(y) \]
也就是说,\(ub(s)\) 必须不小于这个节点所有可行补全中的最大价值。
对于最小化问题,如果我们用 \(lb(s)\) 剪枝,就必须保证:
\[lb(s) \le \min_{y \in completion(s)} cost(y) \]
也就是说,\(lb(s)\) 必须不大于这个节点所有可行补全中的最小成本。
这听起来有些绕,但本质很简单:
- 最大化问题的上界必须足够乐观,不能低估;
- 最小化问题的下界也必须足够乐观,不能高估。
如果这个性质不成立,剪枝就不安全。算法可能直接丢掉真正的最优解。因此,bound 不是装饰性的性能优化,而是分支限界法正确性的一部分。
0/1 背包:用分数背包构造上界
0/1 背包问题很适合说明分支限界法。给定 \(n\) 个物品,每个物品有重量 \(w_i\) 和价值 \(v_i\),背包容量为 \(W\)。每个物品只能选或不选,因此解向量是:
\[x_i \in {0,1} \]
其中 \(x_i=1\) 表示选择第 \(i\) 个物品。目标是最大化总价值:
\[\max \sum_{i=1}^{n} v_i x_i \]
约束是总重量不超过容量:
\[\sum_{i=1}^{n} w_i x_i \le W \]

搜索树的第 \(i\) 层对应是否选择第 \(i\) 个物品。如果某个节点已经超重,那么它是不可行节点,可以直接剪掉。这是回溯式的可行性剪枝。
但分支限界法还会进一步问:如果当前还没超重,它有没有可能超过当前最优解?假设当前已经决定了前 \(k\) 个物品,当前重量为 \(w\),当前价值为 \(v\)。最粗糙的上界是:
\[ub = v + \sum_{i=k+1}^{n} v_i \]
这个上界一定安全,因为它假设后面所有物品都能被装进去。但它太乐观了,很多明显不可能超过 \(best\) 的节点也无法被剪掉。更精细的做法,是借用分数背包的思想。
把剩余物品按照单位价值 \(v_i/w_i\) 从高到低排序,然后尽可能装入背包。如果最后一个物品装不下,就允许只装一部分。这会得到一个上界。因为分数背包是 0/1 背包的松弛:它去掉了"物品不可切分"的约束,允许物品被部分选取。放松约束之后,可选方案只会变多,不会变少。因此,分数背包在同一子树中得到的最优值,一定不会小于 0/1 背包的真实最优值。
也就是说:
\[ub \ge \text{当前子树中的真实最优价值} \]
虽然这个 \(ub\) 本身不一定能被 0/1 背包达到,但如果它都不超过当前已知最优值:
\[ub \le best \]
那么这个子树一定不可能产生更优解,可以安全剪掉。
这里有一个重要权衡:
- 很松的上界也能保证正确性,但剪枝效果差;
- 很紧的上界能剪掉更多节点,但计算成本更高。
分支限界法的工程设计,往往就在这两者之间寻找平衡。
最大团:回溯和限界合在一起
前面的例子里,N 皇后偏向可行性剪枝,背包偏向最优性剪枝。最大团问题则能很好地展示两者如何结合。
最大团问题是:给定一个无向图,找到最大的顶点集合,使得集合中任意两个顶点之间都有边。
一个搜索状态可以写成:
text
C: 当前已经选入团的顶点集合
P: 仍然可能加入 C 的候选顶点集合
best: 当前找到的最大团大小
其中 \(C\) 必须始终是一个团。
如果选择一个顶点 \(v \in P\) 加入当前团,那么后续还能加入的顶点,必须同时满足两个条件:
- 它原本就在候选集合 \(P\) 中;
- 它必须与 \(v\) 相邻。
因此新状态是:
\[C' = C \cup {v} \]
\[P' = P \cap N(v) \]
这里的 \(P \cap N(v)\) 本身就是一种状态级剪枝。它直接排除了所有不可能与 \(v\) 共处同一个团的顶点。
一个简化版搜索可以写成:
text
expand(C, P):
if |C| + |P| <= best:
return
choose v from P
expand(C ∪ {v}, P ∩ N(v))
expand(C, P \ {v})
其中:
\[|C| + |P| \]
是一个朴素上界。因为即使候选集合 \(P\) 中所有顶点都能加入,最终团大小也不会超过 \(|C|+|P|\)。
所以如果:
\[|C| + |P| \le best \]
这个节点就可以剪掉。
但这个上界通常很松,因为 \(P\) 中的顶点未必两两相邻。候选点很多,不代表它们都能一起加入团。
更强的做法是对候选子图 \(P\) 做一次贪心染色。
如果 \(P\) 可以被染成 \(k\) 种颜色,那么从 \(P\) 中最多只能选出 \(k\) 个顶点加入团。
原因是:同一种颜色中的顶点两两不相邻,而一个团要求任意两个顶点都相邻。所以同一颜色中最多只能贡献一个顶点。
于是我们得到更强的上界:
\[|C| + coloring_bound(P) \]
如果:
\[|C| + coloring_bound(P) \le best \]
也可以安全剪枝。
这个例子很有代表性。它说明 bound 不一定来自原问题本身,也可以来自另一个更容易计算的结构。这里我们不是在真正求图着色的最优解,而是用一次贪心染色快速得到一个安全的上界。
更重要的是,这个 bound 依然有证明:候选集合中的团大小不可能超过候选子图的染色数。因此,用染色结果给最大团搜索做上界是安全的。
TSP:下界来自必要条件和问题松弛
旅行商问题也是分支限界法中的经典例子。给定 \(n\) 个城市和两两之间的距离,要求每个城市访问一次并回到起点,使总路程最短。
这是一个最小化问题,所以我们需要下界。一个最简单的必要条件是:在最终的 Hamilton 回路中,每个城市的度数都是 2,也就是有一条边进入,一条边离开。因此,对于对称 TSP,可以对每个城市取与它相连的两条最便宜的边,把这些成本加起来再除以 2,得到一个粗下界:
\[lb = \frac{\sum_i (min1_i + min2_i)}{2} \]

更强的下界通常来自问题松弛。例如,对于某个已经固定部分路径的节点,剩余城市最终必须被连接起来。我们可以用剩余城市上的最小生成树成本,再加上从路径端点连接到剩余集合的必要边,构造一个下界。
更经典的做法是 1-tree lower bound:固定一个根城市,对其他城市求最小生成树,再加上根城市关联的两条最便宜边。这个结构比普通 MST 更接近 TSP 回路,因此通常能给出更紧的下界。
TSP 的例子再次说明:bound 往往来自原问题的必要条件或松弛问题。它可以不是一个合法解,但必须是一个安全的乐观估计。
待处理节点集合:PT 不一定是优先队列
很多教材在讲分支限界法时,会提到一个待处理节点表,常记为 PT。
需要注意的是,PT 的本质不是优先队列,而是活节点集合:所有还没有被剪掉、也还没有被扩展完的节点,都可以放在里面。
至于下一步扩展哪个节点,是搜索策略的问题。
如果用 FIFO 策略,PT 表现为队列,搜索更接近广度优先。
如果用 LIFO 策略,PT 表现为栈,搜索更接近深度优先。
如果每次选择 bound 最有希望的节点,PT 通常用优先队列实现,搜索就是 best-first 分支限界。
所以,分支限界法的本质不是"必须使用优先队列",而是:每个节点都有一个界,搜索过程中可以用这个界来剪枝,也可以用它来决定扩展顺序。
一个节点通常需要保存:
text
partial_solution
level
current_value_or_cost
bound
parent_or_path
这和普通 DFS 很不同。
回溯法沿着递归栈向下走,路径天然保存在调用栈里。best-first 分支限界则可能跳跃式地处理节点:这一步扩展左边子树的某个节点,下一步可能跳到右边子树的另一个节点。
因此,如果最后不仅要得到最优值,还要得到对应的完整解,就需要额外保存路径、父指针,或者在节点中保存足够的状态信息。
最坏复杂度不会消失
回溯法和分支限界法都属于精确搜索方法。它们没有改变很多组合问题的最坏情况本质。如果问题本身具有指数级搜索空间,在最坏情况下,算法仍然可能访问指数级数量的节点。
剪枝改善的是实际搜索规模,而不是魔法般消除复杂性。一个好的剪枝条件,可能在真实数据上把搜索量从天文数字降到可以接受;但在理论最坏情况下,它仍然可能无能为力。
这也是为什么我们需要同时关心两件事:
- 正确性:剪枝一定不能漏掉答案或最优解;
- 有效性:剪枝要足够早、足够强、计算成本足够低。
搜索算法的设计,往往就是在这两者之间反复权衡。
和 A*、动态规划、整数规划的关系
理解回溯和分支限界后,很多算法思想会变得更容易联系起来。
A* 搜索也使用估价函数来决定优先扩展哪个节点。它的:
\[f(n)=g(n)+h(n) \]
和分支限界中的 bound 有相似之处。区别在于,A* 通常讨论图搜索和最短路语境,而分支限界更常用于组合优化的解空间树。如果 A* 要保证最优性,启发函数 \(h(n)\) 通常需要满足 admissible,也就是不能高估从当前节点到目标的剩余代价。这和分支限界中"bound 必须安全"的思想是一致的。
动态规划也可以看成避免重复搜索的技术。如果搜索树中大量不同路径会到达相同状态,那么继续把它当作树搜索就会产生大量重复计算。此时,记忆化搜索或动态规划可能比单纯回溯更合适。
整数规划中的分支限界也高度依赖松弛思想。很多整数规划求解器会先解线性规划松弛问题,用松弛解提供界,再通过分支逐步逼近整数最优解。这里的逻辑和 0/1 背包中用分数背包提供上界,本质上是一样的。
这些连接说明,搜索算法并不是孤立技巧。它们共同关心:怎么表示状态?怎么避免无效遍历?怎么如何证明没有错过正确答案或最优答案?
结语
回溯除了表面模板:选择、递归、撤销选择。真正值得掌握的是这几个问题:
- 我的解空间树是什么?
- 一个节点代表哪些可能的补全?
- 我什么时候能证明这个节点下面没有合法解?
- 如果是最优化问题,我能不能证明这个节点下面没有更优解?
- 我的 bound 是否安全?是否足够紧?计算是否划算?
而所有有效的剪枝,本质上都在做同一件事:在答案出现之前,证明某些答案不可能存在。