在这里涉及到的回溯中的抽象树,都是“选哪一个元素”的思想。
1.第77题. 组合
回溯法就用递归来解决嵌套层数的问题。
把组合问题抽象为如下树形结构:
可以看出这棵树,一开始集合是 1,2,3,4, 从左向右取数,取过的数,不再重复取。
第一次取1,集合变为2,3,4 ,因为k为2,我们只需要再取一个数就可以了,分别取2,3,4,得到集合[1,2] [1,3] [1,4],以此类推。
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现n相当于树的宽度,k相当于树的深度。
那么如何在这个树上遍历,然后收集到我们要的结果集呢?
图中每次搜索到了叶子节点,我们就找到了一个结果。
相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
回溯法三部曲
- 递归函数的返回值以及参数
在这里要定义两个全局变量,一个用来存放符合条件单一结果,一个用来存放符合条件结果的集合。
函数里一定有两个参数,既然是集合n里面取k个数,那么n和k是两个int型的参数。
然后还需要一个参数,为int型变量startIndex,这个参数用来记录本层递归的中,集合从哪里开始遍历,startIndex 是为了防止出现重复的组合
那么整体代码如下:
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件单一结果
void backtracking(int n, int k, int startIndex)
- 回溯函数终止条件
什么时候到达所谓的叶子节点了呢?
path这个数组的大小如果达到k,说明我们找到了一个子集大小为k的组合了,在图中path存的就是根节点到叶子节点的路径。
此时用result二维数组,把path保存起来,并终止本层递归。
所以终止条件代码如下:
if (path.size() == k) {
result.push_back(path);
return;
}
- 单层搜索的过程
回溯法的搜索过程就是一个树型结构的遍历过程,在如下图中,可以看出for循环用来横向遍历,递归的过程是纵向遍历。
在该层要对每一个可能的方向进行试探,并作现场恢复。
代码如下:
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
path.pop_back(); // 回溯,撤销处理的节点
}
可以看出backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回。
完整代码如下:
class Solution {
List<Integer> path;
List<List<Integer>> res;
public List<List<Integer>> combine(int n, int k) {
path = new ArrayList<>();
res = new ArrayList<>();
dfs(n,k,1);
return res;
}
public void dfs(int n, int k, int startIndex){
if(path.size()==k){
res.add(new ArrayList<>(path));
return;
}
for(int i=startIndex ; i <= n ; i++){
path.add(i);
dfs(n,k,i+1);
path.remove(path.size()-1);
}
}
}
回顾框架:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
剪枝优化
我们说过,回溯法虽然是暴力搜索,但也有时候可以有点剪枝优化一下的。
在遍历的过程中有如下代码:
for (int i = startIndex; i <= n; i++) {
path.push_back(i);
backtracking(n, k, i + 1);
path.pop_back();
}
举一个例子,n = 4,k = 4的话,那么第一层for循环的时候,从元素2开始的遍历都没有意义了。 在第二层for循环,从元素3开始的遍历都没有意义了。
可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
接下来看一下优化过程如下:
-
已经选择的元素个数:path.size();
-
还需要的元素个数为: k - path.size();
-
在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历
所以优化之后的for循环是:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
优化后整体代码如下:
class Solution {
List<Integer> path;
List<List<Integer>> res;
public List<List<Integer>> combine(int n, int k) {
path = new ArrayList<>();
res = new ArrayList<>();
dfs(n,k,1);
return res;
}
public void dfs(int n, int k, int startIndex){
if(path.size()==k){
res.add(new ArrayList<>(path));
return;
}
for(int i=startIndex ; i <= n-(k- path.size())+1 ; i++){
path.add(i);
dfs(n,k,i+1);
path.remove(path.size()-1);
}
}
}
2.216.组合总和III
本题就是在[1,2,3,4,5,6,7,8,9]这个集合中找到和为n的k个数的组合。
相对于77. 组合 (opens new window),无非就是多了一个限制,本题是要找到和为n的k个数的组合,而整个集合已经是固定的了[1,...,9]。
回溯三部曲
- 确定递归函数参数
和77. 组合 (opens new window)一样,依然需要一维数组path来存放符合条件的结果,二维数组result来存放结果集。
这里我依然定义path 和 result为全局变量。
vector<vector<int>> result; // 存放结果集
vector<int> path; // 符合条件的结果
接下来还需要如下参数:
- targetSum(int)目标和,也就是题目中的n。
- k(int)就是题目中要求k个数的集合。
- sum(int)为已经收集的元素的总和,也就是path里元素的总和。
- startIndex(int)为下一层for循环搜索的起始位置。
代码如下:
vector<vector<int>> result;
vector<int> path;
void backtracking(int targetSum, int k, int sum, int startIndex)
其实这里sum这个参数也可以省略,每次targetSum减去选取的元素数值,然后判断如果targetSum为0了,说明收集到符合条件的结果了,这里为了直观便于理解,还是加一个sum参数。
- 确定终止条件
k其实就已经限制树的深度,因为就取k个元素,树再往下深了没有意义。
终止代码如下:
if (path.size() == k) {
if (sum == targetSum) result.push_back(path);
return; // 如果path.size() == k 但sum != targetSum 直接返回
}
- 单层搜索过程
本题和77. 组合 (opens new window)区别之一就是集合固定的就是9个数[1,...,9],所以for循环固定i<=9
处理过程就是 path收集每次选取的元素,相当于树型结构里的边,sum来统计path里元素的总和。
代码如下:
for (int i = startIndex; i <= 9; i++) {
sum += i;
path.push_back(i);
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}
最后代码如下:
class Solution {
List<Integer> path;
List<List<Integer>> res;
int sum;
public List<List<Integer>> combinationSum3(int k, int n) {
path = new ArrayList<>();
res = new ArrayList<>();
dfs(k,n,1);
return res;
}
public void dfs(int k , int n, int startIndex){
if(sum > n) return ;
if(path.size() == k ){
if(sum == n){
res.add(new ArrayList<>(path));
}
return ;
}
for(int i=startIndex ; i<=9 - (k - path.size()) + 1 ;i++){ // 9 - (k - path.size()) 为剪枝操作
sum+=i;
path.add(i);
dfs(k,n,i+1);
path.remove(path.size()-1);
sum-=i;
}
}
}
3.17.电话号码的字母组合
思路
组合问题,嵌套for循环的思路 ,考虑回溯法。
1.参数 : 字符串、index
2.返回条件 index == 字符串.size()
3.层逻辑 遍历 字符串.getIndex(index) 下的所有字母
数字和字母如何映射
使用 map 或定义一个二维数组
List<String> res;
StringBuilder path;
String[] mapping = {"","","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
public List<String> letterCombinations(String digits) {
res = new ArrayList<>();
path = new StringBuilder();
if(digits.isEmpty()) return res;
dfs(digits,0);
return res;
}
public void dfs(String digits, int index){
if(index == digits.length()){
res.add(new String(path));
return ;
}
int digit = digits.charAt(index) - '0';
for(char c : mapping[digit].toCharArray()){
path.append(c);
dfs(digits,index+1);
path.deleteCharAt(path.length()-1);
}
}
总结
回溯法抽象为树形结构后,其遍历过程就是:for循环横向遍历,递归纵向遍历,回溯不断调整结果集。
4.39. 组合总和
思路
本题和77.组合 (opens new window),216.组合总和III (opens new window)的区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
而在77.组合 (opens new window)和216.组合总和III (opens new window)中都可以知道要递归K层,因为要取k个元素的组合。
回溯三部曲
- 递归函数参数
这里依然是定义两个全局变量,二维数组result存放结果集,数组path存放符合条件的结果。(这两个变量可以作为函数参数传入)
首先是题目中给出的参数,集合candidates, 和目标值target。
此外我还定义了int型的sum变量来统计单一结果path里的总和,其实这个sum也可以不用,用target做相应的减法就可以了,最后如何target==0就说明找到符合的结果了,但为了代码逻辑清晰,我依然用了sum。
本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?
我举过例子,如果是一个集合来求组合的话,就需要startIndex,例如:77.组合 (opens new window),216.组合总和III (opens new window)。
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:17.电话号码的字母组合(opens new window)
注意以上只是说求组合的情况,如果是排列问题,又是另一套分析的套路,后面在讲解排列的时候会重点介绍。
代码如下:
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循环依然是从startIndex开始,搜索candidates集合。
本题元素为可重复选取的。
for (int i = startIndex; i < candidates.size(); i++) {
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i); // 关键点:不用i+1了,表示可以重复读取当前的数
sum -= candidates[i]; // 回溯
path.pop_back(); // 回溯
}
完整代码如下:
class Solution {
List<List<Integer>> res;
List<Integer> path;
int sum = 0;
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates);
res = new ArrayList<>();
path = new ArrayList<>();
dfs(candidates,target,0);
return res;
}
public void dfs(int[] candidates,int target,int startIndex){
if(sum == target){
res.add(new ArrayList<>(path));
}
// if(sum > target) return ; 也可以这里剪枝
for(int i = startIndex ; i < candidates.length && sum + candidates[i] <= target ; i++){//剪枝操作
sum += candidates[i];
path.add(candidates[i]);
dfs(candidates,target,i);
path.remove(path.size()-1);
sum-= candidates[i];
}
}
}
5.40.组合总和II
这道题目和39.组合总和 (opens new window)如下区别:
- 本题candidates 中的每个数字在每个组合中只能使用一次。
- 本题数组candidates的元素是有重复的,而39.组合总和 (opens new window)是无重复元素的数组candidates
如果把所有组合求出来,再用set或者map去重,则很容易超时。
所以要在搜索的过程中就去掉重复组合。
所谓去重,其实就是使用过的元素不能重复选取。
组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。
题目要求元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
为了理解去重我们来举一个例子,candidates = [1, 1, 2], target = 3,(方便起见candidates已经排序了)
强调一下,树层去重的话,需要对数组排序!
选择过程树形结构如图所示:
个人理解,这里为什么要对树层去重:在数组排序后,假若元素 a1 已经在前面出现过了,现在又在同一树层出现了元素 a2 ,且 a2 == a1, 所有从a2出发的可能的情况,都可以从a1出发 经过 若干 选或不选得到。同时为了避免a2和a1之间出现其他元素,需要a2和a1之间只有可能有和它们相等的元素,这也是对树层去重要先排序的原因。
如果前面a1 used == true 的情况下,再出现a2,则是在树枝上,不去重。
回溯三部曲
- 递归函数参数
与39.组合总和 (opens new window)套路相同,此题还需要加一个bool型数组used,用来记录同一树枝上的元素是否使用过。
这个集合去重的重任就是used来完成的。
代码如下:
vector<vector<int>> result; // 存放组合集合
vector<int> path; // 符合条件的组合
void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used) {
- 递归终止条件
与39.组合总和 (opens new window)相同,终止条件为 sum > target
和 sum == target
。
代码如下:
if (sum > target) { // 这个条件其实可以省略
return;
}
if (sum == target) {
result.push_back(path);
return;
}
sum > target
这个条件其实可以省略,因为在递归单层遍历的时候,会有剪枝的操作,下面会介绍到。
- 单层搜索的逻辑
这里与39.组合总和 (opens new window)最大的不同就是要去重了。
前面我们提到:要去重的是“同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。
如果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]使用过
可能有的录友想,为什么 used[i - 1] == false 就是同一树层呢,因为同一树层,used[i - 1] == false 才能表示,当前取的 candidates[i] 是从 candidates[i - 1] 回溯而来的。
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
boolean[] used;
int sum =0;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
used = new boolean[candidates.length];
dfs(candidates,target,sum,0);
return res;
}
public void dfs(int[] candidates, int target, int sum , int startIndex){
if(sum == target){
res.add(new ArrayList<>(path));
return;
}
for(int i=startIndex ; i < candidates.length && sum <= target; i++){
if( i != 0 && candidates[i] == candidates[i-1] && used[i-1] == false) continue;
used[i] = true;
path.add(candidates[i]);
sum += candidates[i];
dfs(candidates,target,sum,i+1);
sum-= candidates[i];
path.remove(path.size()-1);
used[i]=false;
}
}
}
补充
这里直接用startIndex来去重也是可以的, 就不用used数组了。
chatGPT的建议:used
数组的使用问题:used
数组的使用不当。used
数组通常用于防止在同一层的递归中使用相同的元素,但这里其实不需要 used
数组,因为我们只要在遍历数组时跳过前一个未被选中的相同元素即可。
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int sum =0;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
dfs(candidates,target,sum,0);
return res;
}
public void dfs(int[] candidates, int target, int sum , int startIndex){
if(sum == target){
res.add(new ArrayList<>(path));
return;
}
for(int i=startIndex ; i < candidates.length && sum <= target; i++){
if( i > startIndex && candidates[i] == candidates[i-1] ) continue;
path.add(candidates[i]);
sum += candidates[i];
dfs(candidates,target,sum,i+1);
sum-= candidates[i];
path.remove(path.size()-1);
}
}
}