回溯算法理论基础
回溯算法 -
一种搜索方式。
回溯是递归的副产品,只要有递归就会有回溯。
递归函数的下面就是回溯的过程。
一般说回溯函数就是递归函数。
回溯搜索法的效率:纯暴力,穷举「并不高效」。 --- 因为有些问题很复杂,只能穷举,最多剪枝。
一般可以解决如下几种问题:
组合问题:N个数里面按一定规则找出k个数的集合
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
排列问题:N个数按一定规则全排列,有几种排列方式
棋盘问题:N皇后,解数独等等
如何理解?抽象为树形结构
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度。
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
回溯法的模板:
回溯三部曲
回溯函数模板返回值以及参数(回溯算法中函数返回值一般为void。回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。)
void backtracking(参数)
回溯函数终止条件(什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来- 收集结果,并结束本层递归。)
if (终止条件) {
存放结果;
return;
}
回溯搜索的遍历过程
在上面我们提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
参考资料:https://programmercarl.com/回溯算法理论基础.html#题目分类大纲如下
77. 组合
力扣链接:https://leetcode.cn/problems/combinations/
这道题用暴力的方式:如果是k=50的情况,那么就需要50个for循环嵌套,那么是很难实现的。
因此只能考虑回溯。
组合问题抽象为如下树形结构:
n相当于树的宽度,k相当于树的深度。
可以看出这棵树,一开始集合是 1,2,3,4, 从左向右取数,取过的数,不再重复取。
第一次取1,集合变为2,3,4 ,因为k为2,我们只需要再取一个数就可以了,分别取2,3,4,得到集合[1,2] [1,3] [1,4],以此类推。
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现n相当于树的宽度,k相当于树的深度。
图中每次搜索到了叶子节点,我们就找到了一个结果。
相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
依照回溯三部曲「for循环中嵌套一个递归思路」:
回溯返回值以及参数:
需要两个全局变量 - 一个存放所有的结果集;一个存放单个集合的结果集「可以将这两个变量放入回溯方法中,但是影响代码可读性 - 适合作为全局变量」
List<List<Integer>> result;
List<Integer> list;
返回值:void
参数:int k 「组合的大小」,int n「右边的位置」,int left「左边的位置 - 确定从那个地方开始取数(搜索的起始位置)」
回溯的终止条件:如果list.size = k,那就将这个结果储存进集合,然后返回。
回溯的单层逻辑:使用for循环(int left; left <=n; left++) - 将i放入list,然后递归backtraking(k, n, i+1);回溯:删除list中最后一个元素 - list.remove(list.size()-1)。
具体代码实现:
class Solution {
public List<List<Integer>> combine(int n, int k) {
result = new ArrayList<>();
list = new ArrayList<>();
backtraking(n, k, 1);
return result;
}
List<List<Integer>> result;
List<Integer> list;
public void backtraking(int n, int k, int left){
//终止条件
if(list.size() == k){
result.add(new ArrayList<>(list));//需要注意 - 不能添加list,这是一个引用地址;需要新建一个ArrayList存放list中的数据;list是会改变的
return;
}
//单层处理逻辑
for(int i=left; i<=n; i++){
list.add(i);
//递归
backtraking(n, k, i+1);
//回溯
list.remove(list.size()-1);
}
}
}
注意在存放结果集的时候不要存放引用地址。
参考资料:https://programmercarl.com/0077.组合.html#回溯法三部曲
剪枝优化
这里也有一个可以考虑的剪枝优化:
可以看出来,在[1234]集合中,当取4的时候,这个集合就是空的。
因此是可以剪枝的 - 对于搜索效率的提升是很大的。
那么怎么确定这个剪枝的范围呢?
所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
优化过程如下:
已经选择的元素个数:list.size();
所需需要的元素个数为: k - list.size();
列表中剩余元素(n-i) >= 所需需要的元素个数(k - path.size())
在集合n中至多要从该起始位置 : i <= n - (k - path.size()) + 1,开始遍历
所以优化之后的for循环是:
for(int i = startIndex; i <= n -(k - path.size())+1; i++)
具体代码:
class Solution {
public List<List<Integer>> combine(int n, int k) {
result = new ArrayList<>();
list = new ArrayList<>();
backtraking(n, k, 1);
return result;
}
List<List<Integer>> result;
List<Integer> list;
public void backtraking(int n, int k, int left){
//终止条件
if(list.size() == k){
result.add(new ArrayList<>(list));//需要注意 - 不能添加list,这是一个引用地址;需要新建一个ArrayList存放list中的数据;list是会改变的
return;
}
//单层处理逻辑
for(int i=left; i<=n-(k-list.size())+1; i++){//剪枝优化
list.add(i);
//递归
backtraking(n, k, i+1);
//回溯
list.remove(list.size()-1);
}
}
}
216.组合总和III
力扣链接:https://leetcode.cn/problems/combination-sum-iii/
不能使用暴力的原因:不能控制要嵌套几层for循环 - 考虑回溯
这里的条件是:集合是固定的 - 从1到9;求和为n,size为k
本题k相当于树的深度,9(因为整个集合就是9个数)就是树的宽度。
回溯三部曲:
确定回溯返回值以及输入参数:
两个全局变量:
List<List<Integer>> result;
List<Integer> list;
输入:backtraking(int n「求和值」, int k「集合要求大小」, int left「取值起始位置」, int sum「此时list求和大小」);
输出:void
回溯终止条件:当list.size() == k时:如果sum == n,那么将list放入result,然后return;否则直接return;
回溯单层逻辑:for(int i= left; i<=9; i++){list.add(i); sum+= i}; 递归 - backtraking(n, k, i+1; sum); 回溯:sum-= list.(list.size()-1); list.remove(list.size()-1);
这里也可以进行剪枝优化:
对遍历的树宽度进行优化:
深度为k,因此还需要的数是:k - list.size();
这里的元素总共为9,起始点为i --- 因此 9-i >= k - list.size(), 才需要往后遍历: i<=9-(k-list.size())+1
如果sum已经比n大了,那么就不用再往下进行回溯搜索了,因为后面都比n要大。 -- 注意在return之前需要进行回溯操作|或者放在终止条件中,就不用再回溯
代码具体实现1:
class Solution {
public List<List<Integer>> combinationSum3(int k, int n) {
result = new ArrayList<>();
list = new ArrayList<>();
backtraking(k, n, 0, 1);
return result;
}
List<List<Integer>> result;
List<Integer> list;
public void backtraking(int k, int n, int sum, int left){
//终止条件
if(list.size() == k){
if(sum == n){
result.add(new ArrayList<>(list));
}
return;
}
for(int i=left; i<= 9-(k-list.size())+1; i++){//单层循环+剪枝优化1
list.add(i);
sum += i;
//剪枝优化2
if(sum > n){
//先回溯
sum -= list.get(list.size()-1);
list.remove(list.size()-1);
//再return
return;
}
//递归
backtraking(k, n, sum, i+1);
//回溯
sum -= list.get(list.size()-1);
list.remove(list.size()-1);
}
}
}
代码具体实现2:
class Solution {
public List<List<Integer>> combinationSum3(int k, int n) {
result = new ArrayList<>();
list = new ArrayList<>();
backtraking(k, n, 0, 1);
return result;
}
List<List<Integer>> result;
List<Integer> list;
public void backtraking(int k, int n, int sum, int left){
//终止条件
if(sum > n){ //剪枝优化2
return;
}
if(list.size() == k){
if(sum == n){
result.add(new ArrayList<>(list));
}
return;
}
for(int i=left; i<= 9-(k-list.size())+1; i++){//单层循环+剪枝优化1
list.add(i);
sum += i;
//递归
backtraking(k, n, sum, i+1);
//回溯
sum -= list.get(list.size()-1);
list.remove(list.size()-1);
}
}
}
参考资料:https://programmercarl.com/0216.组合总和III.html
17.电话号码的字母组合
题目链接:https://leetcode.cn/problems/letter-combinations-of-a-phone-number/
首先这道题是输入电话号码 - 然后给出可能的字母组合。
因此需要对数字 - 字母做一个映射。
可以考虑的方式是 - 采用map;或者用数组。
有一个巧妙的方式是使用一维数组:对应下标储存对应的字符串。
String[] map = {"", "", abc, def....}
这样直接使用mep[i]就能找到对应的字母。
同样,因为不能确定需要使用多少层的for循环进行嵌套 ---这道题需要用回溯算法。
树的深度是数字的个数;树的宽度是第一个集合中的字母数量
回溯三部曲:
输出以及输入的参数:
两个全局变量:List<String> result - 所有字符串组合; String s - 当前字符串
输出:void
输入:backtraking(String digits - 输入字符串, int index - 当前遍历到数字的下标) - 这道题和之前77.组合不一样的是:之前是在一个集合里面遍历,因此需要确定第二层起始的遍历的位置;而这里是在不同的集合中进行比那里,因此需要确定的是第二层需要遍历的集合是哪一个。
回溯的终止条件:if(index = digits.length()) - 当index等于字符串的长度时,s中的元素已经满了「这里index代表的是当前遍历到的数字的下标,因此当index = digits.length()的时候,已经把数字都遍历完了,此时index已经不在digits这个字符中了,也就结束了」。「也可以用其他的参数控制,比如说」
回溯的单层逻辑:先找到数字的映射 letters,然后for(int i = 0; i<letter.length();i++) - 将letters.chatAt(i) 放入s中;递归 —— backtraking(digits, index+1); 回溯:删除s最后一个字符
具体代码实现:
class Solution {
public List<String> letterCombinations(String digits) {
result = new ArrayList<>();
s = new StringBuffer();
//判断一下空的情况
if(digits == null || digits.length() ==0){
return result;
}
backtraking(digits, 0);
return result;
}
String[] map = {"", "", "abc", "def", "ghi", "jkl", "mno", "qprs", "tuv", "wxyz"};
List<String> result;
StringBuffer s;
public void backtraking(String digits, int index){
//终止条件
if(index == digits.length()){
result.add(s.toString());
return;
}
//获得映射字母串
int d = digits.charAt(index) -'0';
String m = map[d];
for(int i=0; i< m.length(); i++){
s.append(m.substring(i,i+1));
//递归
backtraking(digits, index+1);
//回溯
s.deleteCharAt(s.length()-1);
}
}
}
参考资料:https://programmercarl.com/0017.电话号码的字母组合.html#java