回溯作为常见的算法之一,相信大家也曾经遇到过回溯相关的问题,觉得摸不着头脑,实际上回溯相关的问题也是有套路的。
一、什么是回溯
回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就 “回溯” 返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为 “回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。
回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。
(以上解释摘自Leetcode回溯专题介绍,另外建议大家可以按照专题来刷题)
所以可以看出来,回溯就是一种深度优先遍历,先选择一条路走,直到走不通就退回来选择另一条路。
二、回溯的框架
有了上面的初步解释后,就可以得到回溯的基本框架,我们需要一些变量:所有合理的路径就是结果集,正在进行的某一个路径
定义结果集
定义当前路径
back(路径,选择列表){
if(结束条件){
结果集中加入当前路径
}
for(选择 in 选择列表){
在可选择路径中选择一条路
back(当前路径,可继续选择的分支节点)
撤销选择
}
}
所以我们在考虑结果集时,就得考虑什么时候结束、选择列表分别表示什么
三、全排列
[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.组合总和II、216.组合总和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);
}
}
}
所以总结下来就是:写回溯,需要考虑:
结束条件,是否需要使用状态数组,遍历的起始和终止条件、剪枝条件、再次回溯参数
回溯的框架都是大同小异,是需要细节部分进行调整,希望自己也可以多多刷题总结,大家一起进步。