持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第16天,点击查看活动详情
之前刷题都致力于完成题目本身,并没有进行总结,从本文开始将会致力于总结题目本身用到的思想,本文从回溯问题开始。
题目,本文包含以下几个相同类型的问题:
- 给定两个整数
n和k,返回范围[1,n]中所有可能的k个数的组合。 - 找出所有相加之和为
n的k个数的组合,且满足:- 只能使用数字1-9。
- 每个数字最多使用一次。
- 给定一个只包含数字
2-9的字符串,返回其所有可能的字母组合。 - 给定一个无重复的数组和一个目标target,要求找出数组中数字之和为target的不同组合,数组中元素可以使用多次,但需要保证组合不同。
- 给定一个集合和一个目标数,找出集合中所有可以使数字和为target的组合。集合中的每个数字只能使用一次,并且解集中不能包含重复的组合。
解题思路
回溯法介绍
在解决本题之前,首先介绍一下回溯。
回溯,即递归。其本质为穷举,通过穷举的方式找出所有的可能,最后选出我们需要答案。因此回溯法本身效率不高,只能通过剪枝的方法提高问题。
回溯法一般可以解决以下几种问题:
- 组合问题:
N个数里面按一定规则找出k个数的集合 - 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个
N个数的集合里有多少符合条件的子集 - 排列问题:
N个数按一定规则全排列,有几种排列方式 - 棋盘问题:
N皇后,解数独等等
通常来说,回溯法是有模板的,模板如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
LeetCode 77 组合
下面回到本题,本题就是从组合开始,组合和排列的区别为组合不要求有序,而排列是有序的。那么根据上面的思路很容易可以得到下面的代码:
private List<List<Integer>> list = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
ArrayList<Integer> temp = new ArrayList<>();
backtrace(n, 1, k, temp);
return list;
}
public void backtrace(int n, int cur, int k, ArrayList<Integer> temp){
if(temp.size()==k) {
list.add(new ArrayList<>(temp));
return;
}
for(int i=cur;i<=n;i++){
temp.add(i);
backtrace(n, i+1, k, temp);
temp.remove(temp.size()-1);
}
}
本题也是可以优化的,可以发现每个集合的元素必须等于k,那如果从遍历开始到最终结束的集合大小都不可能为k,那之后的回溯显然不需要进行,即当前已经收集temp.size()个元素,还需k-temp.size()个,那上界就应该为n- (k - temp.size()) + 1,最终快了8ms。
此处为什么加1可以模拟一下,假设起始为1,并且此时temp.size()为0,k为2, n为4,那正确应该是从3开始搜索都是合理的,而此处4-(2-0)+1=3。
LeetCode 216 组合总和
下面看力扣216题的组合总和问题,本题的思路和上面相同,甚至套路都一模一样,可得代码如下:
private List<List<Integer>> resCom = new ArrayList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
backtrace(k, n, new ArrayList<>(), 1);
return resCom;
}
public void backtrace(int k, int n, ArrayList<Integer> temp, int cur){
if(n == 0 && temp.size() == k){
resCom.add(new ArrayList<>(temp));
return;
}
for(int i=cur;i<=9;i++){
temp.add(i);
backtrace(k, n-i, temp, i+1);
temp.remove(temp.size()-1);
}
}
本题和上面的题目一样也可以进行剪枝,当n<0时,则此时可以直接停止,除此之外,因为已经限制了结果集合中的元素,因此可以和上面的一样限定最终有效起始索引为9-(k-temp.size())+1。
最终剪枝的回溯代码如下:
public void backtrace(int k, int n, ArrayList<Integer> temp, int cur){
if(n<0) return;
if(n == 0 && temp.size() == k){
resCom.add(new ArrayList<>(temp));
return;
}
for(int i=cur;i<=9-(k-temp.size())+1;i++){
temp.add(i);
backtrace(k, n-i, temp, i+1);
temp.remove(temp.size()-1);
}
}
LeetCode 17、电话号码的字母组合
本题首先需要将数字键盘上的字母使用数据结构保存下来,此处想到的是用字符数组,之后的思路和之前的思路也是一致的,此处的难点是搞清楚字符对应的位置,可得代码如下:
private List<String> resDig = new ArrayList<>();
public List<String> letterCombinations(String digits) {
if(digits.length() == 0) return resDig;
char[][] arr = {
{'a', 'b', 'c'},
{'d', 'e', 'f'},
{'g', 'h', 'i'},
{'j', 'k', 'l'},
{'m', 'n', 'o'},
{'p', 'q', 'r', 's'},
{'t', 'u', 'v'},
{'w', 'x', 'y', 'z'}
};
backtrace(arr, digits, 0, new StringBuilder());
return resDig;
}
public void backtrace(char[][] arr, String digits, int cur, StringBuilder sb){
if(sb.length() == digits.length()){
resDig.add(sb.toString());
return;
}
for(int i = 0; i<arr[digits.charAt(cur) - '0' - 2].length; i++){
sb.append(arr[digits.charAt(cur) - '0' - 2][i]);
backtrace(arr, digits, cur+1, sb);
sb.deleteCharAt(sb.length()-1);
}
}
LeetCode 39、组合总和
本题和216的组合总和类似,不过本题给定了具体的数组,并且数组中的数字可以重复使用,需要保证最终的结果是不同的数字组合,此处的关键点在于起始索引的移动,之前都需要+1,而此处不需要了,可得代码如下:
private List<List<Integer>> resComb = new ArrayList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backtrace(candidates, target, new ArrayList<>(), 0);
return resComb;
}
public void backtrace(int[] candidates, int target, ArrayList<Integer> arr, int cur){
if(target<0) return;
if(target == 0){
resComb.add(new ArrayList<>(arr));
return;
}
for(int i=cur;i<candidates.length;i++){
arr.add(candidates[i]);
backtrace(candidates, target-candidates[i], arr, i);
arr.remove(arr.size()-1);
}
}
LeetCode 40、组数总和II
本题的难点在于解集中不能包含重复的组合,因为给定的集合是包含重复数字的,如果按照上面的思路,那得到的结果是包含重复组合的,即使对集合进行排序了也没有效果,一个思路是先对集合进行去重,之后对去重的数组进行回溯,但这破坏了原来的数组。另一个思路是首先对数组进行排序,之后通过判断相邻元素是否相同和元素是否已经使用来进行去重,代码如下:
private List<List<Integer>> resCombin = new ArrayList<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
boolean[] visited = new boolean[candidates.length];
backtrace(candidates, target, 0, new ArrayList<>(), visited);
return resCombin;
}
public void backtrace(int[] candidates, int target, int cur, ArrayList<Integer> temp, boolean[] visited){
if(target<0) return;
if(target == 0){
resCombin.add(new ArrayList<>(temp));
return;
}
for(int i=cur;i<candidates.length;i++){
if(i>0&&candidates[i]==candidates[i-1]&&!visited[i-1]) continue;
temp.add(candidates[i]);
visited[i] = true;
backtrace(candidates, target-candidates[i], i+1, temp, visited);
temp.remove(temp.size()-1);
visited[i] = false;
}
}