1. 回溯法
回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法。
基本思想:
- 回溯法在问题的解空间树中进行深度优先搜索,从根结点出发当探索到某一结点时,要先判断该结点是否包含问题的解
- 如果包含,就从该结点出发继续按深度优先策略搜索
- 否则逐层向其祖先结点回溯(退回一步重新选择)
- 满足回溯条件的某个状态的点称为“回溯点”
- 算法结束条件:
- 求所有解:回溯到根,且根的所有子树均已搜索完成
- 求任一解:只要搜索到问题的一个解就可以结束
- 问题的解空间
- 问题的解空间应至少包含该问题的一个(最优)解
- 例如:对于有n种备选物品的0/1背包问题而言,解空间可以由长度为n的向量来表示,显然:该解空间包含了对该问题所有可能的解法
- 定义了问题的解空间后,可以将其组织成树或图的形式。例如:n = 3 的0/1背包问题,解空间可用一棵完全二叉树表示,从根到任一叶结点的路径表示解空间的一个元素
生成问题状态的基本方法:
- 基本概念
- 扩展结点:一个正在产生子结点的结点称为扩展结点
- 活结点:一个自身已生成但其子结点尚未全部生成的结点
- 死结点:一个所有子结点已经产生的结点称做死结点
- 深度优先的问题状态生成法
- 对一个扩展结点R,一旦产生了它的一个子结点C,则将其作为新扩展结点,并对以C为根的子树进行穷尽搜索;在完成对子树C的穷尽搜索后,将R重新变成扩展结点;继续生成R的下一个子结点,若存在,则对其进行穷尽搜索
- 宽度优先的问题状态生成法
- 在一个扩展结点变成死结点之前,它一直是扩展结点
回溯法的解题思路:
- 针对所给问题,定义问题的解空间
- 确定易于搜索的解空间结构
- 从根结点开始深度优先搜索解空间(利用剪枝避免无效搜索)
- 此时:根结点成为活结点,并成为当前的扩展结点
- 进一步的搜索从当前扩展结点开始,向纵深方向移至一个新结点,该新结点成为新的活结点,并成为当前扩展结点
- 若在当前扩展结点处不能再向纵深方向移动,则当前扩展结点变为死结点,此时应回溯至最近的活结点,将其作为当前扩展结点
- 回溯法以这种方式递归地在解空间中搜索
- 直至找到所要求的解,或者解空间中已经没有活结点为止
通用算法框架:
void backtrack (int t)
{
if (t>n)
output(x); //已到叶子结点,输出结果
else
// f(n,t),g(n,t)表示当前扩展结点处未搜索过的子树的起始编号和终止编号
for (int i=f(n,t);i<=g(n,t);i++) {
x[t]=h(i); // h(i):表示在当前扩展结点处x[t]的第i个可选值
//constraint(t)为true表示在当前扩展结点处x[1:t]的取值满足问题的约束条件
//bound(t)为true表示在当前扩展结点处x[1:t]的取值尚未导致目标函数越界
if (constraint(t)&&bound(t))
backtrack(t+1);
}
}
用回溯法解题时常用到两种典型的解空间树:子集树与排列树
- 第一类解空间树:子集树
- 当问题是:从n个元素的集合S中找出满足某种性质的子集时,相应的解空间树称为子集树
- 例如n个物品的0/1背包问题,这类子集树通常有 2n 个叶结点,解空间树的结点总数为 2n+1−1 ,遍历子集树的算法需Ω( 2n )计算时间
- 子集树回溯算法框架:
void backtrack (int t) {
if (t > n){
output(x);
}
else{
// 对当前扩展结点的所有可能取值进行枚举
for (int i = 0; i <= 1; i++) {
x[t] = i;
if (constraint(t) && bound(t)) backtrack(t+1);
}
}
}// 执行时,从Backtrack(1)开始
- 第二类解空间树:排列树
- 当问题是:确定n个元素满足某种性质的排列时,相应的解空间树称为排列树
- 例如旅行商问题,排列树通常有n!个叶结点,因此遍历排列树需要Ω(n!)计算时间
- 排列树回溯算法框架:
void backtrack (int t) {
if (t > n){
output(x);
}
else{
for (int i = t; i <= n; i++) {
swap(x[t], x[i]);
if (constraint(t) && bound(t)) backtrack(t+1);
swap(x[t], x[i]);
}
}
} // 调用Backtrack(1)前,首先将数组x初始化为单位排列[1,2, ..., n]
2. 示例: 旅行商问题
问题描述:
- 某推销员要去若干城市推销商品,已知各城市间的开销(路程或旅费),要求选择一条从驻地出发,经过每个城市一遍,最后回到驻地的路线,使总开销最小
- 形式化描述如下:给定带权图G=(V,E),已知边的权重为正数,图中的一条周游路线是包括V中每个顶点的一条回路,一条周游路线的开销是这条路线上所有边的权重之和,要求在图G中找出一条具有最小开销的周游路线
求解思路:
- 利用排列生成问题的回溯算法Backtrack()
- 解空间为X={12341, 12431, 13241, 13421, 14231, 14321}
- 构造解空间树
- 从根结点到任一叶结点的路径定义了图G的一条周游路线,例如:A->L 对应周游路线(1, 2, 3, 4, 1)
- 解空间树中的每个叶结点恰好对应于图G的每一条周游路线,解空间树中的叶结点个数为(n-1)!
backtrack(int i){
if (i>n){// 输出可行解,与当前最优解比较
if (sum + A[x[n]][1] < m || m = ∞ ) {
m = sum + A[x[n]][1];
for( k=1 ; k <= n; k++) S[k] = x[k];
}
}//sum记录(x[1],x[2]),…, (x[i-2],x[i-1])的距离和
else{
for( k = i ; k<=n; k++ )// 依次处理当前扩展结点的分支
if ( sum + A[x[i-1]][x[k]] < m || m = ∞ ){
swap( x[i], x[k]);
sum += A[x[i-1]][x[i]];
backtrack(i+1);
sum -= A[x[i-1]][x[i]];
swap(x[i],x[j]);
}
}
}// 初始调用:Backtrack(2
3. 示例: 0/1背包问题
问题描述:
设:n = 4,c = 7,p = [9, 10, 7, 4],w = [3, 5, 2, 1]
怎样计算价值上界?
- 易求得这四个物品的单位重量价值分别为:[3, 2, 3.5, 4]
- 按物品单位重量价值递减的顺序装入物品
- 依次装入物品4、3、1之后,剩余背包容量为1
- 所以只能容纳物品2的20%
- 得到解向量x = [1, 1, 0.2, 1],相应价值为22
- 虽然x并不是0/1背包问题的可行解,但它提供了一个最优的价值上界(最优值不超过22)
- 为便于计算上界函数,可先对物品按单位价值从大到小排序,对每个扩展结点,只需按顺序考查排在其后的物品即可
0/1背包问题的回溯算法:
backtrack(int i){
if( i > n) { // vc当前背包价值,m当前最优价值
m = ( m < vc )? vc : m; output(x);
} else { // wc当前背包重量
if ( wc + w[i] <= C ) { // 左子树(将 i 放入背包)
x[i]= 1; wc += w[i]; vc += v[i];
backtrack(i+1);
x[i]= 0; wc -= w[i]; vc -= v[i];
}
if(Bound(i+1) > m){ // 右子树(拿出物品i)
backtrack(i+1);
}
}
}
限界函数的实现:
// 根据当前背包内物品情况求出:当前可行解的价值上界
// w[i]和v[i]均已按物品单位价值递减顺序排好序
int Bound(int i){
int wr = c - wc; // 背包剩余容量
int vb = vc; // vc为当前背包价值
// 按单位价值递减顺序装入物品
while(i <= n && w[i] <= wr){
wr -= w[i]; vb += v[i]; ++i;
}
if(i <= n) vb += (v[i]/w[i])*wr; // 继续装满背包
return vb; // 返回背包价值上界
}
4. 示例: 装载问题
问题描述:
- 有n个集装箱要装上2艘载重量分别为 c1 和 c2 的轮船,其中集装箱 i 的重量为 wi,且 ∑ni=1wi⩽c1+c2
- 装载问题要求:确定是否有一个合理的装载方案可将这n个集装箱装上这2艘轮船?如果有,找出一种装载方案。
- 示例:n=3,c1=c2=50
- 若:w=[10, 40, 40],则可以将集装箱1和2装到第一艘船上,将3号集装箱装到第二艘船上
- 若:w=[20,40,40],则无法将这三个集装箱全部装船
问题分析:
- 若给定问题有解
- 首先将第一艘轮船尽可能装满
- 将剩余的集装箱装上第二艘轮船
- 将第一艘轮船尽可能装满等价于
- 选取全体集装箱的一个子集
- 使该子集中集装箱重量之和最接近 c1
- 由此可知,装载问题等价于特殊的0-1背包问题
算法设计:
- 解空间的表达:子集树
- 剪枝函数(1)
- 约束函数: ∑ni=1wixi⩽c1 ,减去不满足该约束的子树
- 剪枝函数(2): 限界函数(用于剪去不含最优解的子树)
- 设:R是解空间树第k层上的当前扩展结点
- 设:wc表示当前结点对应的的装载重量 wc=∑ki=1wixi
- 设:wm表示当前的最优载重量
- 设:wr表示剩余集装箱的重量 wr=∑ni=k+1wi
- 定义限界函数为: w=wc+wr
- 以R为根的子树中任一叶结点对应的载重量均不会超过w
- 因此当 w≤wm 时,可将R的右子树剪去
代码:
void backtrack (int i) {
if (i > n){
if(wc > wm) wm = wc; return;
}
wr -= w[i];
if (wc + w[i] <= c){ // x[i] = 1; 搜索左子树
wc += w[i];
backtrack(i+1);
wc -= w[i];
}
if (wc + wr > wm){ // x[i] = 0; 搜索右子树
backtrack(i+1);
}
wr += w[i];
}
5. 示例: n-皇后问题
问题描述:
- 根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子
- n-皇后问题
- 在n×n的棋盘上放置彼此不受攻击的n个皇后
- 即:任何2个皇后不放在同一行或同一列或同一斜线上
问题分析:
- 问题的解向量:(x1, x2, … , xn)
- 采用数组下标 i 表示皇后所在的行号
- 采用数组元素x[i]表示皇后 i 的列号
- 采用解空间树:子集树
- 剪枝函数
- 显约束(对解向量的直接约束):xi =1, 2, … , n
- 隐约束1:任意两个皇后不同列 :xi ≠ xj
- 隐约束2:任意两个皇后不处于同一对角线,即 |i-j| ≠ |xi - xj|
代码:
bool Bound(int k){
for (int i = 1; i < k; i++){
if ((abs(k-i)==abs(x[i]-x[k]))||(x[i]==x[k]))
return false;
}
return true;
}
void Backtrack(int t){
if (t>n) output(x);
else {
for (int i = 1; i <= n; i++) {
x[t] = i;
if (Bound(t)) Backtrack(t+1);
}
}
}