目录

回溯法

回溯法的本质

回溯法的本质是对穷举法的一个改进,但和穷举法不同的是,回溯法每次只构造候选解的一个分量,然后评估这个部分构造解:如果加上剩下的分量也不能求得一个解,就不会生成剩下得分量。最坏得情况下,依然存在解空间指数爆炸的问题,但这种方法使得我们至少可以对某些组合难题的较大实例进行求解。

回溯法是以构造一棵状态空间树为基础的,树的根节点代表了查找解之前的最初状态。树的第一层节点代表了对解的第一个分量所做的选择,第二层节点代表了对解的第二个分量所作的选择。以此类推,如果一个部分构造解仍然有可能导致一个完整解,我们说这个部分解在树中的相应节点是有希望的;否则,我们说它是没有希望的。叶子则要么代表了没希望的死胡同,要么代表了算法找到的完整解。

大多数情况下,一个回溯算法的状态空间树是按照深度优先的方式来构造的。如果当前节点是有希望的,通过向部分解添加下一个分量的第一个合法选择,就生成了节点的第一个子女,而处理也会转向这个子女节点。如果当前节点变得没希望了,该算法回溯到该节点的父母,考虑部分解的最后一个分量下一个可能的选择。如果这种解不存在,它再回溯到树的上一层,以此类推。最后,如果该算法找到了问题的一个完整解,它要么就停止了(如果只需要一个解),要么继续查找其他可能的解。

回溯法框架

回溯法,一般可以解决如下几种问题:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等等

其大致框架为:

定义一个回溯函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 参数要修改就应该用引用传递
// 一般没有返回值
void backtrack(参数) {
    // 回溯函数本身就是一个递归函数
    // 所以在函数开头就要定义递归出口
    if (终止条件) {
        存放结果; // 
        return;
    }
    // 开始遍历解空间
    for (选择:状态空间树本层中的元素(树中当前节点的子节点数量)) {
        // 处理节点
        backtrack(参数, 修改后的参数);  // 递归
        // 回溯,撤销处理结果 
    }
}

这里面有几个细节:

  1. 终止条件的选择,一般情况下都是当前解的每个分量都选择完了,就说明到达了终点,因为没有下一个解分量了。如果返回的结果要求包含解,那么我们可以用存放解的容器的长度来判断即可。当我们不需要保留解集时,我们可以专门定义一个变量指示当前进行选择的是第几个分量(或者说是状态空间树的第几层)。

  2. 处理节点时后,代表当前解的分量已经做出了选择,接下来进入递归函数,在递归函数中要更新的参数是最关键的,根据不同的场景,更新不同的参数,因为在回溯阶段需要撤销所有的更改,也就意味退回到当前解分量的选择,选择其他的值作为当前解的分量。所以递归函数中有的参数若不是引用的话,天然地就是值传递,并不改变原来的值,那么实现了自动回溯,这类值往往不是我们要保存的值。我们要保存修改的值一般都是要使用引用传递的,那么回溯阶段,就需要做一些pop的操作了。

状态空间树的高度就是解的分量数目,每个分量可选择的范围就是该分量对应层的宽度

经典例题

组合问题

如果是一个集合来求组合的话,也就是解的各个分量的取值集合是同一个的话,就需要startIndex,例如LeetCode77和216

如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如LeetCode17

例题1: 给定两个整数 n 和 k,返回 1 … n 中所有可能的 k 个数的组合。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:

    void backtrack(vector<vector<int>>& res, vector<int>& path, 
                    int startIndex, int n, int k) {
        if (path.size() == k) {
            res.push_back(path);
            return;
 
       }
        // 每次从startIndex开始, 下次递归startIndex加一
        // 那么下一个解分量选择就排除startIndex这个元素了
        for (int i = startIndex; i <= n - (k - path.size()) + 1; ++i) {
            path.push_back(i);
            backtrack(res, path, i + 1, n, k);
            path.pop_back();
        }
    }

    vector<vector<int>> combine(int n, int k) {
        vector<vector<int>> res;
        vector<int> path;
        backtrack(res, path, 1, n, k);
        return res;
    }
};

排列问题

例题2 : 给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        vector<vector<int>> res;
        vector<int> path;
        vector<bool> used(nums.size(), false);
        sort(nums.begin(), nums.end());
        backtrack(res, path, nums, used);
        return res;
    }

    void backtrack(vector<vector<int>>& res, vector<int>& path, 
                vector<int>& nums, vector<bool>& used) {
        if (path.size() == nums.size()) {
            res.emplace_back(path);
            return;
        } 
        // 使用used数组来检测是否已选择某个元素
        // 前一个元素没选取代表是从当前层横向遍历
        // 前一个元素被选取了代表是往下一层遍历
        for (int i = 0; i < nums.size(); ++i) {
            if (used[i] || i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
                continue;
            }
            used[i] = true;
            path.emplace_back(nums[i]);
            backtrack(res, path, nums, used);
            path.pop_back();
            used[i] = false;
        }
    }
};