原理
初入门
有时会遇到这样一类题目,它的问题可以分解,但是又不能得出明确的动态规划或是递归解法,此时可以考虑用回溯法解决此类问题。回溯法的优点 在于其程序结构明确,可读性强,易于理解,而且通过对问题的分析可以大大提高运行效率。但是,对于可以得出明显的递推公式迭代求解的问题,还是不要用回溯法,因为它花费的时间比较长。
回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。可以认为回溯算法一个”通用解题法“,这是由他试探性的行为决定的,就好比求一个最优解,我可能没有很好的概念知道怎么做会更快的求出这个最优解,但是我可以尝试所有的方法,先试探性的尝试每一个组合,看看到底通不通,如果不通,则折回去,由最近的一个节点继续向前尝试其他的组合,如此反复。这样所有解都出来了,在做一下比较,能求不出最优解吗?
基本定义和概念
回溯法中,首先需要明确下面三个概念:
约束函数:约束函数是根据题意定出的。通过描述合法解的一般特征用于去除不合法的解,从而避免继续搜索出这个不合法解的剩余部分。因此,约束函数是对于任何状态空间树上的节点都有效、等价的。
状态空间树:刚刚已经提到,状态空间树是一个对所有解的图形描述。树上的每个子节点的解都只有一个部分与父节点不同。
扩展节点、活结点、死结点:所谓扩展节点,就是当前正在求出它的子节点的节点,在DFS中,只允许有一个扩展节点。活结点就是通过与约束函数的对照,节点本身和其父节点均满足约束函数要求的节点;死结点反之。由此很容易知道死结点是不必求出其子节点的(没有意义)。
为什么用DFS
深度优先搜索(DFS)和广度优先搜索(FIFO)在分支界限法中,一般用的是FIFO或最小耗费搜索;其思想是一次性将一个节点的所有子节点求出并将其放入一个待求子节点的队列。通过遍历这个队列(队列在 遍历过程中不断增长)完成搜索。而DFS的作法则是将每一条合法路径求出后再转而向上求第二条合法路径。而在回溯法中,一般都用DFS。为什么呢?这是因 为可以通过约束函数杀死一些节点从而节省时间,由于DFS是将路径逐一求出的,通过在求路径的过程中杀死节点即可省去求所有子节点所花费的时间。FIFO 理论上也是可以做到这样的,但是通过对比不难发现,DFS在以这种方法解决问题时思路要清晰非常多。
回溯法可以被认为是一个有过剪枝的DFS过程,利用回溯法解题的具体步骤。
首先,要通过读题完成下面三个步骤:
(1) 描述解的形式,定义一个解空间,它包含问题的所有解。
(2) 构造状态空间树。
(3) 构造约束函数(用于杀死节点)。
然后就要通过DFS思想完成回溯,完整过程如下:
(1) 设置初始化的方案(给变量赋初值,读入已知数据等)。
(2) 变换方式去试探,若全部试完则转(7)。
(3) 判断此法是否成功(通过约束函数),不成功则转(2)。
(4) 试探成功则前进一步再试探。
(5) 正确方案还未找到则转(2)。
(6) 已找到一种方案则记录并打印。
(7) 退回一步(回溯),若未退到头则转(2)。
(8) 已退到头则结束或打印无解。
回溯方法的步骤如下:
- 定义一个解空间,它包含问题的解。
- 用适于搜索的方式组织该空间。
- 用深度优先法搜索该空间,利用限界函数避免移动到不可能产生解的子空间。
举栗子
Given n pairs of parentheses, write a function to generate all combinations of well-formed parentheses. For example, given n = 3, a solution set is: “((()))”, “(()())”, “(())()”, “()(())”, “()()()”
这里要先提一下画括号的一个性质1,也就是要先(,才能),也就是说在求解的一步步的路上,左括号的数量一定是大于等于右括号的。根据这个性质以及n=2,我们可以排除很多不可能的解空间。下图的每一行表示第几个字符是左括号还是右括号,X掉的表示不符合要求的解。
好,现在先来看第一个字符,这里可以填(,也可以填),所以解空间可以分成第一行1和2两个节点,但是由于我们性质1,所以2节点是不满足要求的,所以我们就不在2节点的解空间进行。然后我们看第二个字符,这时候3和4节点都满足条件,所以我们就可以分成两个更小的解空间,以此类推,每个子节点都是一个解。这里n=2时的答案就是(())和()()。
如果使用穷举法,那么就需要判断2^2n个解是否正确,如果使用回溯的方法,那么会排除掉大部分的非解空间。
这个例子说明的是回溯这种方法通过构图所得的解,但其实编程的时候不是这样进行的,而是先优先选择左括号,直到得到第一个解,然后往回退到前一个含左括号(的节点,然后改成)后继续按我们规则求解,然后再往回退到更前一个左括号(,再求解。也就是说编程的时候不是同时进行解空间的扩展,而是一个个求解的(如1和2节点不是同时期的,而是1节点的解空间计算完之后退到2节点的)。
编程思路
能用回溯来解决的问题一般有以下几个特点:
1、解是多维特征,并且每一维的取值范围都是一样的且有限(括号题目中的取值就是左括号或者右括号,并且每个节点都是这样的取值,这点是最重要的)
2、一般来说,题目要求可能性解的数量,这样子比较可能采用回溯
对题目进行分析概括之后,符合回溯的就可以进行编程了:
1、包含一个保存解的全局变量(用于保存一个解)
2、状态控制的全局变量(用于对当前步骤的进度进行分析,从而排除一些不可能解)
3、编程的过程采用一步步进行,也就是一维一维的进度知道最后每一维都计算完得到一个解;
4、3得到一个符合解之后,往回退,直到某个节点可以选择其他值,重复步骤3。
实践
生成符合规范的括号
class Solution {
public:
vector<string> generateParenthesis(int n) {
vector<string> result;
num = n;
str.clear();
for(int i = 0; i < n*2; ++i)
{
str.push_back('0');
}
l = 0;
r = 0;
bt(result, 0);
return result;
}
//全局状态控制变量,l和r分别表示已用左右括号的数量;
int num;
int l;
int r;
//解(全局变量);
string str;
//迭代回溯函数;
//第一个参数其实是为了保存每一个解,如果只需要打印出来那就不需要这个参数了;
//k表示当前计算的解的前进进度;
void bt(vector<string> &result, int k)
{
//迭代终点,也就是得到了一个新解;
if(k == num*2)
{
result.push_back(str);
}
//解的计算未到终点,继续进度;
else
{
//先进行尝试性填充左括号来求解;
if(l < num)
{
str[k] = '(';
l++;
bt(result, k+1);
//////!!!这一步是必须的,恢复到上一节点,选择其他,这样子才能达到回溯的目的!;
--l;
}
//填充右括号;
if(r < num && l > r)
{
str[k] = ')';
r++;
bt(result, k+1);
--r;
}//到这里有隐含的排除信息,也就是不符合if内要求的解空间都被排除掉了;
}
}
};
combination sum
首先使用深度优先的算法进行计算:
class Solution {
public:
vector<vector<int>>res;
vector<int>ans;
int nums_len;
vector<vector<int> > combinationSum(vector<int> &candidates, int target) {
if(candidates.empty()) return res;
nums_len = candidates.size();
dfs(0, 0, target, candidates);
return res;
}
void dfs(int start, int sum, int target,vector<int> candidates){
if(sum == target) {
res.push_back(ans); return; }
else if(sum > target)
return;
else{
for(int i = start; i < nums_len; i++){
ans.push_back(candidates[i]);
dfs(i, sum + candidates[i], target, candidates);
ans.pop_back();
}
}
}
};
在上面的基础上使用回溯:
class Solution {
public:
void dfs(int target, vector<int>& candidates, int start, vector<int> temp, vector<vector<int>>& res) {
if (target == 0) {
res.push_back(temp);
return;
}
for (int i = start; i < candidates.size(); i++) {
if (i != 0 && candidates[i] == candidates[i - 1]) continue;
int n = target - candidates[i];
if (n >= 0) {
temp.push_back(candidates[i]);
dfs(n, candidates, i, temp, res);
temp.pop_back();
}
}
return;
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
unordered_map<int, bool> map;
vector<vector<int>> res;
vector<int> temp;
dfs(target, candidates, 0, temp, res);
return res;
}
};
class Solution {
public:
std::vector<std::vector<int> > combinationSum(std::vector<int> &candidates, int target) {
std::sort(candidates.begin(), candidates.end());
std::vector<std::vector<int> > res;
std::vector<int> combination;
combinationSum(candidates, target, res, combination, 0);
return res;
}
private:
void combinationSum(std::vector<int> &candidates, int target, std::vector<std::vector<int> > &res, std::vector<int> &combination, int begin) {
if (!target) {
res.push_back(combination);
return;
}
for (int i = begin; i != candidates.size() && target >= candidates[i]; ++i) {
combination.push_back(candidates[i]);
combinationSum(candidates, target - candidates[i], res, combination, i);
combination.pop_back();
}
}
};
https://discuss.leetcode.com/topic/14654/accepted-16ms-c-solution-use-backtracking-easy-understand
n queen
问题描述:在n*n格的棋盘上放置彼此不受攻击的n个皇后。按照国际象棋的规矩,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。n后问题等价于在n*n格的棋盘上方置n个皇后,任何2个皇后不放在同一行或同一列或同一斜线上。我们需要求的是可放置的总数。
class Solution {
public:
vector<vector<string> > solveNQueens(int n) {
vector<vector<string>> xxx;
vector<vector<int> > res;
vector<int> com;
int len=n;
c3(res,com,n,len,0);
hua(xxx,res,len);
return xxx;
}
private:
void c3(vector<vector<int> > &res,vector<int> &com,int n,int len,int begin)
{
if(len == 0 && judge(com))
{
res.push_back(com);
return ;
}
for(int i=0;i<n&&len>0;++i)
{
com.push_back(i);
if(judge(com)) //剪枝,没有的话会超时
c3(res,com,n,len-1,i);
com.pop_back();
}
}
bool judge(vector<int> &com)
{
for(int i=0;i<com.size();i++)
{
for(int j=i+1;j<com.size();j++)
{
if(com[i] == com[j] || abs(i-j) == abs(com[i]-com[j]))
return false;
}
}
return true;
}
void hua(vector<vector<string>> &str,vector<vector<int>> &com,int len)
{
for(auto c:com)
{
vector<string> ge;
for(auto d:c)
{
string tem="";
for(int i=0;i<len;++i)
{
if(i==d)
tem +="Q";
else
tem +=".";
}
ge.push_back(tem);
}
str.push_back(ge);
}
}
};
总结
bool finished = FALSE; /* 是否获得全部解? */
backtrack(int a[], int k, data input)
{
int c[MAXCANDIDATES]; /*这次搜索的候选 */
int ncandidates; /* 候选数目 */
int i; /* counter */
if (is_a_solution(a,k,input))
process_solution(a,k,input);
else {
k = k+1;
construct_candidates(a,k,input,c,&ncandidates);
for (i=0; i<ncandidates; i++) {
a[k] = c[i];
make_move(a,k,input);
backtrack(a,k,input);
unmake_move(a,k,input);
if (finished) return; /* 如果符合终止条件就提前退出 */
}
}
}
a[]表示当前获得的部分解;k表示搜索深度;input表示用于传递的更多的参数;is_a_solution(a,k,input)判断当前的部分解向量a[1…k]是否是一个符合条件的解;construct_candidates(a,k,input,c,ncandidates)根据目前状态,构造这一步可能的选择,存入c[]数组,其长度存入ncandidates;process_solution(a,k,input)对于符合条件的解进行处理,通常是输出、计数等;make_move(a,k,input)和unmake_move(a,k,input)前者将采取的选择更新到原始数据结构上,后者把这一行为撤销。
参考链接:
全面解析:
http://www.cnblogs.com/wuyuegb2312/p/3273337.html
回溯详解:
blog.youkuaiyun.com/tinyway/article/details/24797271?locationNum=2
http://www.jb51.net/article/46892.htm
n queen:
http://www.cnblogs.com/yanqi110/p/4931158.html
http://www.jb51.net/article/46892.htm