本文参考:
Carl的回溯专题
集合划分问题
回溯法本质上就是一种暴力穷举,我们只是通过一些代码逻辑的书写,能够很好地控制所有情况的枚举,然后记录下其中符合条件的枚举结果。有很多问题都只能通过这种暴力枚举的方法做出来,最多进行几次剪枝,缩小枚举范围,比如下面相关题目中的组合问题、排列问题、棋盘问题等等。
回溯其实和深度优先遍历思想是一致的,都是一种递归的应用,搜索空间可以理解成一棵树,都是自顶向下不断枚举出所有的情况,参考算法学习-深度优先遍历。但是区别就是,回溯习惯在所有函数外面定义一些全局变量,比如下面的「路径」,这些变量在进入回溯递归的时候会增加,如果在回溯的递归结束的时候不修改,则会对下一次递归产生影响(因为是全局可见的)。然而深度优先遍历常常讲这些全局变量以另一种(原值+改变)的形式传入递归函数中了,当递归函数结束的时候,这些形参就还是原先的值。
相关模板如下:
明确三个概念:
// 定义全局路径变量 path,已经做出的选择
LinkedList<Integer> path=new LinkedList<>();
// 定义全局结果变量 result列表或者元素
List<String> res=new ArrayList<>();
int res;
// 定义选择列表控制变量,used或index
Set<String> used=new HashSet<>();
void backtracking(路径,选择) {
// 视情况选择是否直接返回
有时候第一步还需要进行path更新
有时候直接base情况返回
if (结束条件) {
result.add(底层结果)或者更新result
return;
}
for (选择:选择列表(当前节点的可访问分支)) {
处理结果;
backtracking(路径,选择); // 递归
回溯,撤销结果
}
}
基本的组合问题,只限定了个数,原数组中没有重复的元素。
class Solution {
ArrayList<List<Integer>> res=new ArrayList<>();
LinkedList<Integer> path=new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
backTracing(n,k,1);
return res;
}
public void backTracing(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.addLast(i);
backTracing(n,k,i+1);
path.removeLast();
}
}
}
无重复元素 的整数数组 candidates,candidates 中的 同一个 数字可以 无限制重复被选取。
class Solution {
List<List<Integer>> res;
LinkedList<Integer> path;
public List<List<Integer>> combinationSum(int[] candidates, int target) {
res=new ArrayList<>();
path=new LinkedList<>();
backTracing(candidates,target,0,0);
return res;
}
public void backTracing(int[]candidates,int target,int startIndex,int sum){
if(sum>target) return;
if(sum==target){
res.add(new ArrayList<>(path));
}
for(int i=startIndex;i<candidates.length;i++){
sum+=candidates[i];
path.add(candidates[i]);
backTracing(candidates,target,i,sum);
path.removeLast();
sum-=candidates[i];
}
}
}
本题candidates 中的每个数字在每个组合中只能使用一次。
本题数组candidates的元素是有重复的,而39.组合总和是无重复元素的数组candidates
采用排序去重的思想。
class Solution {
List<List<Integer>>res;
LinkedList<Integer> path;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
res=new ArrayList<>();
path=new LinkedList<>();
Arrays.sort(candidates);
backTracing(candidates,target,0,0);
return res;
}
public void backTracing(int[]candidates,int target,int startIndex,int sum){
if(sum>target) return;
if(sum==target){
res.add(new ArrayList<>(path));
return;
}
for(int i=startIndex;i<candidates.length;i++){
if(i>startIndex&&candidates[i]==candidates[i-1]) continue;
path.addLast(candidates[i]);
sum+=candidates[i];
backTracing(candidates,target,i+1,sum);
sum-=candidates[i];
path.removeLast();
}
}
}
经典回溯,数组中元素互不相同
class Solution {
LinkedList<Integer> path;
List<List<Integer>> ans;
public List<List<Integer>> subsets(int[] nums) {
path=new LinkedList<>();
ans=new ArrayList<>();
backTracing(nums,0);
return ans;
}
public void backTracing(int[]nums,int startIndex){
// 叶子节点和非叶子节点直接加入
ans.add(new ArrayList<Integer>(path));
// 叶子节点返回
if(startIndex==nums.length){
return;
}
for(int i=startIndex;i<=nums.length-1;i++){
path.add(nums[i]);
backTracing(nums,i+1);
path.removeLast();
}
}
}
用DFS遍历+回溯,将所有从根节点到叶子节点的路径收集起来,从根节点到叶子节点的序列长度就是树的高度,分支就是看当前元素是否有大小写两种可能,如果不能则继续往下搜索,如果能则有另一条搜索选择。
class Solution:
def letterCasePermutation(self, s: str) -> List[str]:
l=list(s)
ans=[]
def dfs(i):
if i== len(s):
ans.append(''.join(l))
return
# 保持原样
dfs(i+1)
# 可以转换大小写
if l[i].isalpha():
l[i]=chr(ord(l[i])^32)
dfs(i+1)
# 转换回来
l[i]=chr(ord(l[i])^32)
dfs(0)
return ans
显式回溯,每次递归后path弹出
class Solution {
LinkedList<Integer> path=new LinkedList<>();
List<String> res=new ArrayList<>();
public List<String> binaryTreePaths(TreeNode root) {
dfs(root);
return res;
}
public void dfs(TreeNode root){
//前序遍历
//上来就压入路径,非空判断做在下面
path.add(root.val);
//进行结果的返回
if(root.left==null&&root.right==null){
StringBuilder sb=new StringBuilder();
for(int i=0;i<path.size();i++){
if(i!=path.size()-1) sb.append(path.get(i)+"->");
else sb.append(path.get(i));
}
res.add(sb.toString());
}
//非空判断,继续递归回溯
if(root.left!=null){
dfs(root.left);
path.removeLast();
}
//非空判断,继续递归回溯
if(root.right!=null){
dfs(root.right);
path.removeLast();
}
}
}
或者将回溯隐藏在递归调用中,
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
List<String> res;
public List<String> binaryTreePaths(TreeNode root) {
res=new ArrayList<>();
dfs(root,"");
return res;
}
public void dfs(TreeNode root,String path){
if(root==null) return;
//收集结果
if(root.left==null&&root.right==null){
res.add(path+root.val);
return;
}
//前序遍历
path+=root.val;
//继续递归
//本质上也是一种回溯了,下一层弹出来还是path不变
dfs(root.left,path+"->");
dfs(root.right,path+"->");
}
}
一步步进行优化,刚开始我想到的只是下面的回溯,通过path记录所有经过的节点,到叶子节点上才进行该条路径的求和,同时只进入非空的分支。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
LinkedList<Integer> path;
int sum;
public int sumNumbers(TreeNode root) {
path=new LinkedList<>();
backTracing(root);
return sum;
}
public void backTracing(TreeNode root){
path.add(root.val);
if (root.left==null&&root.right==null){
sum+=number(path);
return;
}
if (root.left!=null){
backTracing(root.left);
path.removeLast();
}
if (root.right!=null){
backTracing(root.right);
path.removeLast();
}
}
public int number(LinkedList<Integer> path){
int res=0;
for(int i:path){
res=res*10+i;
}
return res;
}
}
进一步简化到下面直接用一个变量num记录到目前为止路径代表的数字,
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
int num;
int sum;
public int sumNumbers(TreeNode root) {
backTracing(root);
return sum;
}
public void backTracing(TreeNode root){
num=num*10+root.val;
if (root.left==null&&root.right==null){
sum+=num;
return;
}
if (root.left!=null){
backTracing(root.left);
num=(num-root.left.val)/10;
}
if (root.right!=null){
backTracing(root.right);
num=(num-root.right.val)/10;
}
}
}
下面这个回溯直接将return base情况改为了null,搜索过程中加上叶子节点的num值,回溯的话只需要在两个backTracing最后,把当前的root回溯了,因为return null的时候并不会修改num值。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
int num;
int sum;
public int sumNumbers(TreeNode root) {
backTracing(root);
return sum;
}
public void backTracing(TreeNode root){
if(root==null) return;
num=num*10+root.val;
if (root.left==null&&root.right==null){
sum+=num;
}
backTracing(root.left);
backTracing(root.right);
num=(num-root.val)/10;
}
}
接下来是dfs的做法:
无返回值的dfs类似上面的回溯做法,不过将全局需要回溯的num放到了递归函数的形参中。
class Solution {
int sum;
public int sumNumbers(TreeNode root) {
backTracing(root,0);
return sum;
}
public void backTracing(TreeNode root,int num){
if(root==null) return;
num=num*10+root.val;
if (root.left==null&&root.right==null){
sum+=num;
}
backTracing(root.left,num);
backTracing(root.right,num);
}
}
带返回值的dfs,直接将sum作为返回值:
class Solution {
public int sumNumbers(TreeNode root) {
return dfs(root,0);
}
public int dfs(TreeNode root,int num){
if(root==null) return 0;
// 叶子结点返回路径数字num
if (root.left==null&&root.right==null){
return num*10+root.val;
}
// 非叶子结点返回它以下能达到的路径的数字的和
return dfs(root.left,num*10+root.val)+dfs(root.right,num*10+root.val);
}
}
找到二叉树中所有路径的字符串,该字符串需要从底向上构造,对字符串排序选出最小的字符串即可。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
LinkedList<Integer> path;
ArrayList<String> res;
public String smallestFromLeaf(TreeNode root) {
path=new LinkedList<>();
res=new ArrayList<>();
dfs(root);
Collections.sort(res);
return res.get(0);
}
public void dfs(TreeNode root){
path.add(root.val);
if(root.left==null&&root.right==null){
StringBuilder sb=new StringBuilder();
for(int i=path.size()-1;i>=0;i--){
sb.append((char)('a'+path.get(i)));
}
res.add(sb.toString());
return;
}
if(root.left!=null){
dfs(root.left);
path.removeLast();
}
if(root.right!=null){
dfs(root.right);
path.removeLast();
}
}
}
这里不需要记录所有路径,只需要判断是否存在路径,因此直接运用提供的递归函数进行递归就可以,并且由于是targetSum基本数据类型,可以采用递归函数中带targetSum参数的回溯。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public boolean hasPathSum(TreeNode root, int targetSum) {
//没找到满足条件的叶子才会进入这一步
if(root==null) return false;
//如果是满足条件的叶子节点早就返回了
if(root.left==null&&root.right==null&&targetSum==root.val) return true;
//否则继续向下搜索
int leftsum=targetSum-root.val;
return hasPathSum(root.left,leftsum)||hasPathSum(root.right,leftsum);
}
}
这题查找从根结点开始到叶子节点符合和的路径。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
List<List<Integer>> res;
LinkedList<Integer> path;
public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
res=new ArrayList<>();
path=new LinkedList<>();
if(root==null) return res;
dfs(root,targetSum);
return res;
}
//从root节点开始往下找和为leftsum的路径(还未包括root.val)
public void dfs(TreeNode root, int leftsum){
//前序遍历
//上来就压入路径,更新总和
leftsum-=root.val;
path.add(root.val);
//进行结果的返回,注意要将path进行深复制
if(root.left==null&&root.right==null&&leftsum==0){
res.add(new ArrayList<>(path));
//和后面的双重递归区分
return;
}
//进入前判空
if(root.left!=null){
dfs(root.left,leftsum);
path.removeLast();
}
//进入前判空
if(root.right!=null){
dfs(root.right,leftsum);
path.removeLast();
}
}
}
这题不需要从根结点开始,也不需要在叶子节点结束,但是也不同于后面的后序遍历,本题的路径方向需要从上往下。采用双重递归,外面一层是先序遍历二叉树,里面一层是从该节点开始,从上往下先序遍历查找给定和的路径。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
int count=0;
public int pathSum(TreeNode root, int targetSum) {
dfs(root,targetSum);
return count;
}
//查找从root开始(不包括root),和为targetSum的路径
public void dfs(TreeNode root,int targetSum){
if(root==null) return;
//双重递归,始终找的是和为targetSum的路径
dfs2(root,targetSum);
dfs(root.left,targetSum);
dfs(root.right,targetSum);
}
//查找从root开始(不包括root),和为sum的路径
public void dfs2(TreeNode root, long sum){
sum -= root.val;
//不能return,因为不要求到叶子节点,因此在某个点下面可能继续找到和为sum的
if(sum==0){
count++;
}
if(root.left!=null)dfs2(root.left,sum);
if(root.right!=null)dfs2(root.right,sum);
}
}
参考集合划分问题,从两种视角解决这一题,数组元素的视角:
class Solution {
public boolean canPartitionKSubsets(int[] nums, int k) {
int sum=0;
for(int i:nums) sum+=i;
if(sum%k!=0) return false;
int target=sum/k;
int []backet=new int[k];
return backTracing(nums,k,0,backet,target);
}
//第index个元素加入某个组以后,能否实现正确划分
//index、backet路径变量随着节点的加入而不断改变
public boolean backTracing(int[]nums, int k, int index, int[]backet, int target){
//选到最后元素以外,直接return
if(index==nums.length){
return true;
}
//选择列表为k个桶
for(int i=0;i<k;i++){
//剪枝操作:如果某元素同一层在选择桶的时候,当前桶和上一个桶内的元素和相等,上一层已经做过选择了,当前可以直接跳过
if(i>0&&backet[i]==backet[i-1]) continue;
//提前进行剪枝,选择下一桶
if(backet[i]+nums[index]>target) continue;
backet[i]+=nums[index];
if(backTracing(nums,k,index+1,backet,target)) return true;
backet[i]-=nums[index];
}
return false;
}
}
暴搜+回溯。这题本质上也是枚举所有可能的情况,因此采用回溯复原交换过的元素,难想到的点是,交换是将s1和s2从左到右依次比对,碰到一个不相同的字符就对s2进行交换,交换的元素是在s2中当前位置往后找到与s1当前字符相同的字符,然后交换s2元素,上面结束条件就是搜索到了s1最后,这样子实现了将s2变成s1。我们暴搜回溯所有的情况,更新外部交换全局变量result的最小值。合理地剪枝,当前交换次数已经大于等于到目前为止的最小交换次数了以后,就可以考虑回溯了。
class Solution {
//全局变量res
int res=Integer.MAX_VALUE;
public int kSimilarity(String s1, String s2) {
backTracing(s1.toCharArray(),s2.toCharArray(),0,0);
return res;
}
//从s1的start出发进行交换,到目前为止的交换次数为current
//选择列表控制变量start
public void backTracing(char[]s1,char[]s2,int start,int current){
//结束条件就是搜索到了s1最后
if(start>=s1.length-1){
res=Math.min(res,current);
return;
}
//剪枝
if(current>=res) return;
//如果不同,则进行交换继续往下搜索
if(s1[start]!=s2[start]){
for(int j=start+1;j<s2.length;j++){
if(s1[start]==s2[j]){
swap(s2,start,j);
backTracing(s1,s2,start+1,current+1);
swap(s2,start,j);
}
}
}else{
//如果相同,也继续往下搜索
backTracing(s1,s2,start+1,current);
}
}
public void swap(char[]s2,int i,int j){
char temp=s2[i];
s2[i]=s2[j];
s2[j]=temp;
}
}