JOVANA
Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

回溯

有些问题没有巧妙的捷径——你是真的得去*试*各种组合。**回溯**做这件事而不被淹没:它一块一块地构建一个候选解,一旦某条路绝无可能成立就立刻放弃它,并悄悄*撤销*它最近的一步去试下一个。它是一种有纪律、会剪枝的搜索——也是排列、子集,以及著名的 N 皇后背后的引擎。

构建、检查、撤销

[[backtracking|回溯]]是一种有结构地探遍一个问题所有可能性的办法,它通过增量地构建解来进行。每一步你做一个试探性的选择,递归下去把它延展,如果那条路走到死胡同,你就撤销这个选择、再试另一个。想象用一团线走迷宫:你往前走,一旦撞墙,就把线收回到上一个岔路口,换个方向再拐。

回溯本质上是在一棵决策树上做深度优先搜索,由递归驱动。树的每个节点是一个部分解;每条分支是一个可能的下一步选择;每片叶子是一个完整的候选解。那个`撤销`步骤——把状态还原到做选择之前的样子——正是它让一个函数能探遍整棵树而不在身后留下烂摊子。

实例:所有排列

我们来生成 `[1, 2, 3]` 的每一种排列。在递归的每一层,我们挑一个还没用过的数,把它标记为已用,递归去填剩下的,然后再取消标记。这个标记与取消标记,*就是*那对选择/撤销。

                       [ ]                 <- nothing chosen yet
          /             |             \
        [1]            [2]            [3]
       /   \          /   \          /   \
    [1,2] [1,3]    [2,1] [2,3]    [3,1] [3,2]
      |     |        |     |        |     |
  [1,2,3][1,3,2] [2,1,3][2,3,1] [3,1,2][3,2,1]   <- 6 leaves = 3! perms
{1,2,3} 排列的搜索树。往下走 = 一个选择;弹回上面 = 一次撤销。每片叶子都是一个完整答案。
#include <vector>

void permute(std::vector<int>& nums, std::vector<bool>& used,
             std::vector<int>& path, std::vector<std::vector<int>>& out) {
    if (path.size() == nums.size()) { out.push_back(path); return; } // leaf
    for (int i = 0; i < (int)nums.size(); i++) {
        if (used[i]) continue;          // skip already-chosen numbers
        used[i] = true; path.push_back(nums[i]);   // CHOOSE
        permute(nums, used, path, out);            // EXPLORE
        path.pop_back(); used[i] = false;          // UN-CHOOSE
    }
}
选择(压入 + 标记已用)、探索(递归)、撤销选择(弹出 + 取消标记)。这三行正好对应那句口诀。

子集,与剪枝的威力

子集还要更简单:对每个元素,唯一的决定就是*要还是不要。*那是一棵深度为 `n` 的二叉选择树,给出 2^n 个子集。回溯这样走它:把一个元素纳入(选择)、递归、再把它排除(撤销)。但回溯之所以真正胜过暴力,靠的是剪枝:一旦一个部分解被证明毫无希望,你就停下回退——一刀砍掉整棵浪费功夫的子树。

N 皇后是个招牌例子。在一个 `n×n` 的棋盘上放 `n` 个皇后,使得彼此都不能攻击。朴素的摆法数目大得吓人,但回溯一次放一行皇后,并在一个新皇后与已放的某个共列或同对角线时*立即剪枝*。这一个检查,在那庞大的树还没被探索之前,就把它的绝大部分扔掉了——把一个不可能的搜索变成了一个可处理的搜索。

通用模板

几乎每一个回溯解都套同一个形状。一旦你写过几遍,新题就变成了填空:什么算一个完整解,有哪些可选的选择,以及是什么让一个选择无效(这样你才能剪枝)。

  1. 如果部分解已经完整,把它记录下来并返回(你到了一片叶子)。
  2. 遍历下一个位置上可用的选择。
  3. 剪枝:如果一个选择明显无效,现在就跳过它——连递归都不要。
  4. 选择:把这个选择应用到部分解上。
  5. 探索:递归去延展它。
  6. 撤销选择:在试下一个之前撤销这个选择。

如果这个模板让你觉得眼熟,那太好了——它正是你在遍历树和图时见过的那同一种深度优先下降,只不过现在用在一棵*决策*之树、而非*数据*之树上。递归、DFS 和回溯,是同一个思想的三张脸:先深入,再回来。