一、模板

  1. void backtracking(参数) {
  2. if (终止条件) {
  3. 存放结果;
  4. return;
  5. }
  6. for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
  7. 处理节点;
  8. backtracking(路径,选择列表); // 递归
  9. 回溯,撤销处理结果
  10. }
  11. }

二、组合

2.1 例一 77 组合

给定两个整数 n 和 k,返回 1 … n 中所有可能的 k 个数的组合。
示例:
输入: n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
640.png
可以看出这个棵树,一开始集合是 1,2,3,4, 从左向右取数,取过的数,不在重复取。
第一次取1,集合变为2,3,4 ,因为k为2,我们只需要再取一个数就可以了,分别取2,3,4,得到集合[1,2] [1,3] [1,4],以此类推。
「每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围」
「图中可以发现n相当于树的宽度,k相当于树的深度」
那么如何在这个树上遍历,然后收集到我们要的结果集呢?
「图中每次搜索到了叶子节点,我们就找到了一个结果」
相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。

递归函数的返回值以及参数

在这里要定义两个全局变量,一个用来存放符合条件单一结果,一个用来存放符合条件结果的集合。
代码如下:

  1. vector<vector<int>> result; // 存放符合条件结果的集合
  2. vector<int> path; // 用来存放符合条件结果

函数里一定有两个参数,既然是集合n里面取k的数,那么n和k是两个int型的参数。
然后还需要一个参数,为int型变量startIndex,这个参数用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,…,n] )。
为什么要有这个startIndex呢?
「每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围,就是要靠startIndex」
从下图中红线部分可以看出,在集合[1,2,3,4]取1之后,下一层递归,就要在[2,3,4]中取数了,那么下一层递归如何知道从[2,3,4]中取数呢,靠的就是startIndex。

640 (1).png
所以需要startIndex来记录下一层递归,搜索的起始位置。
那么整体代码如下:

  1. vector<vector<int>> result; // 存放符合条件结果的集合
  2. vector<int> path; // 用来存放符合条件单一结果
  3. void backtracking(int n, int k, int startIndex)

回溯函数终止条件

什么时候到达所谓的叶子节点了呢?
path这个数组的大小如果达到k,说明我们找到了一个子集大小为k的组合了,在图中path存的就是根节点到叶子节点的路径。
如图红色部分:
640 (2).png
此时用result二维数组,把path保存起来,并终止本层递归。
所以终止条件代码如下:

  1. if (path.size() == k) {
  2. result.push_back(path);
  3. return;
  4. }

单层搜索的过程

回溯法的搜索过程就是一个树型结构的遍历过程,在如下图中,可以看出for循环用来横向遍历,递归的过程是纵向遍历。

640 (3).png

  1. for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
  2. path.push_back(i); // 处理节点
  3. backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
  4. path.pop_back(); // 回溯,撤销处理的节点
  5. }

可以看出backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回。
backtracking的下面部分就是回溯的操作了,撤销本次处理的结果。
关键地方都讲完了,组合问题C++完整代码如下:

  1. class Solution {
  2. private:
  3. vector<vector<int>> result; // 存放符合条件结果的集合
  4. vector<int> path; // 用来存放符合条件结果
  5. void backtracking(int n, int k, int startIndex) {
  6. if (path.size() == k) {
  7. result.push_back(path);
  8. return;
  9. }
  10. for (int i = startIndex; i <= n; i++) {
  11. path.push_back(i); // 处理节点
  12. backtracking(n, k, i + 1); // 递归
  13. path.pop_back(); // 回溯,撤销处理的节点
  14. }
  15. }
  16. public:
  17. vector<vector<int>> combine(int n, int k) {
  18. result.clear(); // 可以不写
  19. path.clear(); // 可以不写
  20. backtracking(n, k, 1);
  21. return result;
  22. }
  23. };

剪枝优化

640 (4).png
图中每一个节点(图中为矩形),就代表本层的一个for循环,那么每一层的for循环从第二个数开始遍历的话,都没有意义,都是无效遍历。
「所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置」
「如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了」
注意代码中i,就是for循环里选择的起始位置。
接下来看一下优化过程如下:

  1. 已经选择的元素个数:path.size();
  2. 还需要的元素个数为: k - path.size();
  3. 在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历

为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。
举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
从2开始搜索都是合理的,可以是组合[2, 3, 4]。
这里大家想不懂的话,建议也举一个例子,就知道是不是要+1了。
所以优化之后的for循环是:
一旦i>n - (k - path.size()) + 1,剩下的数字个数不足以达到k个,所以都不满足条件,直接剪枝

  1. for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
  1. class Solution {
  2. private:
  3. vector<vector<int>> result;
  4. vector<int> path;
  5. void backtracking(int n, int k, int startIndex) {
  6. if (path.size() == k) {
  7. result.push_back(path);
  8. return;
  9. }
  10. for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { // 优化的地方
  11. path.push_back(i); // 处理节点
  12. backtracking(n, k, i + 1);
  13. path.pop_back(); // 回溯,撤销处理的节点
  14. }
  15. }
  16. public:
  17. vector<vector<int>> combine(int n, int k) {
  18. backtracking(n, k, 1);
  19. return result;
  20. }
  21. };

2.2 例二 第216 组合总和III

找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
说明:

  • 所有数字都是正整数。
  • 解集不能包含重复的组合。

示例 1:
输入: k = 3, n = 7
输出: [[1,2,4]]
示例 2:
输入: k = 3, n = 9
输出: [[1,2,6], [1,3,5], [2,3,4]]

思路

本题就是在[1,2,3,4,5,6,7,8,9]这个集合中找到和为n的k个数的组合。
相对于组合问题,无非就是多了一个限制,本题是要找到和为n的k个数的组合,而整个集合已经是固定的了[1,…,9]。
想到这一点了,做过组合之后,本题是简单一些了。
本题k相当于树的深度,9(因为整个集合就是9个数)就是树的宽度。
例如 k = 2,n = 4的话,就是在集合[1,2,3,4,5,6,7,8,9]中求 k(个数) = 2, n(和) = 4的组合。
选取过程如图:
640 (5).png

确定递归函数参数

和组合问题一样,依然需要一维数组path来存放符合条件的结果,二维数组result来存放结果集。
这里我依然定义path 和 result为全局变量。
至于为什么取名为path?从上面树形结构中,可以看出,结果其实就是一条根节点到叶子节点的路径。

  1. vector<vector<int>> result; // 存放结果集
  2. vector<int> path; // 符合条件的结果

接下来还需要如下参数:

  • targetSum(int)目标和,也就是题目中的n。
  • k(int)就是题目中要求k个数的集合。
  • sum(int)为已经收集的元素的总和,也就是path里元素的总和。
  • startIndex(int)为下一层for循环搜索的起始位置。

所以代码如下:

  1. vector<vector<int>> result;
  2. vector<int> path;
  3. void backtracking(int targetSum, int k, int sum, int startIndex)

其实这里sum这个参数也可以省略,每次targetSum减去选取的元素数值,然后判断如果targetSum为0了,说明收集到符合条件的结果了,我这里为了直观便于理解,还是加一个sum参数。
还要强调一下,回溯法中递归函数参数很难一次性确定下来,一般先写逻辑,需要啥参数了,填什么参数。

确定终止条件

在上面已经说了,k其实就已经限制树的深度,因为就取k个元素,树再往下深了没有意义。
所以如果path.size() 和 k相等了,就终止。
如果此时path里收集到的元素和(sum) 和targetSum(就是题目描述的n)相同了,就用result收集当前的结果。
所以 终止代码如下:

  1. if (path.size() == k) {
  2. if (sum == targetSum) result.push_back(path);
  3. return; // 如果path.size() == k 但sum != targetSum 直接返回
  4. }

单层搜索过程

本题和回溯算法:求组合问题!区别之一就是集合固定的就是9个数[1,…,9],所以for循环固定i<=9
处理过程就是 path收集每次选取的元素,相当于树型结构里的边,sum来统计path里元素的总和。
代码如下:

  1. for (int i = startIndex; i <= 9; i++) {
  2. sum += i;
  3. path.push_back(i);
  4. backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
  5. sum -= i; // 回溯
  6. path.pop_back(); // 回溯
  7. }

「别忘了处理过程 和 回溯过程是一一对应的,处理有加,回溯就要有减!」

  1. class Solution {
  2. private:
  3. vector<vector<int>> result; // 存放结果集
  4. vector<int> path; // 符合条件的结果
  5. // targetSum:目标和,也就是题目中的n。
  6. // k:题目中要求k个数的集合。
  7. // sum:已经收集的元素的总和,也就是path里元素的总和。
  8. // startIndex:下一层for循环搜索的起始位置。
  9. void backtracking(int targetSum, int k, int sum, int startIndex) {
  10. if (path.size() == k) {
  11. if (sum == targetSum) result.push_back(path);
  12. return; // 如果path.size() == k 但sum != targetSum 直接返回
  13. }
  14. for (int i = startIndex; i <= 9; i++) {
  15. sum += i; // 处理
  16. path.push_back(i); // 处理
  17. backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
  18. sum -= i; // 回溯
  19. path.pop_back(); // 回溯
  20. }
  21. }
  22. public:
  23. vector<vector<int>> combinationSum3(int k, int n) {
  24. result.clear(); // 可以不加
  25. path.clear(); // 可以不加
  26. backtracking(n, k, 0, 1);
  27. return result;
  28. }
  29. };

剪枝优化

640 (6).png

已选元素总和如果已经大于n(图中数值为4)了,那么往后遍历就没有意义了,直接剪掉。
那么剪枝的地方一定是在递归终止的地方剪,剪枝代码如下:

  1. if (sum > targetSum) { // 剪枝操作
  2. return;
  3. }
  1. class Solution {
  2. private:
  3. vector<vector<int>> result; // 存放结果集
  4. vector<int> path; // 符合条件的结果
  5. // targetSum:目标和,也就是题目中的n。
  6. // k:题目中要求k个数的集合。
  7. // sum:已经收集的元素的总和,也就是path里元素的总和。
  8. // startIndex:下一层for循环搜索的起始位置。
  9. void backtracking(int targetSum, int k, int sum, int startIndex) {
  10. if (sum > targetSum) { // 剪枝操作
  11. return;
  12. }
  13. if (path.size() == k) {
  14. if (sum == targetSum) result.push_back(path);
  15. return;// 如果path.size() == k 但sum != targetSum 直接返回
  16. }
  17. for (int i = startIndex; i <= 9; i++) {
  18. sum += i; // 处理
  19. path.push_back(i); // 处理
  20. backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
  21. sum -= i; // 回溯
  22. path.pop_back(); // 回溯
  23. }
  24. }
  25. public:
  26. vector<vector<int>> combinationSum3(int k, int n) {
  27. result.clear(); // 可以不加
  28. path.clear(); // 可以不加
  29. backtracking(n, k, 0, 1);
  30. return result;
  31. }
  32. };