回溯概述
回溯是递归的副产品,只要有递归就会有回溯。所以,回溯函数也就是递归函数。
所有回溯法的问题都可以抽象为树形结构——一棵高度有限的N叉树。
回溯算法模板框架:
for循环横向遍历,递归纵向遍历,回溯不断调整结果集。
回溯三部曲:
1.回溯函数:
返回值——一般为void。(因为要遍历整棵树)
参数——一般是先写逻辑,然后需要什么参数,就填什么参数。
2.终止条件:
搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
3.遍历过程:
回溯法一般是在集合中递归搜索:集合的大小构成了树的宽度,递归的深度构成了树的深度。
回溯模板:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
可以看到:
终止条件在void里面,for的后面
剪枝:在终止条件之后的if里,或者在for()里
一些心得:
部分题,主函数里用sort
for的开始和结束一般都不固定,例如必需的STARTINDEX和剪枝里的n - ( k - path.size() ) + 1
和二叉树模板不同的是需要回溯撤销处理结果(类似层序遍历)
性能分析:
-
组合:
时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n)
组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
空间复杂度: O ( n ) O(n) O(n)
和子集问题同理。 -
子集:
时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n)
因为每一个元素的状态无外乎取与不取,所以时间复杂度为 O ( 2 n ) O(2^n) O(2n),构造每一组子集都需要填进数组,又有需要 O ( n ) O(n) O(n),最终时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n)。
空间复杂度: O ( n ) O(n) O(n)
递归深度为n,所以系统栈所用空间为 O ( n ) O(n) O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为 O ( n ) O(n) O(n)。 -
排列:
时间复杂度: O ( n ! ) O(n!) O(n!)
这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。
空间复杂度: O ( n ) O(n) O(n)
和子集问题同理。 -
时间复杂度:一般回溯算法的复杂度,都是指数级别的。
-
空间复杂度:把系统栈(不是数据结构里的栈)所占空间算进去。
篇末总结
回溯法经常和二叉树遍历、深度优先搜索混在一起,因为这两种方式都是用了递归。
优化回溯算法只有剪枝一种方法。
回溯算法能解决如下问题:
组合问题:N个数里面按一定规则找出k个数的集合
排列问题:N个数按一定规则全排列,有几种排列方式
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
棋盘问题:N皇后,解数独等等
每一道回溯法的题目都可以将遍历过程抽象为树形结构。
组合问题:
如果是一个集合来求组合的话,就需要startIndex;
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,从0开始。
排列问题:
1- 每层都是从0开始搜索而不是startIndex
2- 需要used数组记录path里都放了哪些元素了
子集问题:
一定要排序
组合问题
77.组合
其实不定义这两个全局遍历也是可以的,把这两个变量放进递归函数的参数里,但函数里参数太多影响可读性,所以我定义全局变量了。
return必须有!!
剪枝优化的地方,这里的n其实相当于遍历的终点;
// 是不是可以对标二叉树的层序遍历?
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking( int startindex, int n, int k){
if(path.size()==k) {
result.push_back(path);
return;
}
for(int i = startindex; i<=n-(k-path.size())+1; i++){注意是 ≤ !
path.push_back(i);
backtracking(i+1, n, k);注意是i+1, ++i和i++都不行!(why++i不行?)///WRONG k-i(尽量在for里面)
path.pop_back();
}
return;
}
vector<vector<int>> combine(int n, int k) {
result.clear();
path.clear();
backtracking(1, n, k);
return result;
}
};
216. 组合总和 III
之前(3月底)的疑惑解决了。主要是终止条件是size= =k且sum= =n,漏了sum<n的情况,但也罪不至于在远处的for报错int溢出还是什么的。。。
for(int i = startIndex; i <= 9-(k-path.size())+1; i++){
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
int sum;// = 0;
///sum也可以不加到参数里(就像path 一样,在所有函数外面设置成全局变量!反之如果设置成参数那就不需要设置全局变量了。)
void backtracking(int k, int n, int startindex){, int sum){
if(sum>n) return;// 剪枝操作
if(sum == n && path.size()==k ){
result.push_back(path);
return;
}
for(int i = startindex; i<=9-(k-path.size())+1; i++){///9,注意背会这一句
sum+=i;
path.push_back(i);
backtracking(k, n, i+1);, sum);
path.pop_back();
sum-=i;
}
return;
}
vector<vector<int>> combinationSum3(int k, int n) {
result.clear();
path.clear();
backtracking(k, n, 1);, 0);
return result;
}
};
17. 电话号码的字母组合
横向遍历单个按键对应的字母集合,纵向遍历按键;
注意本题的前处理步骤 —— 数字和字母如何映射;
二刷感想:
和前面讲解过的77. 组合和216.组合总和的区别:本题是多个集合求组合
(本质是一样的,只不过本题非共用数据,体现地更直观。)
这个index是记录遍历第几个数字了,就是用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。
注意这里for循环,可不像是前两题,从startIndex开始遍历的。(因为这里的横向遍历不会受纵向的影响(不是共用一套系统),所以是固定的for的起始和结束,但之前两道题实际上也是创造了横向纵向两套遍历体系
三刷感想:
回溯参数就是边写边加进去的;
一定要加clear(),以及特殊情况判断:
res.clear();必须有
if(digits.size()==0) return res;///必须有,而且是和上一句一起有
class Solution {
public:
vector<string>res;
string path;
vector<string> anjian = {
"",
"",
"abc",
"def",
"ghi",
"jkl",
"mno",
"pqrs",
"tuv",
"wxyz"
};
void dfs(string digits, int index){
if(path.size()==digits.size()){
res.push_back(path);
return;
}
string str = anjian[digits[index]-'0'];//index = 0, digits[index] = '2', anjian[digits[index]-'0'] = "abc"
for(int i = 0; i<str.size(); i++){
char ch = str[i];
path.push_back(ch);
dfs(digits, index+1);
path.pop_back();
}
}
vector<string> letterCombinations(string digits) {
res.clear();必须有
if(digits.size()==0) return res;///必须有,而且是和上一句一起有
dfs(digits, 0);
return res;
}
};
39.组合总和
画图对理解很重要
分析:
1.
// 本题和我们之前讲过的77.组合、216.组合总和III有两点不同:
// 组合没有数量要求
// 元素可无限重复选取
2.
// **在求和问题中,sort加剪枝是常见的套路!**本题的剪枝优化,这个优化如果是初学者的话并不容易想到
sort函数默认为升序,也可进行降序排序。
sort函数的时间复杂度为n*log2n,比冒泡之类的排序算法效率要高。
sort函数包含在头文件为#include< algorithm >中。
还有一种剪枝,在for里面判断,这样避免了dfs再多运行一次之后再判断sum。
注意必须是>=,不能是>!
4.
答案
class Solution{
private:
vector<int> path;
vector<vector<int>> result;
void backtracking( vector<int>& candidates, int target, int sum, int startIndex){
if (sum == target) {
result.push_back(path);
return;
}
for(int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++){
/ i 不是从0开始,是从starti开始的!/ + candidates[i] (上一轮for的i+1)这样避免了再次进入for,直接剪枝
path.push_back(candidates[i]);
sum += candidates[i];
backtracking( candidates, target, sum, i);/!!!是i,WRONG startindex OR i+1
sum -= candidates[i];
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target){
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0);
return result;
}
};
40.组合总和II
分析:
本题同样是求组合总和,但就是因为其数组candidates有重复元素,而要求不能有重复的组合,所以相对于39.组合总和难度提升了不少。
强调一下,树层去重的话,需要对数组排序!
// 这里直接用startIndex来去重也是可以的, 就不用used数组了。
同上一题一样的技巧:在for里回溯,这样不会更新sum!if (i>startIndex && candidates[i] == candidates[i-1]) continue;
在横向遍历阶段,要对同一树层使用过的元素进行跳过。
知识点:
求和——sort;
同一数组——startindex;
去重——数组内有重复数值的元素,但输出结果不能重复——used数组+sort排序;
used数组——定义及初始化在主函数里;用过又还原的才需要跳过;for内跳过,用的是continue;
for内剪枝——注意是>=;
其他——用target减到0代替sum;i>0才能判断used[i-1];dfs用i++;path输入的是candidates[i]不是i。
不懂:
为什么是continue不能是break?用break会只输出部分结果
class Solution {
public:
vector<vector<int>> res;
vector<int> path;
void dfs(vector<int>& candidates, int target, int startindex, vector<int>& used) {
if(target==0){
res.push_back(path);
return;
}
for(int i = startindex; i<candidates.size() && target-candidates[i]>=0; i++){//这句话必须有,不然会超时
if(i>0&& used[i-1]==0 &&candidates[i-1]==candidates[i])continue; // break;
path.push_back(candidates[i]);
used[i]=1;
dfs(candidates, target-candidates[i], i+1,used);
path.pop_back();
used[i]=0;
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
sort(candidates.begin(),candidates.end());
vector<int> used(candidates.size(), 0);
dfs(candidates, target, 0, used);
return res;
}
};
131. 分割回文串(need again)
// /*切割问题类似组合问题。
// 那么在代码里什么是切割线: startIndex ,表示下一轮递归遍历的起始位置,这个 startIndex 就是切割线
// 判断一个字符串是否是回文: 可以使用双指针法,一个指针从前向后,一个指针从后先前,如果前后指针所指向的元素是相等的,就是回文字符串了。
// 几个难点:
// 切割问题可以抽象为组合问题
// 如何模拟那些切割线
// 切割问题中递归如何终止
// 在递归循环中如何截取子串
// 如何判断回文
// 关于模拟切割线,其实就是index是上一层已经确定了的分割线,i是这一层试图寻找的新分割线
// 除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1。*/
只需一个额外参数,因为末项就是i!
class Solution {
private:
vector<vector<string>> result;
vector<string> path; // 放已经回文的子串
void backtracking (const string& s, int startIndex) {
// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
if (startIndex >= s.size()) {// startindex 是闭
result.push_back(path);
return;
}
for (int i = startIndex; i < s.size(); i++) {
if (isPalindrome(s, startIndex, i)) { // !!! i 是末项,startindex 是首项!!
string str = s.substr(startIndex, i - startIndex + 1);// 获取[startIndex,i](双闭区间)在s中的子串
path.push_back(str);
} else { // 不是回文,跳过
continue;
}
backtracking(s, i + 1); // 寻找i+1为起始位置的子串
path.pop_back(); // 回溯过程,弹出本次已经填了的子串
}
}
bool isPalindrome(const string& s, int start, int end) {//注意这里只判断s的一部分是不是子串,这样更方便!
for (int i = start, j = end; i < j; i++, j--) {
if (s[i] != s[j]) {
return false;
}
}
return true;
}
public:
vector<vector<string>> partition(string s) {
result.clear();
path.clear();
backtracking(s, 0);
return result;
}
};
93.复原IP地址(need again)
string作参数要不要加&
试过了两个&加和不加都能通过。最好加上?
std::string str,str可以被修改,而且会调用拷贝构造函数。
std::sring& str,str可以被修改,但不会调用拷贝构造函数。
const::string str ,str不能被修改,但会调用拷贝构造函数。
const::string& str,str不能被修改,而且也不会调用拷贝构造函数。
// /*startIndex一定是需要的,因为不能重复分割,记录下一层递归分割的起始位置。
// 本题我们还需要一个变量pointNum,记录添加逗点的数量。
// */
class Solution{
private:
vector<string> result;
void backtracking (string& s, int startIndex, int pointNum){
if ( pointNum == 3) {// 逗点数量为3时,分隔结束
if (isValid(s, startIndex, s.size()-1)){// 判断第四段子字符串是否合法,如果合法就放进result中
result.push_back(s);
}
return;
}
for (int i = startIndex; i < s.size(); i++){
if (isValid(s, startIndex, i)){先加入一步判断,在此前提下进行递归
s.insert(s.begin()+ i + 1, '.');
///直接在指定位置插入元素,后面的元素都后移,更新原字符串
///insert(pos , elem): "在迭代器 pos 指定的位置之前插入一个新元素elem,并返回表示新插入元素位置的迭代器。"
///ctrl+1出现第一个标签页!!!
pointNum ++;
backtracking(s, i +2, pointNum);【多次指出】startindex 只是最初的标记点,之后主场是i!!!
pointNum --;
s.erase( s.begin() + i + 1);
}else break;
}
}
bool isValid( const string& s, int start, int end ){
// 主要是这个函数比较长:判断字符串s在闭区间[start, end]所组成的数字是否合法
if (start> end) return false;
if (s[start] == '0' && start != end) return false;
//2. 0开头的多位数不合法
int num = 0;
for(int i = start; i <= end; i++){闭区间所以是<=end
if (s[i] > '9' || s[i] < '0')return false;
// 3. 遇到非数字字符不合法【【注意char也是可以比较大小的!】】
num = num* 10 + (s[i] - '0');
///【【注意string转多位数字的经典写法!】】string '225' -> int 2 2 5 -> int 255!!!!!!
if(num > 255)return false;
// 4. 如果大于255了不合法
}
return true;
}
public:
vector<string> restoreIpAddresses (string& s){
result.clear();
if (s.size() > 12) return result; // 剪枝
backtracking(s, 0 ,0);
return result;
}
};
子集问题
组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
其实子集也是一种组合,集合是无序的,那么取过的元素不会重复取,for就要从startIndex开始,而不是从0开始!
78. 子集
注意此时 if 和 res 更新的写法和上一章的组合问题不同。
class Solution {
public:
vector<vector<int>> res;
vector<int> path;
void dfs(vector<int>& nums, int startindex){
res.push_back(path);// 收集子集,要放在终止添加的上面,否则会漏掉自己;收集每一轮的第一个空集[]
if(startindex==nums.size()) return;
for(int i = startindex;i<nums.size(); i++ ){
path.push_back(nums[i]);
dfs(nums, i+1);
path.pop_back();
}
}
vector<vector<int>> subsets(vector<int>& nums) {
res.clear();
if(nums.size()==0) return res;
dfs(nums, 0);
return res;
}
};
90. 子集II
-
本题和上一题的区别:“其中可能包含重复元素”。那么需要去重。
-
去重,有两个要点:
1- (sort):注意去重之前,需要先对集合【排序】
2-(used数组):理解“树层去重”和“树枝去重”非常重要。 -
used数组:
当used[i - 1] == false,说明同一树层candidates[i - 1]使用过(F -> T ->F),我们要对同一树层使用过的元素进行跳过 -
以下还没理解当时怎么写的:
在 public 里定义并初始化变量,在 private 里使用!!
(猜是因为 used 初始化的参数是在 public 里定义的 nums ,如果在 private 里直接定义缺省值有点麻烦。总之要记住,不要觉得有点变扭)
【代文18. :】如果把unordered_set uset放在类成员的位置(相当于全局变量),就把树枝的情况都记录了,不是单纯的控制某一节点下的同一层了,它控制的就是整棵树,包括树枝。所以这么写不行!
本题也可以不使用used数组来去重,因为递归的时候下一个startIndex是i+1而不是0,从而可以用startIndex操作。
如果要是全排列的话,每次要从0开始遍历,为了跳过已入栈的元素,需要使用used。
class Solution {
public:
vector<vector<int>> res;
vector<int> path;
void dfs(vector<int>& nums, int startindex,vector<bool>& used){
res.push_back(path);// 收集子集,要放在终止添加的上面,否则会漏掉自己;收集每一轮的第一个空集[]
if(startindex==nums.size()) return;
for(int i = startindex;i<nums.size(); i++){
if(i>0 && nums[i]==nums[i-1] && used[i-1]==0) continue;
path.push_back(nums[i]);
used[i] = 1;
dfs(nums, i+1, used);
used[i] = 0;
path.pop_back();
}
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
res.clear();
if(nums.size()==0) return res;
sort(nums.begin(), nums.end());
vector<bool> used(nums.size(), 0);
dfs(nums, 0,used);
return res;
}
};
491. 递增子序列
(need again:二刷没做三刷做了)
// 相信大家在本题中处处都能看到是回溯算法:求子集问题(二)的身影,但处处又都是陷阱。
分析:
- 本题不能对原数组排序,排完序的数组都是自增子序列了
- used去重:因为不连续(4675可能选4和最后一个5)
所以这个uesd是模仿哈希表的,是以元素值作为导向的;
题目说数值范围[-100, 100],对应i+100范围[0,200],判断的是if ( used[nums[i]+100]==1 || (!path.empty() && nums[i]<path.back()) ) continue;
,对应(是非空且不递增)或(同层上一个用过了)就跳过;
对同一个 startindex ,对 used 清零。要知道used只负责本层!
另外,在本层,记录这个元素【的值】用过了,后面不能再用了,一层之内不需要再回溯清零,所以后续used不用回溯为初值了。
如果数值范围小的话,尽量用数组,不要用哈希表。 - 其他:本题不能重复使用元素,所以需要startIndex;题目要求递增子序列大小至少为2,影响到 dfs函数和主函数开头的判断;
class Solution {
public:
vector<vector<int>> res;
vector<int> path;
void dfs(vector<int>& nums, int startindex){
if(path.size()>=2){
res.push_back(path);
//return;
}
bool used[201] = {0};
for(int i = startindex;i<nums.size(); i++){
if(used[nums[i]+100]==1
|| (!path.empty() && nums[i]<path.back()) ) continue;
path.push_back(nums[i]);
used[nums[i]+100] = 1;
dfs(nums, i+1);
path.pop_back();
}
}
vector<vector<int>> findSubsequences(vector<int>& nums) {
res.clear();
if(nums.size()<2) return res;
dfs(nums, 0);
return res;
}
};
全排列问题
46.全排列
排列问题的不同:
- 每层都是从0开始搜索而不是startIndex
- 需要used数组记录path里都放了哪些元素了(标记path 里已经选择的元素,一个排列里一个元素只能使用一次。)
组合里面在元素值一样但下标不同的时候去重,排列都需要去重,因为每次 for 都从0开始:如果元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1,就涉及到去重的used了。
class Solution {
public:
vector<vector<int>> res;
vector<int>path;
void dfs(vector<int>& nums,vector<bool>& used){
if(path.size()==nums.size()){
res.push_back(path);
return;
}
for(int i = 0; i<nums.size(); i++){
if(used[i]==1)continue;///此处和组合不同,反而有点类似491(不连续有条件的子集)问题,但查找的值是nums的下标。
path.push_back(nums[i]);
used[i]=1;
dfs(nums,used);
path.pop_back();
used[i]=0;
}
}
vector<vector<int>> permute(vector<int>& nums) {
res.clear();
vector<bool>used(nums.size(), 0);
dfs(nums, used);
return res;
}
};
47. 全排列II
融合了491,不希望值重复+排列不能重复,写了两个used:
class Solution {
public:
vector<vector<int>>res;
vector<int>path;
void dfs(vector<int>& nums,vector<bool>& used1){
if(path.size()==nums.size()){
res.push_back(path);
return;
}
vector<bool> used2(201,0);///类似491,对值查找;小范围用数组不用哈希
for(int i = 0; i<nums.size(); i++){
if(used1[i]==1 || used2[nums[i]+10]==1)continue;//类似491,对下标查找
used2[nums[i]+10]=1;
path.push_back(nums[i]);
used1[i]=1;
dfs(nums, used1);
used1[i]=0;
path.pop_back();
}
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
res.clear();
vector<bool> used1(nums.size(), 0);
dfs(nums, used1);
return res;
}
};
【代】答案,sort了一下,这样就可以把相邻的值都挨在一起,从而知道下标关联关系,从而可以只用一个used:
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking (vector<int>& nums, vector<bool>& used) {
// 此时说明找到了一组
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
// used[i - 1] == true,说明同一树枝nums[i - 1]使用过
// used[i - 1] == false,说明同一树层nums[i - 1]使用过
// 如果同一树层nums[i - 1]使用过则直接跳过
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
continue;
}
if (used[i] == false) {
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = false;
}
}
}
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
result.clear();
path.clear();
sort(nums.begin(), nums.end()); // 排序
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};