1.4 回溯法(LC:22 + 78 + 77 + 46)
回溯法:
利用了递归的思想,类似枚举
- 对于枚举的理解:查看后面几题中的图示,都是将所有的情况枚举出来,画出树状图
一层一层向下递归,尝试搜索所有可能的答案:
- 找到答案:返回答案,接着尝试有没有别的可能
- 找不到答案:返回上一层递归,尝试别的路径
可以应用于:求已知序列的子序列,下面几道题都是这种类型
22. 括号生成 - 力扣(LeetCode) (leetcode-cn.com)
难度:中等
数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
示例 1:
输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]
示例2:
输入:n = 1
输出:["()"]
提示:
1 <= n <= 8
思路:
以n = 2举例,图示如下:
记左括号的个数为left,右括号的个数为right
写递归,首先一定要明确每层递归的结束条件是什么,这是需要写在递归方法体的最前面的,这里递归的终止条件为(即该答案不对或得到正确答案了,要返回上一层递归,尝试别的路径):
- 错误答案路径的递归的终止条件:right > left
- 正确答案路径的递归的终止条件:right == 2 且 left == 2
因为一定要先有左括号,才能有右括号,所以需要先添加左括号,再添加右括号,这是代码中先判断:if(left < n),后判断:if(right < left)的原因
代码:
class Solution {
public List<String> generateParenthesis(int n) {
List<String> res = new ArrayList<>();
backtracking(n, res, 0, 0, "");
return res;
}
private void backtracking(int n, List res, int left, int right, String str){
//错误路径的递归的终止条件
//(因为后面先判断left < n,再判断right < left,就说明已经默认先添加左括号再添加右括号,即left < right的情况不会发生,这个if判断实际上是用不到的)
if(left < right){
return;
}
//正确路径的递归的终止条件
if(left == n && right == n){
res.add(str);
return;
}
if(left < n){
backtracking(n, res, left + 1, right, str + "(");
}
if(right < left){
backtracking(n, res, left, right + 1, str + ")");
}
}
}
复杂度分析:
时间复杂度:hard to say
空间复杂度:O(n),除了返回的答案res之外,所需要的额外空间取决于递归栈的深度,每一层递归函数需要 O(1) 的空间,最多递归 2n 层,因此空间复杂度为 O(n)
78. 子集 - 力扣(LeetCode) (leetcode-cn.com)
难度:中等
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums 中的所有元素 互不相同
思路:
为了避免重复,在下面的图示中每次向下递归的元素都是该元素后面的元素
解法1:暴力法-扩展法
解法2和解法3中画图非常重要(图中的走法,就是避免重复的走法),才能弄清楚回溯的过程和DFS的过程(回溯和DFS都利用了递归的思想)
解法2:回溯法
对于剪枝的理解:本来是一棵完整的树,经过剪枝,去掉不想要的部分,得到如上图所示的树
递归的终止条件为(剪枝条件):
递归生成的子集长度 == 需要的子集长度
解法3:DFS
递归的终止条件为(剪枝条件):
递归到的数组元素是数组的最后一个元素(如上图中每条递归线最深的元素都是3):递归到的数组元素的索引 == 数组长度 - 1
辨析回溯法和DFS的区别:
- 回溯法:使用for循环(这个for循环不是指回溯方法backtracking中的for循环),进行3次递归,每次递归生成确定的长度为k的子集(即每次递归的深度是一样的,都是k),每次递归添加到res中的子集长度都是k
- DFS:不使用for循环,直接递归到最深层,并且边递归边添加子集,每次添加的子集长度是不固定的,一直添加到最深层
一个细节点:
- List list1 = new ArrayList<>(list):(要求list也是ArrayList类型)创建一个 ArrayList类型的、和list中元素相同的 对象list1,这种操作(指:new ArrayList<>(list))相当于复制一份list中的元素,复制品起名为list1,list和list1的地址值是不同的,所以操作list不会对list1产生影响
- 注意区分:如果list不是ArrayList类型,假设为一个int类型的数,则List list1 = new ArrayList<>(list)表示:创建一个初始化容量为list大小的ArrayList
代码:
解法1:暴力法-扩展法
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
res.add(new ArrayList<>()); //先加入一个空的子集[]
for(int item : nums){
List<List<Integer>> subset = new ArrayList<>();
for(List<Integer> list : res){
List<Integer> temp = new ArrayList<>(list); //ArrayList<>(list)表示:temp复制了一份list
//不能在原本的list中直接添加item,这样就修改了res
//因此需要额外复制一个和list内容一样的temp,但是temp和list的内存空间不一样
temp.add(item);
subset.add(temp);
}
for(List<Integer> list1 : subset){
res.add(list1);
}
}
return res;
}
}
解法2:回溯法
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
res.add(new ArrayList<>()); //先添加空子集[]
//循环添加:子集长度分别为1至nums数组长度
for(int i = 1; i <= nums.length; i++){
backtracking(nums, res, i, 0, new ArrayList<>());
}
return res;
}
//nums:数组,res:输出
//length:子集的长度,index:当前递归到的数组的索引位置,subset:当前递归生成的子集
private void backtracking(int[] nums, List<List<Integer>> res, int length, int index, List<Integer> subset){
//递归的终止条件(剪枝条件):递归生成的子集长度 == 想要得到的子集长度
if(subset.size() == length){
res.add(new ArrayList<>(subset)); //一定不能写成res.add(subset)
return;
}
//从数组的索引为i开始,进行向下递归
for(int i = index; i < nums.length; i++){
subset.add(nums[i]);
//从数组的索引为i+1开始,进行向下递归
backtracking(nums, res, length, i + 1, subset);
subset.remove(subset.size() - 1); //remove(int index)(List中的删除指定索引上的元素),本行代码是在每层递归结束后,
//将最后一个添加到subset中的元素删除,也就是在回溯的过程中不断删除元素
//这一行代码非常重要,因为这一步实现了:图示中由 深层递归 向 浅层递归 回溯的关键(即从 下一层 回到 上一层)
}
}
}
解法3:DFS
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
dfs(nums, 0, res, new ArrayList<>());
return res;
}
private void dfs(int[] nums, int index, List<List<Integer>> res, ArrayList<Integer> subset){
//res.add(new ArrayList<>());
res.add(new ArrayList<>(subset));
//注意:一定不能写成res.add(subset)
//原因:subset是引用类型,传递的是subset的地址值,而subset一直在变化,如果直接将subset添加到res中,则subset变化,之前添加的subset也会随之变化
//由此出现的错误现象:res中的所有元素都是一样的
//解决办法:新造一个对象,将subset对应的堆空间中的数据值复制一份到新的对象中,然后将新的对象add到res中即可
//递归终止条件:index最终为i+1,所以应该和nums.length比较,而不是和nums.length-1比较
if(index == nums.length){
return;
}
for(int i = index; i < nums.length; i++){
subset.add(nums[i]);
dfs(nums, i + 1, res, subset);
subset.remove(subset.size() - 1); //这一行代码非常重要,因为这一步实现了:图示中由 深层递归 向 浅层递归 回溯的关键(即从 下一层 回到 上一层)
}
}
}
77. 组合 - 力扣(LeetCode) (leetcode-cn.com)
难度:中等
给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。
示例:
输入: n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
思路:
本题和上题一致,可以理解为:
求一个长度为n的数组中,所有可能的长度为k的子集,子集中不能有重复元素
本题同样可以使用回溯法和DFS,但是由于需要生成的子集长度是固定的,所以回溯法和DFS的图示与写法是相同的
图示:
递归终止条件:
递归生成的子集长度 == k
代码:
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> res = new ArrayList<>();
if(k == 0){
res.add(new ArrayList<>());
return res;
}
backtracking(n, k, 0, res, new ArrayList<>());
return res;
}
//n:数组长度及取值范围,k:生成的子集长度,res:输出
//length:需要生成的子集的长度,index:当前递归到的数组的索引位置,subset:当前递归生成的子集
private void backtracking(int n, int k, int index, List<List<Integer>> res, ArrayList<Integer> subset){
int length = subset.size();
if(length == k){
res.add(new ArrayList<>(subset));
return;
}
for(int i = index; i < n; i++){
subset.add(i + 1); //数组中的元素是从1开始到n的,而索引i是从0开始到n-1的
backtracking(n, k, i + 1, res, subset);
subset.remove(subset.size() - 1); //这一行代码非常重要,因为这一步实现了:图示中由 深层递归 向 浅层递归 回溯的关键(即从 下一层 回到 上一层)
}
}
}
46. 全排列 - 力扣(LeetCode) (leetcode-cn.com)
难度:中等
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
输入: [1,2,3]
输出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
思路:
本题和前两题一致,可以理解为:
对一个长度为n的数组nums,得到其可以重复的、长度为n的子集
本题同样可以使用回溯法和DFS,但是由于需要生成的子集长度是固定的,所以回溯法和DFS的图示与写法是相同的
图示:
递归的终止条件:
子集的长度 == 数组的长度n
一个细节点:
因为本题需要得到全排列,是可以重复的子集,所以在不同的递归线中,是可以出现相同的元素的
代码:
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
backtracking(nums, 0, nums.length, res, new ArrayList<>());
return res;
}
//nums:数组,res:输出
//n:数组的长度,index:当前递归到的数组的索引位置,subset:当前递归生成的子集
private void backtracking(int[] nums, int index, int n, List<List<Integer>> res, ArrayList<Integer> subset){
int length = subset.size();
if(length == n){
res.add(new ArrayList<>(subset));
return;
}
for(int i = index; i < nums.length; i++){
//只需要满足添加不同的元素即可,因为需要得到全排列,即[1,2]和[2,1]是两个不同的子集
if(subset.contains(nums[i])){
continue; //跳出本次循环,目的是找到subset中没有的元素,这样才能继续向下一层递归
}else{
subset.add(nums[i]);
backtracking(nums, 0, n, res, subset);
subset.remove(subset.size() - 1); //这一行代码非常重要,因为这一步实现了:图示中由 深层递归 向 浅层递归 回溯的关键(即从 下一层 回到 上一层)
}
}
}
}