回溯
——任何算法的核心都是穷举,回溯算法就是一个暴力穷举算法。
回溯算法实际上是一个类似枚举的搜索尝试过程,主要在搜索过程中寻找问题的解,当发现不满足求解条件时,就“回溯”返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。
回溯过程需要考虑三个问题:
- 路径:已经做出的选择;
- 选择列表:当前还可以做的选择;
- 结束条件:无法再做选择的条件;
回溯的大体框架:
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
回溯的核心就是 for 循环里面的递归,在递归调用之前做选择,在递归调用之后撤销选择。
注意:
如果在回溯过程中消除了部分重叠子问题的计算,这就相当于对回溯算法进行了剪枝,提升了算法在某些情况下的效率,但算不上质的飞跃。
1 全排列问题
参考东哥的公众号labuladong,看看是否能够解决我对回溯的困扰。
求三个数的全排列[1,2,3]
,有3!
种排法。其回溯树如下:
从根遍历这棵树,记录路径上的数字,就是所有的全排列。我们将这棵树称为回溯算法的决策树。之所以叫决策树,是因为每个节点上都在做决策,可以向哪边走。
针对红色节点所在路径,2
就是已经做出的选择即路径,1,3
就是待选择列表,结束条件就是选择列表为空的时候。
路径和选择列表可以作为每个节点的属性。比如蓝色节点的路径为3,1
,待选择列表为2
。
如何遍历一棵树?各种搜索问题其实都是树的遍历问题,而多叉树的遍历框架就是这样:
void traverse(TreeNode root) {
for (TreeNode child : root.childern)
// 前序遍历需要的操作
traverse(child);
// 后序遍历需要的操作
}
而所谓的前序遍历和后序遍历,他们只是两个很有用的时间点:前序遍历的代码在进入某一个节点之前的那个时间点执行(即进入它的子树之前操作),后序遍历代码在离开某个节点之后的那个时间点执行(即它的子树遍历完之后)。
「路径」和「选择」是每个节点的属性,函数在树上游走要正确维护节点的属性,那么就要在这两个特殊时间点搞点动作:
再看看回溯算法的这段核心框架
for 选择 in 选择列表:
# 做选择
将该选择从待选择列表中移除
路径.add(选择)
backtrack(路径, 选择列表)
# 撤销选择
路径.remove(选择)
将该选择再加入待选择列表
我们只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到每个节点的选择列表和路径。
直接看全排列代码:
力扣:全排列
List<List<Integer>> res = new LinkedList<>();// 记录结果
/* 主函数,输入一组不重复的数字,返回它们的全排列 */
List<List<Integer>> permute(int[] nums) {
// 记录「路径」
LinkedList<Integer> track = new LinkedList<>();
backtrack(nums, track);
return res;
}
// 路径:记录在 track 中
// 选择列表:nums 中不存在于 track 的那些元素
// 结束条件:nums 中的元素全都在 track 中出现
void backtrack(int[] nums, LinkedList<Integer> track) {
// 触发结束条件
if (track.size() == nums.length) {
res.add(new LinkedList(track));
return;
}
for (int i = 0; i < nums.length; i++) {
// 排除不合法的选择,也可以用数组记录已经选择的节点
if (track.contains(nums[i]))
continue;
// 做选择
track.add(nums[i]);
// 进入下一层决策树
backtrack(nums, track);
// 取消选择
track.removeLast();
}
}
注意:
链表使用contains方法需要 O(N) 的时间复杂度。
2 电话号码的字母组合
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
示例:
输入:“23”
输出:[“ad”, “ae”, “af”, “bd”, “be”, “bf”, “cd”, “ce”, “cf”].
说明: 尽管上面的答案是按字典序排列的,但是你可以任意选择答案输出的顺序。
来源:力扣(LeetCode)
DFS+回溯
class Solution {
public static String[] mapString = {"","","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
public List<String> letterCombinations(String digits) {
List<String> ret = new ArrayList<>();
StringBuilder curStr = new StringBuilder();
dfs(ret,curStr,digits,0);
return ret;
}
public void dfs(List<String> ret, StringBuilder curStr,String digits,int index){
//边界,找到一种组合,放入数组中,结束此路径,向上回溯
if(index==digits.length()){
if(curStr.length()!=0){
ret.add(curStr.toString());
}
return;
}
//找到当前字符在String映射表中位置
int mapIndex = digits.charAt(index)-48;
String str = mapString[mapIndex];
//遍历每一种可能的组合
for(int i=0;i<str.length();i++){
curStr.append(str.charAt(i));
dfs(ret,curStr,digits,index+1);
curStr.deleteCharAt(curStr.length()-1);
}
}
}
3 组合总和
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的数字可以无限制重复被选取。
说明:
所有数字(包括 target)都是正整数。
解集不能包含重复的组合。
示例 1:
输入: candidates = [2,3,6,7], target = 7,
所求解集为:
[
[7],
[2,2,3]
]
示例 2:
输入: candidates = [2,3,5], target = 8,
所求解集为:
[
[2,2,2,2],
[2,3,3],
[3,5]
]
来源:力扣(LeetCode)
此题相加的元素可以重复,所以取下一个元素时可以从当前位置开始。
- 从第一个元素开始相加
- 让局部和继续累加候选的剩余值
- 局部和等于目标值,保存组合,向上回退,寻找其它组合。
首先对数组进行排序,这样有利于剪枝。nums数组用来记录每个下标的元素用了多少个,用来记录数字组合。
剪枝操作:sum + candidates[i] > target
的时候表示sum + candidates[n](n > i)
的元素都会大于target,所以break,然后往上回溯。
class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> ret = new ArrayList<>();
if(candidates.length==0 || candidates==null){
return ret;
}
int sum =0;
int[] nums = new int[candidates.length];//记录每个下标的元素用了多少个,用来记录组合元素
Arrays.sort(candidates);//利于剪枝操作
dfs(ret,candidates,nums,sum,target,0);
return ret;
}
public void dfs(List<List<Integer>> ret, int[] candidates, int[] nums, int sum, int target, int index){
if(sum==target){
List<Integer> list = new ArrayList<>();
for(int i=0;i<nums.length;i++){
if(nums[i]>0){
int tmp = nums[i];//不能破坏nums,所以用tmp代替
while(tmp-->0){
list.add(candidates[i]);
}
}
}
ret.add(list);
return;
}
for(int i=index;i<candidates.length;i++){
if(sum+candidates[i]<=target){
nums[i]++;
dfs(ret,candidates,nums,sum+candidates[i],target,i);
nums[i]--;
}else{
break;
}
}
}
}
4 求和
输入两个整数 n 和 m,从数列1,2,3…n 中随意取几个数,使其和等于 m ,要求将其中所有的可能组合列出来
输入描述:
每个测试输入包含2个整数,n和m
输出描述:
按每个组合的字典序排列输出,每行输出一种组合
输入
5 5
输出
1 4 2 3 5
链接:求和
import java.util.*;
public class Main{
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
List<List<Integer>> ret = new ArrayList<>();
List<Integer> list = new ArrayList<>();
dfs(n,m,1,ret,list);
for(List<Integer> li:ret){
int size = li.size();
for(int i=0;i<size-1;i++){
System.out.print(li.get(i)+" ");
}
System.out.println(li.get(size-1));
}
}
public static void dfs(int n, int target,int index,List<List<Integer>> ret,List<Integer> list){
if(target==0){
ret.add(new ArrayList<>(list));
return;
}
for(int i=index;i<=target&&i<=n;i++){
list.add(i);
// 求1...n 中取若干个数字和为m, 把问题拆解为
//求2...n 中取若干给数字和为m - 1
dfs(n,target-i,i+1,ret,list);
list.remove(list.size()-1);
}
}
}
5 活字印刷
你有一套活字字模 tiles,其中每个字模上都刻有一个字母 tiles[i]。返回你可以印出的非空字母序列的数目。
注意:本题中,每个活字字模只能使用一次。
示例 1:
输入:“AAB”
输出:8
解释:可能的序列为 “A”, “B”, “AA”, “AB”, “BA”, “AAB”, “ABA”, “BAA”。
示例 2:
输入:“AAABBC”
输出:188
提示:
1 <= tiles.length <= 7
tiles 由大写英文字母组成
来源:力扣(LeetCode)
解析:
此题组合的长度不唯一,最小组合长度为1,最大组合长度为tiles的长度。
按照题意tiles中每一个位置的字符在组合中只能出现一次,所以可以用一个标记辅助。
当去组合新的组合时,可以与tiles中的每一个位置组合,但是如果当前位置已经在当前组合中出现过,则跳过。
虽然此题中每一个位置的字符在组合中只能出现一次,但是tiles中可能有相同的字符,所以需要考虑重复的组合,而Set可以天然去重,可以用其去重。
DFS+回溯
- 当前组合不为空,则插入set中
- 继续恰当组合拼接新的组合,尝试拼接tiles每一个位置的字符
- 如果当前位置已在组合中出现过,则返回到2,否则标记当前位置,继续拼接更长的组合
- 回溯,尝试组合其它位置,返回2
当所有位置都已经使用过时,当前递归就结束了,继续向上层DFS回退,最终返回set的大小即为组合数目。
class Solution {
public int numTilePossibilities(String tiles) {
if(tiles.length()==0){
return 0;
}
//保存所有的组合
HashSet<String> set = new HashSet<>();
//拼接当前组合
StringBuilder curStr = new StringBuilder();
//标记全部初始化为未使用,记录每个字母的使用情况
int[] nums = new int[tiles.length()];
dfs(set, curStr, nums,tiles);
return set.size();
}
public void dfs(HashSet<String> set, StringBuilder curStr, int[] nums, String tiles){
//添加新的组合
if(curStr.length()!=0){
set.add(curStr.toString());
}
//标记保证所有位都用完之后,就结束了
for(int i=0;i<tiles.length();i++){
//当前位置的字符已经用过,直接跳过
if(nums[i]==1){
continue;
}
//标记当前字母已经用过
nums[i]=1;
dfs(set,curStr.append(tiles.charAt(i)),nums,tiles);
//回退,尝试其它字符
nums[i]=0;
curStr.deleteCharAt(curStr.length()-1);
}
}
}
6 N皇后
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
上图为 8 皇后问题的一种解法。
给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。
每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
示例:
输入: 4
输出: [
[".Q…", // 解法 1
“…Q”,
“Q…”,
“…Q.”],
["…Q.", // 解法 2
“Q…”,
“…Q”,
“.Q…”]
]
解释: 4 皇后问题存在两个不同的解法。
提示:
皇后,是国际象棋中的棋子,意味着国王的妻子。皇后只做一件事,那就是“吃子”。当她遇见可以吃的棋子时,就迅速冲上去吃掉棋子。当然,她横、竖、斜都可走一到七步,可进可退。(引用自 百度百科 - 皇后 )
来源:力扣(LeetCode)
N皇后问题:把N个皇后放置N*N的二维矩阵中,保证他们相互不能攻击:即不在同一行,同一列,同一个斜线上。
思想:DFS+回溯
从第一行开始放置皇后,每确定一个位置,判断是否会冲突:是否在同一列(不可能在同一行,所以行不用考虑)。
- 同一列:纵坐标相同
- 反对角线(红色)对应的位置,横坐标加上纵坐标的值是相同的。
- 正对角线(黑色)对应的位置,横坐标减去纵坐标的值也是相同的。
当前位置确定之后,继续确定下一行的位置。回退,尝试当前行的其它位置。
class pair{
public int x;
public int y;
public pair(int x,int y){
this.x = x;
this.y = y;
}
}
class Solution {
public List<List<String>> solveNQueens(int n) {
//按坐标位置存放所有解决方案
List<List<pair>> solutions = new ArrayList<>();
//存放一种解决方案中的所有皇后的位置
List<pair> solution = new ArrayList<>();
dfs(solutions,solution,0,n);
//把坐标位置转成string
return tranString(solutions,n);
}
public void dfs(List<List<pair>> solutions,List<pair> solution,int row, int n){
if(row==n){
List<pair> list = new ArrayList<>();
for(pair x:solution){
list.add(x);
}
solutions.add(list);
}
//尝试当前行的每一个位置是否可以放置一个皇后
for(int col=0;col<n;col++){
if(isValid(row,col,solution)){
//如果可以,在保存当前位置,继续确定下一行的皇后位置
//直接调用构造函数,内部构造pair
solution.add(new pair(row,col));
dfs(solutions,solution,row+1,n);
//回溯,删除当前位置,尝试当前行的其它位置
solution.remove(solution.size()-1);
}
}
}
//solution:一个解决方案,从第一行开始到当前行的上一行每一行已经放置皇后的点
public boolean isValid(int row, int col, List<pair> solution){
//判断当前行尝试的皇后位置是否和前面几行的皇后位置有冲突
//i.secongd == col:第i个皇后位置是否和前面几行的皇后位置有冲突
//i.first + i.second == row +col:第i个皇后与当前点在撇上,横坐标+纵坐标值相同
//i.first - i.second == row - col:第i个皇后与当前点在捺上,横坐标-纵坐标值相同
for(pair i:solution){
if(i.y==col || i.x+i.y == col+row || i.x-i.y == row-col){
return false;
}
}
return true;
}
public List<List<String>> tranString(List<List<pair>> solutions, int n){
//把每一种解决方案都转换为string形式,最终结果
List<List<String>> ret = new ArrayList<>();
//n*n char:每行有n个元素,把皇后的位置修改为Q
for(List<pair> solution:solutions){
List<StringBuilder> strs = new ArrayList<>();
//先把.都填完
for(int i=0;i<n;i++){
StringBuilder str = new StringBuilder();
for(int j=0;j<n;j++){
str.append('.');
}
strs.add(str);
}
//把每一行皇后的位置修改为Q
for(pair i:solution){
strs.get(i.x).setCharAt(i.y,'Q');
}
List<String> cur = new ArrayList<>();
for(StringBuilder i:strs){
cur.add(i.toString());
}
ret.add(cur);
}
return ret;
}
}
注意:
if(row==n){
List<pair> list = new ArrayList<>();
for(pair x:solution){
list.add(x);
}
solutions.add(list);
}
这地方一定要new ArrayList<>()
. 不可以直接solutions.add(solution)
,如果这样做相当于是添加的引用,如果后期solution发生改变,那么solutions中添加的元素也都跟着solution改变。
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
List<List<Integer>> ret = new ArrayList<>();
List<Integer> list = new ArrayList<>();
for(int i=0;i<5;i++){
list.add(i);
}
ret.add(list);
System.out.println(ret);
list.remove(list.get(list.size()-1));
System.out.println(ret);
}
结果:
[[0, 1, 2, 3, 4]]
[[0, 1, 2, 3]] //后期list发生修改后,ret中的元素也跟着变化。