回溯算法小结
1.回溯算法定义:
回溯法采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:
找到一个可能存在的正确的答案;
在尝试了所有可能的分步方法后宣告该问题没有答案;
(来自维基百科)
1.1 回溯算法和深度优先遍历
回溯算法也叫 回溯搜索算法,「搜索」即「搜索所有的解」。回溯算法从初始状态出发,采用 深度优先遍历 的方式,得到问题的 所有的解。因为采用遍历的方式,所以可以得到所有的解。回溯在某种程度上也是暴力搜索。
1.2 回溯算法适用范围
题目常见的形式就是问某一个问题的所有解决方案。如果解决一个问题有多个解决方案,每一个解决方案有多个步骤,题目要求我们得到所有的解,就可以使用回溯算法。多个解决方案,每一个解决方案有多个步骤,通常可以建模成一个 树形问题。而树形问题中有着很明显的递归结构,因此 回溯算法递归地建立了局部的可能的解决方案,当发现一个可能的解决方案无法得出正确的结果时,回退到上一步,尝试下一个可能的解决方案 ,这里的 **「回退」就是「回溯」**的意思。
1.3 例题
46. 全排列
public class permute {
public List<List<Integer>> permute(int[] nums) {
int len=nums.length;
List<List<Integer>> res=new ArrayList<>();
if(len==0) return res;
Deque<Integer> path=new ArrayDeque<>();
boolean[] used=new boolean[len];
backTrack(nums,len,0,path,used,res);
return res;
}
/**
*
* @param nums
* @param len
* @param index 当前需要确定的path中的元素的下标
* @param path 记录当前的路径
* @param used 记录已经被选择的数字
* @param res
*/
public void backTrack(int[] nums,int len,int index,Deque<Integer> path,boolean[] used,List<List<Integer>> res){
// 递归终止条件
if(index==len)
{
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < len; i++) {
if(used[i]) continue;
used[i]=true;
path.offerLast(nums[i]);
backTrack(nums,len,index+1,path,used,res);
path.pollLast();
used[i]=false;
}
}
}
113. 路径总和 II
public class pathSum {
List<List<Integer>> res = new ArrayList<>();
Deque<Integer> path = new ArrayDeque<>();
public List<List<Integer>> pathSum(TreeNode root, int sum) {
if (root == null) return res;
backTrack(root, sum);
return res;
}
/**
*
* @param root
* @param sum
*/
public void backTrack(TreeNode root, int sum) {
// 首先考虑终止条件
if (root==null) {
return;
}
path.offerLast(root.val);
sum-=root.val;
if(root.left==null && root.right==null &&sum==0) res.add(new ArrayList<>(path));
backTrack(root.left,sum);
backTrack(root.right,sum);
path.pollLast();
}
public static void main(String[] args) {
TreeNode root = new TreeNode(5);
root.left = new TreeNode(4);
}
}
2. 剪枝
回溯算法其实是一个遍历的算法,通过遍历搜索所有的解 其实是没有技巧的,并且时间复杂度很高。因此在遍历的时候,如果能够提前知道 即将要遍历分支 不能搜索到符合条件的结果,这一分支就可以跳过,这一步操作就像是在一棵树上剪去一个枝叶,因此称为 剪枝。在我个人看来,剪枝一般体现在backTrack函数中,for循环遍历所有元素时,添加一个if,满足某种条件的直接continue
47. 全排列 II
public class permuteUnique {
List<List<Integer>> res=new ArrayList<>();
Deque<Integer> path=new ArrayDeque<>();
public List<List<Integer>> permuteUnique(int[] nums) {
int len=nums.length;
if(len==0) return res;
Arrays.sort(nums);
boolean[] used=new boolean[len];
backTrack(nums,len,0,used);
return res;
}
public void backTrack(int[] nums,int len,int index,boolean[] used){
if(len==index){
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < len; i++) {
if(used[i]) continue;
// 剪枝条件
if(i>0 && nums[i]==nums[i-1] && !used[i-1]) continue;
used[i]=true;
path.offerLast(nums[i]);
backTrack(nums,len,index+1,used);
path.pollLast();
used[i]=false;
}
}
List<List<Integer>> res = new ArrayList<>();
Deque<Integer> path = new ArrayDeque<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
int len = candidates.length;
if (len == 0) return res;
backTrack(candidates, target, len ,0);
return res;
}
public void backTrack(int[] candidates, int target, int len, int begin) {
if (target == 0) {
res.add(new ArrayList<>(path));
return;
}
for (int i = begin; i < len; i++) {
if (target < candidates[i]) return;
path.offerLast(candidates[i]);
backTrack(candidates, target-candidates[i], len ,i);
path.pollLast();
}
39. 组合总和
public class combinationSum {
List<List<Integer>> res = new ArrayList<>();
Deque<Integer> path = new ArrayDeque<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
int len = candidates.length;
if (len == 0) return res;
backTrack(candidates, target, len ,0);
return res;
}
public void backTrack(int[] candidates, int target, int len, int begin) {
if (target == 0) {
res.add(new ArrayList<>(path));
return;
}
for (int i = begin; i < len; i++) {
if (target < candidates[i]) return;
path.offerLast(candidates[i]);
backTrack(candidates, target-candidates[i], len ,i);
path.pollLast();
}
}
public static void main(String[] args) {
int[] candidates = {2, 3, 6, 7};
combinationSum cs = new combinationSum();
List<List<Integer>> lists = cs.combinationSum(candidates, 7);
System.out.println(lists);
}
}
注意理解begin在里面所起到的作用:
-
没用begin时,candidates = [2,3,6,7], target = 7,答案:
-
使用后答案:
区别在于前者把同样元素的List当成不同答案了。
打个比方,当2开头的方案被遍历完了之后,方法中的begin
相当于+1了,所以从3开始的方案中,不再重复出现前面出现过的2.
注意,这里就是此题可以用begin
排除重复方案的原因。用used
数组可以起到一样的效果,但是这里begin相对占用的时间和空间更小。
40. 组合总和 II
public class combinationSum2 {
List<List<Integer>> res = new ArrayList<>();
Deque<Integer> path = new ArrayDeque<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
int len = candidates.length;
if (len == 0) return res;
Arrays.sort(candidates);
backTrack(candidates, target, len ,0);
return res;
}
public void backTrack(int[] candidates, int target, int len, int begin) {
if (target == 0) {
res.add(new ArrayList<>(path));
return;
}
for (int i = begin; i < len; i++) {
if (target < candidates[i]) return;
// 剪枝 有重复数字导致的重复方案
if(i>begin && candidates[i]==candidates[i-1]) continue;
path.offerLast(candidates[i]);
// i+1是因为不能重复使用
backTrack(candidates, target-candidates[i], len ,i+1);
path.pollLast();
}
}
public static void main(String[] args) {
int[] candidates = {10,1,2,7,6,1,5};
combinationSum2 cs = new combinationSum2();
List<List<Integer>> lists = cs.combinationSum2(candidates, 8);
System.out.println(lists);
}
}
注意思考剪枝过程的那一句是怎么起作用的,为什么前面直接return 但是后面用continue
if(i>begin && candidates[i]==candidates[i-1]) continue;
因为,该数组事先排序过了,当target<candidates[i]后,无论i遍历到后面的哪个元素,都成立,所以可以直接return。
第二个主要是删除重复方案,不代表后面的i组成的方案都不成立。
---- 未完 待续 内容主要参考自https://leetcode-cn.com/leetbook/read/learning-algorithms-with-leetcode/9el6vj/
if(i>begin && candidates[i]==candidates[i-1]) continue;
因为,该数组事先排序过了,当target<candidates[i]后,无论i遍历到后面的哪个元素,都成立,所以可以直接return。
第二个主要是删除重复方案,不代表后面的i组成的方案都不成立。
---- 未完 待续 内容主要参考自https://leetcode-cn.com/leetbook/read/learning-algorithms-with-leetcode/9el6vj/