回溯算法
一、回溯法理论基础
1.1 回溯法解决的问题
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
1.2 回溯法的理解
回溯法解决的问题都可以抽象为树形结构,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度。
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
1.3 回溯法模板
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
二、组合问题
2.1 组合
77.组合
https://leetcode-cn.com/problems/combinations/
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
你可以按 任何顺序 返回答案。
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
把组合问题抽象为如下树形结构:
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现n相当于树的宽度,k相当于树的深度。
2.2 剪枝优化
遍历的范围是可以剪枝优化的,怎么优化呢?
来举一个例子,n = 4,k = 4的话,那么第一层for循环的时候,从元素2开始的遍历都没有意义了。 在第二层for循环,从元素3开始的遍历都没有意义了。
这么说有点抽象,如图所示:
图中每一个节点(图中为矩形),就代表本层的一个for循环,那么每一层的for循环从第二个数开始遍历的话,都没有意义,都是无效遍历。
所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
注意代码中i,就是for循环里选择的起始位置。
for (int i = startIndex; i <= n; i++) {
接下来看一下优化过程如下:
- 已经选择的元素个数:path.size();
- 还需要的元素个数为: k - path.size();
- 在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历
为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。
举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
从2开始搜索都是合理的,可以是组合[2, 3, 4]。
这里大家想不懂的话,建议也举一个例子,就知道是不是要+1了。
所以优化之后的for循环是:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
优化后整体代码
public class 组合_77 {
//返回结果集 二维数组
List<List<Integer>> result = new ArrayList<>();
//频繁的增删 使用linkedlist更好
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
combineHelper(1, k, n);
return result;
}
/**
* 回溯模板
* 每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围,就是要靠startIndex
* @param startIndex 用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,...,n] )。
*/
private void combineHelper(int startIndex,int k, int n){
//终止条件
if (path.size() == k){
//存放结果
result.add(new ArrayList<>(path));
return;
}
//本层集合中元素
//剪枝优化
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++){
//处理节点 考虑选择当前位置
path.add(i);
//递归
combineHelper(i + 1, k, n);
//回溯 撤销处理结果 考虑不选择当前位置
path.removeLast();
}
}
}
2.3 组合求和
数组无重复元素,不能重复选,解集不重复
https://leetcode-cn.com/problems/combination-sum-iii/
找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
说明:
- 所有数字都是正整数。
- 解集不能包含重复的组合。
示例 1: 输入: k = 3, n = 7 输出: [[1,2,4]]
示例 2: 输入: k = 3, n = 9 输出: [[1,2,6], [1,3,5], [2,3,4]
相对于77. 组合 (opens new window),无非就是多了一个限制,本题是要找到和为n的k个数的组合,而整个集合已经是固定的了[1,...,9]。同时也要考虑剪枝,已选元素总和如果已经大于n(图中数值为4)了,那么往后遍历就没有意义了,直接剪掉。
整体代码:
public class 组合总和三_216 {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
int sum = 0;
public List<List<Integer>> combinationSum3(int k, int n) {
backTracking(1, k, n);
return result;
}
private void backTracking(int cur, int k, int targetSum) {
// 减枝
if (sum > targetSum) {
return;
}
if (path.size() == k) {
if (sum == targetSum) {
result.add(new ArrayList<>(path));
}
return;
}
// 减枝 9 - (k - path.size()) + 1
for (int i = cur; i <= 9 - (k - path.size()) + 1; i++) {
path.add(i);
sum += i;
backTracking(i + 1, k, targetSum);
//回溯
path.removeLast();
//回溯
sum -= i;
}
}
}
数组无重复元素,可以重复选,解集不重复
https://leetcode-cn.com/problems/combination-sum/
给定一个无重复元素的数组 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] ]
本题和77.组合 (opens new window),216.组合总和III (opens new window)和区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
本题搜索的过程抽象成树形结构如下:
注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。
其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。那么可以在for循环的搜索范围上做做剪枝处理。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。
最后整体代码:
public class 组合总和_39 {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
int sum = 0;
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates); // 先进行排序
backtracking(candidates, target, 0);
return result;
}
//cur 用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,...,n] )。
public void backtracking(int[] candidates, int target, int cur) {
// 找到了数字和为 target 的组合
if (sum == target) {
result.add(new ArrayList<>(path));
return;
}
//这里没有个数的限制
for (int i = cur; i < candidates.length; i++) {
// 如果 sum + candidates[i] > target 就终止遍历
//剪枝 后面的必然也大
if (sum + candidates[i] > target) {
break;
}
path.add(candidates[i]);
sum += candidates[i];
//关键点 不用i+1了,表示可以重复读取当前的数
backtracking(candidates, target, i);
// 回溯,移除路径 path 最后一个元素
sum -= candidates[i];
path.removeLast();
}
}
}
数组有重复元素,解集不重复
https://leetcode-cn.com/problems/combination-sum-ii/
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。
说明: 所有数字(包括目标数)都是正整数。 解集不能包含重复的组合。
示例 1: 输入: candidates = [10,1,2,7,6,1,5], target = 8, 所求解集为: [ [1, 7], [1, 2, 5], [2, 6], [1, 1, 6] ]
示例 2: 输入: candidates = [2,5,2,1,2], target = 5, 所求解集为: [ [1,2,2], [5] ]
这道题目和39.组合总和 如下区别:
- 本题candidates 中的每个数字在每个组合中只能使用一次。
- 本题数组candidates的元素是有重复的,而39.组合总和是无重复元素的数组candidates
最后本题和39.组合总和要求一样,解集不能包含重复的组合。
本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合。
所谓去重,其实就是使用过的元素不能重复选取。
组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
为了理解去重举一个例子,candidates = [1, 1, 2], target = 3,(方便起见candidates已经排序了)
强调一下,树层去重的话,需要对数组排序!
此题还需要加一个bool型数组used,用来记录同一树枝上的元素是否使用过。这个集合去重的重任就是used来完成的。
如果candidates[i] == candidates[i - 1]
并且 used[i - 1] == false
,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]。
此时for循环里就应该做continue的操作,这块比较抽象,如图:
将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:
- used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- used[i - 1] == false,说明同一树层candidates[i - 1]使用过
整体代码:
public class 组合总和二_40 {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = 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 result;
}
public void backTracking(int[] candidates, int target, int index, boolean[] used) {
if (sum == target) {
result.add(new ArrayList<>(path));
return;
}
for (int i = index; i < candidates.length ;i++) {
//剪枝
if (sum + candidates[i] > target) {
break;
}
// 同 90题一样 同一树枝上可以出现重复,但同一树层不能出现重复
// 跳过同一树层使用过的元素
if (i > 0 && candidates[i] == candidates[i - 1]&& !used[i - 1]) {
continue;
}
used[i] = true;
sum += candidates[i];
path.add(candidates[i]);
//每个节点仅能选择一次,所以从下一位开始
backTracking(candidates, target, i + 1, used);
//回溯 恢复
used[i] = false;
sum -= candidates[i];
path.removeLast();
}
}
}
三、子集问题
3.1子集
数组无重复元素,解集不重复
78.子集
https://leetcode-cn.com/problems/subsets/
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例: 输入: nums = [1,2,3] 输出: [ [3], [1], [2], [1,2,3], [1,3], [2,3], [1,2], [] ]
求子集问题和[77.组合]和[131.分割回文串]不一样了。
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。
那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!
有同学问了,什么时候for可以从0开始呢?
求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,排列问题我们后续的文章就会讲到的。
以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:
从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
整体代码:
class Solution {
List<List<Integer>> result = new ArrayList<>();// 存放符合条件结果的集合
LinkedList<Integer> path = new LinkedList<>();// 用来存放符合条件结果
public List<List<Integer>> subsets(int[] nums) {
if (nums.length == 0){
result.add(new ArrayList<>());
return result;
}
subsetsHelper(nums, 0);
return result;
}
private void subsetsHelper(int[] nums, int startIndex) {
result.add(new ArrayList<>(path));//遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
if (startIndex >= nums.length) { //终止条件可不加
return;
}
for (int i = startIndex; i < nums.length; i++) {
path.add(nums[i]);
//递归
subsetsHelper(nums, i + 1);
//回溯 撤销处理结果 考虑不选择当前位置
path.removeLast();
}
}
}
数组有重复元素,解集不重复
- 子集Ⅱ
https://leetcode-cn.com/problems/subsets-ii/
给定一个可能包含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例:
- 输入: [1,2,2]
- 输出: [ [2], [1], [1,2,2], [2,2], [1,2], [] ]
这道题目和[78.子集]区别就是集合里有重复元素了,而且求取的子集要去重。
那么关于回溯算法中的去重问题,在[40.组合总和II]中已经详细讲解过了,和本题是一个套路。
先对数组进行排序
用示例中的[1, 2, 2] 来举例,如图所示:
要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
使用used数组的整体代码:
class Solution {
// 同一树枝上可以出现重复,但同一树层不能出现重复
List<List<Integer>> result = new ArrayList<>();// 存放符合条件结果的集合
LinkedList<Integer> path = new LinkedList<>();// 用来存放符合条件结果
boolean[] used;
public List<List<Integer>> subsetsWithDup( int[] nums ) {
// 先排好序
Arrays.sort( nums );
used = new boolean[nums.length];
subsetsWithDupHelper( nums, 0 );
return result;
}
private void subsetsWithDupHelper( int[] nums, int startIndex ) {
result.add( new ArrayList<>(path));
if (startIndex >= nums.length){
return;
}
for ( int i = startIndex; i < nums.length; i++ ) {
// 跳过当前树层使用过的、相同的元素
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]){
continue;
}
path.add(nums[i]);
used[i] = true;
subsetsWithDupHelper( nums, i + 1 );
path.removeLast();
used[i] = false;
}
}
}
3.2 递增子序列
- 递归子序列
https://leetcode-cn.com/problems/increasing-subsequences/
给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是2。
示例:
- 输入: [4, 6, 7, 7]
- 输出: [[4, 6], [4, 7], [4, 6, 7], [4, 6, 7, 7], [6, 7], [6, 7, 7], [7,7], [4,7,7]]
说明:
- 给定数组的长度不会超过15。
- 数组中的整数范围是 [-100,100]。
- 给定数组中可能包含重复数字,相等的数字应该被视为递增的一种情况。
这个递增子序列比较像是取有序的子集。而且本题也要求不能有相同的递增子序列。
但本题求自增子序列,是不能对原数组经行排序的,排完序的数组都是自增子序列了。
所以不能使用之前的去重逻辑!
本题给出的示例,还是一个有序数组 [4, 6, 7, 7],这更容易误导大家按照排序的思路去做了。
为了有鲜明的对比,我用[4, 7, 6, 7]这个数组来举例,抽象为树形结构如图:
整体代码:
public class 递增子序列_491 {
LinkedList<Integer> path = new LinkedList<>();
List<List<Integer>> res = new ArrayList<>();
public List<List<Integer>> findSubsequences(int[] nums) {
backtracking(nums,0);
return res;
}
private void backtracking (int[] nums, int startIndex) {
//题目要求递增子序列大小至少为2
if (path.size() > 1) {
res.add(new ArrayList<>(path));
// 注意这里不要加return,因为要取树上的所有节点
}
int[] used = new int[201];// nums范围是[-100,100] 将其映射到[0,201]
for (int i = startIndex; i < nums.length; i++) {
if (!path.isEmpty() && nums[i] < path.get(path.size() - 1) || (used[nums[i] + 100] == 1)){
continue;
}
used[nums[i] + 100] = 1; // 记录这个元素在本层用过了,本层后面不能再用了
path.add(nums[i]);
backtracking(nums, i + 1);
path.removeLast();
}
}
}
四、排列问题
本文作者:王陸
本文链接:https://www.cnblogs.com/wkfvawl/p/16030987.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
2020-03-20 C++设计模式——享元模式Flyweight-Pattern