一学就废的回溯算法第一弹

一、导论

 对算法与数据结构掌握与理解不透彻,很难写出优秀简洁的代码。亡羊补牢为时不晚,所以工作后也时常拿起旧书本回炉重造磨练这些基本功。学习算法不仅会收获很多,而且还会给你带来成就感。
 如果说算法思想的艺术,那归于动态规划;但如果说用计算机执行机制解决问题的艺术,那非回溯算法莫属了,也由衷的赞叹,原来计算机还能这么执行。其实,「回溯是递归的副产品,只要有递归就会有回溯」,所以回溯法也经常和二叉树遍历深度优先搜索混在一起,因为这两种方式都是用了递归。许多复杂的,规模较大的问题都可以使用回溯法,它有“通用解题方法”的美称
 但是,这里各位小伙伴也要清楚,回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。
 接下来,先跟着小编来瞧瞧一些我们接触过的有关排列子集等数学问题求解的图解。我们先不讲何为“回溯算法”及其求解框架,一些相关的概念我们暂且放一放,我们先对问题求解的过程有个清晰的认识,再回过头来归纳总结。

二、排列问题

1、n个不重复的数进行排列组合

 我们在高中的时候就做过排列组合的数学题,我们也知道 n 个不重复的数,全排列共有 n! 个。
 那么我们当时是怎么穷举全排列的呢?比方说给三个数 [1,2,3],你肯定不会无规律地乱穷举,一般是这样:
 先固定第一位为 1,然后第二位可以是 2,那么第三位只能是 3;然后可以把第二位变成 3,第三位就只能是 2 了;然后就只能变化第一位,变成 2,然后再穷举后两位……。有一点我们要注意:排列是有序的,也就是说[1,2] 和[2,1] 是两个集合。因此,对1、2、3进行全排列得到的结果,其实就是依次固定第一位为1、2和3来进行排列得到的结果集合。
 我们创建nums数组存放1、2、3,也就是{1,2,3} 。创建select数组用来记录哪些数字我们已经选择标记了,那我们就不能重复选择。举例子,select[0,0,0]表示所有数字均未被选取标记;select[1,0,0]代表nums数组第一个数字1被选取定住,剩下两个数可以参与排列;select[1,1,0]表示nums数组里的1和2被选取定住。这里要注意,通过select数组并不能知道我们数字选取的顺序,只能知道这些数字不能再选了而已。创建一个path数组,用来存储我们每次选取的哪个数字,通过这些数字在数组里的位置可以判断其选取的顺序。
 好了,接下来就是一个求解的过程。小编绘制成了一张大图,如下所示:

排列无重复.png
 可以发现,其实一个大的问题可以分成1个或者多个子问题来进行解决。比如对1、2、3进行全排列,可以分成3个具有相同解决思路的子问题来解决,它可以分解成:
 ① 第一个位置选择1后,对2、3进行全排列
 ② 第一个位置选择2后,对1、3进行全排列
 ③ 第一个位置选择3后,对1、2进行全排列
 这3个子问题得到的结果就构成了大问题的解。
 因此,对n个不重复的数进行全排列,就是选取1个数后【n种选法】,然后对剩下的n-1个不重复的数进行全排列。这显然就是递归的思想,因此我们可以采用递归算法的设计思路来设计程序。
 我们看上图,可以发现子问题不能分解的条件就是select[1,1,1],也就是nums数组所有数字都被选取定住了,这也就是递归的终止条件。但这样去判断有点复杂,其实只要路径数组path长度等于nums数组长度,当前递归也就终止了。
 接下来,我们来设计程序。
第一步:定义一个函数,功能是对n个不重复的数进行全排列。这里的函数参数设置为select数组,主要是模拟对剩下的n-1个数进行全排列,毕竟被标记了的位置可是不能选的。

vector > result;//二维动态数组,存放得到的每种可能的路径(排列) 
vector nums; //存放进行排列的数字 
vector path;//路径数组,存放得到的排列
vector select(numsnumber,false);//标记数组【选择数组】,初始化成所有数字均为被标记 
//对n个不重复的数进行全排列
void traceback(vector select)
{


}

第二步:对n个不重复的数进行全排列,就是选取1个数后【n种选法】,然后对剩下的n-1个不重复的数进行全排列。如何实现递归的时候传入剩下的n-1个数,就是对选取的数字进行标记,更新select数组。

vector > result;//二维动态数组,存放得到的每种可能的路径(排列) 
vector nums; //存放进行排列的数字 
vector path;//路径数组,存放得到的排列
vector select(numsnumber,false);//标记数组【选择数组】,初始化成所有数字均为被标记 
//对n个不重复的数进行全排列
void traceback(vector select)
{ 
    for(int i=0;i

第三步:添加递归终止条件

vector > result;//二维动态数组,存放得到的每种可能的路径(排列) 
vector nums; //存放进行排列的数字 
vector path;//路径数组,存放得到的排列
vector select(numsnumber,false);//标记数组【选择数组】,初始化成所有数字均为被标记 
//对n个不重复的数进行全排列
void traceback(vector select)
{ 
    //递归终止条件 
    if(nums.size()==path.size())
    {
        //加入二维数组里保存 
        result.push_back(path);
        //递归结束 
        return;
    }
    for(int i=0;i

 这三步走完,我们的程序就结束了吗?上机去运行可以发现,我们输出的结果只有一种,那就是{1,2,3}。小脑袋瓜嗡嗡的,想想我们还有哪里做的不到位呢?
 当然有,我们按照上述的做法,第一次递归结束后,select数组的状态是{1,1,1}。也就是全被选取定住了,接下来我们当然没法进行排列了。回想我们上面找到的问题间的联系,睁大眼睛瞅瞅,可以发现我们定住1进行排列后,如果想要定住2进行排列,是不是要取消之前定住的那些数字。如下图所示:

状态变化
 所以呢,递归结束后,我们还要干一件事情,撤销刚才选取定住的数字的标记,并从路径数组移除。这个操作简简单单,但是就是这么一步操作,可是有个很响亮的称呼,叫做“回溯”
 我们继续补充代码。
第四步:进行回溯

vector > result;//二维动态数组,存放得到的每种可能的路径(排列) 
vector nums; //存放进行排列的数字 
vector path;//路径数组,存放得到的排列
vector select(numsnumber,false);//标记数组【选择数组】,初始化成所有数字均为被标记 
//对n个不重复的数进行全排列
void traceback(vector select)
{ 
    //递归终止条件 
    if(nums.size()==path.size())
    {
        //加入二维数组里保存 
        result.push_back(path);
        //递归结束 
        return;
    }
    for(int i=0;i

 走到这里,我们进行n个无重复数的全排列就完成了。这就是所谓的“回溯算法”,想不到吧!
 接下里,继续来分析。这里,小编把上图变成一棵树,如下图所示:

变成一棵树
 字母顺序代表我们节点的访问顺序。
 A、B、C、D、E、F、G、H、I、J、K、L、M、N、O、P。
 我们回顾下图的有关知识,仔细观察下,可以发现我们从根节点A开始就是一直往下走,走不通就掉头。这不就是我们所熟悉的深度优先搜索算法(DFS)嘛!哈哈哈,其实深度优先搜索算法也可以使用递归的思想来解决。我们再睁大眼睛瞧瞧我们节点的访问顺序,不就是树的前序遍历打印出来的结果。
 我们可以发现,n个数形成的这棵排列树总共有2n个叶结点,其结点总个数为2(n+1)-1。时间复杂度为O(2n)计算。
 这里,小编继续把相关代码进行小小的美化,贴上完整代码:

#include
#include
#include 
using namespace std;
class Solution {
public:
    vector > result;
    vector > permute(vector& nums) {
        vector select(nums.size(),false);
        vector path;
        trackback(select,path,nums);
        return result;
    }
    void trackback(vector select,vector path,vector& nums)
    {
        if(nums.size()==path.size())
        {
            result.push_back(path);
            return;
        }
        for(int i=0;i nums;
    int numssize;
    cin>>numssize;
    for(int i=0;i>a;
        nums.push_back(a);
    }
    Solution pailietree;
    pailietree.permute(nums);
    pailietree.PrintResult();
    return 0;
}

 可以发现,按照这样的思路我们的代码需要创建好几个动态数组,这样的话随着递归深度的增加,我们占用的内存就越来越大。我们来做改进,这里先看小编绘制的图,如下所示:


交换+递归+回溯

 可以发现,我们不需要借助select数组进行标记和path数组存储路径,只需要在原先nums数组里对应位置的元素进行交换,等到交换到最后一个位置,此时重新得到的新的nums数组就是一个排列。当然这还是一个递归的过程,一个大问题同样可以分成几个小的问题来解决。我们重新设计代码,如下所示:

#include
#include
#include 
using namespace std;
void swap(int &a,int &b)
{
    int temp = a;
    a = b;
    b = temp;
}

void output(vector& nums)
{
    for(int i=0;i& nums)
{
    //递归终止,输出此时的nums数组,即为一个排列 
    if(begin>=nums.size())
    {
        output(nums);
        return ;
    }
    for(int i=begin;i nums;
    int numssize;
    cin>>numssize;
    for(int i=0;i>a;
        nums.push_back(a);
    }
    trackback(0,nums);//从第一个位置开始 
    return 0;
}
2、进行排列的数有重复

 举个例子,我们现在给出一个数组[1,1,2],对其进行求解,求出全部可能的排列。那这个时候,我们就不能使用我们上面的代码了,需要在这个基础上进行修改。同样,我们先来看图。

排列有重复
 其实代码修改的地方只有两处:
 ① 先对数组nums进行排序,这样重复的元素就是相邻的。
 ② for循环里,当遍历了数组nums的一个值时,如果它和前一位元素相等,即nums[i-1] == nums[i],并且前一位元素已经搜索并回溯过了,即已经被标记过,为了避免生成重复的排列数组,也跳过。除了上述条件外,只有当前元素也未被访问标记过,才可以继续进行递归。
 可以发现,上图中有个名词叫做“剪枝”。其实很好理解,小伙伴们看上图应该很快就明白,就是在一棵树上剪除掉一些枝叶等【不满足条件的枝叶】。
 代码如下:

#include
#include
#include 
using namespace std;
class Solution {
public:
    vector > result;
    vector > permute(vector& nums) {
        vector select(nums.size(),false);
        vector path;
        trackback(select,path,nums);
        return result;
    }
    void trackback(vector select,vector path,vector& nums)
    {
        if(nums.size()==path.size())
        {
            result.push_back(path);
            return;
        }
        for(int i=0;i0 && select[i-1] && nums[i-1]==nums[i])
            continue;
            if(!select[i]){
                path.push_back(nums[i]);
                select[i]=true;
                trackback(select,path,nums);
                select[i]=false;
                path.pop_back();    
            }
        }
    }
    void PrintResult()
    {
        for(int m=0; m nums;
    int numssize;
    cin>>numssize;
    for(int i=0;i>a;
        nums.push_back(a);
    }
    sort(nums.begin(),nums.end());
    Solution pailietree;
    pailietree.permute(nums);
    pailietree.PrintResult();
    return 0;
}

 这是对第一份代码修改,我们继续贴上第二份代码:

#include
#include
#include 
using namespace std;
void swap(int &a,int &b)
{
    int temp = a;
    a = b;
    b = temp;
}

void output(vector& nums)
{
    for(int i=0;i& nums){
    for(int i=x;i& nums)
{
    //递归终止,输出此时的nums数组,即为一个排列 
    if(begin>=nums.size())
    {
        output(nums);
        return ;
    }
    for(int i=begin;i nums;
    int numssize;
    cin>>numssize;
    for(int i=0;i>a;
        nums.push_back(a);
    }
    //sort(nums.begin(),nums.end());//排序 
    trackback(0,nums);//从第一个位置开始 
    return 0;
}

三、子集问题

1、给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。

 我们在高中的时候就做过集合求子集的数学题,我们也知道集合里有n个数,那它的子集有2^n个【包括空集】。
 比方说给三个数1、2、3,组成一个集合{1,2,3}。它的真子集有{1}、{1,2}、{1,2,3}、{1,3}、{2}、{2,3}、{3} 。加上空集{},得到它的所有子集。
 小编绘制了大图如下所示:


子集无重复

 我们先来看看{1}的子集,可以发现:
 P({1})={{},{1}}
 P({1,2})={{},{1},{2},{1,2}}
 有:
 P({1,2})={{},{1},{2},{1,2}}
 P({1,2})=P({1})UP({1}).add(2)【集合{1}的每个子集都加上2,得到{{2},{1,2}}】
 我们继续来看,有:
 P({1,2,3})={{},{1},{2},{3},{1,2},{1,3},{2,3},{1,2,3}}
 同样的,有:
 P({1,2,3})= P({1,2})U P({1,2}).add(3)【集合{1,2}的每个子集都加上3,得到{{3},{1,3},{2,3},{1,2,3}}】
 这明显也是将一个大的问题分成了小的问题,同样的可以使用递归的思想来设计程序。
 代码如下所示:

#include 
#include 

using namespace std;

class Solution {
public:
    vector > subsets(vector& nums) {
        vector > result; //开辟二维数组
        vector item; //开辟一维数组
        result.push_back(item); //保存无任何元素时的一维数组(空集)
        generate(0, nums, item, result);
        return result;
    }
    void output(vector > result)
    {
         for(int m=0; m& nums, vector& item, vector >& result){
        if(start>= nums.size()){ //递归结束条件
            return;
        }
        for(int i=start;i nums;
    int numssize;
    cin>>numssize;
    for(int i=0;i>a;
        nums.push_back(a);
    }
    Solution pailietree;
    pailietree.output(pailietree.subsets(nums));
    //pailietree.PrintResult();
    return 0;
}
2、给定一组含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。

 举个例子,我们现在给出一个数组[1,1,2],对其进行求解,求出全部可能的排列。那这个时候,我们就不能使用我们上面的代码了,需要在这个基础上进行修改。同样,我们先来看图。


子集 有重复

 其实代码修改的地方只有两处:
 ① 先对数组nums进行排序,这样重复的元素就是相邻的。
 ② for循环里,当遍历了数组nums的一个值时,如果它和前一位元素相等,即nums[i-1] == nums[i],说明存在重复元素,直接跳过。
 当然,这只是一种处理方法,另外一种就更简单了,我们直接把重复的元素删除后重新生成一个数组进行求解就可以了。例如数组为{1,2,2},我们直接删除重复的元素变成{1,2},他们的子集是一样的。
 代码如下:

#include 
#include 

using namespace std;

class Solution {
public:
    vector > subsets(vector& nums) {
        vector > result; //开辟二维数组
        vector item; //开辟一维数组
        result.push_back(item); //保存无任何元素时的一维数组(空集)
        generate(0, nums, item, result);
        return result;
    }
    void output(vector > result)
    {
         for(int m=0; m& nums, vector& item, vector >& result){
        if(start>= nums.size()){ //递归结束条件
            return;
        }
        for(int i=start;i nums;
    int numssize;
    cin>>numssize;
    for(int i=0;i>a;
        nums.push_back(a);
    }
    Solution pailietree;
    pailietree.output(pailietree.subsets(nums));
    //pailietree.PrintResult();
    return 0;
}
3、给定两个整数 n 和 k,返回 1 … n 中所有可能的 k 个数的组合。

 举个例子:
 输入:n = 4, k = 2
 输出:
 [
 [ ]
 [1,2],
 [1,3],
 [1,4],
 [2,3],
 [2,4],
 [3,4],
 ]
 其实这道题也非常简单,我们只需要先找到{1,2,3,4}的所有子集,然后找到只有两个元素的子集就行了。
 因此我们只需要将上述代码再重新修改下,修改下满足的结束条件就行。代码如下所示:

#include 
#include 

using namespace std;

class Solution {
public:
    vector > subsets(vector& nums,int k) {
        vector > result; //开辟二维数组
        vector item; //开辟一维数组
        result.push_back(item); //保存无任何元素时的一维数组(空集)
        generate(0, nums, item, result,k);
        return result;
    }
    
    void output(vector > result)
    {
         for(int m=0; m& nums, vector& item, vector >& result,int k){
        if(start>= nums.size()){ //递归结束条件
            return;
        }
        for(int i=start;i nums;
    int numssize;
    int k;
    cin>>numssize;
    cin>>k;
    for(int i=0;i

 其实,我们上述只是求解子集问题的一种代码模板。我们还可以来对代码进行修改,使其更加简洁。
 我们来看下图,其中1代表该数字被选择,0代表不选。


子集树.png

 可以发现,按照这样的方式绘制出来的树的叶子节点,可以得到所有的子集。每一条路径对应一种选择,比如{1,1,1}代表3个数都被选取,{1,0,1}代表1、3被选取。其实就相当于做了一个标记。其实这也是一个递归的过程,我们根据上图来设计代码。在编写程序的时候,我们首要做的就是构建这样的一颗树,代码如下所示:

#include 
#include 
using namespace std;

class Solution {
private:
    vector nums;
    vector tag;//标记 
public: 
    void initial()
    {
        int numssize;
        cin>>numssize;
        for(int i=0;i>a;
            nums.push_back(a);
            tag.push_back(0); 
        }
    } 
    void output(vector tag) {
        for (int i = 0; i < nums.size(); i++) {
            if(tag[i] == 1){
                cout<= nums.size())
            output(tag);
        else
            for (int i = 0; i <= 1; i++) {
                tag[t] = i;
                backtrack(t+1);
            }
        
    }
};


int main()
{
    Solution pailietree;
    pailietree.initial();
    pailietree.backtrack(0);
    //pailietree.PrintResult()
    return 0;
}

 但是我们上述的代码只能处理给定一组不含重复元素的整数数组 nums的子集问题,对于有重复元素的整数数组求解,需要添加判断的条件,对这颗树进行剪枝。
 这里小编简单画了个图,更加直观的看出我们究竟是要剪掉哪些树枝。


剪枝

 这里,我们可以知道,如果我们选择的元素是重复的,那么我们只要发现上一次重复的元素在当前同一条路径被选为1,我们就必须把以它为根节点的树全部剪掉【发现元素的值等于父节点,且该父节点被选取,也就是标记为1,将该元素作为根节点的子树全部剪掉】。代码如下:

#include 
#include 
#include 
using namespace std;

class Solution {
private:
    vector nums;
    vector tag;//标记 
public: 
    void initial()
    {
        int numssize;
        cin>>numssize;
        for(int i=0;i>a;
            nums.push_back(a);
            tag.push_back(0); 
        }
    } 
    void output(vector tag) {
        
        for (int i = 0; i < tag.size(); i++) {
            if(tag[i] == 2){
                return;
            }
        }
        cout<<"{ ";
        for (int i = 0; i < tag.size(); i++) {
            if(tag[i] == 1){
                cout<= nums.size())
            output(tag);
        else
            for (int i = 0; i <= 1; i++) {
                tag[t] = i;
                if(constraint(t))
                    backtrack(t+1);
            }
        
    }
};


int main()
{
    Solution pailietree;
    pailietree.initial();
    pailietree.backtrack(0);
    //pailietree.PrintResult()
    return 0;
}

 输入:
 3
 1
 2
 2
 得到输出为:
 {}
 {2}
 {1}
 {1,2}
如果是要输出集合S中限定元素数量的子集,那又该怎么设计呢?其实同样的我们也只是需要添加约束条件就可以了,代码如下所示:

#include 
#include 
#include 
using namespace std;

class Solution {
private:
   vector nums;
   vector tag;//标记 
public: 
   void initial()
   {
       int numssize;
       cin>>numssize;
       for(int i=0;i>a;
           nums.push_back(a);
           tag.push_back(0); 
       }
   } 
   void output(vector tag) {
       
       for (int i = 0; i < tag.size(); i++) {
           if(tag[i] == 2){
               return;
           }
       }
       cout<<"{ ";
       for (int i = 0; i < tag.size(); i++) {
           if(tag[i] == 1){
               cout< tag, int t) {
       int num = 0;
       for (int i = 0; i <= t; i++) {
           if(tag[i] == 1){
               num++;
           }
       }
       return num;
   }
   
   void backtrack(int t){
       if(t >= nums.size())
           output(tag);
       else
           for (int i = 0; i <= 1; i++) {
               tag[t] = i;
               if(count(tag,t)<4)
                   backtrack(t+1);
           }
       
   }
};


int main()
{
   Solution pailietree;
   pailietree.initial();
   pailietree.backtrack(0);
   //pailietree.PrintResult()
   return 0;
}

 其实,在小编这个模板上继续添加一些相对的约束条件,就可以解决leetcode上的类似的问题了。有兴趣的小伙伴们可以自己动手继续学习下。

四、递归与回溯的区别

 可以发现,我们所谓的回溯算法,其实与“递归”紧密联系。
 递归是一种算法结构。递归会出现在子程序中,形式上表现为直接或间接的自己调用自己。典型的例子是阶乘,计算规律为:n!=n×(n−1)!n!=n×(n−1)!,如果用 C++ 代码表示,基本如下所示:

\\求阶乘函数
int f(int n) {
    if (n < =1) {
        return 1;
    }
    return n * f(n - 1)
}

 回溯是一种算法思想,它是用递归实现的。回溯的过程类似于穷举法,但回溯有“剪枝”功能,即自我判断过程。例如有求和问题,给定有 7 个元素的组合 [1, 2, 3, 4, 5, 6, 7],求加和为 7 的子集。累加计算中,选择 1+2+3+4 时,判断得到结果为 10 大于 7,那么后面的 5, 6, 7 就没有必要计算了。这种方法属于搜索过程中的优化,即“剪枝”功能。
用一个比较通俗的说法来解释递归和回溯:
 我们在路上走着,前面是一个多岔路口,因为我们并不知道应该走哪条路,所以我们需要尝试。尝试的过程就是一个函数。
 我们选择了一个方向,后来发现又有一个多岔路口,这时候又需要进行一次选择。所以我们需要在上一次尝试结果的基础上,再做一次尝试,即在函数内部再调用一次函数,这就是递归的过程。
 这样重复了若干次之后,发现这次选择的这条路走不通,这时候我们知道我们上一个路口选错了,所以我们要回到上一个路口重新选择其他路,这就是回溯的思想。

五、回溯与深度优先遍历的异同

 我们前面已经分析过,我们所谓的回溯算法就是构造了一棵树,然后在这棵树上使用深度优先搜索算法。
① 访问的次序不同:深度优先遍历的目的是“遍历”,本质是无序的,重要的是是否被访问过,因此在实现上只需要对于每个位置是否被访问就足够了。回溯法的目的是“求解过程”,本质是有序的,也就是说必须每一步都是要求的次序。
② 访问次数不同:深度优先遍历对已经访问过的顶点不再访问。回溯法中已经访问过的顶点可能再次访问。
③ 剪枝不同:深度优先遍历不含剪枝。
 实际上,除了剪枝是回溯法的一个明显特征外(并非任何回溯法都包含剪枝部分),很难严格区分回溯法与深度优先遍历。因为这些算法很多是递归算法,在递归调用中隐含着状态的自动回退和恢复。

六、一点归纳

 文章写到这里,小伙伴们应该可以发现。我们使用回溯算法解决某个问题的时候,我们依据问题绘制出来的图形是一棵树,根据这棵树的节点【一般是指叶子节点】,我们可以得到所有问题的解。这棵树我们就把它叫做“解空间树”
 事实上,我们解题的过程是一个不断判断决策的过程,我们把每一步判断决策的过程对应于解空间树的一个分支结点,而各种可能的不同结果,则对应得到结点的每一个孩子及各棵子树,而一系列判断决策的解题过程,就对应着解空间树的生长过程;而问题最终所有可能的解,都会呈现在这棵解空间树的叶子上
 在包含问题所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度搜索解空间树。当搜索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯(其实回溯法就是对隐式图的深度优先搜索算法)。若用回溯法求问题的所有解时,要回溯到根,且根结点的所有可行的子树都要已被检索一遍才结束。而若使用回溯法求任一个解时,只要搜索到问题的一个解就可以结束。
 我们前面举得例子可以看到,对于存在不满足条件的解,我们是怎么处理的呢。就是通过设置判断条件来处理,处理之后可以发现我们得到的这棵树有些树枝被剪掉了,这就是所谓的“剪枝”。这个条件我们称呼为“约束条件”【函数的话可以叫做“约束函数”或“剪枝函数”】。
 这里其实要注意下,剪枝函数包括限界函数和约束函数。而所谓的限界函数,简单说就是剪掉得不到最优解的子树【后续会来分享所谓的分支限界法】。
所以回溯法的解题步骤就是下面三步:
 ① 针对所给问题,定义问题的解空间;
 ② 确定易于搜索的解空间结构;
 ③ 以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。
 哈哈,其实这就是大多数书籍里的描述了。为什么小编上面要分成子集树排列树来阐述呢?
 其实,这是因为这两棵解空间树是回溯法解题时常遇到的两类典型的解空间树。所以小编就单独拎出来来讲了下。我们现在对这两种树来做个简单总结。

七、子集树和排列树

 上述写了这么多,其实还是为了我们这两种树做铺垫。因为以往的教材或书籍里讲解的时候基本都是贴上相关概念以及通用的算法描述框架,并没有具体到如何得到的,如何去运用。小编在上面已经给出了具体的代码,其中就包含这两种树的程序设计范示

1、子集树
基本概念特征及算法框架

 当所给问题是从n个元素的集合S中找出S满足某种性质的子集时,相应的解空间树称为子集树。如0-1背包问题,从所给重量、价值不同的物品中挑选几个物品放入背包,使得在满足背包不超重的情况下,背包内物品价值最大。它的解空间就是一个典型的子集树。这类子集树通常有2n个叶结点,其结点总个数为2(n+1)-1。遍历子集树的算法需O(2n)计算时间。
 用回溯法搜索子集树的一般算法可描述为:

/**
     * output(x)     记录或输出得到的可行解x
     * constraint(t) 当前结点的约束函数
     * bount(t)      当前结点的限界函数
     * @param t  t为当前解空间的层数
     */
    void backtrack(int t){
    void Backtrack(int t) {     //t 表示当前是树的第t层,即对集合 S 中的第 t 个元素进行判断
        if (t > n)
            output(x);          //大于S中总的元素个数 ,遍历完成 
        else
            for (int i = 0; i < = l; i++) {     // 两种可能 加入或者不加入到解集合 
                x[t] = i;
                if (Constraint(t) && Bound(t)){     //满足约数条件  
                        Backtrack(t + 1);           //对 t+1 层进行判断 
                    } 
            }
    }
回溯算法解01背包

 这里小编直接贴上代码,如下所示:

#include   
   
#define N 3         //物品的数量  
#define C 4        //背包的容量  
   
int w[N]={1,4,3};  //每个物品的重量  
int v[N]={1500,3000,2000};   //每个物品的价值  
int x[N]={0,0,0};   //x[i]=1代表物品i放入背包,0代表不放入  
   
int CurWeight = 0;  //当前放入背包的物品总重量  
int CurValue = 0;   //当前放入背包的物品总价值  
   
int BestValue = 0;  //最优值;当前的最大价值,初始化为0  
int BestX[N];       //最优解;BestX[i]=1代表物品i放入背包,0代表不放入  
   
//t = 0 to N-1  
void backtrack(int t)  
{  
    //叶子节点,输出结果  
    if(t>N-1)   
    {  
        //如果找到了一个更优的解  
        if(CurValue>BestValue)  
        {  
            //保存更优的值和解  
            BestValue = CurValue;  
            for(int i=0;i
2、排列树
基本概念特征及算法框架

 当所给问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树。例如旅行售货员问题(如下图)的解空间树是一棵排列树,这类排列树通常有n!个叶结点。遍历子集树的算法需O(n!)计算时间。
 用回溯法搜索排列树的一般算法可描述为:

    /**
     * output(x)     记录或输出得到的可行解x
     * constraint(t) 当前结点的约束函数
     * bount(t)      当前结点的限界函数
     * @param t  t为当前解空间的层数
     */
    void backtrack(int t){                              //t 表示集合 S 的第 t 个元素
        if(t >= n)
            output(x);
        else
            for (int i = t; i <= n; i++) {        //第t 个元素与其后面的所有元素进行交换位置 
                swap(x[t], x[i]);
                if(constraint(t) && bount(t))
                    backtrack(t+1);
                swap(x[t], x[i]);
            }
    }

八皇后问题

 八皇后问题描述如下:在8×8格的国际象棋上摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法!

八皇后
思路:
 (1)一维数组nums存放皇后位置,nums[i]=j 表示i行j列有一个皇后
 (2)按行放置皇后,循环穷举该行所有位置
 (3)皇后位置合法则递归放下一行
 (4)皇后位置非法则进行下次循环,用回溯的思想
 (5)当放满8个皇后(即i=8)时,计数器+1,跳出递归
ps:八皇后共92种放法
 套用框架代码如下:

#include
#include
#include 
#include 
using namespace std;
int n=0;
void swap(int &a,int &b)
{
    int temp = a;
    a = b;
    b = temp;
}

void output(vector& nums)
{
    n++;
    for(int i=0;i& nums){
   for(int j=0;j& nums)
{
    //递归终止,输出此时的nums数组,即为一个排列 
    if(begin>=nums.size())
    {
        output(nums);
        return ;
    }
    for(int i=begin;i nums; 
    for(int i=0;i<8;i++){
        nums.push_back(i+1);
    }
    trackback(0,nums);//从第一个位置开始 
    cout<<"共有"<

 当然,上述是套用代码框架,理解起来可能不是那么直观。那下面小编也贴上另一份代码,当然也还是回溯的思想。代码如下:

#include
#include
using namespace std;

int n=8;
int total=0;
int *c=new int(n);

bool is_ok(int row){
    for(int j=0;j!=row;j++){
        if(c[row]==c[j] || row-c[row]==j-c[j] || row+c[row]==j+c[j])
            return false;
    }
    return true;
}

void queen(int row){
    if(row==n)
        total++;
    else
        for(int col=0;col!=n;col++){
            c[row]=col;
            if(is_ok(row))
                queen(row+1);
        }
}

int main(){
    queen(0);
    cout<

八、写在最后

 其实回溯算法在算法设计中还是非常有用的,有很多经典的问题也可以使用回溯算法来进行求解。比如:
 (1)装载问题
 (2)旅行售货员问题
 (3)迷宫问题
 (4)图的m着色问题
 上述这些都是非常经典的问题。有空的话,小编将就这些问题,使用回溯算法进行解决,内化知识,加深理解。
 啰啰嗦嗦写了一大堆,画图也画了好久,不知道对小伙伴们有没有帮助。至少于小编个人而言,在知识的总结过程中加深了理解。小编也不是啥编程高手,写的文章难免也存在不少不足之处。有点击阅读的小伙伴们,看完之后发现有错误的地方,请多多指正!

你可能感兴趣的:(一学就废的回溯算法第一弹)