背景介绍:回溯法是一种穷举类型的算法,与其说它是一种算法,倒不如说它是一种试法。回溯法并没有什么高深的算法思想,虽然名字起的很高规格,但其实它的算法性连二分查找都比不上。这里说的算法性其实就是指技巧性,对问题特性了解越深入,越能创造出很巧妙的算法,在时间复杂度的级别上提高算法效率。这体现了算法效率与适用性之间的矛盾,二分查找效率很高,但适用性比较低,类似的还有著名的KMP算法。而穷举法效率最低,但几乎适用于所有问题。
好像有点儿扯远了,我们还是接着说回溯法。回溯法是一种试探性算法,从这一点上看,它很像穷举法。但它终究不是穷举法,回溯法是有组织的进行穷举,在试探过程中不断通过题设要求减少搜索空间,而这种减少不是一个一个解的减少,而是对搜索空间进行大规模剪枝,从而使得实际搜索空间远远小于问题的解空间,所以回溯法的实际运行效率还是比较高的。
应用场景:回溯法的应用背景说大很大,说小很小。算法大都在“不得不”的情况 下才会使用,如果有别的算法,那它很有可能比回溯法高效,别忘了,回溯法是基于穷举的。回溯法适用于解排列组合类问题,也就是说目标解是从一个候选空间中选择出来的。从数量级上考虑,设候选空间的大小为n,如果选择是可重复的,那生成的搜索树为完全n叉树,搜索空间为n^n(如0-1背包问题,生成的解空间为高度为n完全二叉树,其中n为物体个数)。如果选择不能重复,那生成的解空间为n!(如TSP问题生成的解空间为n!,其中n为城市个数)。也就是说,当我们通过分析发现问题的解空间为n^n或者n!时,那很可能要用到我们的回溯法了。
搜索策略:要用回溯法解决问题,那首先要确定问题的状态空间树。这个并不是很难,就看每一步选择有多少个可选值就可以了,第一步有8个可选值,那树第一层就有8个节点,第二步有5个可选值,那第一层每个节点都有5个分支,则第二层有8×5=40个节点,以此类推……到第n层一共有m1×m2×……×mn个节点,其中mi为第i步的可选值的个数。
确定了状态空间树,那下一步就是搜索了。这时候就体现出回溯法的优势了,前面不是说了嘛,回溯法的特点就是有规律、有组织的进行搜索,那下面就来看一下回溯法是如何进行搜索的:(PS:这部分内容大都取自清华大学郑宗汉的《算法设计与分析》一书,当然也有自己的一些理解)在开始搜索之前,我们先来说一下我们要做的事情,我们要得到一个解向量solution,每个分量对应每一步选择的结果,显然这个解向量的长度应该为n(我们采用C语言的标准,下标范围为0到n-1)。好了,现在我们有了一个状态空间树(逻辑上的,并不用实现)和一个解向量(物理上的,要用来装数据的)。现在可以开始搜索了,先设定一个下标r,这个r就是解向量的下标,也用于标识状态树的第r行。先做第一步,令r=0,选solution[0],也就是从树的第0行选择一个值放入solution[0],显然刚开始我们应该选择第一个,即前面提到的8个里面的第一个。然后看这个半成品解向量是否是可行的,也就是说看看刚才选择的那个值是否满足要求,加入那个值不满足要求,那应该选择第二个,以此类推直到选择一个可行的值,放入solution[0]。然后r++进行第二步,选择solution[1],同样的,我们应该从树的第二行中选择第一个看构成的解是否可行(此时解向量中包含两个元素),这样的步骤一直进行下去,直到出现这样的情况
(1)r=n-1了,也就是说我们得到了问题的一个可行解,这时候就要看题设要求了,如果只要求找到一个可行解,那此时算法就可以停止了。
(2)某一层的候选值选完了,我们知道,没一层的候选值都有一定个数,如上面提到的例子中第二层只有5个候选值,如果这五个候选值都试探完了还是没有可行解那该怎么办呢?这里体现的思想就是我们回溯法名字的由来,回溯。也就是令r--退回去,从新选择上面的解。比如上面的例子先选择8个中的第一个作为解的一部分,然后发现后面的5个和前面这个都不能组成可行解,那这就说明前面那个选择是不可行的,和后面是不搭配的。所以应该返回去选择8个中的第二个,然后再对5个进行选择,看哪个与这个第二个想匹配。
(3)最后一种情况,因为我们这个过程中有回溯过程,即r--的过程,那可能最后r小于0了,这说明整个树都搜索完了,也就是问题没有可行解。
代码实现:回溯法一般有两种代码实现方案,递归方法和非递归方法。相比之下,递归设计方法比较简单,用前面提到的r作为递归变量即可,如果满足搜索条件,则递归调用r+1对应函数,如果不满足,则递归调用r-1对应的函数。基础步为当r<0或r=n-1分别对应无解和得到可行解,这个就不多说了。非递归方法,也就是循环方法设计细节比较多,但只要掌握了其特点,对不同问题的适用性很强(即代码只通过很少的修改就可以应用到不同问题),加之其效率高于递归算法(循环的优势),所以这里我们着重讲一下回溯的非递归代码实现。
先看一下最终代码吧:
<pre name="code" class="cpp">void backtrack_item()
{
initial(x);//初始化解向量
i = 0; k[i] = 1; flag = false;//初始化
while(i>=0){//当行数大于0时做
while(k[i]<m[i]){
x[i] = get(i,k[i]);//得到选择元素
if(constrain(x) && bound(x)){//是可行解
if(solution(x)){//是最终解
flag = true;break;
}
else{//不是最终解
i = i+1;k[i] = 0;
}
}
else{//不是可行解
k[i]=k[i]+1;
}
}
if(flag) break;
k[i]=1;i = i-1;k[i]=k[i]+1; //回溯
}
if(!flag){//无解
initial(x);//重置解向量
}
}
代码逻辑性比较强,我们慢慢说,注意和前面说的搜索过程相结合。
首先初始化解向量,然后用i表示当前行数,这里多了一个k[i],表示选第i行的第几个值,这是为了适应一些候选元素不是数值或者数值非连续变化的情况,比如候选解可能是一些字符串,这个时候x[i],就得用来装字符串了,而这个位置就用k[i]来装。而flag正如其名字一样,是一个标志位,用来退出两个循环。
然后循环开始,也就是搜索开始,这个最大的循环是用于控制搜索树的深度的,当i小于0时,说明搜索已经完成且没有找到可行解,所以退出循环后应重置解向量。当i大于等于0时,进入循环体。
进入循环体后,紧接着又是一个循环体,这个循环体用用控制树的每一行的搜索,其中m[i]为第i-1行中每棵树的分枝数。如果k[i]<m[i]不满足,说明这一行已经搜索完且没有找到可行解,应该进行回溯了,这里注意两个工作一是恢复现场,即重置当前已经搜索到尽头的行,二是回溯前的准备即将上一行的元素往后移一位,否则就成死循环了。如果k[i]<m[i],说明第i行的元素还没有试完应进入循环体继续试探。
循环体内,get函数用来将这个位置信息转化成真正的信息。然后判断这个解是否是部分可行解。这里的判断条件有两个,一是看是否满足约束条件,而是看是否满足当前的上下界条件,上下界条件是分支限界里用的,这里不多说。如果不是可行解,那应该搜索当前层下一个元素,即k[i]=k[i]+1。如果是部分可行解,则继续判断是否是最终可行解,如果是,那算法停机。如果不是,则保存当前的选择,继续搜索下一行。
应用举例:纸上谈兵已经做完了,该来点儿实践了。这里举两个小例子以示回溯法的基本应用,更高深的留待大家自己去研究。
第一个例子是八皇后问题,是回溯法应用中很典型的一个。还有一个是图的着色问题,也是很明显的回溯问题。这两个问题的描述就不多说了,大家百度一下会有很多,这里主要是看一看代码的惊人一致性。
直接上代码吧:
八皇后问题:
<pre name="code" class="cpp">bool placeOk(int *p,const int n)
{
for(int i=0;i<n;i++){
if(p[i]==p[n] || abs(i-n)==abs(p[i]-p[n]))
return false;
}
return true;
}
void queens_n(const int n)
{
int *x = new int[n];
for(int j=0;j<n;j++){//初始化解向量
x[j] = 1;
}
int i = 0; bool flag = false;//初始化
while(i>=0){
while(x[i]<=n){
if(placeOk(x,i) ){//得到可行解
if(i==n-1) {flag=true; break;}//得到最终可行解,退出
else{//得到部分可行解,搜索下一行
i++; x[i]=1;
}
}
else{//当前解不可行
x[i]++;
}
}
if(flag) break;
x[i]=1;i--;x[i]++;//回溯
}
//省去了重新初始化,直接释放内存
if(!flag) cout<<"没有可行解!"<<endl;
else{
for(int i=0;i<n;i++){
cout<<x[i]<<" ";
}cout<<endl;
}
delete[] x;
}
经过两段代码的比较可发现,二者的相似程度是很高的,尤其是主循环部分,只是更改了少许代码。下面再看着色问题的代码。
<pre name="code" class="cpp">bool placeOk(int *x,int k,int **c,int n)
{
//自己可实现
}
bool m_colouring(int n,int m,int x[],int **c)
{
//输入:n为顶点个数,m为颜色种类,x为解向量,c为邻接矩阵
for(int i=0;i<n;i++) x[i]=0;//初始化解向量
int i = 0; bool flag = false;//初始化
while(i>=0){
while(x[i]<=m){
if(placeOk(x,i,c,n)){//得到可行解
if(i==n-1) {flag=true; break;}//得到最终可行解,退出
else{//得到部分可行解,搜索下一行
i++; x[i]=0;
}
}
else{//当前解不可行
x[i]++;
}
}
if(flag) break;
x[i]=0;i--;x[i]++;//回溯
}
if(flag)return true;
else return false;
}
里面的部分代码没有实现,这一部分也恰好是两个问题不同的部分,即判断当前的解是否是部分可行解。 由于篇幅问题,就不深究这两个例子了,关键希望大家能从中体会出回溯法的模式,具体实现还是要大家仔细琢磨的。
最后总结:通过比较上面三段代码可发现,这几乎就是复制粘贴出来的。这说明回溯法是一种通用性很高的算法模型,这是因为我们回溯法面向的是一棵空间搜索树,这课树已经完成了从实际问题到数学表达的建模。而每棵树的特性都是相当一致的,所以我们的算法也具有高度的一致性。从这个角度看,一旦掌握了回溯法,那以后用起来是比较简单的,所以回溯法是一个很值得学习的算法。