算法介绍
回溯算法(Backtracking)是对树形或者图形结构执行一次深度优先遍历,实际上类似枚举的搜索尝试过程,在遍历的过程中寻找问题的解。
基本思想是:从问题的某一种状态(初始状态)出发,搜索从这种状态出发所能达到的所有“状态”,当一条路走到“尽头”的时候(不能再前进),再后退一步或若干步,从另一种可能“状态”出发,继续搜索,直到所有的“路径”(状态)都试探过。
深度优先遍历有个特点:当发现已不满足求解条件时,就返回,尝试别的路径。此时对象类型变量就需要重置成为和之前一样,称为状态重置。
实际上,回溯算法就是暴力搜索算法。
练习题目
1. 39.组合总和
题目:给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个。
示例 1:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。
示例 2:
输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]
示例 3:
输入: candidates = [2], target = 1
输出: []
求解:
本题是经典回溯例题,需注意的点如下:
①递归终止条件;
②子问题起始遍历索引;
③结果集添加需根据path队列重新构造一个对象;
本题递归终止条件有两个,分别是数字和大于目标值与数字和等于目标值;子问题起始遍历索引是从start开始,因为其组合元素可以重复;
代码如下:
class Solution {
//返回值列表
List<List<Integer>> res=new ArrayList<>();
//记录队列
Deque<Integer> path=new ArrayDeque<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backTracking(candidates,target,0,0);
return res;
}
public void backTracking(int[] arr,int target,int start,int sum){
//数字和大于目标值,不符合返回
if(sum>target){
return;
}
//数字和等于目标值,添加到返回值列表
if(sum==target){
//!!!重要
//需重新生成一个列表,原因是path是引用的地址
//若直接add(path)则res内的所有列表为空,
//因为是该步未实际的将path添加,
//而是等最后的removeLast将path清空后,
//通过path地址将清空后的path添加进去
res.add(new ArrayList<>(path));
return;
}
//从start出发开始遍历
for(int i=start;i<arr.length;i++){
//将元素添加到队列末尾
path.addLast(arr[i]);
//通过sum记录数字和,递归遍历
backTracking(arr,target,i,sum+arr[i]);
//进行回溯,将队列末尾元素删除
path.removeLast();
}
}
}
2. 46.全排列
题目: 给定一个不含重复数字的数组 nums ,返回其所有可能的全排列 。你可以按任意顺序返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
示例 3:
输入:nums = [1]
输出:[[1]]
求解:
本题思路较为简单,是较为常规的回溯算法思路,需要注意的主要有两点:
①回溯过程中任何元素都可作为全排列的头元素且无序,故从0开始遍历;
②增加的一个记录状态数组:记录当前数据有无被遍历,来对遍历条件进行筛选;
代码如下:
class Solution {
List<List <Integer>> res=new ArrayList<>();
Deque<Integer> path=new ArrayDeque<>();
public List<List<Integer>> permute(int[] nums) {
//记录状态数组:记录当前数据有无被遍历
//初始化数组全为0,若已遍历修改状态为1
int [] flag=new int[nums.length];
dfs(nums,flag);
return res;
}
public void dfs(int[] arr,int[] flag){
if(path.size() == arr.length){
res.add(new ArrayList<>(path));
return;
}
//!!!
//任何元素都可作为全排列的头元素,故从0开始遍历
for(int i=0;i<arr.length;i++){
//若未被遍历,则可以添加
if(flag[i]==0){
flag[i]=1;
path.addLast(arr[i]);
dfs(arr,flag);
//回溯,状态复原
flag[i]=0;
path.removeLast();
}
}
}
}
3. 51.N皇后
题目: 按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
示例 1:
输入:n = 4
输出:[[“.Q…”,“…Q”,“Q…”,“…Q.”],[“…Q.”,“Q…”,“…Q”,“.Q…”]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
示例 2:
输入:n = 1
输出:[[“Q”]]
求解:
本题可以通过暴力枚举将 N 个皇后放置在 N×N 的棋盘上的所有可能的情况,并对每一种情况判断是否满足皇后彼此之间不相互攻击。但此暴力解法的时间复杂度非常高,需加以约束条件进行优化剪枝。
本题关键是个皇后必须位于不同行和不同列,因此将 N 个皇后放置在 N×N 的棋盘上,一定是每一行有且仅有一个皇后,每一列有且仅有一个皇后,且任何两个皇后都不能在同一条斜线上(主对角线与副对角线两个方向)。
使用三个集合column、dia1、dia2 分别记录每一列以及两个方向的每条斜线上是否有皇后。
行方向使用索引start记录,每次递归进行start+1,故不需判断行方向;
列方向使用column记录,从0到n-1,每次需判断是否相等;
主对角线方向用dia1记录,利用当前点行号-列号进行判断,如(0,1)、(1,2)点结果都为-1;
副对角线方向用dia2记录,利用当前点行号+列号进行判断,如(0,1)、(1,0)点结果都为1;
每次放置皇后时,对于每个位置判断其是否在三个集合中,如果三个集合都不包含当前位置,则当前位置是可以放置皇后的位置。
代码如下:
class Solution {
//结果列表
List<List<String>> res = new ArrayList<>();
//列信息列表
List<Integer> column=new ArrayList<>();
//反斜杆对角线
List<Integer> dia1=new ArrayList<>();
//斜杆对角线
List<Integer> dia2=new ArrayList<>();
public List<List<String>> solveNQueens(int n) {
//存储每行的Queen位置
int[] arr=new int[n];
//填充-1,避免0位置冲突
Arrays.fill(arr,-1);
dfs(arr,0);
return res;
}
public void dfs(int[] arr,int start){
//行与n相等,结束遍历
if(start == arr.length){
//添加棋盘方案
res.add(generate(arr));
return;
}
//列
for(int i=0;i<arr.length;i++){
//判断列
if(column.contains(i)){
continue;
}
//判断反斜杆对角线:行-列相等
int dia11=start-i;
if(dia1.contains(dia11)){
continue;
}
//判断斜杆对角线:行+列相等
int dia22=start+i;
if(dia2.contains(dia22)){
continue;
}
arr[start]=i;
column.add(i);
dia1.add(dia11);
dia2.add(dia22);
//start+1表示行,故行永不相等,无需判断行
dfs(arr,start+1);
//回溯,进行状态复原
arr[start]=-1;
column.remove(column.size()-1);
dia1.remove(dia1.size()-1);
dia2.remove(dia2.size()-1);
}
}
//根据arr生成string列表
public List<String> generate(int[] arr){
List<String> lis=new ArrayList<>();
for(int i=0;i<arr.length;i++){
//利用char数组和new String生成字符串
char[] row = new char[arr.length];
Arrays.fill(row, '.');
row[arr[i]] = 'Q';
lis.add(new String(row));
}
return lis;
}
}
总结
以上就是今天要讲的内容,本文仅仅简单介绍了回溯算法及相关案例,其中结束递归条件与状态重置尤为重要,其余情况则需根据题目条件具体分析,如子问题起始遍历条件,子问题筛选等。
参考《力扣 回溯》
https://leetcode.cn/tag/backtracking/problemset/