【算法】回溯问题常见题目 ——全排列、组合总和

回溯作为常见的算法之一,相信大家也曾经遇到过回溯相关的问题,觉得摸不着头脑,实际上回溯相关的问题也是有套路的。

一、什么是回溯

回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就 “回溯” 返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为 “回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。
回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。
(以上解释摘自Leetcode回溯专题介绍,另外建议大家可以按照专题来刷题)

所以可以看出来,回溯就是一种深度优先遍历,先选择一条路走,直到走不通就退回来选择另一条路。

二、回溯的框架

有了上面的初步解释后,就可以得到回溯的基本框架,我们需要一些变量:所有合理的路径就是结果集,正在进行的某一个路径

   定义结果集
   定义当前路径
   back(路径,选择列表){
   		if(结束条件){
   			结果集中加入当前路径
   		}
   		for(选择 in 选择列表){
	   		在可选择路径中选择一条路
			back(当前路径,可继续选择的分支节点)
			撤销选择
   		}
	}
   

所以我们在考虑结果集时,就得考虑什么时候结束、选择列表分别表示什么

三、全排列

全排列系列的两题:46.全排列47.全排列II

[46] 全排列

在这里插入图片描述
首先题目声明:没有重复,那么我们就不需要使用一个数据保存已经使用过的节点状态。只要当前路径中没有存在过该数,就加入。
由于是全排列,每个数字都能作为开头,所以我们在循环时就要从nums[0]开始循环。如果遇到使用过的节点就跳过。代码如下:

class Solution {

    List<List<Integer>> res = new ArrayList<>();
    List<Integer> tmp = new ArrayList<>();

    public List<List<Integer>> permute(int[] nums) {
        if (nums.length == 0) {
            return res;
        }
        back(nums);
        return res;
    }

    private void back(int[] nums) {
        if (tmp.size() == nums.length) {
            res.add(new ArrayList(tmp));
            return ;
        }

        for (int i = 0; i < nums.length; i ++) {  //每次都从0开始
            if (tmp.contains(nums[i])) continue;  //遇到使用过的就跳过

            tmp.add(nums[i]);          //选择
            back(nums);               //回溯
            tmp.remove(tmp.size()-1); //撤销选择
        }
    }
}

[47] 全排列II

在这里插入图片描述
这题就开始有重复的序列,所以就需要我们进行去重了。首先要对原数组进行排序这样重复的数字在一起方便剪枝。
由于有重复数字,所以不能简单的使用contains来判断,所以就要使用一个数组used来记录使用过的节点。
什么时候剪枝呢? 当当前节点和前一个节点相同并且前一个节点没有被使用过 ,就剪枝。
如果当前节点已经被使用过,剪枝

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    List<Integer> tmp = new ArrayList<>();
   

    public List<List<Integer>> permuteUnique(int[] nums) {
        if (nums == null || nums.length == 0) {
            return res;
        }
        Arrays.sort(nums);
        boolean[] used = new boolean[nums.length];
        back(nums,used);
        return res;
    }
    public void back(int[] nums,boolean[] used) {
        if (tmp.size() == nums.length) {
            res.add(new ArrayList<>(tmp));
            return ;
        }

        for (int i = 0;i < nums.length; i ++) {
            if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;  //与前一个数字相同,且前一个未被使用,剪枝
            if (!used[i]){   //当前节点被使用过,剪枝
                used[i] = true; // 更改状态
                tmp.add(nums[i]); //加入选择
                back(nums,used); //回溯
                used[i] = false; //撤销状态
                tmp.remove(tmp.size()-1); // 撤销选择
            }   
        }
    }
}

四、组合总和

组合总和系列的三道题:39.组合总和40.组合总和II216.组合总和III

[39] 组合总和

在这里插入图片描述
题目说明 无重复! 没有限制使用次数,所以不需要使用used数组标记状态
首先,对数组进行排序,从小到大排序后,更好的进行回溯剪枝判断
对于给定数组中的每个数,有两种状态:加入、不加入,所以是遍历整个数组,对每个数字进行选择。所以就需要一个index来标记遍历到哪一个数字了
由于可以无限次使用,所以选择了i处的数字后回溯的下一次还可以选择i
结束条件是 target == 0

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    List<Integer> tmp = new ArrayList<>();

    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        if (candidates == null || candidates.length == 0) {
            return res;
        }
        Arrays.sort(candidates);  //排序
        back(candidates,0,target);
        return res;
    }

    public void back(int[] candidates,int index,int target) {
        if (target == 0) {  
            res.add(new ArrayList<>(tmp));
            return ;
        }
        for (int i = index; i < candidates.length; i ++) { //从index开始
            if (target-candidates[i] < 0) continue;   //如果选择i处的数字加入,得到的结果比target大,所以剪枝
            tmp.add(candidates[i]);           //加入选择
            back(candidates,i,target-candidates[i]); //回溯,index传入i
            tmp.remove(tmp.size()-1); //删除选择
        }
    }
}

[40] 组合总和II

在这里插入图片描述
数组有重复而且每个数字不可以无限次使用,所以要使用used数组标记状态
首先还是要排序,每次还是要传入index来标记。
与全排列II一样,如果当前数和前一个数相同而且上一个数没被使用,就剪枝。
每次回溯index都要是当前i+1,且每次选择都要更改uesd状态位

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    List<Integer> tmp = new ArrayList<>();
    boolean[] used;
    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        if (candidates == null || candidates.length == 0) {
            return res;
        }

        Arrays.sort(candidates);
        used = new boolean[candidates.length];
        back(candidates,0,target);
        return res;
    }

    public void back(int[] candidates,int index,int target) {
        if (target == 0) {
            res.add(new ArrayList<>(tmp));
            return;
        }
        if (target < 0) {
            return;
        }
        for (int i = index; i < candidates.length; i ++) {
            //i > index 保证了不会越界
            if (i > index && candidates[i] == candidates[i-1] && !used[i-1]) continue;
            tmp.add(candidates[i]);
            used[i] = true;
            back(candidates,i+1,target-candidates[i]);
            used[i] = false;
            tmp.remove(tmp.size()-1);
        }
    }
}

[216] 组合总和III

在这里插入图片描述
这题限制是多了一个限制条件,就是限制每一个解集中节点的个数,所以我们就要保存还可以添加节点的个数
如果同时k==0,而且个数n == 0 那就符合结果加入结果集。如果只有一个条件满足那就剪枝。
每次回溯的参数就是个数n-1,总和k-i

class Solution {
    List<List<Integer>> res = new ArrayList<>();
    List<Integer> tmp = new ArrayList<>();

    public List<List<Integer>> combinationSum3(int k, int n) {
        if (n <= 0 || k <= 0) {
            return res;
        }
        back(k,n,1);
        return res;
    }

    private void back(int k,int n,int index) {
        if (n == 0 && k == 0) {
            res.add(new ArrayList(tmp));
            return ;
        }
        if (k == 0 || n == 0) {
            return ;
        }
        for (int i = index; i <= 9; i ++) {。
            tmp.add(i);
            back(k-1,n-i,i+1);
            tmp.remove(tmp.size()-1);
        }
    }
}

所以总结下来就是:写回溯,需要考虑:
结束条件,是否需要使用状态数组,遍历的起始和终止条件、剪枝条件、再次回溯参数

回溯的框架都是大同小异,是需要细节部分进行调整,希望自己也可以多多刷题总结,大家一起进步。

猜你喜欢

转载自blog.csdn.net/Moo_Lavender/article/details/105874207
今日推荐