本文参考《代码随想录》
原文笔记链接:
https://www.programmercarl.com/
B站视频链接:
https://www.bilibili.com/video/BV1cy4y167mM?spm_id_from=333.337.search-card.all.click&vd_source=c2d3149b8b6fdae1d68e10dfaabfb1de
回溯法简单来说就是按照深度优先的顺序,穷举所有可能性的算法,但是回溯算法比暴力穷举法更高明的地方就是回溯算法可以随时判断当前状态是否符合问题的条件。一旦不符合条件,那么就退回到上一个状态,省去了继续往下探索的时间。
回溯是递归的副产品,只要有递归就会有回溯。
虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
回溯法,一般可以解决如下几种问题:
注意:
组合是不强调元素顺序的,排列是强调元素顺序。
例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。
回溯法解决的问题都可以抽象为树形结构,所有回溯法的问题都可以抽象为树形结构!
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度都构成的树的深度。
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
void backtracking(参数)
if (终止条件) {
存放结果;
return;
}
回溯搜索的遍历过程
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
如图:
注意图中,我特意举例集合大小和孩子的数量是相等的!
回溯函数遍历过程伪代码如下:
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
大家可以从图中看出for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
分析完过程,回溯算法模板框架如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
题目:给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
leetcode链接:https://leetcode.cn/problems/combinations/
示例 1:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
示例 2:
输入:n = 1, k = 1
输出:[[1]]
本题这是回溯法的经典题目。
直接的解法当然是使用for循环,例如示例中k为2,很容易想到 用两个for循环,这样就可以输出 和示例中一样的结果。
代码如下:
int n = 4;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
cout << i << " " << j << endl;
}
}
输入:n = 100, k = 3 那么就三层for循环,代码如下:
int n = 100;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
for (int u = j + 1; u <= n; n++) {
cout << i << " " << j << " " << u << endl;
}
}
}
但当k很大的情况下,譬如k=50,暴力写法需要嵌套50层for循环,会让人感到绝望。那么我们可以使用回溯法就用递归来解决嵌套层数的问题。
递归来做层叠嵌套(可以理解是开k层for循环),**每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了。**此时递归的层数大家应该知道了,例如:n为100,k为50的情况下,就是递归50层。
上面说到回溯法解决的问题都可以抽象为树形结构(N叉树),用树形结构来理解回溯就容易多了。将组合问题抽象成树形结构如下:
可以看出这个棵树,一开始集合是 1,2,3,4, 从左向右取数,取过的数,不在重复取。
第一次取1,集合变为2,3,4 ,因为k为2,我们只需要再取一个数就可以了,分别取2,3,4,得到集合[1,2] [1,3] [1,4],以此类推。
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现n相当于树的宽度,k相当于树的深度。
图中每次搜索到了叶子节点,我们就找到了一个结果,只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
其实不定义这两个全局遍历也是可以的,把这两个变量放进递归函数的参数里,但函数里参数太多影响可读性,所以我定义全局变量了。
函数里一定有两个参数,既然是集合n里面取k的数,那么n和k是两个int型的参数。
然后还需要一个参数,为int型变量startIndex,这个参数用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,…,n] )。
为什么要有这个startIndex呢?
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围,就是要靠startIndex。
从下图中红线部分可以看出,在集合[1,2,3,4]取1之后,下一层递归,就要在[2,3,4]中取数了,那么下一层递归如何知道从[2,3,4]中取数呢,靠的就是startIndex。
所以需要startIndex来记录下一层递归,搜索的起始位置。
所以这一部分代码为:
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
private void backtracking(int n, int k, int startIndex)
if (path.size() == k){
result.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i <= n; i++) {
path.push(i); // 处理节点
backtracking(n, k, i + 1); // 递归
path.pop(); // 回溯,撤销处理的节点
}
可以看出backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回。
backtracking的下面部分就是回溯的操作了,撤销本次处理的结果。
完整代码如下:
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
/**
* 每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围,就是要靠startIndex
* @param startIndex 用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,...,n] )。
*/
private void backtracking(int n, int k, int startIndex){
//终止条件
if (path.size() == k){
result.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i <= n; i++){
path.push(i);
backtracking(n, k, i + 1);
path.pop();
}
}
}
回溯法虽然是暴力搜索,但也有时候可以有点剪枝优化一下的。
在遍历过程中:
for (int i = startIndex; i <= n; i++) {
path.push(i);
backtracking(n, k, i + 1);
path.pop();
}
如何优化呢?我们可以从这个循环下手。
举一个例子,n = 4,k = 4的话,那么第一层for循环的时候,从元素2开始的遍历都没有意义了。在第二层for循环,从元素3开始的遍历都没有意义了。如图所示:
图中每一个节点(图中为矩形),就代表本层的一个for循环,那么每一层的for循环从第二个数开始遍历的话,都没有意义,都是无效遍历。所以如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
优化过程如下:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
优化后的整体代码:
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
/**
* 每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围,就是要靠startIndex
* @param startIndex 用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,...,n] )。
*/
private void backtracking(int n, int k, int startIndex){
//终止条件
if (path.size() == k){
result.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++){
path.push(i);
backtracking(n, k, i + 1);
path.pop();
}
}
}
题目:给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个
链接:https://leetcode.cn/problems/combination-sum
示例 1:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。
示例 2:
输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]
示例 3:
输入: candidates = [2], target = 1
输出: []
提示:
1 <= candidates.length <= 30
1 <= candidates[i] <= 200
candidate 中的每个元素都 互不相同
1 <= target <= 500
相对于上一题组合问题,本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
本题搜索的过程抽象成树形结构如下:
注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
递归函数参数
定义两个全局变量:
List<List<Integer>> lists=new ArrayList<>();//存放结果集
LinkedList<Integer> list=new LinkedList();//存放符合条件的结果
首先是题目中给出的参数,集合candidates, 和目标值target。此外还定义了int型的sum变量来统计单一结果path里的总和。本题还需要startIndex来控制for循环的起始位置
代码如下:
public void backtracing(int[] candidates, int target,int sum,int startIndex)
递归终止条件
如下图树形结构中,从叶子节点可以清晰看到,终止只有两种情况,sum大于target和sum等于target,且当sum等于target需要收集结果。
代码如下:
if (sum==target)
{
lists.add(new LinkedList<>(list));
return;
}
if (sum>target)
{
return;
}
单层搜索的逻辑
单层for循环依然是从startIndex开始,搜索candidates集合。与上一题的区别在于本题元素可重复。
for (int i=startIndex;i<candidates.length;i++){
list.add(candidates[i]);
sum=sum+candidates[i];
backtracing(candidates,target,sum,i);//关键点:使用i,表示可以直接重复读取当前元素
sum=sum-candidates[i];//回溯
list.removeLast();//回溯
}
完整代码如下:
class Solution {
List<List<Integer>> lists=new ArrayList<>();
LinkedList<Integer> list=new LinkedList();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
int sum=0;
backtracing(candidates,target,sum,0);
return lists;
}
public void backtracing(int[] candidates, int target,int sum,int startIndex){
if (sum==target)
{
lists.add(new LinkedList<>(list));
return;
}
if (sum>target)
{
return;
}
for (int i=startIndex;i<candidates.length;i++){
list.add(candidates[i]);
sum=sum+candidates[i];
backtracing(candidates,target,sum,i);
sum=sum-candidates[i];
list.removeLast();
}
}
}
在如上图的树形结构之中,对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。
如图:
for循环剪枝代码如下:
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)
优化后代码如下:
class Solution {
List<List<Integer>> lists=new ArrayList<>();//存放结果集
LinkedList<Integer> list=new LinkedList();//存放符合条件的结果
public List<List<Integer>> combinationSum(int[] candidates, int target) {
int sum=0;
Array.sort(candidates);// 需要排序
backtracing(candidates,target,sum,0);
return lists;
}
public void backtracing(int[] candidates, int target,int sum,int startIndex){
if (sum==target)
{
lists.add(new LinkedList<>(list));
return;
}
if (sum>target)
{
return;
}
// 如果 sum + candidates[i] > target 就终止遍历
for (int i=startIndex; i < candidates.length && sum + candidates[i] <= target;i++){
list.add(candidates[i]);
sum=sum+candidates[i];
backtracing(candidates,target,sum,i);
sum=sum-candidates[i];
list.removeLast();
}
}
}
题目:给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。
返回 s 所有可能的分割方案。
示例: 输入: “aab” 输出: [ [“aa”,“b”], [“a”,“a”,“b”]
力扣链接:https://leetcode.cn/problems/palindrome-partitioning/
示例 1:
输入:s = “aab”
输出:[[“a”,“a”,“b”],[“aa”,“b”]]
示例 2:
输入:s = “a”
输出:[[“a”]]
提示:
1 <= s.length <= 16
s 仅由小写英文字母组成
本题这涉及到两个关键问题:
1、切割问题,有不同的切割方式
2、判断回文
这种题目,想用for循环暴力解法,可能都不那么容易写出来,所以要换一种暴力的方式,就是回溯。
一些同学可能想不清楚 回溯究竟是如何切割字符串呢?我们来分析一下切割,其实切割问题类似组合问题。
例如对于字符串abcdef:
LinkedList<String> path=new LinkedList<>();
List<List<String>> result=new ArrayList<>();`
public void backtracking(String s,int startIndex){
if (startIndex>=s.length()){
result.add(new LinkedList<>(path));
return;
}
}
来看看在递归循环,中如何截取子串呢?
在for (int i = startIndex; i < s.size(); i++)循环中,我们 定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在path中,path用来记录切割过的回文子串。
for (int i=startIndex;i<s.length();i++){
if (isPlalindrome(s.substring(startIndex,i+1))){
path.add(s.substring(startIndex,i+1));
backtracking(s,i+1);
path.removeLast();
}else {
continue;
}
}
注意切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为i + 1。
完整代码如下:
class Solution {
LinkedList<String> path=new LinkedList<>();
List<List<String>> result=new ArrayList<>();
public List<List<String>> partition(String s) {
backtracking(s,0);
return result;
}
//回文判断,详见算法专栏《判断回文字符串》
public boolean isPlalindrome(String s){
StringBuilder sb=new StringBuilder(s);
sb.reverse();
return sb.toString().equals(s);
}
public void backtracking(String s,int startIndex){
if (startIndex>=s.length()){
result.add(new LinkedList<>(path));
return;
}
for (int i=startIndex;i<s.length();i++){
if (isPlalindrome(s.substring(startIndex,i+1))){
path.add(s.substring(startIndex,i+1));
backtracking(s,i+1);
path.removeLast();
}else {
continue;
}
}
}
}