回溯原理以及伪代码
// 递归出口 if (//终⽌条件) { //存放结果; return; }
for (//选择:本层集合中元素(树中节点孩⼦的数量就是集合的⼤⼩)) { //处理节点; backtracking(//路径,选择列表); // 递归 //回溯,撤销处理结果 }
void backtracking(//参数) { if (// 终止条件) { // 存放结果; return; } for (// 选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { // 处理节点; backtracking(// 路径,选择列表); // 递归 // 回溯,撤销处理结果 } }
套用模板
组合
题目一
某人准备跑20圈来锻炼自己的身体,他准备分多次(>1)跑完,每次都跑正整数圈,然后休息下再继续跑。 为了有效地提高自己的体能,他决定每次跑的圈数都必须比上次跑的多 设第一次圈数不能小于0,那么请问他可以有多少种跑完这 20 圈的方案? 输出方案总数,以及每种方案的排序。(比如1,19/ 1,2,17 都是有效方案)
#include <iostream> #include <vector> using namespace std; #define Num 20 // 跑20圈 // 每次都跑整数圈 // 后一次比前一次多 // 第一次不能小于0 // 1,19 | 1,2,17 void dfs(int times, int num, vector<int> &temp, vector<vector<int>> &result) { // 递归出口 if(times == 0) { result.push_back(temp); return; } // 横向递归,从1到19开始穷举 for(int i = num + 1; i <= times; ++i) { temp.push_back(i); // 自增 dfs(times-i,i,temp,result); // 回溯 temp.pop_back(); } } int main() { vector<int> temp; vector<vector<int>> result; dfs(Num, 0, temp, result); cout << result.size() << "\n"; for(auto & i : result) { for(auto & j : i) { cout << j << " "; } cout << "\n"; } }
题目二
给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。
先横向遍历然后对剩余的结果进行穷举,每次递归前首先判断递归出口,满足该次纵向遍历就结束了,否则继续穷举。横向遍历确定每一种情况。
class Solution { public: void dfs(int n, int currNum, vector<int>&path,vector<vector<int>>&result,int k) { // 递归出口 if(path.size() == k) { result.push_back(path); return; } // 横向递归 for(int i = currNum + 1; i <= n;++i) { // 处理 path.push_back(i); // 纵向递归 dfs(n, i,path,result,k); // 回溯 path.pop_back(); } } vector<vector<int>> combine(int n, int k) { vector<int> path; vector<vector<int>> result; dfs(n,0,path,result,k); for(auto &i: result) { for(auto & j : i) { cout << j << " "; } cout << "\n"; } return result; } };
题目三
找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。 说明: 所有数字都是正整数。 解集不能包含重复的组合。
class Solution { public: void dfs(int n, int currNum, vector<int>&path,vector<vector<int>>&result,int k) { // 递归出口 if(path.size() == k && n == 0) { result.push_back(path); return; } // 横向递归 for(int i = currNum + 1; i <= 9;++i) { // 处理 path.push_back(i); // 纵向递归 dfs(n-i, i,path,result,k); // 回溯 path.pop_back(); } } vector<vector<int>> combinationSum3(int k, int n) { vector<int> path; vector<vector<int>> result; dfs(n, 0, path, result, k); for(auto &i: result) { for(auto & j : i) { cout << j << " "; } cout << "\n"; } return result; } };
从上面三题来看,这一种类型的题目的重心是确定递归出口,以及每一次横向递归的for循环的参数的设置
由横向递归来区分每一种情况,确保组合的每个数都不会相同
题目4
// 给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。 // 给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
class Solution { private: unordered_map<int, vector<char>> keypad; public: void InitKeyPad() { keypad[2] = {'a', 'b', 'c'}; keypad[3] = {'d', 'e', 'f'}; keypad[4] = {'g', 'h', 'i'}; keypad[5] = {'j', 'k', 'l'}; keypad[6] = {'m', 'n', 'o'}; keypad[7] = {'p', 'q', 'r', 's'}; keypad[8] = {'t', 'u', 'v'}; keypad[9] = {'w', 'x', 'y', 'z'}; } void dfs(string& str, vector<string>& result, string digits, int index) { // 递归出口 if (str.size() == digits.size()) { result.push_back(str); return; } // 横向递归确保选取每个vector数组的不同字符 // 纵向递归确保选取不同的vector数组 int num = digits[index] - '0'; cout << "num = " << num << "\n"; vector<char> vec = keypad[num]; cout << vec.size() << "\n"; for (int j = 0; j < vec.size(); ++j) { // 处理逻辑 str.push_back(vec[j]); // 纵向递归 dfs(str, result, digits,index+1); // 退回上一步 str.pop_back(); } } vector<string> letterCombinations(string digits) { InitKeyPad(); vector<string> result; string str; int index = 0; cout << digits.size() << "\n"; if (digits.size() == 0) { return result; } dfs(str, result, digits, index); for (auto& i : result) { cout << i << " "; } return result; } };
这一题与上述的三题是不一样的,特殊点在于有两个递归出口
其次是横向递归,之前的题目,横向递归是为了确保选取不同的字符或者数字,纵向递归是在剔除了第一次选取的元素的数组上进行操作。
而这一题横向递归依旧是在剔除了前一个元素的数组上进行操作,但是该次for循环的操作是在不同的数组上进行操作的,使用index来确保了前一次递归和后一次递归所操作的数组是不一致的。
使用递归for循环是确保穷举出所有情况。
题目5
//给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。 //candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。 //对于给定的输入,保证和为 target 的不同组合数少于 150 个。 //示例 1: //输入:candidates = [2,3,6,7], target = 7 //输出:[[2,2,3],[7]] //解释: //2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。 //7 也是一个候选, 7 = 7 。 //仅有这两种组合。
class Solution { private: vector<int>path; vector<vector<int>> result; public: void dfs(vector<int> &candidates, int target, int startNum) { // 递归出口 if(target == 0) { result.push_back(path); return; } if(target < 0) { return; } // 可以重复选取参数 // 穷举 for(int i = startNum; i < candidates.size(); ++i) { // 处理逻辑 path.push_back(candidates[i]); // 分割数组的操作是由dfs的参数startNum决定的 dfs(candidates,target - candidates[i],i); path.pop_back(); } } vector<vector<int>> combinationSum(vector<int>& candidates, int target) { path.clear(); result.clear(); dfs(candidates,target,0); for(auto &i : result) { for(auto &j : i) { cout << j << " "; } cout << "\n"; } return result; } };
这一题与1-3题的明显区别就是回溯的时候使用的数组不再进行切割,而是否进行重复选取的核心决定于纵向递归的参数startNum,横向操作只是为了穷举出所有情况。
通过上述五题梳理一下思路,遇到这种组合题,首先定义递归出口,无论成功还是失败都会回溯到上一步继续执行剩余的操作,区别是成功的情况会添加到result中,失败是直接返回。
横向递归是为了定义从数组的哪一个位置开始穷举,而纵向递归是为了穷举当前位置开始的出所有的情况。无论成功还是失败都会进行回溯,回溯后,横向递归重新定义起始位置,这要根据回溯的情况来判断。
// 输入: k = 3, n = 7 // 输出: [[1,2,4]] // 解释: // 1 + 2 + 4 = 7
首先正常情况是1,2,3,......,9,一共递归了9层,在第9层时for循环不符合结束,回溯到第8层,此时i = 8,让i取9结果就变成了1,2,3,4,5,6,7,9;在第8层for循环退出,以此往复,一直回溯到第3层发现分支4是成功的,走到递归出口,然后继续执行这一层的for循环,直到1,2,9为止退出,最终回溯到第一层,再接着从2开始,2,3,4,5,6,7,8,9重复上述的过程。
为什么path.pop_back()会导致回溯呢?
首先递归用一个形象化的故事来解释
假如你坐在电影院的最后一排看电影,小美坐在第一排,你没有带手机却想告诉小美,晚上一起去吃火锅。
你需要拍一拍前面一个人的肩膀说,你能帮我带个话吗?我想请第一排的小美吃火锅。于是前面的人拍了拍后面一个人的肩膀重复这句话,直到第二排的人拍了小美的肩膀传递了这句话,并等待小美的回复。此时所有人都在等待这个小美的回复才能继续进行后续的回复。当小美回复说可以,第二排的人回头告诉第三排,以此往复,你终于收到了小美的答复。
第8层的递归(第8层的for循环)只有获取到了第9层的结果才能继续往下执行(执行for循环,获取下一个参数),而path.pop_back()就是为了保持第8层的穷举结果的
整体的逻辑就是先穷举后判断,打破穷举的唯一途径就是该层的for循环被打破,导致函数执行结束。
横向递归是为了确保每次穷举的数组的情况,而纵向递归是穷举的深度,即递归到第几层。
所以横向递归最重要的是判断条件,它决定了每次穷举数组的宽度,纵向递归根据是否选取可重复参数来设置横向递归,更倾向于递归层度之间的联系来设置横向递归。