回溯算法(DFS)通用解题框架总结+实例分析

很少写这么长的总结,算法对上学期一个简单交代,就是时间花的漫长的,用了一下午。惩罚自己以后多写一点,不写不是中国人~嘻嘻?

什么是回溯算法?
回溯算法有“通用的解题法”之称。用它可以系统地搜索一个问题地所有解或任一解。回溯法是一个既带有系统性又带有跳跃性的搜索算法。它在问题的解空间树中,按深度优先策略,从根节点出发搜索解空间树。算法搜索至解空间树的任一结点时,先判断该结点是否包含问题的解。如果肯定不包含,则跳过对以该结点为根的子树的搜索,逐层向其祖先结点回溯。否则,进入该子树,继续按深度优先策略搜索。回溯法求问题的所有解时,要回溯到根,且根结点的所有子树都已被搜索遍才结束。回溯法求问题的一个解时,只要搜索到问题的一个解就可结束。这种以深度优先方式系统搜索问题解的算法称为回溯法,它适用于解组合数较大的问题。
回溯算法能解决哪些问题?
计数问题、优化问题、判定问题,通常能用搜索解决的问题都能用回溯算法。

回溯法的算法框架

●解空间树

●回溯基本思想

●约束与限界

●设计步骤

●代码框架

(1)解空间树

       用回溯法解问题时,应明确定义问题的解空间。问题的解空间至少应包含问题的一个(最优)解。子集数排列树是用回溯法解题时常遇到的两类典型的解空间树。

当所给的问题是从n个元素的集合S中找出满足某种性质的子集时,相应的解空间树称为子集树。

图为0-1背包问题的子集树,最后的解空间为{(1,1,1),(1,1,0),(1,0,1),(1,0,0),(0,1,1),(0,1,0),(0,0,1),(0,0,0)}

 

当所给的问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树。

图为旅行商问题的排列树,最后的解空间为{(c1,c2,c3,c4),(c1,c2,c4,c3),(c1,c3,c2,c4),(c1,c3,c4,c2),(c1,c4,c2,c3),(c1,c4,c3,c2)}

当然问题的解空间不止这两种,比如解决整数划分问题,单源最短路径问题,可以用路径树去定义它的解空间树。常见的解空间树如我上面介绍的两种,子集树和排列树。

(2)回溯基本思想

       确定了解空间的组织结构后,回溯法从开始结点(根结点)出发,以深度优先方式搜索整个解空间。这个开始结点成为活结点,同时也成为当前的扩展结点。在当前的扩展结点处,搜索向纵深方向移至个新结点。这个新结点就成为新的活结点,并成为当前扩展结点。如果在当前的扩展结点处不能再向纵深方向移动,则当前扩展结点就成为死结点。此时,应往回移动(回溯)至最近的一个活结点处,并使这个活结点成为当前的扩展结点。回溯法以这种工作方式递归地在解空间中搜索,直至找到所要求的解或解空间中已无活结点时为止。

       例如,对于n= 3时的0- 1背包问题,考虑下面的具体实例:w= [16,15,15],p= [45,25,25],c = 30。从图的根结点开始搜索其解空间.开始时,根结点是唯一的活结点,也是当前的扩展结点。在这个扩展结点处,可以沿纵深方向移至结点B或结点C。假设选择先移至结点B。此时,结点A和结点B是活结点,结点B成为当前扩展结点。由于选取了W1,故在结点B处剩余背包容量是r=14,获取的价值为45。从结点B处,可以移至结点D或E。由于移至结点D至少需要w2 = 15的背包容量,而现在仅有的背包容量是r=14,故移至结点D导致不可行解。搜索至结点E不需要背包容量,因而是可行的。从而选择移至结点E。此时,E成为新的扩展结点,结点A、B和E是活结点。在结点E处,r=14,获取的价值为45。从结点E处,可以向纵深移至结点J或K。移至结点J导致不可行解,而移向结点K是可行的,于是移向结点K,它成为新的扩展结点。由于结点K是叶结点,故得到个可行解。这个解相应的价值为45.x;的取值由根结点到叶结点K的路径唯一一 确定,即x= (1 ,0,0)。由于在结点K处已不能再向纵深扩展,所以结点K成为死结点。再返回到结点E处。此时在结点E处也没有可扩展的结点,它也成为死结点。

       接下来又返回到结点B处。结点B同样也成为死结点,从而结点A再次成为当前扩展结点。结点A还可继续扩展,从而到达结点C。此时,r=30,获取的价值为0。从结点C可移向结点F或G。假设移至结点F,它成为新的扩展结点。结点A,C和F是活结点。在结点F处,r=15,获取的价值为25。从结点F向纵深移至结点L处,此时,r = 0,获取的价值为50。由于L是叶结点,而且是迄今为止找到的获取价值最高的可行解,因此记录这个可行解。结点L不可扩展,我们又返回到结点F处。按此方式继续搜索,可搜索遍整个解空间。搜索结束后找到的最好解是相应0-1背包问题的最优解。

      下面再看一个用回溯法解旅行售货员问题的例子。

      旅行售货员问题的提法是:某售货员要到若千城市去推销商品,已知各城市之间的路程(或旅费)。他要选定一条从驻地出发 ,经过每个城市一遍,最后回到驻地的路线,使总的路程(或总旅费)最小。

      问题刚提出时 ,不少人都认为这个问题很简单。后来,人们在实践中才逐步认识到,这个问题只是叙述简单,易于为人们所理解,而其计算复杂性却是问题的输人规模的指数函数,属于相当难解的问题之一。事实 上,它是NP完全问题。这个问题可以用图论的语言形式描述。

      设G=(V,E)是一个带权图。图中各边的费用(权)为正数。图中的一条周游路线是包括V中的每个顶点在内的一条回路。周游路线的费用是这条路线上所有边的费用之和。旅行售货员问题要在图G中找出费用最小的周游路线。

      图是一个4顶点无向带权图,顶点序列c1,c2,c4,c3,c1;       c1,c3,c2,c4,c1       和c1,c4,c3,c2,c1是该图中3条不同的周游路线。

      旅行售货员问题的解空间可以组织成一棵树,从树的根结点到任一叶结点的路径定义了图G的一条周游路线。右图是当n= 4时解空间树的示例。其中从根结点A到叶结点L的路径上边的标号组成一条周游路线c1,c2,c3,c4,c1.而从根结点A到叶结点O的路径则表示周游路线c1,c3,c4,c2,c1.图G的每条周游路线都恰好对应于解空间树中一条从根结点到叶结点的路径。因此,解空间树中叶结点个数为(n-1)!。

     对于图中的图G,用回溯法找最小费用周游路线时,从解空间树的根结点A出发,搜索至B,C,F,L.在叶结点L处记录找到的周游路线c1,c2,c3,c4,c1,该周游路线的费用为59。从叶结点L返回至最近活结点F处。由于F处已没有可扩展结点,算法又返回到结点C处。结点C成为新扩展结点,由新扩展结点,算法再移至结点G后又移至结点M,得到周游路线c1,c2,c4,c3,c1,其费用为66。这个费用不比已有周游路线c1,c2,c3,c4,c1的费用更小。因此,舍弃该结点。算法又依次返回至结点G,C,B。从结点B,算法继续搜索至结点D, H,N。在叶结点N处,相应的周游路线c1,c3,c2,c4,c1的费用为25。它是当前找到的最好的一条周游路线。从结点N算法返回至结点H,D,然后再从结点D开始继续向纵深搜索至结点O。依此方式算法继续搜索遍整个解空间,最终得到最小费用周游路线c1,c3,c2,c4,c1。(下面有详细代码)

(3)约束与限界

       回溯法搜索解空间树时,通常采用两种策略(剪枝函数)避免无效搜索,提高回溯法的搜索效率。

       其一是用约束函数(Constraint(t))在扩展结点处剪去不满足约束的子树;

       其二是用限界函数(Bound(t))剪去得不到最优解的子树;

(4)设计步骤

      1.针对所给问题,定义问题的解空间;

      2.以深度优先方式搜索解空间;

      3.在搜索过程中用剪枝函数避免无效搜索。

(5)代码框架

 

1t: 递归深度,范围:1~n

2f(n,t), g(n,t): 当前扩展结点处未搜索过的子树的起止编号。

3h(i): 当前扩展结点处x[t]的第i个可选值。

4main函数调用一次Backtrack(1)即完成回溯。

 搜索子集树的回溯算法

接口:前t-1层已决策,求第t层遍历各种决策的解,层号:1~n

搜索排列树的回溯算法

接口:前t-1个位置已排好,求第t个位置遍历各种决策的解,位置号:1~n

实例分析

一:DFS子集树类型 ——装载问题

1.问题描述:

一批集装箱共n个要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为Wi且W1+W2+……+Wn<=c1+c2;试确定一个合理的装载方案使这n个集装箱装上这两艘轮船。

2.问题分析:

容易去证明:如果一个装载问题有解,则采用下面的策略可以得到最优装载方案:

(1)首先将第一艘轮船尽可能装满;

(2)然后将剩余的集装箱装在第二艘轮船上。

那么在这个过程中,我们需要找到尽可能把第一个轮船装满的解。

首先分析问题得解空间结构,应该是一个子集树。

然后列出D型变量,T型变量,C型变量

1、(D型)问题描述型数据(descriptive),通常为问题规模和问题输入数据,它们在整个算法过程中一直不变,可设为const型。

2、(T型)目标型数据(target),保存最优值、最优解。

3、(C型)当前路径相关数据(current),通常保存当前路径和局部解的信息,若递归前用增量语句更新,递归后特别要注意恢复原值。

 

//D型 
int n;     // 集装箱数量
int w[100];         // 集装箱重量
int c ;        // 第一艘船能承受载重
//T型
int bestw;    // 最优载重量
//C型
int cw;        // 当前载重量

1.1     (只有约束剪枝) 先来个简单的,我们只求最优的装载量就好了(bestw);

void Backtrack(int t)
{
    if(t>n){
        bestw = cw>bestw? cw : bestw;
        return ;
    }
    if(cw+w[t]<=c){
        cw+=w[t];
        Backtrack(t+1);
        cw-=w[t]; 
    }
    Backtrack(t+1);
}

这个n相当于子集树的层数,t代表目前所在的层上,如果t>n,算法搜索至叶节点,更新bestw;cw+w[t]<=c就是约束剪枝,cw+w[t]相当于问能不能进入左子树。不行的话进入右子树,右子树不需要什么判断。

1.2     (约束剪枝+限界剪枝)我们也只求bestw;

对于前面的算法,可以引入一个上界函数,用于剪去不含最优解的子树,从而改进算法平均效率,怎么定义呢?我们定义个r,r为剩余集装箱重量,当cw+r<=bestw,可将右子树剪去。为什么呢?就当你后面(t层以后)所有的集装箱都装上船,你当前的载重量就加上后面集装箱重量都小于bestw,那只能说明你不是最优的。

 

int n;
int w[100];
int c;
int r;

int bestw;

int cw;

void Backtrack(int t)
{
    if(t>n){
        bestw = cw > bestw? cw : bestw;
        return ;
    }    
    r-=w[t];
    if(cw+w[t]<=c){
        cw+=w[t];
        Backtrack(t+1);
        cw-=w[t];
    }
    if(cw+r>bestw){
        Backtrack(t+1);
    }
    r+=w[t];
}


1.3    (约束剪枝+限界剪枝) 构造最优解

构造最优解呢,就再定义2个数组变量,(x)一个记录从根至当前结点的路径,(bestx)另一个记录当前最优解,到叶节点时,就修正bestx的值


int n;
int w[100];
int c;
int r;

int bestw;
int bestx[100];

int cw;
int x[100];

void Backtrack(int t)
{
    if(t>n){
        if(cw > bestw){
            for(int i=1; i<=n; i++)
                bestx[i] = x[i];
            bestw = cw;
        }
    }
    r-=w[t];
    if(cw+w[t]<=c){
        x[t] = 1; 
        cw+=w[t];
        Backtrack(t+1);
        cw-=w[t]; 
    }
    if(cw+r>bestw){
        x[t] = 0;
        Backtrack(t+1);
    }
    r+=w[t];
}

 

贴一个完整代码:

#include <iostream>
#include <time.h>
using namespace std;

int n;
int w[100];
int c;
int r;

int bestw;
int bestx[100];

int cw;
int x[100];
void Backtrack(int t); 
int main()
{
    clock_t start , end;
    cin >> n;
    cin >> c;
    for(int i=1; i<=n; i++){ 
        cin >> w[i];
        r+=w[i];
    }
    start = clock();
    Backtrack(1);
    end = clock();
    cout << (double)(end-start)/1000 << endl;
    cout << bestw << endl;
    for(int i=1; i<=n; i++)
        cout << bestx[i] << ' ';
    cout << endl;
    return 0;

void Backtrack(int t)
{
    if(t>n){
        if(cw > bestw){
            for(int i=1; i<=n; i++)
                bestx[i] = x[i];
            bestw = cw;
        }
    }
    r-=w[t];
    if(cw+w[t]<=c){
        x[t] = 1; 
        cw+=w[t];
        Backtrack(t+1);
        cw-=w[t]; 
    }
    if(cw+r>bestw){
        x[t] = 0;
        Backtrack(t+1);
    }
    r+=w[t];
}

样例输入 1: 5 50

                      20 15 31 11 23 
样例输出 2: 49

                      0 1 0 1 1 
样例输入 2: 30 800

                      39 50 58 74 58 32 65 14 45 26  41 97 67 80 66 74 48 19  9 10  20 77 64 60 30 49 36 45 41 39 
样例输出 2: 800

                      0 0 0 0 0  0 0 0 0 0  0 1 0 1 1  1 1 0 0 1  1 1 1 1 1  1 0 1 1 1(答案不唯一) 

可以用time函数分别对比下1.1,1.2,1.3,算法所用的时间,效率之间的差异。

二:DFS排列树类型 ——旅行售货员问题

1.问题描述:设有一个售货员从城市1出发,到城市2,3,…,n去推销货物,最后回到城市1.假定任意两个城市i,j间的距离dij(dij=dji)是已知的,问他应沿着什么样的路线走,才能使走过的路线最短?

 2.问题分析:在递归算法Backtrack中,当i=n时,当前扩展结点是排列树的叶结点的父结点。此时算法检测图G是否存在一条从顶点x[n-1]到顶点下x[n]的边和一条从顶点x[n]到顶点1的边,如果这两条边都存在,则找到一条旅行售货员回路。此时,算法还需判断这条回路的费用是否优于已别的当前最优回路的费用bestc。 如果是,则必须更新当前最优值bestc和当前最优解bestx.
       当i<n时,当前扩展结点位于排列树的第i - 1层。图G中存在从顶点x[i -1]到顶点x[i]的边时,x[1: i]构成图G的一 条路径,且当x[1:i]的费用小于当前最优值时算法进入排列树的第i层,否则将剪去相应的子树。算法中用变量cc记录当前路径x[1:i]的费用。

 

算法和子集树差不多,就循环那里稍微不一样,不懂可以看看前面代码模板那里,与Perm全排列很类似。至于为什么要用

w[x[n-1]][x[n]]这样表示,而不用w[n-1][n]表示,就是因为交换  swap(x[t],x[i])知道吧。主函数从Backtrack(2)开始,从图都可以看出来

解旅行售货员问题的回溯算法可描述如下:
#include <iostream>
#include <time.h>
using namespace std;
#define Max 100000
//D型
int n;
int w[100][100];  // 邻接矩阵

//T型
int bestw = Max;
int bestx[100] // 最优顶点;

//C型
int cw;
int x[100] // 顶点;

void Backtrack(int t);

int main()
{
    clock_t start , end;
    cin >> n;
    for(int i=1; i<=n; i++)
        for(int j=1; j<=n; j++) 
            cin >> w[i][j];
    for(int i=1; i<=n; i++)
        x[i] = i;
    start = clock();
    Backtrack(2);
    end= clock();
    cout << (double)(end-start)/1000 << endl;
    cout << bestw << endl;
    for(int i=1; i<=n; i++)
        cout << bestx[i] << ' ';
    cout << "1" << endl; 
    return 0;
}

void Backtrack(int t)
{
    if(t == n){
        if(w[x[n-1]][x[n]]!=-1 && w[x[n]][1]!=-1 && 
            (cw+w[x[n-1]][x[n]]+w[x[n]][1] < bestw || bestw == Max)){
                for(int i=1; i<=n; i++)
                    bestx[i] = x[i];
                bestw = cw+w[x[n-1]][x[n]]+w[x[n]][1];
            }
    }
    else{
        for(int i=t; i<=n; i++){
            if(w[x[t-1]][x[i]] != -1 && (cw+w[x[t-1]][i] < bestw || bestw == Max)){
                swap(x[t],x[i]);
                cw+=w[x[t-1]][x[t]];
                Backtrack(t+1);
                cw-=w[x[t-1]][x[t]];
                swap(x[t],x[i]);
            } 
        }
    }

//
//4
 //-1 30 6 4 
//30 -1 5 10
//6 5 -1 20
//4 10 20 -1
算法效率分析:

子集数时间复杂度为O(2^n)

排列树时间复杂度为O(n!)

  • 2
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值