目录
一.关于回溯法的一些东西
回溯算法实际是一个类似枚举的搜索尝试方法,它的主题思想是在搜索尝试中找问题的解,当不满足求解条件就“回溯”返回,尝试别的路径。回溯算法是尝试搜索算法中最为基本的一种算法,其采用了一种“走不通就掉头”的思想,作为其控制结构。
1.问题的解空间
一个复杂问题的解决方案是由若干个小的决策步骤组成的决策序列,解决一个问题的所有可能的决策序列构成该问题的解空间。
应用回溯法求解问题时,首先应该明确问题的解空间。解空间中满足约束条件的决策序列称为可行解。
一般来说,解任何问题都有一个目标,在约束条件下使目标达到最优的可行解称为该问题的最优解。
问题的解由一个不等长或等长的解向量X={x1,x2,…,xn}组成,其中分量xi表示第i步的操作。所有满足约束条件的解向量组构成了问题的解空间。
问题的解空间一般用树形式来组织,也称为解空间树或状态空间,树中的每一个结点确定所求解问题的一个问题状态。
在有些树中,所有的结点都是解状态,而有些树中,只有叶子结点才是解状态。通常情况下,从根结点到叶子结点(不含搜索失败的结点)的路径构成了解空间的一个可能解。
①子集树O(2^n)
当所给的问题是从n个元素的集合S中找出满足某种性质的子集时,相应的解空间树称为子集树。
②排列树O(n!)
当所给的问题是确定n个元素满足某种性质排列时,相应的解空间树称为排列树。
2.问题的状态
扩展结点:一个正在产生儿子的结点称为扩展结点
活结点:一个自身已生成但其儿子还没有全部生成的结点称做活结点
死结点:一个所有儿子已经产生的结点称做死结点
如下图所示,当从状态si搜索到状态si+1后,如果si+1变为死结点,则从状态si+1回退到si,再从si找其他可能的路径,所以回溯法体现出走不通就退回再走的思路。
若用回溯法求问题的所有解时,需要回溯到根结点,且根结点的所有可行的子树都要已被搜索完才结束。而若使用回溯法求任一个解时,只要搜索到问题的一个解就可以结束。
另外,回溯法搜索解空间时,通常采用两种策略避免无效搜索,提高回溯的搜索效率。
一是用约束函数在扩展结点处剪除不满足约束的子树;
二是用限界函数剪去得不到问题解或最优解的子树。这两类函数统称为剪枝函数。
3.算法的基本步骤
①针对问题,定义问题的解空间(对解进行编码)
②确定易于搜索的解空间组织结构(按树或图组织解)
③以深度优先方式搜索解空间,搜索过程中裁减掉死结点的子树提高搜索效率
4.算法框架
①问题框架:设问题的解是一个n维向量(a1,a2,……,an),约束条件是ai(i=1,2,3……n)之间满足某种条件,记为f(ai)
②非递归回溯框架
int a[n],i;
//初始化数组a[ ];
i=1;
while (i>0(有路可走)) && ([未达到目标]) //还未回溯到头
{
if (i>n) //搜索到叶结点
搜索到一个解,输出;
else //正在处理第i个元素
{
a[i]第一个可能的值;
while (a[i]在不满足约束条件 且 在搜索空间内)
a[i]下一个可能的值;
if (a[i]在搜索空间内)
{
标识占用的资源;
i=i+1;
} //扩展下一个结点
else
{
清理所占的状态空间;
i=i-1;
} //回溯
}
}
③递归框架
一般情况下用递归函数来实现回溯法比较简单,其中i为搜索深度。
int a[n];
try(int i)
{
if (i>n) 输出结果;
else
for( j=下界 ; j<=上界; j++) //枚举i所有可能的路径
{
if ( f(j) ) //满足限界函数和约束条件
{
a[i]=j;
…… //其它操作
try(i+ 1);} }
回溯前的清理工作(如a[i]置空值等);
}
}
二.算法实例
1.装载问题
问题描述
有一批共n个集装箱要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为wi,且装载问题要求确定是否有一个合理的装载方案可将这n个集装箱装上这2艘轮船。如果有,找出一种装载方案。
问题分析
如果一个给定装载问题有解,则采用下面的策略可得到最优装载方案。
(1)首先将第一艘轮船尽可能装满;
(2)将剩余的集装箱装上第二艘轮船。
将第一艘轮船尽可能装满等价于选取全体集装箱的一个子集,使该子集中集装箱重量之和最接近 c1。由此可知,装载问题等价于以下特殊的0-1背包问题。
问题解决
int bound(int t)
{
int rw = 0;
for(int i = t + 1;t <= n;t++)
rw += w[i];
return rw+cw;
}
void backtrack(int i)
{
if(i > n)//到达叶子节点
{
if(cw > bestw)
{
bestw = cw;
for(int i = 1;i <= n;i++)
bestx[i] = x[i];
}
return ;
}
else
{
if(cw + w[i] <= c)//搜索左子树
{
x[i] = -1;
cw += w[i];
backtrack(i + 1);
cw -= w[i];
x[i] = 0;
}
if(bount(i) > bestw)//右子树满足界限条件
{
backtrack(i + 1);
}
}
}
2.流水作业车间调度
问题描述
n个作业要在由2台机器M1和M2组成的流水线上完成加工。每个作业加工的顺序都是先在M1上加工,然后在M2上加工。M1和M2加工作业i所需的时间分别为ai和bi。流水作业调度问题要求确定这n个作业的最优加工顺序,使得从第一个作业在机器M1上开始加工,到最后一个作业在机器M2上加工完成所需的时间最少。作业在机器M1、M2的加工顺序相同。
问题分析
1)问题的解空间是一棵排列树,简单的解决方法就是在搜索排列树的同时,不断更新最优解,最后找到问题的解。用数组x(初值为1,2,3,……,n)模拟不同的排列,在不同排列下计算各种排列下的加工耗时情况。
2)机器M1进行顺序加工,其加工f1时间是固定的,f1[i]= f1[i-1]+a[x[i]]。机器M2则有空闲(图(1))或积压(图(2))的情况,总加工时间f2,当机器M2空闲时,f2[i]=f1+ b[x[i]];当机器M2有积压情况出现时,f2[i]= f2[i-1]+ b[x[i]]。总加工时间就是f2[n]。
3)一个最优调度应使机器M1没有空闲时间,且机器M2的空闲时间最少。在一般情况下,当作业按在机器M1上由小到大排列后,机器M2的空闲时间较少,当然最少情况一定还与M2上的加工时间有关,所以还需要对解空间进行搜索。排序后可以尽快地找到接近最优的解,再加入下一步限界操作就能加速搜索速度。
4)经过以上排序后,在自然数列的排列下,就是一个接近最优的解。因此,在以后的搜索过程中,一当某一排列的前面几步的加工时间已经大于当前的最小值,就无需进行进一步的搜索计算,从而可以提高算法效率。
问题解决
1)用二维数组job[100][2]存储作业在M1、M2上的加工时间。
2)由于f1在计算中,只需要当前值,所以用变量存储即可;而f2在计算中,还依赖前一个作业的数据,所以有必要用数组存储。
3)考虑到回溯过程的需要,用变量f存储当前加工所需要的全部时间。
void fun(int i)
{
if(i == n + 1)//到达叶子节点
{
for(int j = 1;j <= n;j++)
bestx[j] = x[j];
bestf = f;
}
else
{
for(int j = 1;j <= n;j++)
{
//先算f1再算f2,最后结果还是由f2起大作用
f1 += job[x[j]][1];
if(f2[i - 1] > f1)
f2[i] = f1 + job[x[j]][2];
else
f2[i] = f2[i - 1] + job[x[j]][2];
f += f2[i];
if(f < bestf)
{
swap(x[i], x[j]);
fun(i + 1);
swap(x[i], x[j]);
}
//更新f1和f
f1 -= job[x[j]][1];
f -= f2[i];
}
}
}
3.N皇后问题
①四皇后问题(不考虑实际的代码,考虑一下树的构造,没看懂)
问题描述
设有一4×4的棋盘,把4个皇后放在棋盘上,要求满足下列两个条件:
1)任意两个皇后不在同一行上和同一列上;
2)任意两个皇后不在同一条对角线上;
问有多少种放法?
问题分析
这棵排列树仅考虑了条件1)。这棵树也叫做该问题的解空间。结点的编号是按深度优先给出的。
算法:判断能否放置一个新棋子
bool place (k)
{ i ←1;
while i<k do
{if x(i)=x(k) || ABS(x(i)-x(k)) = ABS(i-k)
then return(false);
i ←i+1;}
return(true); }
②八皇后问题
问题描述
要在8*8的国际象棋棋盘中放八个皇后,使任意两个皇后都不能互相吃掉。规则:皇后能吃掉同一行、同一列、同一对角线的任意棋子。如下图为一种方案,求所有的解。
问题分析
不妨设八个皇后为xi,分别在第i行(i=1,2,3,4……,8),这样问题的解空间,就是一个八个皇后所在列的序号,为n元一维向量(x1, x2, x3, x4, x5, x6, x7, x8),搜索空间是1≤xi≤8(i=1,2,3,4……,8),共88个状态。约束条件是八个(1,x1) ,(2,x2) , (3,x3), (4,x4) , (5,x5), (6,x6) , (7,x7), (8,x8)不在同一行、同一列和同一对角线上。
虽然问题共有88个状态,但算法不会真正地搜索这么多的状态,因为前面已经说明,回溯法采用的是“走不通就掉头”的策略。
而形如(1, 1, x3, x4, x5, x6, x7, x8)的状态共有86个,由于1,2号皇后在同一列不满足约束条件,回溯后这86个状态是不会搜索的。
问题解决
法1:加约束条件的枚举算法(暴力循环,适用于n很小的时候)
最简单的算法就是通过八重循环模拟搜索空间中的88个状态,按深度优先思想,从第一个皇后从第一列开始搜索,每前进一步检查是否满足约束条件,不满足时,用continue语句回溯,满足约束条件,开始下一层循环,直到找出问题的解。
①约束条件不在同一列的表达式为xi ≠ xj;
②不在同一主对角线上时xi-i ≠ xj-j;
③不在同一负对角线上时xi+i ≠ xj+j
因此,不在同一对角线上的约束条件表示为abs(xi-xj) ≠ abs(i-j),abs()取绝对值
check(int a[], int n)
{
for(int i = 1;i <= n;i++)
if(abs(a[i] - a[n]) == abs(i - n) || a[i] == a[n])
return 0;
else
return 1;
}
queen()
{
int a[9];
for(a[1] = 1;a[1] <= 8;a[1]++)
for(a[2] = 1;a[2] <= 8;a[2]++)
{
if(check(a,2) == 0)
continue;
for(a[3] = 1;a[3] <= 8;a[3]++)
{
if(check(a,3) == 0)
continue;
......
for(a[8] = 1;a[8] <= 8;a[8]++)
{
if(check(a,8) == 0)
continue;
else
for(int i = 1;i <= 8;i++)
cout<<a[i]<<" ";
}
}
}
}
如果把每一层循环中的检查check去掉只保留最后check(a[],8)的检查,然后check中用双重循环进行遍历,就是盲目搜索,复杂性就是8^8
法2:非递归回溯
int a[20], n;
int check(int k)
{
for(int i = 1;i <= k - 1;k++)
if(abs(a[i] - a[k]) == abs(i - k) || (a[i] == a[k]))
return 0;
else
return 1;
}
backdate(int n)
{
a[1] = 0;
k = 1;
while(k > 0)
{
a[k]++;
while((a[k] < n) && (check(k) == 0))//搜索第k个皇后位置
a[k]++;
if(a[k] <= n)
{
if(k == n)//找到一组解
{
output();
}
else
{
k++;//为第k+1个皇后找到为止
a[k] = 0;
}
else//回溯
k--;
}
}
法3:递归
void nqueen(int a[], int t, int n)
{
if(t > n)//到头了,输出
{
output();
}
else
{
for(int i = 1;i <= n;i++)
{
a[t] = i;
if(check(t))
nqueen(a, t + 1, n);
}
}
}
4.0-1背包问题
问题描述
给定n种物品和一背包。物品i的重量是wi,其价值为vi,背包的容量为C。问应如何选择装入背包的物品,使得装入背包中物品的总价值最大?0-1背包问题是一个特殊的整数规划问题。
问题分析
法1:贪心算法
法2:动态规划
法3:回溯
设n件物品的重量分别为w1、w2、…、wn,用数组w[1..n]存放,物品的价值分别为v1、v2、…、vn,用数组v[1..n]存放。用x[1..n]数组存放最优解,其中每个元素取1或0,x[i]=1表示第i个物品放入背包中,x[i]=0表示第i个物品不放入背包中。这是一个求解优解问题。
问题的求解过程可用一棵二叉树来描述,每个结点表示背包的一种状态,记录当前放入背包的物品总重量和总价值,每个分枝结点下面有两条边表示对某项物品是否放入背包的两种可能的选择。
对第i层上的某个分枝结点来说,指向左孩子的边表示第i个物品放入背包,使背包中物品总重量增加w[i],总价值增加v[i],用tw表示搜索完该分枝结点后装入背包的总重量,tv表示相应的总价值。指向右孩子的边表示第i个物品不放入背包,背包中物品总重量和总价值保持不变。
可能的解结点都在最底层的叶子结点中。每个叶子结点表示在考虑了n个物品的取舍之后的一种最终状态。找出满足条件tw≤W &&tv>maxv的最大叶子结点,根据从根结点到叶子结点的路径信息,即可求出问题的解。
对于以下0/1背包问题,在限制背包总重量W=7时,描述问题求解过程的解空间树如下页图所示,每个结点中有两个数值,前者表示放入背包中物品的总重量,后者表示总价值。在所有树叶子结点中,虚线结点表示满足条件tw≤W的结点,其中带阴影的结点的总价值最大,该结点即为最优解结点。
物品编号 | 重量 | 价值 |
1 | 5 | 4 |
2 | 3 | 4 |
3 | 2 | 3 |
4 | 1 | 1 |
void knap(int w[], int v[], int W, int n, int i, int tw, int tv, int op[])
{
int j;
if(i > n)//找到头了
{
if(tw <= W && tv > maxv)//找到更优解,保存记录
{
maxv = tv;
maxw = tw;
for(j = 1;j <= n;j++)
x[j] = op[j];
}
}
else //未找完
{
op[i] = 1;//选取第i个物品
knap(w, v, W, n, i + 1, tw + w[i], tv + v[i], op);
op[i] = 0;//不选,回溯
knap(w, v, W, n, i + 1, tw, tv, op);
}
}
只对左子树进行限定,但没有对右子树进行限定,实际上对右子树进行限定很因难。假设最优解至少取其中的3个物品(也就是说不选取的物品数应小于等于1,这个条件不一定合理!),从而产生进一步剪枝后的解空间树。
综上,代码如下:
void knap(int w[], int v[], int W, int n, int i, int tw, int tv, int op[])
{
int j;
if(i > n)
{
if(tw <= W && tv > maxv)
{
maxv = tv;
maxw = tw;
for(j = 1;j <= n;j++)
x[j] = op[j];
}
}
else
{
if(tw + w[i] < W)//左孩子结点剪枝:满足条件时才放入第i个物品
{
op[i] = 1;
knap(w, v, W, n, i+1, tw + w[i], tv + v[i], op);
}
op[i] = 0;
m = 0;//m累计不选取的物品数
for(j = 0;j < i;j++)
if(op[i] == 0)
m++;
if(m <= 1)//右孩子结点剪枝:至少要选3个物品
knap(w, v, W, n, i+1, tw, tv, op);
}
}
5.图的m着色问题
问题描述
给定无向连通图G=(V, E)和m种不同的颜色,用这些颜色为图G的各顶点着色,每个顶点着一种颜色。是否有一种着色法使G中相邻的两个顶点有不同的颜色?
这个问题是图的m可着色判定问题。若一个图最少需要m种颜色才能使图中每条边连接的两个顶点着不同颜色,则称这个数m为该图的色数。求一个图的色数m的问题称为图的m可着色优化问题。
编程计算:给定图G=(V, E)和m种不同的颜色,找出所有不同的着色法和着色总数。
输入:第一行是顶点的个数n(2≤n≤10),颜色数m(1≤m≤n)。接下来是顶点之间的相互关系:a b表示a和b相邻。当a,b同时为0时表示输入结束。
输出:输出所有的着色方案,表示某个顶点涂某种颜色号,每个数字的后面有一个空格。最后一行是着色方案总数。
输入样例 | 输出样例 |
5 4 1 3 1 2 1 4 2 3 2 4 2 5 3 4 4 5 0 0 | 1 2 3 4 1 1 2 3 4 3 1 2 4 3 1 1 2 4 3 4 1 3 2 4 1 1 3 2 4 2 1 3 4 2 1 … 4 3 2 1 4 Total=48 |
问题分析
对m种颜色编号为1,2,…,m,由于每个顶点可从m种颜色中选择一种颜色着色,如果无向连通图G=(V, E)的顶点数为n,则解空间的大小为mn种,解空间是非常庞大的,它是一棵m叉树。
当n=3,m=3时的解空间树:
图的m着色问题的约束函数是相邻的两个顶点需要着不同的颜色,但是没有限界函数。
假设无向连通图G=(V, E)的邻接矩阵为a,如果顶点i和j之间有边,则a[i][j]=1,否则a[i][j]=0。
设问题的解向量为X (x1, x2 , …, xm) ,其中xi∈{1, 2, …, m},表示顶点i所着的颜色是x[i],即解空间的每个结点都有m个儿子。
#define NUM 100
int n;//图的顶点数量
int m;//可用颜色数量
int a[NUM][NUM];//图的邻接矩阵
int x[NUM];//当前的解向量
int sum;//已经找到的可m着色方案数量
bool same(int t)
{
for(int i = 1;i <= n;i++)
if(a[t][i] == 1 && x[i] == x[t])
return false;
return true;
}
void backtrack(int t)
{
if(t > n)
{
sum++;
for(int i = 1;i <= n;i++)
cout<<x[i]<<" ";
cout<<endl;
}
else
for(int i = 1;i <= m;i++)
{
x[t] = i;
if(same(t))
backtrack(t + 1);
x[t] = 0;
}
}
算法BackTrack(int t)中,对每个内部结点,其子结点的一种着色是否可行,需要判断子结点的着色与相邻的n个顶点的着色是否相同,因此共需要耗时O(mn),而整个解空间树的内部结点数是:
所以算法BackTrack(int t)的时间复杂度是:
6.着色之考试安排
问题描述
课程考试安排问题转化为图的着色问题
(1)用尽可能少的颜色该图的每个顶点着色,使相邻的顶点着上不同的颜色;
(2)每一种颜色代表一个考试时间,着上相同颜色的顶点是可以安排在同一时间考试的课程;
按顶点度数从大到小排列:F A E C B D
F: 蓝色 ; A,C: 红色 ;E,D: 绿色;B: 黄色 ;
即 A,C 可安排在同一时间考试,E,D可安排在同一时间考试;
7.旅行售货员问题(会画出空间树)
问题描述
旅行售货员问题的提法是:某售货员要到若干城市去推销商品,已知各城市之间的路程(或旅费)。他要选定一条从驻地出发,经过每个城市一遍,最后回到驻地的路线,使总的路程(或总旅费)最小。
设G=(V,E)是一个带权图。图中各边的费用(权)为一正数。图中的一条周游路线是包括V中的每个顶点在内的一条回路。一条周游路线的费用是这条路线上所有边的费用之和。所谓旅行售货员问题就是要在图G中找出一条有最小费用的周游路线。
8.填字游戏(作业)
问题描述
设计一个算法求解填字游戏问题,在3×3个方格的方阵中要填入数字1~10内的某9个数字,每个方格填一个整数,使所有相邻两个方格内的两个整数之和为素数。试求出所有满足这个要求的各种数字填法。
问题分析