回溯算法,本质上是一种穷举算法,属于暴力搜索算法的一种。它虽然可以使用剪枝进行优化,仍不高效,但却实用。它往往能够解决可以抽象成树形结构的问题,亦可以认为是使用 K 层 for循环实现搜索的问题:
注:组合与全排列最大的不同就是,子集划分是否强调元素顺序,强调顺序的为全排列,即组合只往后看,排列前后都要看,可从下图清晰观察到两者的差别。
在回溯算法中,我们需要清楚以下几种规则即可:
使用回溯算法前,可先将问题转化为树形结构
回溯算法解决问题都是在集合中递归子集,即常常以递归为基础实现的
回溯算法基本可以抽象成一颗 N 叉树形式的问题树,其宽度为集合大小,递归(纵向遍历)深度为树的深度
回溯算法常常使用 for 循环来遍历集合区间,即层次(横向)遍历问题树
回溯算法解决的问题结果常常在叶子结点之上
回溯算法需要考虑集合内元素是否可以重复选取,要求不同,方案不同
回溯算法常常使用布尔数组(used)来进行去重工作,必须先对目标集合(存在重复元素)进行排序才能去重
回溯算法剪枝优化常常从可获取的子集中剩余元素条件与要求元素条件相比较,不符合就剪枝
回溯算法如果在递归函数调用前,对全局变量有所调整,必须在递归函数后添加撤销语句
在了解到回溯算法的几项规则之后,再提供一套回溯算法的模板,如下:
伪代码模板如下:
//全局变量
最终结果二维集:stack<stack<Interge>> result;
符合条件结果:stack<Interge> path;
//函数体
void backtracking(参数){
if(剪枝条件){
剪枝语句
}
if(终止条件){
存放结果;
return;
}
for(选择符合条件的本层元素){
处理结点;
backtracking(参数);
撤销语句;
}
}
接下来将结合相关实例说明规则和模板的具体使用与注意点。
LeetCode 题号:40. 组合总和 II
给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用 一次 。
注意:解集不能包含重复的组合。
示例 1:
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出: [[1,1,6],[1,2,5],[1,7],[2,6]]
示例 2:
输入: candidates = [2,5,2,1,2], target = 5,
输出: [[1,2,2],[5]]
提示:
1 <= candidates.length <= 100
1 <= candidates[i] <= 50
1 <= target <= 30
这道题目需要注意以下几点:
由上可知:元素在同一个组合内是可重复的,但两个组合不能相同,即需对树层进行去重。
树层去重可以采用 used 数组记录元素是否使用过,使用过即为 true,否则为 false 。
为理解去重,以 candidates = [1,1,2] ,target = 3 为例,图示如下:
确定回溯算法的返回值和相关参数
used 数组: 记录重复元素
result 存放栈的栈:存放组合元素
path 栈 :记录组合
普通参数:candidates,target,sum(可以使用 target - candidates[i] == 0),startIndex(也可以用它去重,used 更具备普适性)
代码如下:
List<List<Integer>> lists = new ArrayList<>();
Deque<Integer> deque = new LinkedList<>();
int sum = 0;
void backTracking(int[] arr, int target, int index, boolean[] used)
确定回溯递归函数的终止条件
sum > target :可不用加,单层遍历会进行剪枝
sum == target :寻找到符合条件的目标
代码如下:
if(sum > target){
return;
}
if (sum == target) {
//加入结果集
lists.add(new ArrayList(deque));
return;
}
确定单层遍历的过程
去重的基本思路:
假设此时 i = 1,且 candidates[i] == candidates[i-1]
树层去重:used[i-1] == false,则说明同一树层已使用过 candidates[i-1]
树枝去重:used[i-1] == true,则说明同一树枝已使用过 candidates[i-1]
本题中,需要使用到的是树层去重,所以 i > 0 && arr[i] == arr[i - 1] && !used[i - 1]
结果为true,则跳过结点,continue 。
代码如下:
for (int i = index; i < arr.length && arr[i] + sum <= target; i++) {
//出现重复节点,同层的第一个节点已经被访问过,所以直接跳过
if (i > 0 && arr[i] == arr[i - 1] && !used[i - 1]) {
continue;
}
//处理数据
used[i] = true;
sum += arr[i];
deque.push(arr[i]);
//每个节点仅能选择一次,所以从下一位开始
backTracking(arr, target, i + 1, used);
//回溯,撤销结果
int temp = deque.pop();
used[i] = false;
sum -= temp;
}
整体代码如下:
class Solution {
List<List<Integer>> lists = new ArrayList<>();
Deque<Integer> deque = new LinkedList<>();
int sum = 0;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
//为了将重复的数字都放到一起,所以先进行排序
Arrays.sort(candidates);
//加标志数组,用来辅助判断同层节点是否已经遍历
boolean[] used = new boolean[candidates.length];
backTracking(candidates, target, 0, used);
return lists;
}
public void backTracking(int[] arr, int target, int index, boolean[] used) {
if (sum == target) {
lists.add(new ArrayList(deque));
return;
}
for (int i = index; i < arr.length && arr[i] + sum <= target; i++) {
//出现重复节点,同层的第一个节点已经被访问过,所以直接跳过
if (i > 0 && arr[i] == arr[i - 1] && !used[i - 1]) {
continue;
}
used[i] = true;
sum += arr[i];
deque.push(arr[i]);
//每个节点仅能选择一次,所以从下一位开始
backTracking(arr, target, i + 1, used);
int temp = deque.pop();
used[i] = false;
sum -= temp;
}
}
}
LeetCode 题目:47. 全排列 II
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
示例 1:
输入:nums = [1,1,2]
输出:[[1,1,2],[1,2,1],[2,1,1]]
示例 2:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
提示:
1 <= nums.length <= 8
-10 <= nums[i] <= 10
从一个可包含重复数字的序列,要返回一个不重复的全排列,涉及到去重问题,本题两种去重方法都能解决,但树层去重会更高效
排列问题:有序,[3,2] 与 [2,3] 不同,for 循环每次从零开始
叶子结点为最终结果处:收集元素数组长度与 nums 数组一样长时,找到一个全排列结果
整体代码如下:
class Solution {
//存放结果
List<List<Integer>> result = new ArrayList<>();
//暂存结果
List<Integer> path = new ArrayList<>();
public List<List<Integer>> permuteUnique(int[] nums) {
boolean[] used = new boolean[nums.length];
Arrays.fill(used, false);
Arrays.sort(nums);
backTrack(nums, used);
return result;
}
private void backTrack(int[] nums, boolean[] used) {
if (path.size() == nums.length) {
result.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
// used[i - 1] == true,说明同⼀树枝nums[i - 1]使⽤过
// used[i - 1] == false,说明同⼀树层nums[i - 1]使⽤过
// 如果同⼀树层nums[i - 1]使⽤过则直接跳过
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
continue;
}
//如果同⼀树⽀nums[i]没使⽤过开始处理
if (used[i] == false) {
used[i] = true;//标记同⼀树⽀nums[i]使⽤过,防止同一树支重复使用
path.add(nums[i]);
backTrack(nums, used);
path.remove(path.size() - 1);//回溯,说明同⼀树层nums[i]使⽤过,防止下一树层重复
used[i] = false;//回溯
}
}
}
}
回溯算法最主要的难点就是怎么去理解回溯的搜索过程,并且在搜索的过程中完成去重与剪枝的工作,使算法尽可能高效。在进行搜索的时候,也要充分理解单层遍历的逻辑(起始点与终止点的选择)。当然,对于 N 皇后问题的去重问题,以及解数独的二维递归方法,本文,暂时没有提及!后期,在进行说明。