通俗点描述,回溯是基于递归的基础上使得某一步在回归到上一步时能够改变上一步的策略而产生一种新的递归方案,其本质是一种枚举。所以回溯并不算是一种非常高效的算法,但是是一种很有效的算法,因为其时间复杂度很高,如果以暴力枚举为O(n^n)的时间复杂度,回溯的时间复杂度一般也到底O(n!)。回溯之所以比暴力枚举的效率高,很多情况下都是剪枝的功劳
下面举几个例子来细品回溯的巧妙。
迷宫问题
问题简述:在一个由两位数组所构成的迷宫中(迷宫的最外围是墙,迷宫里面有档板)一个小球从任意位置出发,设计一种算法使得小球能够顺利走到迷宫最右下的位置。
思路分析:从起点准备出发,首先我们先指定一个行走策略(这里定制顺序为下,右,上,左),然后我们通过递归的手段按照已制定的策略来判断此点是否可行(先默认可行,然后通过回溯来确定并修改此默认的判断),最后小球到达终点,记录下路径。
(在这里,我们只访问没有访问过的点,实质上就是一种剪枝手段)
代码实现:
public class MiGong {
public static void main(String[] args) {
//创建一个二维数组,模拟迷宫
int[][] map = new int[8][7];
//使用1表示墙
for (int i = 0; i < 7; i++) {
map[0][i] = 1;
map[7][i] = 1;
}
for (int i = 0; i < 8; i++) {
map[i][0] = 1;
map[i][6] = 1;
}
//设置挡板,1表示
map[3][1] = 1;
map[3][2] = 1;
// map[4][3] = 1;
// map[5][4] = 1;
// map[5][5] = 1;
setWay(map, 1, 3);
System.out.println("小球走过并标识过的地图的情况");
for (int i = 0; i < 8; i++) {
for (int j = 0; j < 7; j++) {
System.out.print(map[i][j] + " ");
}
System.out.println();
}
}
//说明
//1.map表示地图
//2.i,j表示从地图的哪个位置开始出发
//3.如果小球能够到map[6][5],位置,则说明通路找到
//4.约定:0表示该点没有走过,1表示墙,2表示通路可以走,3表示该点已经走过但是走不通
//5.在走之前需要确定一个策略(方法)下,右,上,左,如果该点走不通,再回溯
/**
* @param map 表示地图
* @param i 从哪个位置开始找
* @param j
* @return 如果找到通路,就返回true,否则返回false
*/
private static boolean setWay(int[][] map, int i, int j) {
if (map[6][5] == 2) {
return true;
} else {
if (map[i][j] == 0) {
map[i][j] = 2;//先假设此处可以通走
if (setWay(map, i + 1, j)) {
return true;
} else if (setWay(map, i, j + 1)) {
return true;
} else if (setWay(map, i - 1, j)) {
return true;
} else if (setWay(map, i, j - 1)) {
return true;
} else {
map[i][j] = 3;//发现此路不通,修改之前的判断值
return false;
}
} else {
return false;
}
}
}
}
结果如下:
此结果如最开始的分析判断,小球顺利的达到了终点,但是似乎并未体现回溯的过程。
新添三块挡板,也就是放开上述注释的那三行代码,新结果如下:
由于新添了三块挡板,使得小球已经无法再到达目的地,所以从第一处不通的点开始回溯,最后使得那块被挡板和墙所包围的密闭空间的所有点都变成了不可通行的点。
八皇后
问题描述:在8x8格的国际象棋上摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上。
思路分析:国际象棋棋盘是一个二维数组,但是对于记录八个皇后的位置,我们只需用一个一位数组即可(由于每个皇后间不能在同一行,所以可以默认一维数组的下标为行数)。先确定第一个皇后的位置(默认在第一列的位置),然后在第二行中依次放置第二个皇后,成功后放置第三个皇后,以此类推,知道有一个皇后放不下或者是最后一个皇后以及放置成功时开始从最后一步进行回溯,通过改变上一步的策略(这里是指继续尝试未尝试过的位置)来得到正解甚至是更多的解。
函数解析:在这里面需要定义两个函数(需要打印各种放置方法还需额外再定义一个函数),一个函数用于判断当前位置的皇后是否与之前已放置的任意一个皇后发生冲突,还有一个函数用于回溯并记录下正解。
代码如下:
public class Queen8 {
//定义一个max表示共有多少个皇后
int max = 8;
//定义数组array,保存皇后被放置位置的结果
int[] array = new int[max];
static int count = 0;
static int judgeCount = 0;
public static void main(String[] args) {
Queen8 queen8 = new Queen8();
queen8.check(0);
System.out.println("一共有"+(count)+"种解法");
System.out.println("一共判断"+(judgeCount)+"次冲突");
}
//编写一个方法,放置第n个皇后
//特别注意:check的每一层递归时都有一个for循环,进而产生了回溯
private void check(int n) {
if (n == max) {
print();
count++;
return;
}
for (int i = 0; i < max; i++) {
array[n] = i;
if (judge(n)) {
check(n + 1);
}
}
}
//检验该皇后是否和前面已经摆放的皇后发生冲突
/**
* @param n 表示第n个皇后
* @return
*/
private boolean judge(int n) {
judgeCount++;
for (int i = 0; i < n; i++) {
//一维数组,不可能在同一行
//判断是否在同一行或者同一列
if (array[i] == array[n] || Math.abs(n - i) == Math.abs(array[n] - array[i])) {
return false;
}
}
return true;
}
//写一个方法,可以将皇后摆放的位置输出
private void print() {
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
System.out.println();
}
}
结果如下:
(由于方式总数太多,这里就不列举其结果了)92代表总共有92种放置方式,15720代表回溯了一万五千多次,说明回溯的时间复杂度的确挺高的。
组合总和
问题描述:给定一个无重复元素的数组 candidates 和一个目标数 target ,找出candidates 中所有可以使数字和为 target 的组合。
candidates 中的数字可以无限制重复被选取。
思路分析:首先我们先对给定的数组进行排序(方便后序剪枝),然后从给定数组的第一个元素开始求和判断是否等于目标数,大了直接终止递归开始回溯,小了继续递归,正好相等直接记录下次决策并开始回溯到上一阶段继续寻找下一个满足要求的决策。
递归函数分析:此函数需要有5个参数,给定数组nums,目标数target,记录满足条件的数组元素的List集合,当前List集合的数值总和sum,以及当前给定数组的下标start。
代码如下:
List<List<Integer>> lists = new ArrayList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates);
backtrack(candidates, target, new ArrayList<>(), 0, 0);
return lists;
}
private void backtrack(int[] nums, int target, ArrayList<Integer> list, int sum, int start) {
if (sum == target) {
lists.add(new ArrayList<>(list));//实现深拷贝
return;
}
for (int i = start; i < nums.length; i++) {
//sum + nums[i] > target作为循环终止的判断条件可以优化回溯深度
if (sum + nums[i] > target) {
break;
}
sum += nums[i];
list.add(nums[i]);
backtrack(nums, target, list, sum, i);
sum -= nums[i];
list.remove(list.size() - 1);
}
}
这里的sum + nums[i] > target就是剪枝手段,由于使用的都是同一个List集合,所以在每次回溯时都需要把上一个元素在集合List中移除掉。
组合总和 II
问题描述:给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用一次。
思路分析:整体思路和上题差不多,但是由于此题要求数字不能重复使用,所以此题还需额外添加一个visited数组来记录candidates 数组的每个数字是否被访问过,并剪枝去掉因重复与元素而产生的相同分支。
代码如下:
List<List<Integer>> lists = new ArrayList<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
int[] visited = new int[candidates.length];
Arrays.sort(candidates);
backtrack(candidates, target, new ArrayList<>(), 0, 0, visited);
return lists;
}
private void backtrack(int[] nums, int target, ArrayList<Integer> list, int sum, int start, int[] visited) {
if (sum == target) {
lists.add(new ArrayList<>(list));
return;
}
for (int i = start; i < nums.length; i++) {
if (sum + nums[i] > target) {
break;
}
if (visited[i] == 0) {
//去除掉因重复元素而产生的相同分支
if (i > 0 && visited[i - 1] == 0 && nums[i] == nums[i - 1]) {
continue;
}
visited[i] = 1;
sum += nums[i];
list.add(nums[i]);
backtrack(nums, target, list, sum, i, visited);
visited[i] = 0;
sum -= nums[i];
list.remove(list.size() - 1);
}
}
}
组合总和 III
问题描述:找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
思路分析:这题的整体思路和上上题的思路也差不多,只不过在这里多了两个要素,一个是list.size() > length时的递归终止条件,还有一个是回溯的循环条件i是从start+1开始的,这样就不必再使用visited数组来记录节点的访问情况。
代码如下:
private List<List<Integer>> lists = new ArrayList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
backtrack(n, new ArrayList<>(), k, 0, 0);
return lists;
}
private void backtrack(int target, ArrayList<Integer> list, int length, int sum, int start) {
if (list.size() > length) {
return;
}
if (list.size() == length && sum == target) {
lists.add(new ArrayList<>(list));
return;
}
//由于i = start + 1,所以不必考虑节点是否被使用过的情况
for (int i = start + 1; i <= 9; i++) {
if (sum + i > target) {
break;
}
sum += i;
list.add(i);
backtrack(target, list, length, sum, i);
sum -= i;
list.remove(list.size() - 1);
}
}
火车进站
问题描述:给定一个正整数N代表火车数量,0<N<10,接下来输入火车入站的序列,一共N辆火车,每辆火车以数字1-9编号。 要求以字典序排序输出火车出站的序列号。
这个题通常的做法是先求出栈序列号的全排列,然后再去除掉不满足条件的情况。
这里我给出一种通过回溯模拟栈的方法(特别是边进边出的方式)
思路分析:此题的主要目的是模拟火车进站并输出可能出栈的顺序,所以我们需要定义火车所在的三种状态,remain表示还未进站的火车,in表示在栈内的火车,out表示已经出栈的火车,当所以火车都已经进栈即remain内已经没有火车时递归终止,记录下in和out内的火车序列号。一方面我们需要注意当in内没有火车时只能让remain内的火车进入,另一方面我们通过for循环遍历当前in内的火车来回溯从而可以达到一种边进边出的出栈效果。当记录下所有的回溯结果后,最后一步只需要重写集合的排序标准即可得到所要的答案。
代码如下:
static List<String> list = new ArrayList<>();
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
String str = sc.nextLine();
String in = "";
String out = "";
function(str, in, out);
list.sort(Comparator.comparingInt(Integer::valueOf));//按照字符串的数值大小重写排序标准
for (String s : list) {
System.out.println(s);
}
}
private static void function(String remain, String in, String out) {
if (remain.length() == 0) {
list.add(out + in);
} else {
if (in.length() == 0) {//此时只能把remain内的火车进入一个到in里面
function(remain.substring(1, remain.length()), in + remain.charAt(0), out);
} else {
for (int i = 0; i <= in.length(); i++) {//模拟边入边出的场景
//i = 0保证remain的火车全部进入in内
//把remain内的火车进入一个到in里面,并且让in里面的火车出i个到out内
function(remain.substring(1, remain.length()), remain.charAt(0) + in.substring(i, in.length()), out + in.substring(0, i));
}
}
}
}
测试结果:
通过学习以上六个例题,相信大家应该对回溯有了一些基本的认识。
最后再举一个生活中常见的回溯例子,回溯算法很像我们日常下棋时的算棋策略,我们在算棋时每多算一步其实就是多递归一次,而每一步棋会有很多种选择就好比回溯时能够改变递归策略的不同方案,而当我们计算出这步棋不合适就会尝试退回到上一步看看上一步是否有更好的棋招可选,选择好另外一步棋继续执行算棋(递归),这也就是回溯!