回溯法
文章目录
1. 回溯法的基本原理、解空间的概念以及算法框架(子集树、排列树)
【基本原理】
回溯法实际上一个类似穷举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”(即回退),尝试别的路径。
回溯法搜索解空间时,通常采用两种策略【剪枝策略】避免无效搜索,提高回溯的搜索效率:
用 约束函数 在扩展结点处剪除不满足约束的子树;
用 限界函数 剪去得不到问题解或最优解的子树。
归纳起来,用回溯法解题的一般步骤如下:
① 针对所给问题,确定问题的解空间树,问题的解空间树应至少包含问题的一个(最优)解。
② 确定结点的扩展搜索规则。
③ 以深度优先方式 搜索解空间树,并在搜索过程中可以采用剪枝函数来避免无效搜索。
【解空间】
一个复杂问题的解决方案是由若干个小的决策步骤组成的决策序列,解决一个问题的所有可能的决策序列构成该问题的 解空间 。
解空间中满足约束条件的决策序列称为 可行解 。
在约束条件下使目标达到最优的可行解称为该问题的 最优解 。问题的解由一个不等长或等长的解向量 X={x 1,x 2,…,x n} 组成,其中分量 x i 表示第 i 步的 操作。
问题的解空间一般用树形式来组织,也称为 解空间树 或 状态空间。
树中的每一个结点确定所求解问题的一个问题状态。树的根结点位于第 1 层,表示搜索的初始状态,第 2 层的结点表示对解向量的第一个分量做出选择后到达的状态,以此类推。
【算法框架】
1. 子集树
当所给的问题是从 n 个元素的集合 S 中找出满足某种性质的子集时,相应的解空间树称为 子集树 。
int x[n]; //x 存放解向量,全局变量
void backtrack(int i) //求解子集树的递归框架
{
if(i>n) //搜索到叶子结点 输出一个可行解
输出结果;
else
{
for (j= 下界 ; 上界 ;j++) //用 j 枚举 i 所有可能的路径
{
x[i]=j; //产生一个可能的解分量
… //其他操作
if (constraint(i) && bound(i)
backtrack(i+1); //满足约束条件和限界函数 继续下一层
}
}
}
2. 排列树
当所给的问题是确定 n 个元素满足某种性质的排列时,相应的解空间树称为 排列树 。
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] 的恢复操作
}
}
}
2. 剪枝函数如何设计?回溯法相较于蛮力法的优势?
3. 0/1背包问题
【重量和恰好为W】
【问题描述】
有n个重量分别为{w1,w2,…,wn}的物品,它们的价值分别为 {v1,v2,…,vn},给定一个容量为W的背包。
设计从这些物品中选取一部分物品放入该背包的方案,每个物品要么选中要么不选中,要求选中的物品不仅能够放到背包中,而且重量和恰好为W具有最大的价值。以 W = 6 为例:
物品编号 | 重量 | 价值 |
---|---|---|
1 | 5 | 4 |
2 | 3 | 4 |
3 | 2 | 3 |
4 | 1 | 1 |
【问题求解】
【代码】
#include<iostream>
using namespace std;
#define MAXN 105
int n = 4, W = 6;
int w[] = { 0,5,3,2,1 }; //存放四个物品的重量,不包括下标0
int v[] = { 0,4,4,3,1 }; //存放四个物品的价值,不包括下标0
int x[MAXN]; //存放最终解
int maxv; //存放最终解的总价值
//采用解空间为子集树作为框架
//求解0/1背包问题
void dfs(int i, int tw, int tv, int op[])
{
if (i > n) //找到一个满足条件的更优解,保存
{
if (tw <= W && tv > maxv)
{
maxv = tv;
for (int j = 1; j <= n; j++)
x[j] = op[j];
}
}
else
{
op[i] = 1; //选取第i个物品
dfs(i + 1, tw + w[i], tv + v[i], op);
op[i] = 0; //不选取,回溯
dfs(i + 1, tw, tv, op);
}
}
void display()
{
cout << "最佳装填方案:";
cout << "选取物品 [";
for (int i = 0; i <= n; i++)
{
if (x[i] == 1)
cout << " " << i+1 << " ";
}
cout << "] ," << "总重量 =" << W << " 总价值 =" << maxv << endl;
}
int main()
{
int op[MAXN];
dfs(0, 0, 0, op);
display();
system("pause");
return 0;
}
【剪枝】
void dfs(int i, int tw, int rw, int tv, int op[])
{
if (i > n) //找到一个满足条件的更优解,保存
{
if (tw == W && tv > maxv)
{
maxv = tv;
for (int j = 1; j <= n; j++) //复制最优解
x[j] = op[j];
}
}
else
{
if (tw + w[i] <= W)//左剪枝
{
op[i] = 1; //选取第i个物品
dfs(i + 1, tw + w[i], rw-w[i], tv + v[i], op);
}
op[i] = 0; //不选取,回溯
if (tw + rw > W)//右剪枝
{
dfs(i + 1, tw, rw - w[i], tv, op);
}
}
}
【时间复杂度分析】
该算法不考虑剪枝时解空间树中有
2
n
−
1
−
1
2^{n-1}-1
2n−1−1 个结点,对应的算法时间复杂度为 O(
2
n
2^{n}
2n) 。
4. 装载问题
【简单装载问题】
【问题描述】
有 n 个集装箱要装上一艘载重量为 W 的轮船,其中集装箱 i (1 <= i <= n)的重量为 wi 。不考虑集装箱的体积限制,现要这些集装箱中选出若干装上轮船,使它们的重量之和等于 W ,当总重量相同时要求选取的集装箱个数尽可能少。
例如,n =5, W =10 ,w ={5, 2, 6 ,4 ,3} 时,其最佳装载方案是(0 ,0 ,1, 1 ,0 ),即装载第 3 、 4 个集装箱。
【代码】
#include<iostream>
using namespace std;
#define MAXN 105
//问题表示
int n = 5, W = 10;
int w[] = { 0,5,2,6,4,3 };
//结果表示
int maxw;//最优解重量
int x[MAXN];//最优解
int minnum = 999999;//存放最优解集装箱个数
void dfs(int num, int tw, int rw, int op[], int 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
{
if (tw + w[i] <= W)
{
op[i] = 1; //选取第i个物品
dfs(num + 1, tw + w[i], rw - w[i], op, i + 1);
}
if (tw + rw >= W)
{
op[i] = 0;
dfs(num, tw, rw - w[i], op, i + 1);
}
}
}
void showSolusion()
{
cout << "最佳装填方案:";
cout << "选取物品:[";
for (int i = 1; i <= n; i++)
{
if (x[i] == 1)
cout << " " << i << " ";
}
cout << "]," << "最优解重量为:" << maxw << endl;
cout << "最优解存放集装箱个数:" << minnum;
}
int main()
{
int op[MAXN];
int rw = 0;
for (int i = 1; i <= n; i++)
rw += w[i];
dfs(0, 0, rw, op, 1);
showSolusion();
system("pause");
return 0;
}
【复杂装载问题】
【问题描述】
有一批共 n 个集装箱要装上 两艘 载重量分别为 c1 和 c2 的轮船,其中集装箱 i 的重量为 w i ,且 w1+w2+… Wn< c1+c2 。装载问题要求确定是否有一个合理的装载方案可将这些集装箱装上这两艘轮船。如果有,找出一种装载方案。
例如,当n =3 ,c1=c2=50, w ={10 40 40} 时,则可以将集装箱 1和 2 装到第一艘轮船上,而将集装箱 3装到第二艘轮船上。如果 w ={20,40 40},则无法将这 3 个集装箱都装上轮船。
【问题求解】
首先将第一艘轮船尽可能装满,然后将剩余的集装箱装在第二艘轮船上。
【代码】
#include<iostream>
using namespace std;
#define MAXN 105
int n = 3, c1 = 50, c2 = 50;
int w[] = { 0,10,40,40 };
int x[MAXN];
int maxw;
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
{
if (tw + w[i] <= c1)
{
op[i] = 1;
dfs(tw + w[i], rw - w[i], op, i + 1);
}
if (tw + rw > c1)
{
op[i] = 0;
dfs(tw, rw - w[i], op, i + 1);
}
}
}
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;
}
void dispasolusion(int n)
{
cout << "装载方案:[ ";
for (int i = 1; i <= n; i++)
cout << x[i] << " ";
cout << "],最优解重量为:" << maxw << endl;
}
int main()
{
int op[MAXN];
int rw = 0;
for (int i = 1; i <= n; i++)
rw += w[i];
dfs(0, rw, op, 1);
cout << "求解结果:" << endl;
if (solve())
dispasolusion(n);
else
cout << "没有合适的装载方案" << endl;
system("pause");
return 0;
}
5. n皇后问题
【问题描述】
在n×n的方格棋盘上,放置n个皇后,要求每个皇后不同行、不同列、不同左右对角线。
【问题求解】
【代码】
//n皇后
#include<math.h>
#include<iostream>
using namespace std;
#define MAXN 20
int a[MAXN]; //存放皇后的列
int count = 0;
void dispasolusion(int n)
{
printf(" 第%d个解:", ++::count);
for (int i = 0; i <= n; i++)
cout << "皇后的行号:" << i << ",列号:" << a[i];
cout << endl;
}
bool place(int i, int j)
{
if (i == 1) return true;
int k = 1;
while (k < i)
{
if ((a[k] == j) || (abs(i - k) == abs(a[k] - j)))
return false;
k++;
}
return true;
}
void queen(int i, int n)
{
if (i > n)
dispasolusion(n);
else
{
for (int j = 1; j <= n; j++)
{
if (place(i, j))
{
a[i] = j;
queen(i + 1, n);
}
}
}
}
int main()
{
int n;
cin >> n;
cout << n << " 皇后的解如下:" << endl;
queen(1, n);
system("pause");
return 0;
}
【时间复杂度分析】
该算法中每个皇后都要试探n列,共n个皇后,其解空间是一棵子集树,不同于前面一般的二叉树子集树,这里每个结点可能有n棵子树。对应的算法时间复杂度为O(
n
n
n^{n}
nn)。