定义
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。在二叉树系列中,我们已经不止一次,提到了回溯,回溯是递归的副产品,只要有递归就会有回溯。虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案只不过进行了剪枝。
回溯法解决的问题
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
回溯法模板
回溯三部曲。
1 2 3 4
| if (终止条件) { 存放结果; return; }
|
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。所以主要是for循环式横向遍历,递归是纵向遍历。
1 2 3 4 5
| for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking(路径,选择列表); // 递归 回溯,撤销处理结果 }
|
第77题. 组合
力扣题目链接
题目要求在1-n之间返回k个数的组合,所以这时候就可以使用回溯,
将问题抽象成如图所示,因为是组合所以for循环式每取一个数少一个数
回溯法三部曲
定义全局并确定返回值以及参数,这里的startIndex用来记录本层递归的开始位置,这样就不会重复选择同一个数如图
1 2 3
| vector<vector<int>> result; vector<int> path; void backtracking(int n, int k, int startIndex)
|
终止条件
1 2 3 4
| if (path.size() == k) { result.push_back(path); return; }
|
单次循环
1 2 3 4 5 6
| for (int i = startIndex; i <= n; i++) { path.push_back(i); backtracking(n, k, i + 1); path.pop_back(); }
|
整体代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| class Solution { private: vector<vector<int>> result; vector<int> path; void backtracking(int n, int k, int startIndex) { if (path.size() == k) { result.push_back(path); return; } for (int i = startIndex; i <= n; i++) { path.push_back(i); backtracking(n, k, i + 1); path.pop_back(); } } public: vector<vector<int>> combine(int n, int k) { result.clear(); path.clear(); backtracking(n, k, 1); return result; } };
|
- 时间复杂度: O(n * 2^n)
- 空间复杂度: O(n)
时间复杂度分析:对于回溯算法我们需要 我们需要估计的是回溯法实际产生的节点数目,以此计算回溯法的时间复杂度。
我们的目的是要1-n之间返回k个数的组合,所以算法的时间复杂度主要取决于backtracking函数的执行次数。
2^n 代表了每个元素在每个组合中有两种可能性:要么出现,要么不出现;n 代表了在生成每一种组合时,你最多需要做 n 次操作来构建这个组合,这是由于组合的大小最多为 n。在最坏情况下,即每个候选数都被选择了,我们需要对候选数集合进行完整的遍历。这样,对于每一层递归,我们都需要遍历整个候选数集合,
因为每一个元素的状态无外乎取与不取,一共2^n种状态,每种状态都需要 o(n) 的构造时间,最终时间复杂度为 O(n * 2^n) 。
回溯虽然不是一个高效的方法,但是如果使用暴力那会更加复杂,比如5个数中取随机三个就需要三层的for循,可能更加难写。
当然还有一种剪枝操作,就是当如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
1
| for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) {
|
k - path.size()还需要多少个数,n减去还需要的数不足以组成组合的话就通过for提前结束了,提前结束在了对剩余元素个数的判断。
216.组合总和III
力扣题目链接
要求 不存在重复的数字,就是看1-9里有多少个组合
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
| class Solution { private: vector<vector<int>> result; vector<int> path; void backtracking(int targetSum, int k, int sum, int startIndex) { if (sum > targetSum) { return; } if (path.size() == k) { if (sum == targetSum) result.push_back(path); return; } for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { sum += i; path.push_back(i); backtracking(targetSum, k, sum, i + 1); sum -= i; path.pop_back(); } }
public: vector<vector<int>> combinationSum3(int k, int n) { result.clear(); path.clear(); backtracking(n, k, 0, 1); return result; } };
|
- 时间复杂度: O(n * 2^n)
- 空间复杂度: O(n)
93.复原IP地址
力扣题目链接
其实只要意识到这是切割问题,切割问题就可以使用回溯搜索法把所有可能性搜出来
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 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| class Solution { private: vector<string> result; void backtracking(string& s, int startIndex, int pointNum) { if (pointNum == 3) { if (isValid(s, startIndex, s.size() - 1)) { result.push_back(s); } return; } for (int i = startIndex; i < s.size(); i++) { if (isValid(s, startIndex, i)) { s.insert(s.begin() + i + 1 , '.'); pointNum++; backtracking(s, i + 2, pointNum); pointNum--; s.erase(s.begin() + i + 1); } else break; } } bool isValid(const string& s, int start, int end) { if (start > end) { return false; } if (s[start] == '0' && start != end) { return false; } int num = 0; for (int i = start; i <= end; i++) { if (s[i] > '9' || s[i] < '0') { return false; } num = num * 10 + (s[i] - '0'); if (num > 255) { return false; } } return true; } public: vector<string> restoreIpAddresses(string s) { result.clear(); if (s.size() < 4 || s.size() > 12) return result; backtracking(s, 0, 0); return result; } };
|
39. 组合总和
力扣题目链接
本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
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
| class Solution { private: vector<vector<int>> result; vector<int> path; void backtracking(vector<int>& candidates, int target, int sum, int startIndex) { if (sum > target) { return; } if (sum == target) { result.push_back(path); return; }
for (int i = startIndex; i < candidates.size(); i++) { sum += candidates[i]; path.push_back(candidates[i]); backtracking(candidates, target, sum, i); sum -= candidates[i]; path.pop_back(); } } public: vector<vector<int>> combinationSum(vector<int>& candidates, int target) { result.clear(); path.clear(); backtracking(candidates, target, 0, 0); return result; } };
|
主要的点还是在单次循环上。
40.组合总和II
力扣题目链接
这道题目和39.组合总和 (opens new window)如下区别:
- 本题candidates 中的每个数字在每个组合中只能使用一次。
- 本题数组candidates的元素是有重复的,而39.组合总和 (opens new window)是无重复元素的数组candidates
第二点是关键,比如我要怎么确定(1,2,3,2)选择的2是哪个2,并且要保证结果中不能出现两个(1,2)
都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
Q:我们是要同一树层上使用过,还是同一树枝上使用过呢?
A:回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
单独介绍一下单层循环逻辑:**如果candidates[i] == candidates[i - 1]
并且 used[i - 1] == false
,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]**。
此时for循环里就应该做continue的操作。
我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:
- used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- used[i - 1] == false,说明同一树层candidates[i - 1]使用过
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) { if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) { continue; } sum += candidates[i]; path.push_back(candidates[i]); used[i] = true; backtracking(candidates, target, sum, i + 1, used); used[i] = false; sum -= candidates[i]; path.pop_back(); }
|
整体代码
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 33 34 35 36
| class Solution { private: vector<vector<int>> result; vector<int> path; void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used) { if (sum == target) { result.push_back(path); return; } for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) { if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) { continue; } sum += candidates[i]; path.push_back(candidates[i]); used[i] = true; backtracking(candidates, target, sum, i + 1, used); used[i] = false; sum -= candidates[i]; path.pop_back(); } } public: vector<vector<int>> combinationSum2(vector<int>& candidates, int target) { vector<bool> used(candidates.size(), false); path.clear(); result.clear(); sort(candidates.begin(), candidates.end()); backtracking(candidates, target, 0, 0, used); return result; } };
|
131.分割回文串
力扣题目链接
这个题目的关键是要把分割回文串变为一种组合问题并将切割问题,也可以抽象为一棵树形结构,如图:
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 33 34 35 36 37 38
| class Solution { private: vector<vector<string>> result; vector<string> path; void backtracking (const string& s, int startIndex) { if (startIndex >= s.size()) { result.push_back(path); return; } for (int i = startIndex; i < s.size(); i++) { if (isPalindrome(s, startIndex, i)) { string str = s.substr(startIndex, i - startIndex + 1); path.push_back(str); } else { continue; } backtracking(s, i + 1); path.pop_back(); } } bool isPalindrome(const string& s, int start, int end) { for (int i = start, j = end; i < j; i++, j--) { if (s[i] != s[j]) { return false; } } return true; } public: vector<vector<string>> partition(string s) { result.clear(); path.clear(); backtracking(s, 0); return result; } };
|