回溯算法

回溯算法

回溯法是一种以深度优先方式系统搜索问题解的算法,有“通用解题法”之称,可以系统地搜索一个问题地所有解或任一解,是一种既带有系统又带有跳跃性地搜索算法。

基本思想

  1. 在问题的解空间树中,按深度优先策略
  2. 从根节点出发搜索解空间树
  3. 算法搜索至解空间树的任一结点时,先判断该结点是否包含问题的解
  4. 如果肯定不包含,则跳过以该结点为根的子树搜索,逐层向其祖先结点回溯
  5. 否则,进入该子树,继续按深度优先策略搜索

回溯法求问题的解时,要回溯到根,且根节点的所有子树都已经被搜索到才结束。

问题的解空间

用回溯法解问题时,应明确定义问题的解空间,问题的解空间至少应包含问题的一个(最优)解。

回溯法搜索解空间时,通常采用两种策略来避免无效搜索,提高回溯法的搜索效率。

  • 约束函数在扩展结点处剪去不满足约束的子树
  • 限界函数剪去得不到最优解的子树
  • 这两类函数统称为剪枝函数。

用回溯法解题通常包含一下三个步骤

  • 针对所给问题,定义问题的解空间
  • 确定易于搜索的解空间结构
  • 以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索

递归回溯

 //形参t表示递归深度,即当前扩展结点在解空间树的深度
void backtrack(int t)
{
	if (t > n)
	{
		output(x);//叶子节点,输出结果,x是可行解  
	} 
	else
	{
		for i = 1 to k//当前节点的所有子节点  
		{
			x[t] = value(i); //每个子节点的值赋值给x  
		  if (constraint(t) && bound(t))//满足约束条件和限界条件  
				backtrack(t + 1);  //递归下一层  
		}
	}
}

迭代回溯

void iterativeBacktrack ()  
{  
    int t=1;  
    while (t>0) {  
        if(ExistSubNode(t)) //当前节点的存在子节点  
        {  
            for i = 1 to k  //遍历当前节点的所有子节点  
            {  
                x[t]=value(i);//每个子节点的值赋值给x  
                if (constraint(t)&&bound(t))//满足约束条件和限界条件   
                {  
                    //solution表示在节点t处得到了一个解  
                    if (solution(t)) output(x);//得到问题的一个可行解,输出  
                    else t++;//没有得到解,继续向下搜索  
                }  
            }  
        }  
        else //不存在子节点,返回上一层  
        {  
            t--;  
        }  
    }  
}  

用回溯法解题的一个显著特征是在搜索过程中动态产生问题的解空间。在任何时刻,算法只保存从根结点到当前扩展结点的路径。

子集树与排列树简单介绍

子集树

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

void Backtrace(int t)
{
	if (t > n)
	{
		Output(x);
	}
	else
	{
		for (int i = 0; i <= 1; i++)
		{
			x[t] = i;
		}
			
		if (Constrain(t) && Bound(t))
		{
			Backtrace(t + 1);
		}	
	}	
}


排列树

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

void Backtrace(int t)
{
	if (t > n)
	{
		Output(x);
	}
	else
	{
		for (int i = t; i <= n; ++i)
		{
			Swap(x[t], x[i]);
			if (Constrain(t) && Bound(t))
			{
				Backtrace(t + 1);
			}
			Swap(x[t], x[i]);
		}
	}	
}


与动态规划的区别

共同点

用于求解多阶段决策问题。多阶段决策问题即:

  • 求解一个问题分为很多步骤(阶段);
  • 每一个步骤(阶段)可以有很多种选择。

不同点

  • 动态规划只需要求我们评估最优解是什么,最优解对应的具体解是什么并不要求。因此很适合应用于评估一个方案的效果;
  • 回溯算法可以搜索得到所有的方法(当然包括最优解),但是本质上它是一种遍历算法,时间复杂度很高。

回溯VS递归

很多人认为回溯和递归是一样的,其实不然。在回溯法中可以看到有递归的身影,但是两者是有区别的。

回溯法是从问题本身出发,寻找可能实现的所有情况。和穷举法的思想相近,不同在于穷举法是将所有的情况都列举出来以后再一一筛选,而回溯法在列举过程中如果发现当前情况根本不可能存在,就停止后续的所有工作,返回上一步进行新的尝试。

递归是从问题的结果出发,例如求n!,要想知道n!的结果,就需要知道n*(n-1)!的结果。这样不断地向子集提问,不断调用自己的思想就是递归。

回溯和递归唯一的联系就是,回溯法可以用递归思想实现。

为什么不是广度优先遍历

  • 首先是正确性,只有遍历状态空间,才能得到所有符合条件的解,这一点BFS和DFS其实都可以;
  • 在深度优先遍历的时候,不同状态之间的切换很容易 ,可以再看一下上面有很多箭头的那张图,每两个状态之间的差别只有1处,因此回退非常方便,这样全局才能使用一份状态变量完成搜索;
  • 如果使用广度优先遍历,从浅层转到深层,状态的变化就很大,此时我们不得不在每一个状态都新建变量去保存它,从性能来说是不划算的;
  • 如果使用广度优先遍历就得使用队列,然后编写节点类。队列中需要存储每一步的状态信息,需要存储的数据很大,真正能用到的很少。
  • 使用深度优先遍历,直接使用了系统栈,系统栈帮助我们保存了每一个结点的状态信息。我们不用编写结点类,不必手动编写栈完成深度优先遍历。

剪枝

  • 回溯算法会应用「剪枝」技巧达到以加快搜索速度。有些时候,需要做一些预处理工作(例如排序)才能达到剪枝的目的。预处理工作虽然也消耗时间,但能够剪枝节约的时间更多。

提示:剪枝是一种技巧,通常需要根据不同问题场景采用不同的剪枝策略,需要在做题的过程中不断总结。

  • 由于回溯问题本身时间复杂度就很高,所以能用空间换时间就尽量使用空间。

总结

做题的时候,建议 先画树形图 ,画图能帮助我们想清楚递归结构,想清楚如何剪枝。拿题目中的示例,想一想人是怎么做的,一般这样下来,这棵递归树都不难画出。

在画图的过程中思考清楚:

  • 分支如何产生;
  • 题目需要的解在哪里?是在叶子结点、还是在非叶子结点、还是在从跟结点到叶子结点的路径?
  • 哪些搜索会产生不需要的解的?例如:产生重复是什么原因,如果在浅层就知道这个分支不能产生需要的结果,应该提前剪枝,剪枝的条件是什么,代码怎么写?

例题

题型一:排列、组合、子集相关问题

装载问题

算法描述

用回溯法解装载问题时,用子集树表示其解空间显然是最合适的。可行性约束函数可剪去不满足约束条件的子树。

/*
* 首先将第一艘轮船尽可能装满
* 将剩余的集装箱装上第二艘轮船
* 将第一艘轮船尽可能装满等价于选取集装箱的一个子集,使该子集中集装箱重量之和最接近第一艘轮船的重量c1
* 因此,装载问题等价于特殊的0-1背包问题
*/
#include<iostream>
using namespace std;


const int N = 7;
int w[] = { 7,8,5,9,4,6,3 };//集装箱的重量
int c1 = 22;//第一艘船的载重量
int c2 = 20;//第二艘船的载重量
int x[N];//辅助数组
int cw;//当前已选取的集装箱的重量和
int bestw;//当前选取的最优重量
int bestx[N];//最优结果集
int r;
void func(int i)
{
	if (i == N)//到达叶子结点
	{
	   /*
	   * 当前选取的重量和大于最优解
	   * 最优解进行更新
	   * 最优结果集进行更新
       */
		if (cw > bestw)
		{
			bestw = cw;
			for (int j = 0; j < N; ++j)
			{
				bestx[j] = x[j];
			}
		}
	}
	else//还未到达叶子节点
	{
		r -= w[i];
		if (cw + w[i] <= c1) // i节点左子树的剪枝操作
		{
			cw += w[i];
			x[i] = 1;//标记此物品放入
			func(i + 1); //递归下一层
			cw -= w[i];
		}
        
        /*
        * i节点右子树的剪枝操作
        * 当当前已选取的重量和与右子树能够选取得重量和大于当前已经得到得到的最优解时
        * 才需要进入右子树
        * 否则进行剪枝操作
        */
		if (cw + r > bestw) 
		{
			x[i] = 0;
			func(i + 1);
		}
		r += w[i];
	}
}


int main()
{
	for (int w1 : w)
	{
		r += w1;
	}
	func(0);
	cout << "轮船c1:" << c1 << "装入的物品是:";
	for (int i = 0; i < N; ++i)
	{
		if (bestx[i] == 1)
		{
			cout << w[i] << " ";
		}
	}
	cout << endl;
	cout << "轮船c2:" << c2 << "装入的物品是:";
	for (int i = 0; i < N; ++i)
	{
		if (bestx[i] == 0)
		{
			cout << w[i] << " ";
		}
	}
	cout << endl;
	return 0;
}
©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页