回溯算法
回溯算法(backtracking algorithm),也叫试探法,实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。
一、基本思想
回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。
二、解题步骤
用回溯算法解决问题的一般步骤:
- 针对所给问题,定义问题的 解空间,它至少包含问题的一个(最优)解。
- 确定易于搜索的解空间结构,通常为树形结构,使得能用回溯法方便地搜索整个解空间 。
- 以 深度优先 的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。
确定了解空间的组织结构后,回溯法就从开始结点(根结点)出发,以深度优先的方式搜索整个解空间。这个开始结点就成为一个活结点,同时也成为当前的扩展结点。在当前的扩展结点处,搜索向纵深方向移至一个新结点。这个新结点就成为一个新的活结点,并成为当前扩展结点。如果在当前的扩展结点处不能再向纵深方向移动,则当前扩展结点就成为死结点。此时,应往回移动(回溯)至最近的一个活结点处,并使这个活结点成为当前的扩展结点。回溯法即以这种工作方式递归地在解空间中搜索,直至找到所要求的解或解空间中已没有活结点时为止。
剪枝:
不符合条件的不向下搜索了。
例如:
算法结构:
以迷宫问题为例,来说明算法结构。
例如,要从蓝色点到红色点:
首先、从蓝色的开始节点遍历到节点1,然后从节点1遍历到节点2,再从节点2遍历到节点3,节点3继续往后遍历发现这条路走不通,便回退到节点2,此时发现节点2没有可以继续往后遍历的方案便回退到节点1,节点1按照这个规则选择其他遍历方案继续往后遍历,直到遍历到红色点处。
以下便是回溯法解决问题的算法结构:
- x:遍历某一个节点,比如上图的节点1
- 方案数:对于走迷宫问题,某一个节点遍历方案可以为:向上,向下,向左,向右四个方案,所以方案数为4。
- 剪枝:if(方案可行),因为对于实际问题,可能某一个节点的遍历方案没有四种,比如节点1只有向上和向右。通过判断方案是否可行来进行剪枝处理,减少程序遍历的次数。
三、经典问题:
1. 全排列问题
-
问题描述:
给定以一个正整数 n,求出从 1~n 的所有排列组合。
如:输入为 n = 3
输出:1 2 3, 1 3 2, 2 1 3, 2 3 1, 3 1 2, 3 2 1 -
问题分析:
我们从第一位开始处理,每一位数都可以从1~n 中任选一个数 i,所以每一个节点(每一位)向下遍历的方案数为n。
某一个数不能出现两次,这个通过剪枝来完善即可。
-
完整算法:
import java.util.ArrayList; import java.util.List; import java.util.Stack; /** * @projName: algorithm * @packgeName: indi.pentiumcm.thought * @className: BtPerm * @author: pentiumCM * @email: 842679178@qq.com * @date: 2020/4/4 23:10 * @describe: 回溯法 - 全排列问题 */ public class BtPerm { // 记录数字是否已经使用过 int[] used; // 方案数组 int[] items; public List<List<Integer>> permute(int[] nums) { List<List<Integer>> res = new ArrayList<>(); Stack<Integer> path = new Stack<>(); items = nums; used = new int[nums.length]; dfs(0, nums.length, path, res); return res; } /** * @param idx 第 idx 位进行搜索遍历 * @param n 一共多少位 * @param path 对应解空间的一个解 * @param res 解空间 */ void dfs(int idx, int n, Stack<Integer> path, List<List<Integer>> res) { // 解空间:到达叶子节点,对应一个解 if (idx == n) { // 输出解 List<Integer> tmp = new ArrayList<>(); for (int i : path) { tmp.add(i); } res.add(tmp); return; } // 方案数 for (int i = 0; i < items.length; i++) { if (used[i] == 0) { // 第 idx 位选择元素 i used[i] = 1; path.push(items[i]); dfs(idx + 1, n, path, res); // 回溯 path.pop(); used[i] = 0; } } } public static void main(String[] args) { BtPerm btPerm = new BtPerm(); int[] arr = new int[3]; for (int i = 0; i < arr.length; i++) { arr[i] = i + 1; } List<List<Integer>> ans = btPerm.permute(arr); } }
2. 0/1 背包问题
-
问题描述:
0-1 背包问题: 给定 n 种物品和一个容量为 C 的背包,物品 i 的重量是 wi,其价值为 vi
问:应该如何选择装入背包的物品,使得装入背包中的物品的总价值最大? -
分析过程:
我们从第0个物品开始遍历(0对于数组的第一个物体),每一件物品放入背包都有两种方案,放或者不放,用0代表不放该物品,用1代表放该物品。
在遍历第 i 个物体时,通过判断背包剩余容量是否能够放下物体 i,来进行剪枝处理。在下图以红色的叉号即叶子节点的无来表示剪枝。
-
算法实现:
package indi.pentiumcm.thought; import java.util.ArrayList; import java.util.List; /** * @projName: algorithm * @packgeName: indi.pentiumcm.thought * @className: BtBack * @author: pentiumCM * @email: 842679178@qq.com * @date: 2020/4/4 17:47 * @describe: 回溯法 - 0/1背包问题 */ public class BtBack { // 背包容量 public int backCap = 8; // 物品个数 public int goodNums = 4; // 物品的重量 int[] weights = {2, 3, 4, 5}; // 物品的价值 int[] values = {3, 4, 5, 6}; // 表示第 i 个物品是否放入背包 int[] places = new int[goodNums]; // 当前背包放入物体的重量 public int curWeight = 0; // 当前价值 public int curVals = 0; // 最大价值 public int maxVals = 0; /** * 处理第 i 个物体 * * @param i 第 i 个物体 */ public int backtrack(int i, List<Integer[]> res) { // 递归的终止条件 // 遍历完所有的物品,即到叶子节点 if (i == goodNums) { Integer[] resArr = {0, 0, 0, 0, 0}; for (int j = 0; j < goodNums; j++) { resArr[j] = places[j]; } resArr[goodNums] = curVals; res.add(resArr); if (curVals > maxVals) { maxVals = curVals; } return maxVals; } // 第 i 个物品的重量 int weight = weights[i]; // 方案数为 2,方案1用0表示 - 不装第i个物体,方案2用1表示 - 装第i个物体 for (int j = 0; j < 2; j++) { // 不装第 i 个物品 if (j == 0) { places[i] = 0; // 遍历下一个物品 backtrack(i + 1, res); } // 装第 i 个物品 else { // 剪枝处理,判断第 i 个物品是否可以装进背包 curWeight = arrmultiply(places, weights); if (curWeight + weight <= backCap) { places[i] = 1; curVals = arrmultiply(places, values); backtrack(i + 1, res); places[i] = 0; } } } return maxVals; } public int arrmultiply(int[] arr1, int[] arr2) { int result = 0; if (arr1.length == arr2.length) { for (int i = 0; i < arr1.length; i++) { result += arr1[i] * arr2[i]; } } else { System.out.print("数组长度不一致!"); } return result; } public static void main(String[] args) { BtBack btBack = new BtBack(); // 存放解空间的解 List<Integer[]> res = new ArrayList<>(); int max = btBack.backtrack(0, res); System.out.println("最大价值为:" + max); System.out.println("背包中的物品组合及价值(最后一位为价值):"); for (int i = 0; i < res.size(); i++) { Integer[] arr = res.get(i); for (int j = 0; j < arr.length; j++) { System.out.print(arr[j] + " "); } System.out.print("\n"); } } }
运行结果:
3. 字母大小写全排列
LeetCode:https://leetcode-cn.com/problems/letter-case-permutation/
分析过程及算法实现:
package indi.pentiumcm.leetcode;
import java.util.ArrayList;
import java.util.List;
/**
* @projName: algorithm
* @packgeName: indi.pentiumcm.leetcode
* @className: Q19
* @author: pentiumCM
* @email: 842679178@qq.com
* @date: 2020/4/4 11:17
* @describe: LeetCode-784. 字母大小写全排列
*/
public class Q19 {
public void dfs(char[] temp, String S, int start, List<String> res) {
int len = S.length();
// 递归的终止条件
// 遍历到字符串的结尾,直接退出
if (start == len) {
res.add(new String(temp));
return;
}
temp[start] = S.charAt(start);
dfs(temp, S, start + 1, res);
// 当遍历到的位置为字符,遍历另一个分支
if (Character.isLetter(S.charAt(start))) {
char ant = (char) (S.charAt(start) ^ 32);
temp[start] = ant;
dfs(temp, S, start + 1, res);
}
}
public List<String> letterCasePermutation(String S) {
List<String> res = new ArrayList<>();
int len = S.length();
if (len == 0) {
return res;
}
char[] temp = new char[S.length()];
dfs(temp, S, 0, res);
return res;
}
public static void main(String[] args) {
List<String> res = new Q19().letterCasePermutation("a1b2");
}
}