最近我在看自己写过的回溯算法,在这儿总结一些博客或者刷题的知识经验,并以这篇文章来总结回溯算法解决的问题。这里建议了解回溯算法的本质后,看这篇文章进行练习搭配也是不错的选择。此外,本文主要以Java语言实现代码及其细节。
1、回溯算法的三步骤
首先这里先介绍刷题中回溯算法的经典要走的三个步骤。这里建议可以结合看第二部分,即具体实例来结合看。
- 设置递归函数的返回值以及参数。
一般在解决经典的组合问题,暴力枚举是一种非常头铁的做法。在此基础上回溯法构建一种二叉树的结构,在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。其次,递归一般返回类型为void类型。
- 回溯函数终止条件
不同的问题其终止条件各不相同。比如这里的组合问题应该是其长度为k;分割回文串应该是其分割的索引大于给定字符串的长度。
- 单层搜索的过程
单层搜索的过程中设置startIndex指针,比如在组合问题上作为调整选择的范围。即“处理了1怎么能处理2、3、4节点呢?答:下一步就是在后面递归backTracking(n,k,i+1)
。在树的深度方向进行递归以至于处理1节点后就在1节点基础上处理i+1
节点即2节点”。
这就是回溯算法基本的思路,不同的问题需要具体分析。
2、具体实例
组合问题
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。你可以按任何顺序返回答案。
示例 1:
输入:n = 4, k = 2
输出:
[[2,4],[3,4],[2,3],[1,2],[1,3],[1,4]]
示例 2:
输入:n = 1, k = 1
输出:[[1]]
原文:77. 组合
组合问题就是可以交替顺序。比如[1,2]就是[2,1]。因为这个集合中只有一个1和2([1,1,2]和[1,2]不是一个组合)。
从上图来明白组合问题。就是构建一个二叉树,通过一个数层的递归中一个个处理(0-n]中的数进行组合成k=2的组合(如[0,1])。再继续看如下,我们对上图进行一定的改进:
什么意思呢?这里其实是想说明其实我们对二叉树是可以进行剪枝的操作的。比如(遍历3时选择2得到结果[3,2],但是在遍历2时选取3组合成[2,3]是一个组合)。所以我们可以进行如上图的改进操作。
接下来看图是看明白了,代码部分如下(参考第一部分回溯算法的三步骤):
1、首先设置递归函数的返回值以及参数。
一个指针startTemp//这是为了指向1的时候来选取2、3、4其中一个数;同理指向3时来选取4;指向2时选取3、4其中一个数
List<List<Integer>> res//用来返回结果集。题目要求返回List<List<Integer>>
LinkedList<Integer> list//用来存储每一个结果
public void backtracking(int n,int k,int startTemp)//确定void方法
2、判断终止条件:
list.size()==k//只需要判断长度即可
//终止时要先把结果装填到res结果集合中
3、单层搜索的过程:
for(int i=startTemp;i<=n-(k-list.size())+1;i++){//从startTemp开始遍历
list.add(i);//1、处理节点,将节点装填进list集合
backtracking(n,k,i+1);//树层方向上递归,i+1就是
list.removeLast();//递归得到结果要弹出最后的元素(获得1,3时会弹出3)
}
问题1:n-(k-list.size())+1的由来
在遍历[1,4]选取1,2,3,4中,startTemp是来让递归"选择好1后怎么选择到2,3,4"的,而n-(k-list.size())+1的由来如下:
假设已经选取好的list数组结果长度为list.size(),那么就应该还要选择k-list.size()进行插入到结果集中。(因为要有list.size()的组合输出测试)。
根据剪枝的图来看,就比如我们总能在其他数中选择4得到的组合一样,那么我们最多能够从n-(k- list.size())+1起始位置开始遍历()。而‘+1’这个操作是包括起始位置,是要算上左边边缘集合本身的数,即[1,x],(x=2,3,4)。这里可以再仔细模拟一下。
最终代码如下:
class Solution {
//初始化List<List<>>列表
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> list = new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
backtracking(n,k,1);
return res;
}
public void backtracking(int n,int k,int startTemp){
//回溯法
//1、如果达到终止条件,其条件为list长度为2
if(list.size()==k){
res.add(new ArrayList<>(list));
return;
}
//2、对拟定二叉树进行横向for循环遍历
//1 4-(2-1?2)+1
for(int i=startTemp;i<=n-(k-list.size())+1;i++){
list.add(i);
backtracking(n,k,i+1);
list.removeLast();
}
}
}
组合问题2(考虑去重)
给定一个数组 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] ]
原文:40. 组合总和 II
这个地方跟前面的组合问题不同的是,我们需要对组合的结果去重。(比如 candidates = [2,5,2,1,2], target = 5中,我们有3个2,但是在凑成[1,2,2]时只能使用1次,即不能看成是3个“不相等”的数)。
接下来介绍思路:
设置一个used数组,每当处理了candidates 的一个元素,就将其索引置为True以表明使用过。而used更巧妙地应用在于判断是否选用多个数。
接下来代码部分如下(参考第一部分回溯算法的三步骤):
1、首先设置递归函数的返回值以及参数。
一个指针startTemp//这是为了指向第一个数后怎么读后面第二、三个数
List<List<Integer>> res//用来返回结果集。题目要求返回List<List<Integer>>
LinkedList<Integer> list//用来存储每一个结果
boolean[] used//寻找重复使用的数
private void backTracking(int[] candidates,int target,boolean[] used,int startIndex)//确定void方法
2、判断终止条件:
//边界设置:只需要sum=目标数即可。其中sum是当前结果集的和
//if(sum>target) return;
if(sum==target){
res.add(new ArrayList<>(path));
return;
}
3、单层搜索的过程:
for(int i = startIndex;i<candidates.length&&candidates[i]+sum<=target;i++){
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,used,i+1);
int temp = path.removeLast();
used[i] = false;
sum-=temp;
}
问题1:sum + candidates[i] <= target此处起到的是剪枝的效果。比如上图中,[2,2,4]这个地方的叶子节点按理来说不需要我们额外去处理。而此时我们就在for循环中加上这个条件即可。
问题2:
if(i>0 && candidates[i]==candidates[i-1] && !used[i-1]){//针对的是同个树层之间的比较
continue;
}
- 这部分代码添加的前置条件在于对数组本身排好序,即Arrays.sort(candidates)。
- used[i-1]的使用搭配candidates[i]==candidates[i-1]。在candidates[i]==candidates[i-1]等于真的前提下,used[i - 1] == true说明同一树枝candidates[i - 1]使用过;used[i - 1] == false,说明同一树层candidates[i - 1]使用过。具体请看如下图:
则最终代码如下:
class Solution {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
int sum = 0;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
//传参数,包括数组、target、startIndex、used数组
Arrays.sort(candidates);//先排序
boolean[] used = new boolean[candidates.length];
backTracking(candidates,target,used,0);
return res;
}
private void backTracking(int[] candidates,int target,boolean[] used,int startIndex){
//边界设置
//if(sum>target) return;
if(sum==target){
res.add(new ArrayList<>(path));
return;
}
for(int i = startIndex;i<candidates.length&&candidates[i]+sum<=target;i++){
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,used,i+1);
int temp = path.removeLast();
used[i] = false;
sum-=temp;
}
}
}
分割回文串
给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是回文串。返回 s 所有可能的分割方案。回文串是正着读和反着读都一样的字符串。
示例 1:
输入:s = “aab”
输出:[[“a”,“a”,“b”],[“aa”,“b”]]
示例 2:
输入:s = “a”
输出:[[“a”]]
子集问题要求处理的是字符串,切割并使之得出得出相应的结果。
我们可以设置一个分割线,在模拟二叉树递归时插入这个分割线。至于边界判断,那就是分割线到了最后一个字符后面就需要停止了。
接下来就看代码部分:
1、首先设置递归函数的返回值以及参数。
一个指针startIndex//这是为了切割a|ab时候再找切割a|a|b或者a|ab|
List<List<Integer>> res//用来返回结果集。题目要求返回List<List<Integer>>
LinkedList<Integer> path//用来存储每一个结果
private void backTracking(String s, int startIndex) //确定void方法
2、判断终止条件:
if(startIndex>=s.length()){
res.add(new ArrayList<>(path));
return;
}
//分割线到了最后一个字符后面就算是终止
3、单层搜索的过程:
for(int i = startIndex;i<s.length();i++){
//先判断是不是回文串
if (isPalindrome(s, startIndex, i)) {
String str = s.substring(startIndex, i + 1);//如果所切割的部分(比如a|ab就判断a;a|ab|判断到了ab)
path.add(str);
} else {
continue;//如果所切割的内容不是回文串,那么就需要进入(i+1)
}
backTracking(s,i+1);
path.removeLast();
}
其中,再写一个判断回文串的函数:
//判断是否是回文串
private boolean isPalindrome(String s, int startIndex, int end) {
for (int i = startIndex, j = end; i < j; i++, j--) {
if (s.charAt(i) != s.charAt(j)) {
return false;
}
}
return true;
}
到这儿就差不多整理最后的代码了:
class Solution {
List<List<String>> res = new ArrayList<>();
LinkedList<String> path = new LinkedList<>();
public List<List<String>> partition(String s) {
backTracking(s, 0);
return res;
}
private void backTracking(String s, int startIndex) {
if(startIndex>=s.length()){
res.add(new ArrayList<>(path));
return;
}
for(int i = startIndex;i<s.length();i++){
//先判断是不是回文串
if (isPalindrome(s, startIndex, i)) {
String str = s.substring(startIndex, i + 1);
path.add(str);
} else {
continue;//如果所切割的内容不是回文串,那么就需要进入(i+1)
}
backTracking(s,i+1);
path.removeLast();
}
}
//判断是否是回文串
private boolean isPalindrome(String s, int startIndex, int end) {
for (int i = startIndex, j = end; i < j; i++, j--) {
if (s.charAt(i) != s.charAt(j)) {
return false;
}
}
return true;
}
}
子集问题
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
原文:78. 子集
这道题可以将其看成组合问题也不为过。
为什么能稍微看成组合问题呢?因为实际上在组合问题中处理节点部分我们只是多一步输出成结果集并插入于res数组中,代码本质上并没有区别。
接下来介绍三个步骤:
1、首先设置递归函数的返回值以及参数。
startIndex//这是为了指向1的时候来选取2、3其中一个数
List<List<Integer>> res//用来返回结果集。题目要求返回List<List<Integer>>
LinkedList<Integer> path//用来存储每一个结果
public void backTracking(int[] nums,int startIndex)//确定void方法
2、判断终止条件:
startIndex>=nums.length
//这里startIndex来表示大于等于数组长度时来返回
3、单层搜索的过程:
for(int i=startIndex;i<nums.length;i++){//从startIndex开始遍历,并且小于nums.length即可(这里肯定要遍历3的,因为3自己就是集合的一个子集,所以不能像组合写成n-(k-list.size())+1这种形式)
path.add(nums[i]);
backTracking(nums,i+1);
path.removeLast();
}
最终代码:
class Solution {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
if (nums.length == 0){
res.add(new ArrayList<>());
return res;
}
backTracking(nums,0);
return res;
}
public void backTracking(int[] nums,int startIndex){
//处理的结果同样也能成为子集的一部分
res.add(new ArrayList<>(path));
//如果startIndex大于数组长度
if(startIndex>=nums.length){
return;
}
for(int i=startIndex;i<nums.length;i++){
path.add(nums[i]);
backTracking(nums,i+1);
path.removeLast();
}
}
}
排列问题
给定一个不含重复数字的数组nums,返回其所有可能的全排列。你可以按任意顺序返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
示例 3:
输入:nums = [1]
输出:[[1]]
原题:46. 全排列
这里的思路也比较好理清楚。
如上图所示,我们应该只能一轮轮的进行二叉树的递归,并且我们一定要考虑3的情况(所以此题不设置startIndex,因为我们不需要知道1后去定位2,3)。代码实现三步骤如下:
1、首先设置递归函数的返回值以及参数。
List<List<Integer>> res//用来返回结果集。题目要求返回List<List<Integer>>
LinkedList<Integer> path//用来存储每一个结果
boolean[] used = new boolean[nums.length];//处理重复元素。比如选择1时此时就还是从1,2,3选择,此时设置used数组将1置为使用过就可以只使用其一次了
private void backTracking(int[] nums,boolean[] used)//确定void方法
2、判断终止条件:
nums.length == path.size()
//排列组合只需要长度相等即可
3、单层搜索的过程:
for(int i = 0;i<nums.length;i++){
//1、处理节点
if(used[i]==true) continue;//如果有记录使用过就直接跳过
used[i]=true;
path.add(nums[i]);
//2、回溯
backTracking(nums,used);
//3、删除,即再次寻找下一个结果集
path.removeLast();
used[i] =false;
}
最终代码如下:
class Solution {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> permute(int[] nums) {
boolean[] used = new boolean[nums.length];
backTracking(nums,used);
return res;
}
private void backTracking(int[] nums,boolean[] used){
if(nums.length == path.size()){
res.add(new ArrayList<>(path));
}
for(int i = 0;i<nums.length;i++){
//1、处理节点
if(used[i]==true) continue;//如果有记录使用过就直接跳过
used[i]=true;
path.add(nums[i]);
//2、回溯
backTracking(nums,used);
//3、删除,即再次寻找下一个结果集
path.removeLast();
used[i] =false;
}
}
}
总结
后续我会继续补充其他的基本问题,比如N皇后、棋盘问题等等。本文会持续更新,谢谢阅读。