引导
回溯算法写法(※难点)
①画出递归树,找到状态变量(回溯函数的参数)※
②确立结束条件
③找准选择列表(与函数参数相关),做出不同的决策,与第一步紧密关联※
④判断是否需要剪枝
⑤作出选择,递归调用,进入下一层
⑥撤销选择
递归写法:
-
异常/失败退出条件
-
成功退出条件
-
记忆化搜索条件,判定有值直接返回。状态空间一定是memo[m][n]
-
所有不同的决策。每个决策决定了操作后本节点的状态转移 + 当前规模问题的解,全部列出进行递归:这一步向上向下向左向右(要构造一棵树出来,重点是方向/策略的判断),走出去了结果就完全不同,因此是不同决策;要全部列出来当前能够进行的决策。
-
处理当前达到的地方并记录记忆化
-
清除当前决策到达的标记
如果有结果集合,入参中需要传入结果集合对某种可能解进行收录
两种类型:
自顶向下:不断记录当前的历程并且向下深入即可,最后一步存储了所有的历程以及结果,并且是最终问题的解。过程中无需回溯,往往没有对本步结果的处理,在本步骤对dfs的返回值做任何处理,只需要在后续清除影响即可
dfs往往也是void
自底向上(回溯):返回结果,这一步的结果是一个小问题小范围的解,然后回溯回去变为一个大范围解的一部分。需要记录状态/当前规模问题的解
dfs需要返回值并且进行据此修改本次问题的解
DFS 和回溯算法区别
DFS 是一个劲的往某一个方向搜索,而回溯算法建立在 DFS 基础之上的,不同的是在搜索过程中,达到结束条件后,恢复状态,回溯上一层,再次搜索。因此回溯算法与 DFS 的区别就是有无状态重置
https://leetcode-cn.com/problems/subsets/solution/c-zong-jie-liao-hui-su-wen-ti-lei-xing-dai-ni-gao-/
自顶向下类型
113. 路径总和 II
https://leetcode-cn.com/problems/path-sum-ii/对于方案记录,某一个临时方案的任何处理必须在进行本次递归动作后立即回退,任何添加都要无污染,否则会造成某些方案是混合了之前解的。
自顶向下类型:不断记录当前的历程并且向下深入即可,在最后的时候做结果判断与收录
// 正确解法1:
public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
List<List<Integer>> ans = new ArrayList<>(); // 存储最终所有的结果
List<Integer> tmp = new ArrayList<>(); // 存储当前结果
if(root == null) {
return ans;
}
tmp.add(root.val);
dfs(root, ans, tmp, root.val, targetSum);
return ans;
}
private void dfs(TreeNode treeNode, List<List<Integer>> ans, List<Integer> tmp, int tmpSum, int target) {
// success,到达叶子节点且过程中的总和为target
if(tmpSum == target && (treeNode.left == null && treeNode.right == null)) {
ans.add(new ArrayList<>(tmp));
return;
}
// left
if(treeNode.left != null) {
tmp.add(treeNode.left.val);
// dfs参数变化
dfs(treeNode.left, ans, tmp, tmpSum + treeNode.left.val, target);
tmp.remove(tmp.size() - 1); // 清除影响
}
//right
if(treeNode.right != null) {
tmp.add(treeNode.right.val);
dfs(treeNode.right, ans, tmp, tmpSum + treeNode.right.val, target);
tmp.remove(tmp.size() - 1);
}
}
// 不良解法1:没有清除当前步骤导致对后续造成影响
private void dfs(TreeNode root, int target, List<Integer> path) {
// fail
if(root == null) {
return;
}
// success
if(root.val == target && root.right == null && root.left == null) {
// 造成下方new的问题出现在这里:每一步都会改变了path,导致最后不得不去new一个新数组
// 对临时变量的处理必须在进行本次递归动作后回退
path.add(root.val);
result.add(new ArrayList<>(path));
return;
}
path.add(root.val);
dfs(root.left, target - root.val, new ArrayList<>(path));
// 这一步如果不用new,path就会受到上一步的影响
dfs(root.right, target - root.val, new ArrayList<>(path));
}
// 对上述的优化,减法版本
class Solution {
private void dfs(TreeNode root, int target, List<Integer> path) {
// fail
if(root == null) {
return;
}
// success
if(target == 0 && root.right == null && root.left == null) {
result.add(new ArrayList<>(path));
return;
}
if(root.left != null) {
path.add(root.left.val);
dfs(root.left, target - root.left.val, path);
path.remove(path.size() - 1);
}
if(root.right != null) {
// 对临时变量的处理全部在进行递归动作前后发生
// 保证了临时变量的纯净,因为知道下一步dfs不会对临时变量造成任何污染
// 唯一的污染就在当前的add操作,下一步remove掉就好
path.add(root.right.val);
dfs(root.right, target - root.right.val, path);
path.remove(path.size() - 1);
}
}
}
// 另一种不良写法:回退影响的位置很怪,不易理解
private void dfs(TreeNode root, int target, List<Integer> path) {
// fail
if(root == null) {
return;
}
// success
if(root.val == target && root.right == null && root.left == null) {
path.add(root.val);
result.add(new ArrayList<>(path));
// 回退对临时变量的污染
path.remove(path.size() - 1);
return;
}
// 这里意味着选取了当前的节点
path.add(root.val);
// 当前节点加入path,去深入孩子节点是没有问题的
dfs(root.left, target - root.val, path);
dfs(root.right, target - root.val, path);
// 回退对上一步大规模解的影响,其实是上一步选取了左节点对右节点的影响,如果不回退,每次选取右节点前都会受到左节点这一步的影响
path.remove(path.size() - 1);
}
39. 组合总和
// 自顶向下
// 减法版本
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> ans = new ArrayList<>();
List<Integer> tmp = new ArrayList<>();
dfs(candidates, ans, tmp, target, 0);
return ans;
}
// 减法版本的dfs
private void dfs(int[] candidates, List<List<Integer>>ans, List<Integer> tmp, int target, int index) {
// 成功
if(target == 0) {
ans.add(new ArrayList<>(tmp)); // 防止堆污染
return;
}
// 失败,往往是剪枝提前退出
if(target < 0) {
return;
}
if(index == candidates.length) {
return;
}
// 不选自己,直接向下一步走去,自顶向下不做任何处理
dfs(candidates, ans, tmp, target, index + 1);
// 选择自己,下一次走到这里还可以选择自己或者不选择自己,在下一步的选择自己就是此步,不选自己就是上一步
// tmp add自己即可
tmp.add(candidates[index]);
dfs(candidates, ans, tmp, target - candidates[index], index);
tmp.remove(tmp.size() - 1); // 清除影响,每一步add都会有一个remove,那么就可以确认dfs操作不会对临时方案结果产生影响
}
// 加法版本 + for循环优化
private void dfs(int[] candidates, List<List<Integer>> ans, List<Integer> tmp, int target, int cur, int index) {
// fail
if(cur > target) {
return;
}
// success
if(cur == target) {
ans.add(new ArrayList<>(tmp));
return;
}
for(int i = index; i < candidates.length; i++) {
// 小优化1,将递归转化为for循环,将几种情况的递归用for循环优化,避免开栈
// 从当前index开始,可以选择自己
tmp.add(candidates[i]);
dfs(candidates, ans, tmp, target,cur + candidates[i], i);
tmp.remove(tmp.size() - 1);
}
}
// 加法版本的dfs
private void dfs1(int[] candidates, List<List<Integer>> ans, List<Integer> tmp, int target, int cur, int index) {
// fail
if(cur > target || index >= candidates.length) {
return;
}
// success
if(cur == target) {
ans.add(new ArrayList<>(tmp));
return;
}
// skip
dfs(candidates, ans, tmp, target,cur, index + 1);
// choose,区别就在于cur是否加上了这个结果
// 结合skip的步骤,可以知道index只能在两个范围内跳动,当前的与下一个的。一直skip的实际上就是for循环中的步骤,但是for循环的效率更高的原因在于避免了递归开栈,直接for循环中搞定
tmp.add(candidates[index]);
dfs(candidates, ans, tmp, target,cur + candidates[index], index);
tmp.remove(tmp.size() - 1);
}
78. 子集 :数组抽取树结构
https://leetcode-cn.com/problems/subsets/
自顶向下解法:最后生成结果
class Solution {
public List<List<Integer>> subsets(int[] nums) {
int len = nums.length;
List<List<Integer>> res = new ArrayList<>();
Deque<Integer> path = new ArrayDeque<>();
dfs(nums,len,0,res,path);
return res;
}
private void dfs(int[] nums,int len,int index,List<List<Integer>> res,Deque<Integer> path){
// 到最后一位就是整个的结束
if (index==len){
res.add(new ArrayList<>(path));
return;
}
//一直不选则当前数;
dfs(nums,len,index+1,res,path);
//选当前数:区别就在于path中添加
path.add(nums[level]);
dfs(nums,len,index+1,res,path);
path.removeLast();
}
}
46. 全排列:两个状态量回退
自顶向下,需要清除2个影响,一个是list的影响,一个是bool矩阵的影响
public class Solution {
public List<List<Integer>> permute(int[] nums) {
int len = nums.length;
// 使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
boolean[] used = new boolean[len];
List<Integer> path = new ArrayList<>();
dfs(nums, len, 0, path, used, res);
return res;
}
private void dfs(int[] nums, int len, int depth,
List<Integer> path, boolean[] used,
List<List<Integer>> res) {
if (depth == len) {
res.add(new ArrayList(path));
return;
}
// 区别在于dfs选择操作的结果,这里要求的是全字母的排列,每一个都不能漏,所以每一次都要从0开始撸(for),但是要判断前面处理过没有,如是引入了used
// 在非叶子结点处,产生不同的分支,这一操作的语义是:在还未选择的数中依次选择一个元素作为下一个位置的元素,这显然得通过一个循环实现。
for (int i = 0; i < len; i++) {
if (!used[i]) {
path.add(nums[i]);
used[i] = true; // 引入了判断是否选取
dfs(nums, len, depth + 1, path, used, res);
// 注意:下面这两行代码发生 「回溯」,回溯发生在从 深层结点 回到 浅层结点 的过程,代码在形式上和递归之前是对称的
// 这里将当前的选择重新调整为了不选
used[i] = false;
path.remove(path.size() - 1);
}
}
}
public static void main(String[] args) {
int[] nums = {
1, 2, 3};
Solution solution = new Solution();
List<List<Integer>> lists = solution.permute(nums);
System.out.println(lists);
}
}