先来回溯算法理论部分,稍微看了看贴了点卡哥的总结:
回溯是递归的副产品,只要有递归就会有回溯。
所以以下讲解中,回溯函数也就是递归函数,指的都是一个函数。
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
此时大家应该好奇了,都什么问题,这么牛逼,只能暴力搜索。
回溯法解决的问题
组合问题:N个数里面按一定规则找出k个数的集合
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
排列问题:N个数按一定规则全排列,有几种排列方式
棋盘问题:N皇后,解数独等等
相信大家看着这些之后会发现,每个问题,都不简单!
另外,会有一些同学可能分不清什么是组合,什么是排列?
组合是不强调元素顺序的,排列是强调元素顺序。
类似递归法的三部曲,回溯法同样存在三部曲:
(因为太久没按照递归三部曲写了所以这里再回忆一下:
- 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
- 确定终止条件:写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
- 确定单层递归的逻辑:确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。
那么回溯算法三部曲:
- 回溯函数模板返回值以及参数
在回溯算法中,我的习惯是函数起名字为backtracking,这个起名大家随意。 回溯算法中函数返回值一般为void。
再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。
- 回溯函数终止条件:
既然是树形结构,那么我们在讲解二叉树的递归 (opens new window)的时候,就知道遍历树形结构一定要有终止条件。
所以回溯也有要终止条件。
什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
所以回溯函数终止条件伪代码如下:
if (终止条件) {
存放结果;
return;
}
- 回溯搜索的遍历过程
分析完过程,回溯算法模板框架如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
这份模板很重要,后面做回溯法的题目都靠它了!
好了,开刷!
77 组合
刷多了二叉树乍一看啥都只能想到二叉树。。看到这种组合题有点懵懵的
可以看出这棵树,一开始集合是 1,2,3,4, 从左向右取数,取过的数,不再重复取。
第一次取1,集合变为2,3,4 ,因为k为2,我们只需要再取一个数就可以了,分别取2,3,4,得到集合[1,2] [1,3] [1,4],以此类推。
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现n相当于树的宽度,k相当于树的深度。
那么如何在这个树上遍历,然后收集到我们要的结果集呢?
图中每次搜索到了叶子节点,我们就找到了一个结果。
相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
讲得好清楚!
于是按照回溯法三部曲开刷:
- 确定返回值及参数:返回值一般都为void,传入startIndex,传入n和k
- 确定本题的终止条件:
startIndex为最后一位的时候可以终止了。(不太对?stratIndex超出之后会自动跳出for循环的,这里的终止条件应该是for循环之外的)(
视频讲的是path大小等于k的时候) - 每层的递归逻辑:从startIndex开始遍历整个数组,加到当前的path中
使用两个全局变量
vector<int> path
作为每次递归的结果
vector<vector<int>> result
作为所有递归的结果汇总
startIndex,每次递归的时候开始搜索的位置
回溯的终点是把上一步的值弹出,把下一个位置的值加进来,而不是无止境地加入新的值)
这里注意C++中vector的插入和弹出函数
.pop_back(), .push_back(i)
class Solution {
public:
vector<vector<int>> result;
vector<int> each;
vector<vector<int>> combine(int n, int k) {
backTrace(n, k, 1);
return result;
}
void backTrace(int n, int k, int startIndex) {
if (each.size() == k) {
result.push_back(each);
return;
}
for (int i = startIndex; i <= n; ++i) {
each.push_back(i);
backTrace(n, k, i + 1);
each.pop_back();
}
return;
}
};
JAVA中没有类似vector的容器类,用list写:
注意list插入是.add(i)
移除最后一位是removeLast();
LinkedList 和List的区别?
类似链表和数组,虽然LinkedList也可以按下表查找,但底层逻辑是通过遍历查找,因此查找多的时候还是需要ArrayList,
使用LinkedList是不用担心扩容问题的,链表是不需要扩容的。
比如调用ArrayList和LinkedList的add(e)方法,都是插入在最后,如果这种操作比较多,那么就用LinkedList,因为不涉及到扩容。
class Solution {
public List<List<Integer>> result= new ArrayList<>();
public List<Integer> each = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
backTrace(n, k, 1);
return result;
}
public void backTrace (int n, int k, int startIndex){
if(each.size()==k){
result.add(new ArrayList<>(each));
}
for(int i= startIndex; i<=n ; ++i){
each.add(i);
backTrace(n,k, i+1);
each.removeLast();
}
}
}
注意把each插入result的操作:
result.add(new ArrayList<>(each));
一开始写的
result.add(each);
插入不成功(为啥)最后插入的全是空数组
剪枝优化:
举例子很容易理解:
n=4, k=3,
那么从2以及之前的开始遍历都是有意义的,stratIndex从3开始,后面只有3,4 ,这部分遍历最终结果数组长度也不会达到要求,也就是不会存入result,因此可以剪掉。
剪枝的部分可以在for循环中改变:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
(其实没太懂这里n - (k - path.size()) + 1,但实际写的时候可以举例计算一下。。)
剪枝AC
216. 组合总和 III
感觉思路和上一题差不多?
可以先写一个不考虑剪枝的方法:
只使用数字1到9,和为n的k个数的组合
回溯三部曲:
- 入参:k, n, startIndex, 出参为void
- 终止条件:数组长度为k,如果和等于n就存入,否则直接return
- 单层递归逻辑:根据startIndex存入,回溯,弹出each最后一位
class Solution {
public:
vector<int> each;
vector<vector<int>> result;
vector<vector<int>> combinationSum3(int k, int n) {
backTrace(k, n, 1);
return result;
}
void backTrace(int k, int n, int startIndex) {
if (each.size() == k) {
if (sumVector(each) == n) {
result.push_back(each);
}
return;
}
for (int i = startIndex; i <= 9; ++i) {
each.push_back(i);
backTrace(k, n, i + 1);
each.pop_back();
}
}
int sumVector(vector<int> each) {
int n = each.size();
int sum = 0;
for (int i = 0; i < n; ++i) {
sum += each[i];
}
return sum;
}
};
不剪枝倒是很好写
剪枝具体可能需要考虑一下:
17.电话号码的字母组合(待修改。。第一版代码和思路都有点问题,最后贴了正确的)
感觉是回溯嵌套回溯?
可以先得出一种
三部曲:
1.终止条件:字符串长度与输入字符串长度相等时终止
2.入参:字符串长度n,当前字符串遍历的数字(需要根据数字能找到对应映射的三个字母)(可以根据stratIndex找到)(感觉需要xIndex和yIndex,分别控制横向和纵向的遍历,yIndex帮助找到当前数字对应的三个字母,xIndex帮助找到当前字母的遍历位置,数字字符串,(先这些吧),出参为void
3.递归逻辑:获取到的yIndex找到对应字母数组(size=3的)获取到xIndex帮助找到当前字母数组中应该添加啥。(感觉会有两个for循环,至少会有两个pop_back)
写了不对:
class Solution {
public:
string each = "";
vector<string> result;
vector<string> letterCombinations(string digits) {
int xIndex = 0;
int yIndex = 0;
backTrace(digits, xIndex, yIndex);
return result;
}
void backTrace(string digits, int xIndex, int yIndex) {
int n = digits.size();
if (each.size() == n) {
result.push_back(each);
return;
}
for (int j = yIndex; j < n; ++j) {
int currNumber = digits[j] - '0';
char firstCharacter = 'a' + (currNumber - 2) * 3;
vector<char> currLetter;
currLetter.push_back(firstCharacter);
currLetter.push_back(firstCharacter + 1);
currLetter.push_back(firstCharacter + 2);
for (int i = xIndex; i < n; ++i) {
each += currLetter[i];
backTrace(digits, i + 1, yIndex);
each.pop_back();
}
backTrace(digits, xIndex, j + 1);
each.pop_back();
}
}
};
class Solution {
public:
vector<string> result;
string each = "";
vector<string> letterCombinations(string digits) {
if(digits.size()==0) return result;
backTrace(digits, 0);
return result;
}
void backTrace(string digits, int index){
if(each.size()==digits.size()){
result.push_back(each);
return;
}
int currNumber = digits[index] - '0';
string curr = "";
curr += (currNumber-2)*3 + 'a';
if(currNumber==8 || currNumber==9){
curr[0]+=1;
}
curr += curr[0] + 1;
curr += curr[1] + 1;
if(currNumber==7 || currNumber==9){
curr += curr[2]+1;
}
for(int i=0; i<curr.size(); ++i){
each.push_back(curr[i]);
backTrace(digits,index+1);
each.pop_back();
}
}
};
思路是对的但因为没写映射导致奇奇怪怪的