回溯算法(backtracking)是暴力搜索算法的一种。
这句话向我们揭示了回溯算法的用途:搜索,因此回溯算法也被称为回溯搜索算法。与“二分查找”、“线性查找”等“查找问题”不同的是,“搜索问题”完成一件事情有可能多种方法,而每一种方法又有多个步骤,回溯算法就是在不断尝试,以得到待求问题的全部的解。
正是由于回溯算法具有“强大的”暴力搜索的能力,添加链接描述它被应用于一些游戏问题,例如:N 皇后、解数独、祖玛游戏、24 点游戏、走迷宫、生成迷宫。许多复杂的、规模较大的问题都可以使用回溯搜索算法得到所有可行解,进而得到最优解,因此回溯算法有“通用解题方法”的美称,回溯算法也是经典的人工智能的基础算法。
从一个非常点经典的问题开始讲回溯算法,这道题是【力扣】的第46题:全排列
给定一个没有重复数字的序列,返回其所有可能的全排列。
示例:
输入: [1, 2, 3]
输出: [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
这道题要求我们返回一个一个没有重复数字的序列的所有可能的排列。以题目示例为例,如果让我们手动去写,相信大家一定都会。在动手尝试写出几个全排列以后,会慢慢找到规律:
1、先下以 1 开头的全排列,它们是:[1, 2, 3], [1, 3, 2];
2、再写下以 2 开头的全排列,它们是:[2, 1, 3], [2, 3, 1];
3、最后写下以 3 开头的全排列,它们是:[3, 1, 2], [3, 2, 1]。
这是一个非常典型的搜索问题,它的特点是:1、有若干个解;2、每一个解的求解过程可以分为若干个步骤,得到所有解是一个不断尝试的过程。
具体说,我们的思路是:按顺序枚举每一位可能出现的数字,之前已经出现的数字在接下来要选择的数字中不能出现。按照这种思路就能够做到不重不漏,把所有的全排列都枚举出来。这样的思路可以用一个树形结构表示。看到这里的朋友,不妨自己先尝试画一下“全排列”问题的树形结构。
画出树形结构
使用编程的方法得到全排列,就是在这样的一个树形结构中进行搜索,从树的根结点到叶子结点的路径 path (下文也会用到的 path 就是此处的意思,就不再重复说明) 就是题目要求的一个全排列。我们只需要执行一次深度优先遍历(深度优先搜索),就能够得到所有的叶子结点。
相信提到深度优先搜索,不少朋友会想到树和图问题中另一个小伙伴的名字,它就是广度优先遍历(广度优先搜索),那么广度优先搜索是否可以应用在这道问题中呢?既然是搜索,广度优先搜索当然可以用于搜索。这个问题大家也不妨思考一下,全排列问题,既然用广搜可以,为什么它是深搜的经典问题。或许我们能想到一块去。
理解为什么是深度优先遍历,和回溯又有什么关系
下面我们解释一下上面的树形结构,请大家从深搜在这棵树上走过的路径来理解以下的几点说明:
1、每一个结点表示了“全排列”问题求解的不同阶段,这些阶段通过变量的“不同的值”体现,这些变量的不同的值,称之为“状态”;
2、深度优先遍历由于有“回头”的过程,在“回头”以后,状态变量需要设置成为和先前一样。在回到上一层结点的过程中,需要撤销上一次选择,这个操作也称之为“状态重置”,“状态重置”就是“回溯”的本意;
3、使用深度优先遍历编写代码,可以直接借助系统栈空间,为我们保存所需要的状态变量。在编码中需要注意:遍历到相应的结点的时候,状态变量的值是必须是正确的。此处我们来认识 path 变量作为状态变量,它在深度优先遍历中的变化:往下走一层的时候,path 变量在尾部追加一个数字,而往回走的时候,需要撤销上一次的选择,这一操作也是在 path 的尾部去掉一个数字,因此 path 变量是一个栈。
下面我们解释如何编码:
由于执行的深度优先遍历,从较深层的结点返回到较浅层结点的时候,需要做“状态重置”,即“回到过去”、“恢复现场”,我们举一个例子:请大家看上面的树形图想象,代码是如何从叶子结点 [1, 2, 3] 到叶子结点 [1, 3, 2] 的。深度优先遍历是这样执行的:
class Solution {
public List<List<Integer>> permute(int[] nums) {
int len = nums.length;
// 使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
boolean[] used = new boolean[len];
List<Integer> l = new ArrayList<>();
dfs(nums, len, 0,l, used, res);
return res;
}
private void dfs(int[] nums, int len, int depth,List<Integer> l, boolean[] used,List<List<Integer>> res) {
if (depth == len) {
res.add(new ArrayList<>(l));
return;
}
for (int i = 0; i < len; i++) {
if (!used[i]) {
l.add(nums[i]);
used[i] = true;
dfs(nu`在这里插入代码片`ms, len, depth + 1, l, used, res);
// 注意:这里是状态重置,是从深层结点回到浅层结点的过程,代码在形式上和递归之前是对称的
used[i] = false;
l.remove(depth);
}
}
}
}
如果像下面这样
if (depth == len) {
res.add(path);
return;
}
这段代码在运行以后输出如下:
[[], [], [], [], [], []]
解释:l 这个变量所指向的对象在递归的过程中只有一份。在深度优先遍历完成以后,由于最后回到了根结点, l 这个变量为空列表。
依然是去想象深度优先遍历的过程,从而理解为什么会到深搜会到原点以后为空列表,因为一开始就是空列表,深搜的过程转了一圈,在不断的选择和回溯的过程以后,回到原点,依然是空列表。
这里需要说明的一点是:
在 Java 语言中,方法传递都是值传递。对象类型的变量在传参的过程中,复制的都是变量的地址。这些地址被添加到 res
变量,但这些地址实际上指向的是同一块内存的地址,因此我们会看到 6 个空的列表对象。解决这个问题的方法很简单,在 res.add(l);
这里做一次拷贝即可。
另一种写法
import java.util.ArrayList;
import java.util.List;
public class Solution {
public List<List<Integer>> permute(int[] nums) {
// 首先是特判
int len = nums.length;
// 使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
boolean[] used = new boolean[len];
List<Integer> path = new ArrayList<>();
dfs(nums, len, 0, path, used, res);
return res;
}
private void dfs(int[] nums, int len, int depth,
List<Integer> path, boolean[] used,
List<List<Integer>> res) {
if (depth == len) {
// 3、不用拷贝,因为每一层传递下来的 path 变量都是新建的
res.add(path);
return;
}
for (int i = 0; i < len; i++) {
if (!used[i]) {
// 1、每一次尝试都创建新的变量表示当前的"状态"
List<Integer> newPath = new ArrayList<>(path);
newPath.add(nums[i]);
boolean[] newUsed = new boolean[len];
System.arraycopy(used, 0, newUsed, 0, len);
newUsed[i] = true;
dfs(nums, len, depth + 1, newPath, newUsed, res);
// 2、无需回溯
}
}
}
}
关于System.arraycopy用法
认识“剪枝”
由于回溯算法的时间复杂度很高,因此,在遍历的时候,如果能够提前知道这一条分支不能搜索到满意的结果,这一分支就可以跳过,这一步操作就是在一棵树上剪去一个枝叶,被人们很形象地称之为剪枝。
回溯算法会大量应用“剪枝”技巧达到以加快搜索速度。这里有几点提示:
1、有时,需要做一些预处理工作(例如排序)才能达到剪枝的目的。虽然预处理工作虽然也消耗时间,但和剪枝能够节约的时间相比是微不足道的。因此,能预处理的话,就尽量预处理;
2、正是因为回溯问题本身时间复杂度就很高,所以能用空间换时间就尽量使用空间。
如果大家玩得转“剪枝”,或许在开篇列出的一些简单的游戏问题就不在话下了。
练习
下面提供一些我做过的“回溯”算法的问题,都是特别基础的使用回溯算法解决的问题,以便大家学习和理解“回溯算法”。以下提供一个经验:
做回溯搜索问题第 1 步都是先画图,画图是非常重要的,只有画图才能帮助我们想清楚递归结构,看清楚、想清楚如何剪枝。具体的做法是:就拿题目中的示例,想一想人手动操作是怎么做的,一般这样下来,这棵递归树都不难画出。
在画图的过程中需要思考清楚的问题有:
1、分支如何产生,即:每一步有哪些选择;
2、题目需要的解在哪里?是在叶子结点、还是在非叶子结点、还是在从跟结点到叶子结点的路径?
3、哪些搜索是会产生不需要的解的,这里要特别清楚深搜是怎么运行的,在深搜的过程中,状态变量发生了什么变化。例如:产生重复是什么原因,如果在浅层就知道这个分支不能产生需要的结果,应该提前剪枝,剪枝的条件是什么,代码怎么写?
题目 | 提示 |
---|---|
47 .全排列 | 注意如何去重剪枝 |
17.电话号码的字母组合 | |
22.括号生成 | |
39.组合总和 | |
40.组合总和 II | |
51. N皇后 | |
61.第k个排序 | |
77.组合 | |
78. 子集 | |
90.子集 ② | |
93.复原ip地址 | |
784. 字母大小写全排列 |
class Solution {
public List<List<Integer>> permuteUnique(int[] nums) {
int len = nums.length;
// 使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();
Arrays.sort(nums);
if (len == 0) {
return res;
}
boolean[] used = new boolean[len];
List<Integer> l = new ArrayList<>();
dfs(nums, len, 0,l, used, res);
return res;
}
private void dfs(int[] nums, int len, int depth,List<Integer> l, boolean[] used,List<List<Integer>> res) {
if (depth == len) {
res.add(new ArrayList<>(l));
return;
}
for (int i = 0; i < len; i++) {
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
if (!used[i]) {
l.add(nums[i]);
used[i] = true;
dfs(nums, len, depth + 1, l, used, res);
// 注意:这里是状态重置,是从深层结点回到浅层结点的过程,代码在形式上和递归之前是对称的
used[i] = false;
l.remove(depth);
}
}
}
}
class Solution {
Map<String,String> map=new HashMap<String,String> ();
List<String> list=new ArrayList<String>();
public List<String> letterCombinations(String digits) {
map.put("2", "abc");
map.put("3", "def");
map.put("4", "ghi");
map.put("5", "jkl");
map.put("6", "mno");
map.put("7", "pqrs");
map.put("8", "tuv");
map.put("9", "wxyz");
backtrack("",digits);
if(digits.length()==0){
return new ArrayList<String>();
}
return list;
}
private void backtrack(String combination,String next_digits) {
if(next_digits.length()==0) {
list.add(combination);
}
else {
//取下一个数
String str=map.get(next_digits.substring(0,1));
int len=str.length();
for(int i=0;i<len;i++) {
backtrack(combination+str.substring(i,i+1), next_digits.substring(1));
/*
* String s=combination;
* combination+=str.substring(i,i+1);
* backtrack(combination, next_digits.substring(1));
* combination=s;
*/
}
}
}
}
class Solution {
public List<String> generateParenthesis(int n) {
int left=0;
int right=0;
String s="";
List<String> list =new ArrayList<String>();
dfs(left,right,n,list,s);
return list;
}
private void dfs(int left,int right,int n, List<String> list, String s){
if(left==n&&right==n){
list.add(s);
}
if(left<n){
dfs(left+1,right,n,list,s+"(");
}
if(right<left){
dfs(left,right+1,n,list,s+")");
}
}
}
class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> list=new ArrayList<List<Integer>>();
List<Integer> l=new ArrayList<Integer>();
int sum=0;
Arrays.sort(candidates);
dfs(list,l,target,sum,candidates);
return list;
}
private void dfs(List<List<Integer>> list,List<Integer> l,int target,int sum,int[] candidates){
if(sum==target){
list.add(l);
}
for(int i=0;i<candidates.length;i++){
if(sum+candidates[i]>target){
break;
}
if(l.size()>0&&l.get(l.size()-1)>candidates[i]){
continue;
}
List<Integer> newL =new ArrayList<Integer>(l);
newL.add(candidates[i]);
dfs(list,newL,target,sum+candidates[i],candidates);
}
}
}
class Solution {
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
List<List<Integer>> list=new ArrayList<List<Integer>>();
List<Integer> l=new ArrayList<Integer>();
int sum=0;
boolean[] used=new boolean[candidates.length];
Arrays.sort(candidates);
dfs(list,l,target,sum,candidates,used);
return list;
}
private void dfs(List<List<Integer>> list,List<Integer> l,int target,int sum,int[] candidates,boolean[] used){
if(sum==target){
list.add(l);
}
for(int i=0;i<candidates.length;i++){
if(sum+candidates[i]>target){
break;
}
if (i > 0 && candidates[i] == candidates[i - 1] && !used[i - 1]) {
continue;
}
if(l.size()>0&&l.get(l.size()-1)>candidates[i]){
continue;
}
if(!used[i]){
List<Integer> newL =new ArrayList<Integer>(l);
newL.add(candidates[i]);
boolean[] newUsed = new boolean[used.length];
System.arraycopy(used, 0, newUsed, 0,used.length);
newUsed[i] = true;
dfs(list,newL,target,sum+candidates[i],candidates, newUsed);
}
}
}
}