编程题分类——回溯
前言
- 回溯算是一种选优搜索法,又称为试探法。按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择目标并不优或达不到目标,就退回一步重新选择。这种走不通再退回回走的技术称为回溯法。满足回溯状态的点称为回溯点。
- 回溯法 采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:
a. 找到一个可能存在的正确的答案;
b. 在尝试了所有可能的分步方法后宣告该问题没有答案。
- 由于回溯算法的时间复杂度很高,因此在遍历的时候,如果能够提前知道这一条分支不能搜索到满意的结果,就可以提前结束,这一步操作称为 剪枝。可以剪枝的地⽅就在递归中每⼀层的for循环所选择的起始位置。
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) //这就是剪纸
始位置
- 回溯和递归是一一对应的,有一个递归,就要有一个回溯。所以,算法尽量协程回溯和递归一一对应的程度。
举例:
if (cur->left) {
traversal(cur->left, path, result);
path.pop_back();
}
if (cur->right) {
traversal(cur->right, path, result);
path.pop_back();
}
- 如果需要遍历整颗树,递归函数就不能有返回值。如果需要遍历某一条固定路线,递归函数就一定要有返回值.
正文
回溯算法做题步骤
- 画图。画出递归图。在画图的过程中思考清楚:
a . 分支如何产生。
b. 题目需要的解在哪里,是在叶子结点,还是非叶子节点,还是在从根节点到叶子节点的路径。
c. 哪些搜索会产生不需要的解的?例如:产生重复是什么原因,如果在浅层就知道这个分支不能产生需要的结果,应该提前剪枝,剪枝的条件是什么,代码怎么写?
- 套用回溯法公式解决全排列问题
backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
- 回溯法小技巧:
a. 按引用传状态
b. 所有的状态修改在递归修改后回改。
- 回溯过程:
for 循环横向遍历,递归纵向遍历,回溯不断调整结果集。
- 递归是回溯的副产品。只要有递归就会有回溯。
回溯算法与动态规划的区别
共同点
用于求解多阶段决策问题。多阶段决策问题即:
a. 求解一个问题分为很多步骤(阶段);
b. 每一个步骤(阶段)可以有多种选择。
不同点
a.动态规划只需要求我们评估最优解是多少,最优解对应的具体解是什么并不要求。因此很适合应用于评估一个方案的效果;
b. 回溯算法可以搜索得到所有的方案(当然包括最优解),但是本质上它是一种遍历算法,时间复杂度很高。
1. 子集
题目
方法一:普通思路:在遍历每一个元素的时候,将前面已经放入的数组加上当前的这个元素。然后再加入返回的数组中。
答案
class Solution {
public:
vector<vector<int>> res;
vector<vector<int>> subsets(vector<int>& nums) {
res.push_back(vector<int>());//将空数组先放入res中
for(int i = 0;i<nums.size();++i)
{
int size = res.size();//判断原来res存储的元素
for(int j = 0;j<size;++j)//对每一个存储的元素,添加上新增加的这个元素,然后,加入返回数组中
{
vector<int> curr = res[j];
curr.push_back(nums[i]);
res.push_back(curr);
}
}
return res;
}
};
2. 分割字符串
题目
题解:
这题是做的第一道分割字符串的题目,还是在一些题解的帮助下做出来的。感觉总结下来就是两句话:
for循环水平遍历,不断选取分割点
递归垂直遍历,不断选择新的字符串长度。
答案
class Solution {
public:
vector<vector<string>> res;
vector<string> path;
vector<vector<string>> partition(string s) {
res.clear();
path.clear();
//time:9:31->10:49
if(s.size()==0)
return res;
dfs(s,0);
return res;
}
void dfs(string s,int startIndex)//不断的选择新的起始点,开始递归
{
if(startIndex>=s.size())//如果选择划分的点大于等于s的大小,则加入返回数组
{
res.push_back(path);
return;
}
for(int i = startIndex;i<s.size();i++)//for循环水平遍历
{
if(isPalindrome(s,startIndex,i))
{
string str = s.substr(startIndex,i-startIndex+1);
path.push_back(str);
}
else
continue;
dfs(s,i+1);//递归,垂直遍历
path.pop_back();
}
}
bool isPalindrome(string &s,int startIndex,int len)
{
for(int i = startIndex,j = len;i<j;i++,j--)
{
if(s[i]!=s[j])
return false;
}
return true;
}
};
3. 复原 IP 地址
题目
题解:
这题其实就是分割字符串的加强版而已。随着回溯题越做越多,已经不像之前对回溯的题目包括答案看都看不懂了。其实回溯就是一层可以弄超多层的for循环算法。我觉得关于这种字符串分割问题一般就是记住几句话:
- for循环水平遍历,也就不断选择起始分割点,递归算法垂直遍历,从当前分割点往后遍历。
- 然后就是for循环里,做选择,做递归,撤销选择。
- for循环的前面要有一个跳出递归的地方,也就是在这个地方,可以把符合条件的字符串加入到返回数组之中。
答案
class Solution {
public:
vector<string> result;//记录结果
//startIndex:搜索的起始位置,pointNum:添加逗点的数量
void backtracking(string& s,int startIndex,int pointNum)
{
if(pointNum==3)//在逗点数量为3时,分割结束
{
if(isValid(s,startIndex,s.size()-1))
{
result.push_back(s);
}
return ;
}
for(int i = startIndex;i<s.size();i++)
{
if(isValid(s,startIndex,i))//判断startIndex到i的这部分是否合法
{
s.insert(s.begin()+i+1,'.');
pointNum++;
backtracking(s,i+2,pointNum);
pointNum--;
s.erase(s.begin()+i+1);
}
else
break;//不合法,直接结束本层循环
}
}
bool isValid(const string& s,int start,int end)
{
if(start>end)
return false;
if(s[start]=='0'&&start!=end)//也就是0开头,但又不是0
{
return false;
}
int num = 0;
for(int j = start;j<=end;j++)//error:注意:这里要=end
{
if(s[j]>'9'||s[j]<'0')
return false;
num = num*10+(s[j]-'0');
if(num>255)
return false;
}
return true;
}
vector<string> restoreIpAddresses(string s) {
//time:13:44
result.clear();
if(s.size()>12)
return result;
backtracking(s,0,0);
return result;
}
};
4. 全排列
题目
答案
class Solution {
public:
void backtrack(vector<vector<int>>& res, vector<int>& output, int first, int len){
// 所有数都填完了
if (first == len) {
res.emplace_back(output);
return;
}
for (int i = first; i < len; ++i) {
// 动态维护数组
swap(output[i], output[first]);
// 继续递归填下一个数
backtrack(res, output, first + 1, len);
// 撤销操作
swap(output[i], output[first]);
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int> > res;
backtrack(res, nums, 0, (int)nums.size());
return res;
}
};
以下的方法是将Output数组分成两个部分,一部分0-first:是安排好顺序的。一部分firts+1-end。 则是要进行交换的。
答案
#include <stdio.h>
#include <iostream>
#include <vector>
using namespace std;
static int i = 0;
void backtrack(vector<vector<int>>& res, vector<int>& output, int first, int len) {
cout << i++ << "进入backtrack的次数" << endl;
if (first == len) {
res.emplace_back(output);
for (int i = 0; i < output.size(); i++)
{
cout << output[i];
}
cout << endl;
return;
}
for (int i = first; i < len; ++i) {
// 动态维护数组
swap(output[i], output[first]);
cout << "做了swap1的 操作" << "i"<<":"<<i<<"——"<<"first"<<":"<<first<<"——"<<output[i] << "——"<<output[first] << endl;
// 继续递归填下一个数
backtrack(res, output, first + 1, len);
// 撤销操作
cout << "做了swap2的 操作" << "i" << ":" << i<<"——" << "first" << ":" << first << "——" << output[i] << "——" << output[first] << endl;
swap(output[i], output[first]);
}
cout << i << "退出tacktrack的次数" << ":" << endl;
}
vector<vector<int> > res;
vector<vector<int>> permute(vector<int>& nums) {
backtrack(res, nums, 0, (int)nums.size());
return res;
}
int main()
{
vector<int> nums = { 1,2,3 };
vector<vector<int>> ret = permute(nums);
for (vector<vector<int>>::iterator iter = ret.begin(); iter != ret.end(); iter++)
{
vector<int> ve = *iter;
for (vector<int>::iterator iter2 = ve.begin(); iter2 != ve.end(); iter2++)
{
cout << *iter2;
}
cout << endl;
}
return 0;
}
5. 全排列2
题目
答案
class Solution {
public:
set<vector<int>> res;
vector<vector<int>> ret;
vector<int> path;
vector<vector<int>> permuteUnique(vector<int>& nums) {
//这里重复数字的这种情况是特殊情况,要重点考虑
if(nums.size()==1)
{
path.push_back(nums[0]);
ret.push_back(path);
return ret;
}
vector<int> used;
backtraking(nums,used);
set<vector<int>>::iterator iter;
for(iter=res.begin();iter!=res.end();iter++)
{
ret.push_back(*iter);
}
return ret;
}
void backtraking(vector<int> nums,vector<int> used)
{
if(path.size()==nums.size()&&res.count(path)<1)
{
res.insert(path);
return;
}
for(int i = 0;i<nums.size();i++)
{
if(used[i])
continue;
path.push_back(nums[i]);
used[i] = true;
backtraking(nums,path);
used[i] = false;
path.pop_back();
}
}
};
`方法一:
答案
class Solution {
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
sort(nums.begin(),nums.end());
int len=nums.size();
vector<bool> visited(len,false);
backtrack(nums,visited,0);
return res;
}
vector<int> path;
vector<vector<int>> res;
void backtrack(vector<int>& nums,vector<bool>& visited, int deepth ) {
int len=nums.size();
if ( deepth == len) {
res.push_back(path);
return;
}
for (int i=0;i<len;i++) {
if (visited[i]) continue;
if (i>0 && nums[i]==nums[i-1] && visited[i-1]) continue;
visited[i] = true;
path.push_back(nums[i]);
backtrack(nums,visited,deepth+1);
path.pop_back();
visited[i] = false;
}
}
};
方法二:
答案
class Solution
{
public:
vector<vector<int>> results;
vector<vector<int>> permuteUnique(vector<int>& nums)
{
map<int, int> m;
for (int i : nums)
{
++m[i];
}
vector<int> path;
backtrack(path, m, nums.size());
return results;
}
void backtrack(vector<int>& path, map<int, int>& selection, int size)
{
if (path.size() == size)
{
results.push_back(path);
return;
}
for (auto iter = selection.begin(); iter != selection.end(); ++iter)
{
if (iter->second > 0)
{
path.push_back(iter->first);
--iter->second;
backtrack(path, selection, size);
++iter->second;
path.pop_back();
}
}
}
};
6. 电话号码的字母组合
题目
题解:
我觉得这题还是很经典的,只是那个电话号码和字母的那个地方有点绕而已,不然,其实还可以。
基本步骤如下:
- 首先,写出map字母和字符串的对应关系。注意,里面的元素要用大括号。
- 主函数开始:边界判断,为0就返回。
- 判断要用回溯法后,接下来决定backtracking的参数.首先,肯定a.题目的参数肯定要传进去。b.题目参数的这个字符串目前走到哪一个肯定要有个标志位index,c:我们的返回值是string的组合,所以,每次也要传入一个待组合的s。这样下来,就确定了参数要填什么。
- backtraking中将s加入res的那个步骤,基本其判断条件就是index到达参数string 的长度。
- 接下来,就是每个返回string的组合的产生了。for循环解决。
- for循环里面就是三部曲:做出选择(组合s),backtraking递归,注意传入的值是idx+1,不要去idx++,那样idx的值就被迫混乱了。
- 然后,就是撤销选择,基本就是这样。
答案
class Solution {
public:
//这里先给出一个map的映射关系
map<char, string> m = {
{'2',"abc"},{'3',"def"},
{'4',"ghi"},{'5',"jkl"},
{'6',"mno"},{'7',"pqrs"},
{'8',"tuv"},{'9',"wxyz"}
};
vector<string> ans;
void dfs(string s, string digits, int idx)
{
if (idx == digits.size()) {
ans.push_back(s);
return;
}
char digt = digits[idx];//这里标识23中的哪一个元素
for (int j = 0; j < m[digt].size(); j++) {
s += m[digt][j];//相当于这个地方就可以不断将每个a与其他的d做连接
dfs(s, digits,idx + 1);//error:这里idx +1就可以了,千万不要idx ++,会导致接下来的for循环产生错误
s.pop_back();//这里做退出,然后,继续连接
}
}
vector<string> letterCombinations(string digits) {
//time:8:55->9:20
//其实就是简单的组合问题
if(digits.size() == 0) return vector<string> ();
string s;
dfs(s, digits, 0);
return ans;
}
};
7. 括号生成
题目
题解:只要不限制时间,就可以用回溯算法,暴力解法。
答案
class Solution {
public:
vector<string> res;
vector<string> generateParenthesis(int n) {
//一定是N个左括号,N个右括号,重点就在于怎么排列这2N个括号为有
if(n<1)
return res;
string s;
dfs(res,n,s,0,0);
return res;
}
void dfs(vector<string> &res,int n,string s,int l,int r)
{
if(l>n||r>n||r>l)
return ;
if(l==n&&r==n)
{
res.push_back(s);
return ;
}
dfs(res,n,s+'(',l+1,r);//这种其实就是暴力的一种方式,不断的往前延伸
dfs(res,n,s+')',l,r+1);//不断的往前延伸,遇到合适的再加入回收数组之中。
return;
}
};
8. 组合总和
解题思路
总体来说,这道题目还是比较遵循回溯法的整体思路的。
首先,我们要确定什么时候,要将path,加入res。肯定是有个值去记录当前计算的和。 所以,传入sum。
接下来,就是弄一个for循环,进行水平遍历,因为,你是要遍历所有元素的。所以用for循环。
for循环里面就是经典的回溯操作了,先进行选择,以及操作。然后回溯。然后撤销刚才的选择。
注意,由于这道题目的特殊性,回溯的时候,是可以从原来的元素开始的。所以,不需要进行i+1操作。也就是是可以从本元素再重新往后面走的。
代码
答案
class Solution {
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
//回溯法:20:18
vector<vector<int>> res;
vector<int> path;
if(candidates.size()==0||target==0)
return res;
int sum = 0;
dfs(candidates,0,0,target,res,path);
return res;
}
void dfs(vector<int> candidates,int startIndex,int sum,int target,vector<vector<int>> &res,vector<int> &path)
{ if(sum>target)
return;
if(sum==target)
{
res.push_back(path);
return;
}
for(int i = startIndex;i<candidates.size();i++)
{
sum+=candidates[i];
path.push_back(candidates[i]);
dfs(candidates,i,sum,target,res,path);
sum-=candidates[i];
path.pop_back();
}
}
};