开篇
算法部分已经更新了动态规划和贪心算法,接下来这一部分将会是又一经典算法——回溯法。
仍然会有大量的习题等着我去琢磨去更新博客,想想虽然挺受折磨,但算法这东西你懂的,学会了的感觉更好。
回溯法介绍
1.问题的解空间
用回溯法求解问题时,应明确定义问题的解空间。问题的解空间至少应包含问题的一个(最优)解。例如,对于0-1背包问题这个经典的问题,我们已经用动态规划法解释过一次,其实用回溯法也可以很好的解决。对于有n种可选择的物品的0-1背包问题,其解空间由长度为n的0-1向量组成。该解空间包含对变量的所有可能的0-1赋值。当n=3时,其解空间如下。
{(0,0,0),(0,1,0),(0,0,1),(1,0,0),(0,1,1),(1,0,1),(1,1,0),(1,1,1)}
定义了问题的解空间后,还应将解空间很好地组织起来,使得能用回溯法方便地搜索整个解空间。通常将解空间组成树或图的形式。
通常组成为子集树或者排列树,我们后文会给出具体形式。
2.回溯法的基本思想
先明确几个概念:
扩展结点:一个正在搜索儿子的结点。
死结点:不能再向纵深扩展的结点。
活结点:与死结点相对,可以向纵深扩展的结点。
确定了解空间的组织结构后,回溯法从开始结点(根节点)出发,以深度优先方式搜索整个解空间。这个开始节点成为活结点。同时成为当前的扩展结点。在当前的扩展结点处,搜索向纵深方向移至一个新结点。这个新结点就成为新的活结点,并成为当前扩展结点。如果在当前的扩展结点处不能再向纵深方向移动,则当前扩展结点就成为死结点。此时,应往回移动,即回溯,至最近的一个活结点处,并使这个活结成为当前的扩展结点。回溯法以这种工作方法递归地在解空间中搜索,直至找到所要求的解或解空间中已无活结点为止。
以上方法就可以帮助我们将解空间变成一个树的结构,我们就是在解空间中不断向下找新的活结点,直到碰到死结点的时候,我们再回到上面的活结点处。
树的剪枝
剪枝:搜索过程中使用约束函数可避免无意义的搜索,那些不满足约束条件的子树。
约束函数 分为显约束和隐约束。
如果是最优化问题,还可以用限界函数,剪去那些不可能含有最有答案结点的子树。
解空间种类
解空间一般分为排列树子集树。
排列树:解n元组定长,树中从根到叶子结点路径为解空间中一个解,代表问题TSP旅行商问题。
子集树:解n元组不定长,这种解空间最具代表性的问题就是0-1背包问题
遍历子集树的时间复杂度为O(2^n)
因为如果树的深度为n,则叶子结点树为2^(n-1)
而遍历排列树的时间复杂度为O(n!)
回溯模板
递归回溯
void Backtrack(int t)
{
//t表示递归深度,即当前扩展结点在解空间树中的深度
if(t > n)
Output(x);
else
{ //f(n,t)为起始结点的标号,g(n,t)为结束结点的标号
for(int i = f(n,t);i<=g(n,t);i++)
{
x[t] = h(i);
//满足约束条件和限界条件
if(Constraint(t)&&Bound(t))
{
Backtrack(t+1)'
}
}
}
}
n用来控制递归深度,当t>n时,算法已搜索到叶节点。
迭代回溯
void IterativeBacktrack(void)
{
int t = 1;
while(t > 0)
{
if(f(n,t)<=g(n,t))
{
for(int i = f(n,t);i<=g(n,t);i++)
{
x[t] = h(i);
if(Constrainit(t)&&Bound(t))
{
if(Solution(t))
Output(x);
else
t++;
}
else
t--;
}
}
}
}
子集树回溯模板
void Backtrack(int t)
{
if(t>n)
Output(x);
else
{
for(int i = 0;i <= 1;i++)
{
x[t] = i;
if(Constraint(t)&&Bound(t))
Backtrack(t+1);
}
}
}
排列树回溯
void Backtrack(int t)
{
if(t > n)
Output(x);
else
{
for(int i = t;i <= n;i++)
{
Swap(x[t],x[i]);
if(Constraint(t)&&Bound(t))
Backtrack(t+1);
Swap(x[t],x[i]);
}
}
}
在调用Backtrack(1)执行回溯搜索前,先将变量数组x初始化为单位排列(1,2,…,n)。
这里要注意一下,我们只是在一维数组中去模拟多种树形遍历的可能性,并没有在树中遍历。