从零开始学回溯算法

2023年9月14日 | 分类: 【概念】

本文在写作过程中参考了大量资料,不能一一列举,还请见谅。

回溯算法的定义:回溯算法也叫试探法,它是一种系统地搜索问题的解的方法。
回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。

解题的一般步骤是:
1.定义一个解空间,它包含问题的解;
2.利用适于搜索的方法组织解空间;
3.利用深度优先法搜索解空间;
4.利用限界函数避免移动到不可能产生解的子空间。

问题的解空间通常是在搜索问题的解的过程中动态产生的,这是回溯算法的一个重要特性。

话不多说,我们来看几个具体的例子慢慢理解它:

1. 八皇后问题

该问题是国际西洋棋棋手马克斯·贝瑟尔于1848年提出:在 8×8 格的国际象棋上摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。

下面的解法参考了《算法竞赛入门经典》。

如果我们逐行放置皇后则肯定没有任意两个皇后位于同一行,只需要判断列和对角线即可。
使用一个二维数组 vis[3][] ,其中 vis[0][i] 表示列,vis[1][i] 和 vis[2][i] 表示对角线。
因为 (x,y) 的 y-x 值标识了主对角线,x+y 值标识了副对角线。由于 y-x 值可能为负,所以存取时要加上 n 。

#include<cstring> 
#include<iostream>
using namespace std;  
int vis[3][15],tot;  
 
void search(int cur)  
{  
    int i,j;  
    if(cur==8) tot++;
    else  
    {  
    	for(i=0;i<8;i++)  
        {  
            if(!vis[0][i]&&!vis[1][cur-i+8]&&!vis[2][cur+i])  
            {  
                vis[0][i]=1;  
                vis[1][cur-i+8]=1;  
                vis[2][cur+i]=1;    
                search(cur+1);  
                //改回辅助的全局变量 
                vis[0][i]=0;       
                vis[1][cur-i+8]=0;  
                vis[2][cur+i]=0;  
            }  
        }  
    }  
}  
 
int main()  
{  
    search(0);   
    cout<<tot<<endl;
}

2. 图的着色问题

给定无向连通图G=(V,E)和m种不同的颜色,用这些颜色为图G的各顶点着色,每个顶点着一种颜色。如果一个图最少需要m种颜色才能使图中每条边连接的2个顶点着不同颜色,则称m为该图的色数。地图着色问题可转换为图的着色问题:以地图中的区域作为图中顶点,2个区域如果邻接,则这2个区域对应的顶点间有一条边,即边表示了区域间的邻接关系。

著名的四色定理就是指每个平面地图都可以只用四种颜色来染色,而且没有两个邻接的区域颜色相同。

给定图和颜色的数目求出着色方法的数目,可以使用回溯法。

#define N 100
#include<iostream>
using namespace std;
int v,e,c,graph[N][N],color[N];
//顶点数,边数,颜色数 
int sum;
 
bool ok(int k)
{
	for(int j=1;j<=v;j++)
	{
		if(graph[k][j]&&(color[j]==color[k])) return false;
	}
	return true;
}
 
void backtrack(int t)
{
	if(t>v) sum++;
 	else
 	{
    	for(int i=1;i<=c;i++)
		{
			color[t]=i;
   			if(ok(t)) backtrack(t+1);
   			//改回辅助的全局变量 
   			color[t]=0;
		}
 	}
}
 
int main()
{
    int i,j;
    cin>>v>>e>>c;                
	for(i=1;i<=v;i++)
	{
		for(j=1;j<=v;j++)
		{
			graph[i][j]=0; 
		}
	}           
	for(int k=1;k<=e;k++)      
	{
		cin>>i>>j;
		graph[i][j]=1;
		graph[j][i]=1;
	}
	for(i=0;i<=v;i++) color[i]=0;
 	backtrack(1);
  	cout<<sum<<endl;
}

3. 装载问题

有一批共n个集装箱要装上2艘载重量分别为c1和c2的船,其中集装箱i的重量为wi,且

装载问题要求确定是否有一个合理的装载方案可将这些集装箱装上这2艘船。如果有,找出一种装载方案。例如当n=3,c1=c2=50且w=[10,40,40]时,则可以将集装箱1和2装到第一艘轮船上,而将集装箱3装到第二艘轮船上;如果w=[20,40,40],则无法将这3个集装箱都装上轮船。容易证明,如果一个给定装载问题有解,则首先将第一艘船尽可能装满再将剩余的集装箱装上第二艘船可得到最优装载方案。将第一艘船尽可能装满等价于选取全体集装箱的一个子集,使该子集中集装箱重量之和最接近c1。用回溯法解装载问题,  时间复杂度O(2^n),在某些情况下优于动态规划算法。剪枝方案是如果当前已经选择的全部物品载重量cw+剩余集装箱的重量r<=当前已知的最优载重量bestw,则删去该分支。

#include<iostream>
using namespace std;  
int n;//集装箱数  
int w[40];//集装箱重量
int c1,c2;//两艘船的载重量  
int ans;//当前载重量  
int bestans;//当前最优载重量  
int r;//剩余集装箱重量  
  
void backtrack(int i)  
{        
    if(i>n)    
    {  
		if(ans>bestans) bestans=ans;  
		return;  
    }  
    r-=w[i];  
    if(ans+w[i]<=c1)  
    {   
      ans+=w[i];  
      backtrack(i+1);  
      //改回辅助的全局变量 
      ans-=w[i];  
    }  
    if(ans+r>bestans) backtrack(i+1);    
    //改回辅助的全局变量 
    r+=w[i];  
}    
  
int maxloading()  
{  
	ans=0;  
	bestans=0;  
    backtrack(1);   
    return bestans;  
}
 
int main()  
{    
	cin>>n>>c1>>c2;  
 	int i=1;  
 	int sum=0;  
 	//集装箱总重量 
 	while(i<=n)  
	{  
		cin>>w[i];  
		r+=w[i];  
		sum+=w[i];  
 		i++;  
 	}    
	maxloading();  
	if(bestans>0&&((sum-bestans)<=c2)) cout<<bestans<<endl;  
 	else if(sum<=c2) cout<<bestans<<endl;  
  	else cout<<"No"<<endl;  
}

4. 批处理作业调度问题

给定n个作业的集合 {J1,J2,…,Jn} 。每个作业必须先由机器1处理,然后由机器2处理。作业Ji需(1≤i≤n)要机器j(1≤j≤2)的处理时间为tji。对于一个确定的作业调度,设Fji是作业i在机器j上完成处理的时间。所有作业在机器2上完成处理的时间和称为该作业调度的完成时间和:。要求对于给定的n个作业,制定最佳作业调度方案,使其完成时间和达到最小。

tji 机器1 机器2
作业1 2 1
作业2 3 1
作业3 2 3

例如,对于这张表格所示的情况,3个作业有3!=6种可能调度方案,很显然最坏复杂度即为O(n!)。如果按照2,3,1的顺序,则作业2的完成时间为4,作业3的完成时间为8,作业1的完成时间为9,完成时间和为21。最优的作业调度顺序为最佳调度方案是1,3,2,其完成时间和为18。

#define MAX 200
#include<iostream>
using namespace std;
int* x1;//作业Ji在机器1上的工作时间
int* x2;//作业Ji在机器2上的工作时间
int number=0;//作业的数目
int* xorder;//作业顺序
int* bestorder;//最优的作业顺序
int bestvalue=MAX;//最优的时间
int xvalue=0;//当前完成用的时间
int f1=0;//机器1完成的时间
int* f2;//机器2完成的时间
 
void backtrack(int k)
{
	if(k>number)
	{
		for(int i=1;i<=number;i++) bestorder[i]=xorder[i];
  		bestvalue=xvalue;
	}
	else
	{
		for(int i=k;i<=number;i++)
  		{
           f1+=x1[xorder[i]];
           f2[k]=(f2[k-1]>f1?f2[k-1]:f1)+x2[xorder[i]];
           xvalue+=f2[k];
           swap(xorder[i],xorder[k]);
           if(xvalue<bestvalue) backtrack(k+1);
           swap(xorder[i],xorder[k]);
           xvalue-=f2[k];
           f1-=x1[xorder[i]];
		}
	}
}
 
int main()
{
	cout<<"请输入作业数目:";
 	cin>>number;
	x1=new int[number+1];
 	x2=new int[number+1];
  	xorder=new int[number+1];
   	bestorder=new int[number+1];
   	f2=new int[number+1];
   	x1[0]=0;
   	x2[0]=0;
   	xorder[0]=0;
   	bestorder[0]=0;
    f2[0]=0;
    cout<<"请输入每个作业在机器1上所用的时间:"<<endl;
    int i;
    for(i=1;i<=number;i++)
    {
		cout<<"第"<<i<<"个作业=";
		cin>>x1[i];
  	}
	cout<<"请输入每个作业在机器2上所用的时间:"<<endl;
 	for(i=1;i<=number;i++)
  	{
   		cout<<"第"<<i<<"个作业=";
     	cin>>x2[i];
  	}
   	for(i=1;i<=number;i++) xorder[i]=i;
    backtrack(1);
    cout<<"最节省的时间为:"<<bestvalue<<endl;
    cout<<"对应的方案为:";
    for(i=1;i<=number;i++) cout<<bestorder[i]<<"  ";
    cout<<endl;
}

5. 再再论背包问题


从零开始学动态规划
从零开始学贪心算法中我们已经讨论过了背包问题,这里我们再次用回溯法求解经典的零一背包问题。

#include<iostream>
using namespace std;
int n,c,bestp;//物品个数,背包容量,最大价值
int p[10000],w[10000],x[10000],bestx[10000];//物品的价值,物品的重量,物品的选中情况
 
void backtrack(int i,int cp,int cw)
{
    if(i>n)
    {
        if(cp>bestp)
        {
            bestp=cp;
            for(i=1;i<=n;i++) bestx[i]=x[i];
        }
    }
    else
	{
        for(int j=0;j<=1;j++)  
        {
            x[i]=j;
            if(cw+x[i]*w[i]<=c)  
            {
                cw+=w[i]*x[i];
                cp+=p[i]*x[i];
                backtrack(i+1,cp,cw);
                cw-=w[i]*x[i];
                cp-=p[i]*x[i];
            }
        }
	}
}
 
int main()
{
    bestp=0; 
    cin>>c>>n;
    for(int i=1;i<=n;i++) cin>>w[i];
    for(int i=1;i<=n;i++) cin>>p[i];
    backtrack(1,0,0);
    cout<<bestp<<endl;
}

6. 最大团问题

给定无向图G=(V, E),U是V的子集。如果对任意u,v属于U有(u,v)属于E,则称U是G的完全子图。G的完全子图U是G的当且仅当U不包含在G的更大的完全子图中。G的最大团是指G中所含顶点数最多的团。如果对任意u,v属于U有(u, v)不属于E,则称U是G的空子图。G的空子图U是G的独立集当且仅当U不包含在G的更大的空子图中。G的最大独立集是G中所含顶点数最多的独立集。G的补图G’=(V’, E’)定义为V’=V且(u, v)属于E’当且仅当(u, v)不属于E。

如图所示,给定无向图G={V, E},其中V={1,2,3,4,5},E={(1,2),(1,4),(1,5),(2,3),(2,5),(3,5),(4,5)}。根据最大团定义,子集{1,2}是图G的一个大小为2的完全子图,但不是一个团,因为它包含于G的更大的完全子图{1,2,5}之中。{1,2,5}是G的一个最大团。{1,4,5}和{2,3,5}也是G的最大团。右侧图是无向图G的补图G’。根据最大独立集定义,{2,4}是G的一个空子图,同时也是G的一个最大独立集。虽然{1,2}也是G’的空子图,但它不是G’的独立集,因为它包含在G’的空子图{1,2,5}中。{1,2,5}是G’的最大独立集。{1,4,5}和{2,3,5}也是G’的最大独立集。

最大团问题可以用回溯法在O(n2^n)的时间内解决。首先设最大团为一个空团,往其中加入一个顶点,然后依次考虑每个顶点,查看该顶点加入团之后仍然构成一个团。程序中采用了一个比较简单的剪枝策略,即如果剩余未考虑的顶点数加上团中顶点数不大于当前解的顶点数,可停止回溯。用邻接矩阵表示图G,n为G的顶点数,cn存储当前团的顶点数,bestn存储最大团的顶点数。当cn+n-i<bestn时,不能找到更大的团,利用剪枝函数剪去。

#include<iostream>
using namespace std;
const int maxnum=101;
bool graph[maxnum][maxnum];
bool use[maxnum],bestuse[maxnum]; 
int cn,bestn,v,e;
 
void backtrack(int i)
{
    if(i>v)
    {
    	if(cn>bestn)
    	{
        	bestn=cn;
        	for(int j=1;j<=v;j++) bestuse[j]=use[j];
        	return;
    	}
    }
    bool flag=true;
    for(int j=1;j<i;j++)
    {
    	if(use[j]&&!graph[j][i])
        {
            flag=false;
            break;
        }
    }
    if(flag)
    {
        cn++;
        use[i]=true;
        backtrack(i+1);
        use[i]=false;
        cn--;
    }
    if(cn+v-i>bestn)  
    {
        use[i]=false;
        backtrack(i+1);
    }
}
 
int main()
{
    cin>>v>>e;
    for(int i=1;i<=e;i++)
    {
    	int p1,p2;
    	cin>>p1>>p2;
  		graph[p1][p2]=true;
  		graph[p2][p1]=true;
    }
    backtrack(1);
    cout<<bestn<<endl;
    for(int i=1;i<=v;i++) 
	{
		if(bestuse[i]) cout<<i<<" ";
	}
    cout<<endl;  
}

7. 圆排列问题

给定n个大小不等的圆c1,c2,…,cn,现要将这n个圆排进一个矩形框中,且要求各圆与矩形框的底边相切。圆排列问题要求从n个圆的所有排列中找出有最小长度的圆排列。例如,当n=3,且所给的3个圆的半径分别为1,1,2时,这3个圆的最小长度的圆排列如图所示。其最小长度为

注意,下面代码中圆排列的圆心横坐标以第一个圆的圆心为原点。所以,总长度为第一个圆的半径+最后一个圆的半径+最后一个圆的横坐标。

#include<cmath>
#include<iostream>
#include<algorithm>
using namespace std;
float minlen=10000,x[4],r[4];//当前最优值,当前圆排列圆心横坐标,当前圆排列
int n;//圆排列中圆的个数
 
//计算当前所选择圆的圆心横坐标
float center(int t)
{
    float temp=0;
	for(int j=1;j<t;j++)
	{
		//由x^2=sqrt((r1+r2)^2-(r1-r2)^2)推导而来
        float valuex=x[j]+2.0*sqrt(r[t]*r[j]);
		if(valuex>temp) temp=valuex;
    }
    return temp;
}
 
//计算当前圆排列的长度
void compute()
{
    float low=0,high=0;
	for(int i=1;i<=n;i++)
	{
		if(x[i]-r[i]<low) low=x[i]-r[i];
        if(x[i]+r[i]>high) high=x[i]+r[i];
    }
    if(high-low<minlen) minlen=high-low;
}
 
void backtrack(int t)
{
    if(t>n) compute();
    else
	{
		for(int j=t;j<=n;j++)
		{
			swap(r[t],r[j]);
			float centerx=center(t);
			if(centerx+r[t]+r[1]<minlen)
			{
				x[t]=centerx;
				backtrack(t+1);
			}
			swap(r[t],r[j]);
		}
	}
}
 
int main()
{
	n=3; 
	r[1]=1,r[2]=1,r[3]=2;
	cout<<"各圆的半径分别为:"<<endl;
	for(int i=1;i<=3;i++) cout<<r[i]<<" ";
	cout<<endl;
	cout<<"最小圆排列长度为:";
	backtrack(1);
	cout<<minlen<<endl;
}

上述算法尚有许多改进的余地。例如,像1,2,…,n-1,n和n,n-1, …,2,1这种互为镜像的排列具有相同的圆排列长度,只计算一个就够了。而且,如果所给的n个圆中有k个圆有相同的半径,则这k个圆产生的k!个完全相同的圆排列,也只需要计算一个。

8. 连续邮资问题

假设国家发行了k种不同面值的邮票,并且规定每张信封上最多只允许贴h张邮票。连续邮资问题要求对于给定的k和h的值,给出邮票面值的最佳设计,在1张信封上可贴出从邮资1开始,增量为1的最大连续邮资区间。例如,当k=5和h=4时,面值为(1,3,11,15,32)的5种邮票可以贴出邮资的最大连续邮资区间是1到70。UVA165就是一道这样的典型例题。用stampval来保存各个面值,用maxval来保存当前所有面值能组成的最大连续面值。那么,stampval[0] 一定等于1,因为1是最小的正整数。相应的,maxval[0]=1*h。接下去就是确定第二个,第三个……第k个邮票的面值了。对于stampval[i+1],它的取值范围是stampval[i]+1~maxval[i]+1。 stampval[i]+1是因为这一次取的面值肯定要比上一次的面值大,而这次取的面值的上限是上次能达到的最大连续面值+1, 是因为如果比这个更大的话, 那么就会出现断层, 即无法组成上次最大面值+1这个数了。 举个例子, 假设可以贴3张邮票,有3种面值,前面2种面值已经确定为1,2, 能达到的最大连续面值为6, 那么接下去第3种面值的取值范围为3~7。如果取得比7更大的话会怎样呢? 动手算下就知道了,假设取8的话, 那么面值为1,2,8,将无法组合出7。直接递归回溯所有情况, 便可知道最大连续值了。

#include<cstdio>  
#include<cstdlib>  
#include<cstring>   
#include<iostream>  
#define MAXN 200  
using namespace std;  
int h,k,ans[MAXN],stampval[MAXN],maxval[MAXN],maxstampval;  
bool vis[MAXN];  
 
//标记每种取到的钱数 
void mark(int n,int m,int sum)
{  
    if(m>h) return;  
	vis[sum]=true;
    for(int i=1;i<=n;++i) mark(n,m+1,sum+stampval[i]);    
}  
  
void backtrack(int cur)
{  
    if(cur>k)
	{  
        if(maxval[cur-1]>maxstampval)
		{  
            maxstampval=maxval[cur-1];  
            memcpy(ans,stampval,sizeof(stampval));  
        }  
        return;  
    }  
    for(int i=stampval[cur-1]+1;i<=maxval[cur-1]+1;++i)
	{  
        memset(vis,0,sizeof(vis));  
        stampval[cur]=i;  
        mark(cur,0,0);  
        int num=0,j=1;  
        while(vis[j++]) ++num;  
        maxval[cur]=num;  
        backtrack(cur+1);  
    }  
}  
  
int main()
{   
	while(scanf("%d %d",&h,&k),h+k)
	{  
		maxval[1]=h;  
        stampval[1]=1;  
        maxstampval=-1;  
        backtrack(2);  
        for(int i=1;i<=k;++i) printf("%3d",ans[i]);  
        printf("->%3d\n",maxstampval);  
    }    
} 

直接递归的求解复杂度太高,不妨尝试计算用不超过m张面值为x[1:i]的邮票贴出邮资k所需的最少邮票数y[k]。通过y[k]可以很快推出r的值。事实上,y[k]可以通过递推在O(n)时间内解决。这里就不再讲解了。

9. 符号三角形问题

下图是由14个“+”和14个“-”组成的符号三角形,第一行有n个符号。2个同号下面都是“+”,2个异号下面都是“-”。

符号三角形问题要求对于给定的n,计算有多少个不同的符号三角形,使其所含的“+”和“-”的个数相同。在第1行前i个符号x[1:i]确定后,就确定了1个由i(i+1)/2个符号组成的三角形。下一步确定第i+1个符号后,在右边再加1条边,就可以扩展为前i+1个符号x[1:i+1]对应的新三角形。这样依次扩展,直到x[1:n]。最终由x[1:n]所确定的符号三角形中含”+”号个数与”-“个数同为n(n+1)/4。因此,当前符号三角形所包含的“+”个数与“-”个数均不超过n*(n+1)/4,可以利用这个条件剪支。对于给定的n,当n*(n+1)/2为奇数时,显然不存在包含的”+”号个数与”-“号个数相同的符号三角形。在回溯前需要简单的判断一下。

#include<iostream>  
using namespace std;   
int n,half,counts,p[100][100],sum;
//第一行的符号个数,n*(n+1)/4,当前"+"号个数,符号三角矩阵,已找到的符号三角形数               
  
void backtrack(int t)  
{  
    if((counts>half)||(t*(t-1)/2-counts>half)) return;  
    if(t>n) sum++;    
    else  
    {  
		for(int i=0;i<2;i++)   
        {  
            p[1][t]=i;//第一行符号  
            counts+=i;//当前"+"号个数  
            for(int j=2;j<=t;j++)   
            {  
                p[j][t-j+1]=p[j-1][t-j+1]^p[j-1][t-j+2];  
                counts+=p[j][t-j+1];  
            }  
            backtrack(t+1);  
            for(int j=2;j<=t;j++)  
            {  
                counts-=p[j][t-j+1];  
            }  
            counts-=i;  
        }  
    }  
}   
 
int main()  
{
	cin>>n;  	
	half=n*(n+1)/2;  
    if(half%2==1)
    {
		cout<<"共有0个不同的符号三角形。"<<endl;
		return 0;
    }
    half=half/2;   
    backtrack(1);  
    cout<<"共有"<<sum<<"个不同的符号三角形。"<<endl;
} 

10. 集合划分问题

给定一个图,图中任意两点的距离已知,请你把这个图的所有的点分成两个子集,要求两个子集之间的所有点的距离和最大。对于图中的每一个点,我们可以设一个数组,用0和1表示属于哪个子集。

#include<iostream> 
using namespace std;   
int graph[25][25]; 
int set[25]; 
int ans,n;
    
void backtrack(int x,int sum) 
{  
    int temp;
    if(x>n) 
    {  
        if(sum>ans) ans=sum; 
        return; 
    } 
    //不选
    set[x]=0;
    temp=0;
    for(int i=1;i<=x;i++) 
    { 
        if(!set[i]) continue;
        temp+=graph[i][x];
    } 
    backtrack(x+1,sum+temp); 
    //选
    set[x]=1;
    temp=0; 
    for(int i=1;i<=x;i++) 
    { 
        if(set[i]) continue; 
        temp+=graph[i][x];
    }   
    backtrack(x+1,sum+temp);   
}  
   
int main() 
{  
    cin>>n;  
    for(int i=1;i<=n;i++) 
    { 
        for(int j=1;j<=n;j++) 
        { 
            cin>>graph[i][j];  
        } 
    }   
    backtrack(1,0); 
    cout<<ans<<endl;
}

关于回溯算法的基础知识就简要介绍到这里,希望能作为大家继续深入学习的基础。