算法设计与分析------回溯法

一、 回溯法

1、定义

​ 在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点(开始结点)出发搜索解空间树。

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

用约束函数在扩展结点处剪除不满足约束的子树;
用限界函数剪去得不到问题解或最优解的子树。

这两类函数统称为剪枝函数

总结:

回溯法 = 深度优先搜索 + 剪枝

2、回溯法解题的一般步骤

①确定问题的解空间树,问题的解空间树应至少包含问题的一个(最优)解。

②确定结点的扩展规则。

③以深度优先方式搜索解空间树,并在搜索过程中可以采用剪枝函数来避免无效搜索。

3、回溯法的算法框架

1. 非递归回溯框架
int x[n];				//x存放解向量,全局变量
void backtrack(int n)			//非递归框架
{  int i=1;				//根结点层次为1
   while (i>=1)			//尚未回溯到头
   {  if(ExistSubNode(t)) 		//当前结点存在子结点
      {  for (j=下界;j<=上界;j++)	//对于子集树,j=0到1循环
         {  x[i]取一个可能的值;
            if (constraint(i) && bound(i)) 
					//x[i]满足约束条件或界限函数
            {  if (x是一个可行解)
		   输出x;
               else	i++;		//进入下一层次
	     }
         }
      }
      else  i--;			//回溯:不存在子结点,返回上一层
   }
}
2.递归的算法框架
(1)解空间为子集树
int x[n];			   //x存放解向量,全局变量
void backtrack(int i)		   //求解子集树的递归框架
{  if(i>n)			   //搜索到叶子结点,输出一个可行解
      输出结果;
   else
   {  for (j=下界;j<=上界;j++)   //用j枚举i所有可能的路径
      {  x[i]=j;		   //产生一个可能的解分量//其他操作
         if (constraint(i) && bound(i))
            backtrack(i+1);	   //满足约束条件和限界函数,继续下一层
      }
   }
}

【例】有一个含n个整数的数组a,所有元素均不相同,设计一个算法求其所有子集(幂集)。

例如,a[]={1,2,3},所有子集是:{},{3},{2},{2,3},{1},{1,3},{1,2},{1,2,3}(输出顺序无关)。

分析:

显然本问题的解空间为子集树,每个元素只有两种扩展,要么选择,要么不选择。

采用深度优先搜索思路。解向量为**x[],x[i]=0表示不选择a[i],x[i]=1表示选择a[i]。

用i扫描数组a,也就是说问题的初始状态是(i=0,x的元素均为0),目标状态是(i=n,x为一个解)。从状态(i,x)可以扩展出两个状态:

不选择a[i]元素  下一个状态为(i+1,x[i]=0)。
选择a[i]元素  下一个状态为(i+1,x[i]=1)。

代码如下:

#include <stdio.h>
#include <string.h>
#define MAXN 100
void dispasolution(int a[],int n,int x[])	//输出一个解
{
	printf("   {");
	for (int i=0;i<n;i++)
		if (x[i]==1)
			printf("%d",a[i]);
	printf("}");
}
void dfs(int a[],int n,int i,int x[])	//回溯算法
{
	if (i>=n)
		dispasolution(a,n,x);
	else
	{
		x[i]=0;
		dfs(a,n,i+1,x);			//不选择a[i]
		x[i]=1;
		dfs(a,n,i+1,x);			//选择a[i]
	}
}
int main()
{
	int a[]={1,2,3};				//s[0..n-1]为给定的字符串,设置为全局变量
	int n=sizeof(a)/sizeof(a[0]);
	int x[MAXN];					//解向量
	memset(x,0,sizeof(x));			//解向量初始化
	printf("求解结果\n");
	dfs(a,n,0,x);
	printf("\n");
}
(2)解空间为排列树
int x[n];			//x存放解向量,并初始化
void backtrack(int i)		//求解排列树的递归框架
{  if(i>n)			//搜索到叶子结点,输出一个可行解
	输出结果;
   else
   {  for (j=i;j<=n;j++)	//用j枚举i所有可能的路径
      {//第i层的结点选择x[j]的操作
         swap(x[i],x[j]);	//为保证排列中每个元素不同,通过交换来实现
         if (constraint(i) && bound(i))
	     backtrack(i+1);	//满足约束条件和限界函数,进入下一层
         swap(x[i],x[j]);	//恢复状态//第i层的结点选择x[j]的恢复操作
      }
   }
}

【例】有一个含n个整数的数组a,所有元素均不相同,求其所有元素的全排列。

例如,a[]={1,2,3},得到结果是(1,2,3)、(1,3,2)、(2,3,1)、(2,1,3)、(3,1,2)、(3,2,1)。

代码如下:

//例5.5的算法
#include <stdio.h>
void swap(int &x,int &y)			//交换x、y
{	int tmp=x;
	x=y; y=tmp;
}
void dispasolution(int a[],int n)	//输出一个解
{
	printf("  (");
	for (int i=0;i<n-1;i++)
		printf("%d,",a[i]);
	printf("%d)",a[n-1]);
}
void dfs(int a[],int n,int i)		//求a[0..n-1]的全排列
{
	if (i>=n)							//递归出口
		dispasolution(a,n);
	else
	{	for (int j=i;j<n;j++)
		{	swap(a[i],a[j]);			//交换a[i]与a[j]
			dfs(a,n,i+1);
			swap(a[i],a[j]);			//交换a[i]与a[j]:恢复
		}
	}
}
int main()
{
	int a[]={1,2,3,4};
	int n=sizeof(a)/sizeof(a[0]);
	printf("a的全排列\n");
	dfs(a,n,0);
	printf("\n");
}

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

1、两者的相同点

回溯法在实现上也是遵循深度优先的,即一步一步往前探索,而不像广度优先遍历那样,由近及远一片一片地搜索。

2、两者的不同点

(1)访问序不同:深度优先遍历目的是“遍历”,本质是无序的。而回溯法目的是“求解过程”,本质是有序的。

(2)访问次数的不同:深度优先遍历对已经访问过的顶点不再访问,所有顶点仅访问一次。而回溯法中已经访问过的顶点可能再次访问。

(3)剪枝的不同:深度优先遍历不含剪枝,而很多回溯算法采用剪枝条件剪除不必要的分枝以提高效能。

5、求解装载问题

问题描述】有n个集装箱要装上一艘载重量为W的轮船,其中集装箱i(1≤i≤n)的重量为wi。不考虑集装箱的体积限制,现要从这些集装箱中选出重量和小于等于W并且尽可能大的若干装上轮船。

例如,n=5,W=10,w={5,2,6,4,3}时,其最佳装载方案是(1,1,0,0,1)或者(0,0,1,1,0),maxw=10。

问题求解】采用带剪枝的回溯法求解。问题的表示如下:

int w[]={0,5,2,6,4,3}; //各集装箱重量,不用下标0的元素

int n=5,W=10;

求解的结果表示如下:

int maxw=0; //存放最优解的总重量

int x[MAXN]; //存放最优解向量

将上述数据设计为全局变量。

求解算法如下:

void dfs(int i,int tw,int rw,int op[])

其中参数i表示考虑的集装箱i,tw表示选择的集装箱重量和,rw表示剩余集装箱的重量和(初始时为全部集装箱重量和),op表示一个解,即对应一个装载方案。

代码如下:

#include <stdio.h>
#include <string.h>
#define MAXN 20						//最多集装箱个数

int w[]={0,5,2,6,4,3};				//各集装箱重量,不用下标0的元素
int	n=5,W=10;
int maxw;							//存放最优解的总重量
int x[MAXN];						//存放最优解向量
int minnum=999999;					//存放最优解的集装箱个数,初值为最大值
void dfs(int num,int tw,int rw,int op[],int i) //考虑第i个集装箱
{
	if (i>n)						//找到一个叶子结点
	{
		if (tw==W && num<minnum)
		{	maxw=tw;				//找到一个满足条件的更优解,保存它
			minnum=num;
			for (int j=1;j<=n;j++)	//复制最优解
				x[j]=op[j];
		}
	}
	else						//尚未找完所有集装箱
	{	op[i]=1;				//选取第i个集装箱
		if (tw+w[i]<=W)			//左孩子结点剪枝:装载满足条件的集装箱
			dfs(num+1,tw+w[i],rw-w[i],op,i+1);
		op[i]=0;				//不选取第i个集装箱,回溯
		if (tw+rw>W)			//右孩子结点剪枝
			dfs(num,tw,rw-w[i],op,i+1);
	}
}
void dispasolution(int n)		//输出一个解
{
	for (int i=1;i<=n;i++)
		if (x[i]==1)
			printf("  选取第%d个集装箱\n",i);
	printf("总重量=%d\n",maxw);
}
int main()
{
	int op[MAXN];				//存放临时解
	memset(op,0,sizeof(op));
	int rw=0;
	for (int i=1;i<=n;i++)
		rw+=w[i];
	dfs(0,0,rw,op,1);
	printf("最优方案\n");
	dispasolution(n);
}

二、回溯法实验

1、实验一 求解复杂装载问题

问题描述】有一批共n个集装箱要装上两艘载重量分别为c1和c2的轮船,其中集装箱i的重量为wi,且w1+w2+…+wn≤c1+c2。

装载问题要求确定是否有一个合理的装载方案可将这些集装箱装上这两艘轮船。如果有,找出一种装载方案。

例如:

  • n=3,c1=c2=50,w={10,40,40}时,可以将集装箱1和2装到第一艘轮船上,而将集装箱3装到第二艘轮船上。
  • n=3,c1=c2=50,w={20,40,40},则无法将这3个集装箱都装上轮船。

问题求解】如果一个给定的复杂装载问题有解,则可以采用如下方式得到一个装载方案:

首先将第一艘轮船尽可能装满,然后将剩余的集装箱装在第二艘轮船上。

  • 可以用反证法证明其正确性。
  • 如果这样做得不到一个装载方案,说明该复杂装载问题没有解!

算法思路(输入为w1,w2,…,wn,c1,c2):

(1)将尽可能多的集装箱装到第一艘轮船上,得到解向量x。

(2)累计第一艘轮船装完后剩余的集装箱重量sum。

(3)若sum<=c2,表示第二艘轮船可以装完,返回true;否则表示第二艘轮船不能装完,返回false。

代码如下:

#include <stdio.h>
#include <string.h>
#define MAXN 20						//最多集装箱个数

int w[]={0,10,40,40};				//各集装箱重量,不用下标0的元素
int	n=3;
int c1=50,c2=50;
int maxw=0;							//存放第一艘轮船最优解的总重量
int x[MAXN];						//存放第一艘轮船最优解向量
void dfs(int tw,int rw,int op[],int i) //求第一艘轮船的最优解
{
	if (i>n)						//找到一个叶子结点
	{
		if (tw<=c1 && tw>maxw)
		{
			maxw=tw;				//找到一个满足条件的更优解,保存它
			for (int j=1;j<=n;j++)	//复制最优解
				x[j]=op[j];
		}
	}
	else						//尚未找完所有集装箱
	{	op[i]=1;				//选取第i个集装箱
		if (tw+w[i]<=c1)		//左孩子结点剪枝:装载满足条件的集装箱
			dfs(tw+w[i],rw-w[i],op,i+1);
		op[i]=0;				//不选取第i个集装箱,回溯
		if (tw+rw>c1)			//右孩子结点剪枝
			dfs(tw,rw-w[i],op,i+1);
	}
}
void dispasolution(int n)		//输出一个解
{
	for (int j=1;j<=n;j++)
		if (x[j]==1)
			printf("\t将第%d个集装箱装上第一艘轮船\n",j);
		else
			printf("\t将第%d个集装箱装上第二艘轮船\n",j);

}
bool solve()			//求解复杂装载问题
{
	int sum=0;			//累计第一艘轮船装完后剩余的集装箱重量
	for (int j=1;j<=n;j++)
		if (x[j]==0)
			sum+=w[j];
	if (sum<=c2)			//第二艘轮船可以装完
		return true;
	else				//第二艘轮船不能装完
		return false;
}

int main()
{
	int op[MAXN];				//存放临时解
	memset(op,0,sizeof(op));
	int rw=0;
	for (int i=1;i<=n;i++)
		rw+=w[i];
	dfs(0,rw,op,1);				//求第一艘轮船的最优解
	printf("求解结果\n");
	if (solve())				//输出结果
	{
		printf("    最优方案\n");
		dispasolution(n);
	}
	else
		printf("    没有合适的装载方案\n");
}

2、实验二 求解图的m着色问题

问题描述】给定无向连通图G和m种不同的颜色。用这些颜色为图G的各顶点着色,每个顶点着一种颜色。如果有一种着色法使G中每条边的两个顶点着不同颜色,则称这个图是m可着色的。图的m着色问题是对于给定图G和m种颜色,找出所有不同的着色法。

输入格式】第1行有3个正整数n、k和m,表示给定的图G有n个顶点和k条边,m种颜色。顶点编号为1,2,…,n。接下来的k行中,每行有两个正整数u、v,表示图G的一条边(u,v)。

输出格式】程序运行结束时,将计算出的不同的着色方案数输出。如果不能着色,程序输出-1。

【输入样例】

5 8 4
1 2
1 3
1 4
2 3
2 4
2 5
3 4
4 5

【输出样例】

48

问题求解】对于图G,采用邻接矩阵a存储,根据求解问题需要,这里a为一个二维数组(下标0不用),当顶点i与顶点j有边时,置ai=1,其他情况置ai=0。

图中的顶点编号为1~n,着色编号为1~m。对于图G中的每一个顶点,可能的着色为1~m,所以对应的解空间是一棵m叉树,高度为n,层次i从1开始。

代码如下:

#include <stdio.h>
#include <string.h>
#define MAXN 20				//图最多的顶点个数

int n,k,m;
int a[MAXN][MAXN];
int count=0;				//全局变量,累计解个数
int x[MAXN];				//全局变量,x[i]表示顶点i的着色
bool Same(int i)			//判断顶点i是否与相邻顶点存在相同的着色
{
	for (int j=1;j<=n;j++)
		if (a[i][j]==1 && x[i]==x[j])
			return false;
	return true;
}
void dfs(int i)					//求解图的m着色问题
{
	if (i>n)					//达到叶子结点
		count++;				//着色方案数增1
	else
	{
		for (int j=1;j<=m;j++)	//试探每一种着色
		{
			x[i]=j;
			if (Same(i))		//可以着色j,进入下一个顶点着色
				dfs(i+1);
			x[i]=0;				//回溯
		}
	}
}
int main()
{
	memset(a,0,sizeof(a));		//a初始化
	memset(x,0,sizeof(x));		//x初始化
	int x,y;
	scanf("%d%d%d",&n,&k,&m);	//输入n,k,m
	for (int j=1;j<=k;j++)
	{
		scanf("%d%d",&x,&y);	//输入一条边的两个顶点
		a[x][y]=1;				//无向图的边对称
		a[y][x]=1;
	}
	dfs(1);						//从顶点1开始搜索
	if (count>0)				//输出结果
		printf("%d\n",count);
	else
		printf("-1\n");
	return 0;
}

3、实验三 求解活动安排问题

​ 【问题描述】假设有一个需要使用某一资源的n个活动所组成的集合S,S={1,…,n}。该资源任何时刻只能被一个活动所占用,活动i有一个开始时间bi和结束时间ei(bi<ei),其执行时间为ei-bi,假设最早活动执行时间为0。

​ 一旦某个活动开始执行,中间不能被打断,直到其执行完毕。若活动i和活动j有bi≥ej或bj≥ei,则称这两个活动兼容。

​ 设计算法求一种最优活动安排方案,使得所有安排的活动个数最多。

问题求解

活动编号i1234
开始时间bi1246
结束时间ei35810

调度方案(一种排列):x**[1],x[2],…,x[n]**

​ 第1步选择活动x[1]

​ …

​ 第i步选择活动x[i]

​ …

​ 第n步选择活动x[n]

  • 采用回溯法求解,相当于找到S={1,…,n}的某个排列即调度方案,使得其中所有兼容活动个数最多,显然对应的解空间是一个是排列树。
  • 直接采用排列树递归框架实现,对于每一种调度方案求出所有兼容活动个数,通过比较求出最多活动个数maxsum,对应的调度方案就是最优调度方案bestx,即为本问题的解。

求解过程

  • 产生所有排列,每个排列x=(x[1],x[2],…,x[n])对应一种调度方案
  • 计算每种调度方案的兼容活动个数sum
  • 比较求出最大的兼容活动个数maxsum和最优方案bestx

代码如下:

#include <stdio.h>
#include <string.h>
#define MAX 51

struct Action
{
	int b;					//活动起始时间
	int e;					//活动结束时间
};
int n=4;
Action A[]={{0,0},{1,3},{2,5},{4,8},{6,10}};	//下标0不用

int x[MAX];					//解向量
int bestx[MAX];				//最优解向量
int laste=0;				//一个方案中最后兼容活动的结束时间
int sum=0;					//一个方案中所有兼容活动个数
int maxsum=0;				//最优方案中所有兼容活动个数
void swap(int &x,int &y)	//交换x、y
{	int tmp=x;
	x=y; y=tmp;
}
void dispasolution()					//输出一个解
{
	printf("最优调度方案\n");
	int laste=0;
	for (int j=1;j<=n;j++)
	{
		if (A[bestx[j]].b>=laste)		//选取活动bestx[j]
		{
			printf("    选取活动%d: [%d,%d)\n",bestx[j],A[bestx[j]].b,A[bestx[j]].e);
			laste=A[bestx[j]].e;
		}
	}
	printf("  安排活动的个数=%d\n",maxsum);
}
void dfs(int i)							//搜索活动问题最优解
{
	if (i>n)							//到达叶结点,产生一种调度方案
    {
		if (sum>maxsum)
		{
			maxsum=sum;
			for (int k=1;k<=n;k++)
				bestx[k]=x[k];
		}
	}
	else
	{
		for(int j=i; j<=n; j++)				//没有到达叶结点,考虑i到n的活动
		{	//第i层结点选择活动x[j]
			int sum1=sum;					//保存sum,laste以便回溯
			int laste1=laste;
			if (A[x[j]].b>=laste)			//活动x[j]与前面兼容
			{
				sum++;						//兼容活动个数增1
				laste=A[x[j]].e;			//修改本方案的最后兼容时间
			}
			swap(x[i],x[j]);				//排序树问题递归框架:交换x[i],x[j]
			dfs(i+1);						//排序树问题递归框架:进入下一层
			swap(x[i],x[j]);				//排序树问题递归框架:交换x[i],x[j]
			sum=sum1;						//回溯
			laste=laste1;					//即撤销第i层结点对活动x[j]的选择,以便再选择其他活动
		}
	}
}
int main()
{
	for (int i=1;i<=n;i++)
		x[i]=i;
	dfs(1);								//i从1开始搜索
	dispasolution();					//输出结果
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

拾亿-唯一

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值