算法学习-回溯问题与剪枝

文章目录

  • 基础知识
    • 算法模板
  • 相关题目
    • 组合问题
        • 77.组合
        • 39.组合总和
        • 40.组合总和II
    • 分割问题
    • 子集问题
        • 79.所有子集
    • 排列问题
        • 784.字母大小写全排列
    • 棋盘问题
    • 二叉树问题
        • 257.二叉树的所有路径
        • 129.求根节点到叶节点数字之和
        • 988.从叶结点开始的最小字符串
        • 112.路径总和
        • 113.路径总和2
        • 437.路径总和3
    • 集合划分问题
        • 698.划分为k个相等的子集
    • 其他问题
        • 854.相似度为K的字符串

回溯问题在算法面试的环节中考察得也十分的多,笔者曾在几个月前跟着Carl刷过一段回溯问题,但是当时主要也是记忆性刷题,对于其中的一些技巧心得没有记录下来,为了帮助自己更好地理解,笔者开设这个专题。

本文参考:

Carl的回溯专题
集合划分问题

基础知识

回溯法本质上就是一种暴力穷举,我们只是通过一些代码逻辑的书写,能够很好地控制所有情况的枚举,然后记录下其中符合条件的枚举结果。有很多问题都只能通过这种暴力枚举的方法做出来,最多进行几次剪枝,缩小枚举范围,比如下面相关题目中的组合问题、排列问题、棋盘问题等等。

回溯其实和深度优先遍历思想是一致的,都是一种递归的应用,搜索空间可以理解成一棵树,都是自顶向下不断枚举出所有的情况,参考算法学习-深度优先遍历。但是区别就是,回溯习惯在所有函数外面定义一些全局变量,比如下面的「路径」,这些变量在进入回溯递归的时候会增加,如果在回溯的递归结束的时候不修改,则会对下一次递归产生影响(因为是全局可见的)。然而深度优先遍历常常讲这些全局变量以另一种(原值+改变)的形式传入递归函数中了,当递归函数结束的时候,这些形参就还是原先的值。

相关模板如下:

算法模板

明确三个概念:

  • 路径:递归过程中做出的选择,比如全局路径变量 path、全局变量sum
  • 选择列表:当前可以做的选择,通常用可访问数组used或者可选择的索引index控制
  • 结束条件:到达决策树底层,无法再选择,判断结果
// 定义全局路径变量 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(路径,选择); // 递归
        回溯,撤销结果
    }
}

相关题目

组合问题

77.组合

基本的组合问题,只限定了个数,原数组中没有重复的元素。

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();
        }
    }
}
39.组合总和

无重复元素 的整数数组 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];
        }
    }
}
40.组合总和II

本题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();
        }
    }
}

分割问题

子集问题

79.所有子集

经典回溯,数组中元素互不相同

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();
        }
    }
}

排列问题

784.字母大小写全排列

用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

棋盘问题

二叉树问题

257.二叉树的所有路径

显式回溯,每次递归后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+"->");
    }
}
129.求根节点到叶节点数字之和

一步步进行优化,刚开始我想到的只是下面的回溯,通过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);
    }
}
988.从叶结点开始的最小字符串

找到二叉树中所有路径的字符串,该字符串需要从底向上构造,对字符串排序选出最小的字符串即可。

/**
 * 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();
        }
    }
}
112.路径总和

这里不需要记录所有路径,只需要判断是否存在路径,因此直接运用提供的递归函数进行递归就可以,并且由于是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);
    }
}
113.路径总和2

这题查找从根结点开始到叶子节点符合和的路径。

/**
 * 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();
        }
    }
}
437.路径总和3

这题不需要从根结点开始,也不需要在叶子节点结束,但是也不同于后面的后序遍历,本题的路径方向需要从上往下。采用双重递归,外面一层是先序遍历二叉树,里面一层是从该节点开始,从上往下先序遍历查找给定和的路径。

/**
 * 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);
    }
}

集合划分问题

698.划分为k个相等的子集

参考集合划分问题,从两种视角解决这一题,数组元素的视角:

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;
    }
}

其他问题

854.相似度为K的字符串

暴搜+回溯。这题本质上也是枚举所有可能的情况,因此采用回溯复原交换过的元素,难想到的点是,交换是将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;
    }
}

你可能感兴趣的:(算法人生,算法,学习,剪枝)