赞
踩
回溯法,又叫试探法,是一种寻找最优解的暴力搜寻法。由于暴力,回溯法的时间复杂度较高,因此在比较一些数字较大的问题,比如最短路径问题等时,运行时间一般比较长。在回溯法中,DFS(深度优先搜索)是一种很重要的工具。
(1)某一种可能情况向前探索,并生成一个子节点;
(2)过程中,一旦发现原来的选择不符合要求,就回溯至父亲结点,然后重新选择另一方向,再次生成子结点,继续向前探索;
(3)如此反复进行,直至求得最优解。
(1)针对具体问题,定义问题的解空间;
(2)确定易于搜索的解空间结构(数据结构的选择);
(3)一般以DFS的方式搜索解空间;
(4)在搜索过程中,可以使用剪枝函数等来优化算法。(剪枝函数:用约束函数和限界函数剪去得不到最优解的子树,统称为剪枝函数。)
解空间:顾名思义,就是一个问题的所有解的集合。(但这离我们要求的最优解还差很远。)
约束条件:有效解的要求,即题目的要求。
约束函数:减去不满足约束条件的子树的函数。
限界函数:去掉得不到最优解的结点的函数。
扩展结点:当前正在产生子结点的结点称为扩展结点。
回溯法处理的解空间类型主要分为以下两种:
子集树:当所给问题是从集合中找出满足某种性质的子集时,相应的解空间树称为子集树。
排列树:当所给问题是从集合中确定满足某种性质的排列时,相应的解空间树称为排列树。
DFS是一种遍历搜索图、树等数据结构的一种算法,更像一种工具;
而回溯法则是为了解决问题不断地生成又放弃一些解决方案(解空间在搜索问题的过程中动态产生是回溯法的一个重要特点),直至找到最优解或搜索完毕为止的一种方法,更像一种指导思想,在解空间中利用DFS进行全面的搜索。
剪枝就是在搜索过程中利用过滤条件来剪去完全不用考虑(已经判断这条路走下去得不到最优解)的搜索路径,从而避免了一些不必要的搜索,优化算法求解速度,当然还必须得保证结果的正确性。
应用到回溯算法中,我们可以提前判断当前路径是否能产生结果集,如果否,就可以提前回溯,这也叫做可行性剪枝。
另外还有一种叫做最优性剪枝,每次记录当前得到的最优值,如果当前结点已经无法产生比当前最优解更优的解时,可以提前回溯。
然而,剪枝的过滤条件不好找,想通过剪枝优化来提高算法高效性,既要保证结果正确性,还要保证剪枝的准确性。
01背包问题就是由子集树解决的一个经典问题。问题如下:
小明打算去拜访同学,他打算带一背包的巧克力作为礼物。他希望装进的巧克力总价值最高(这样可能比较好吃)。然而小明体力有限,巧克力包不能太重,只能有8kg。可供选择的巧克力如下:
序号 | 品牌 | 重量/kg | 价值 |
---|---|---|---|
1 | 费列罗 | 4 | 45 |
2 | 好时之点 | 5 | 57 |
3 | 德芙 | 2 | 22 |
4 | Cudie(西班牙) | 1 | 11 |
5 | 自制 | 6 | 67 |
因为我们考虑的是找子集,所以每个物品只有选与不选两种状态,因此解空间是一个二叉树。在这个树中,每一层的边表示对一个物品的选择与否。如上图所示,选择第一层点0与左边点1间的边,表示选择1号物品,也就是选择左子树走下去;如果不选择1号物品入包,则进入右子树,选择右边点1。那么,一共有n件物品,就有n层的边,n+1层点。最后一层的每一个叶结点分别表示一种选择法,一共有2n+1个叶结点,即解空间中共有2n+1种解,我们要在这些叶结点中选择最佳结点。
我们先给出利用回溯法搜索子树集的伪代码框架:
void search(层数)
{
if(搜索到最底层)
打印出结果解;
else
for(遍历当前层解)
{
if(合适解)
继续搜索;
撤销当前状态的影响; //回溯
}
}
回溯法讲究“暴力”。从暴力的角度思考,想把所有的尽量装满背包的搭配都找出来,需要标记每一种装法(每一个解)最大value,从而找到最优解。我们从第一种巧克力开始装,然后找下一个,判断能否装入,再递归,到达边界,比较,记录较优解,回溯,继续往下找……循环。从子集树的角度将,我们优先选择走左子树,也就是入包;当走到叶结点或不符合约束的重量条件时,回溯到父结点,进入右结点,最后遍历全树。
判断能否装入后可以用一个book数组来标记是否选择入包。
由以上思路写出01背包问题的算法如下:
// 01背包问题-回溯法-子集树 #include <iostream> int n, bag_v, bag_w; // 物品个数、背包最大价值、背包最大容量 int bag[100], x[100], w[100], val[100]; // bag[i]和x[i]表示当前物品是否被选中、物品的重量、价值 // search递归函数,当前节点背包的价值为cur_v(current value),重量为cur_w(current weight) void search(int cur, int cur_v, int cur_w) { if (cur > n) { // 判断子集树的边界 if (cur_v > bag_v) { // 子集树对应的背包价值 是否超过了 最大价值 bag_v = cur_v; // 得到最大价值 for (int i = 1; i <= n; ++i) bag[i] = x[i]; // x表示当前子集树各物品是否被选中,将选中的物品存入bag中 } } else { for (int j = 0; j <= 1; ++j) { // 遍历当前解层:j 代表是否选择该物品 x[cur] = j; if (cur_w + x[cur] * w[cur] <= bag_w) { // 满足重量约束,继续向前寻找配对 cur_w += w[cur] * x[cur]; cur_v += val[cur] * x[cur]; search(cur + 1, cur_v, cur_w); // 递归,下一层物品 // 清除痕迹,回溯上一层 cur_w -= w[cur] * x[cur]; cur_v -= val[cur] * x[cur]; x[cur] = 0; } } } } int main() { bag_v = 0; // 初始化背包最大价值 // 输入数据 std::cout << "请输入背包最大容量:" << std::endl; std::cin >> bag_w; std::cout << "请输入物品个数:" << std::endl; std::cin >> n; std::cout << "请依次输入物品的重量:" << std::endl; for(int i = 1; i <= n; i++) std::cin >> w[i]; std::cout << "请依次输入物品的价值:" << std::endl; for(int i = 1; i <= n; i++) std::cin >> val[i]; search(1, 0, 0); std::cout << "最大价值为:" << std::endl; std::cout << bag_v << std::endl; std::cout << "物品的编号依次为:" << std::endl; for(int i = 1; i <= n; ++i) { if(bag[i] == 1) std::cout << i << " "; } std::cout << std::endl; return 0; }
输出如下:
PS E:\Code\VSCode\Learning\build> .\main.exe
请输入背包最大容量:
8
请输入物品个数:
5
请依次输入物品的重量:
4 5 2 1 6
请依次输入物品的价值:
45 57 22 11 67
最大价值为:
90
物品的编号依次为:
2 3 4
我们可以用一个上界函数bound():当前价值+剩余容量可容纳的最大价值,去和目前的背包最大价值(也就是最优解)比较,如果bound()更小,那就没有继续搜索的意义了,剪去左子树,即不选择当前物品,进入右子树。
因为物品只有选与不选2个决策,而总共有n个物品,所以时间复杂度为O(2n)。因为递归栈最多达到n层,而且存储所有物品的信息也只需要常数个一维数组,所以最终的空间复杂度为O(n)。
那么,我们如何计算这个“剩余容量可容纳的最大价值”呢?首先,我们先将物品按照其单位重量价值从大到小排序,此后就按照顺序考虑各个物品。代码如下:
if(cur_w+w[cur]<=bag_w) //将物品cur放入背包,搜索左子树,即选择当前物品
{
cur_w+=w[cur]; //同步更新当前背包的重量
cur_v+=val[cur]; //同步更新当前背包的总价值
put[cur]=1;
search(cur+1,cur_v,cur_w); //深度搜索进入下一层
cur_w-=w[cur]; //回溯复原
cur_v-=val[cur]; //回溯复原
}
if(bound(cur+1,cur_v,cur_w)>bag_v) //如若符合条件则搜索右子树,即不选择当前物品
{
put[cur]=0;
search(cur+1,cur_v,cur_w);
}
当i<=n,重量超过限制时,leftw为负,我们得到的是一个达不到的理想最大价值,因为此时最后放入的物品单位价值较高,但无法完全塞进书包,我们就去掉多余的部分,只取一部分该物体入包。当然,这是做不到的。因此计算出的值是一个达不到的理想值。
当i>n,重量未超过限制时,则是可达到的最大价值。
这样就解释了这个上界函数的优化。可以看出,这是一个最优性剪枝优化,判断当前结点是否有机会产生更优解。
优化后的算法如下:
#include <iostream> int n, bag_v, bag_w; int bag[100], put[100], w[100], val[100], order[100]; double perp[100]; //按照单位重量价值排序,这里用冒泡 void bubblesort() { int i,j; int temporder = 0; double temp = 0.0; for(i = 1;i <= n; i++) perp[i] = val[i] / w[i]; //计算单位价值(单位重量的物品价值) for(i = 1; i <= n - 1; i++) { for(j = i + 1; j <= n; j++) if(perp[i] < perp[j]) //冒泡排序perp[], order[], sortv[], sortw[] { temp = perp[i]; //冒泡对perp[]排序交换 perp[i] = perp[i]; perp[j] = temp; temporder = order[i]; //冒泡对order[]交换 order[i] = order[j]; order[j] = temporder; temp = val[i]; //冒泡对val[]交换 val[i] = val[j]; val[j] = temp; temp = w[i]; //冒泡对w[]交换 w[i] = w[j]; w[j] = temp; } } } //计算上界函数,功能为剪枝 double bound(int i, int cur_v, int cur_w) { //判断当前背包的总价值cur_v + 剩余容量可容纳的最大价值 <= 当前最优价值 double leftw = bag_w - cur_w; //剩余背包容量 double b = cur_v; //记录当前背包的总价值cur_v,最后求上界 //以物品单位重量价值递减次序装入物品 while(i <= n && w[i] <= leftw) { leftw -= w[i]; b += val[i]; i++; } //装满背包 if(i <= n) b += val[i] / w[i] * leftw; return b; //返回计算出的上界 } void search(int cur, int cur_v, int cur_w) { //search递归函数,当前current节点的价值为current value,重量为current weight if(cur > n) //判断边界 { if(cur_v > bag_v) //是否超过了最大价值 { bag_v = cur_v; //得到最大价值 for(int i = 1; i <= n; i++) bag[order[i]] = put[i]; //put表示当前是否被选中,将选中的物品存入bag中 } } //如若左子节点可行,则直接搜索左子树 //对于右子树,先计算上界函数,以判断是否将其减去 if(cur_w + w[cur] <= bag_w) //将物品cur放入背包,搜索左子树,即选择当前物品 { cur_w += w[cur]; //同步更新当前背包的重量 cur_v += val[cur]; //同步更新当前背包的总价值 put[cur] = 1; search(cur + 1, cur_v, cur_w); //深度搜索进入下一层 cur_w -= w[cur]; //回溯复原 cur_v -= val[cur]; //回溯复原 } if(bound(cur + 1, cur_v, cur_w) > bag_v) //如若符合条件则搜索右子树,即不选择当前物品 { put[cur] = 0; search(cur + 1, cur_v, cur_w); } } int main() { int i; bag_v = 0; //初始化背包最大价值 //输入数据 std::cout << "请输入背包最大容量:" << std::endl;; std::cin >> bag_w; std::cout << "请输入物品个数:" << std::endl; std::cin >> n; std::cout << "请依次输入物品的重量:" << std::endl; for(i = 1; i <= n; i++) std::cin >> w[i]; std::cout << "请依次输入物品的价值:" << std::endl; for(i = 1; i <= n; i++) std::cin >> val[i]; for(i = 1; i <= n; i++) //新增的order数组,存储初始编号 order[i] = i; search(1, 0, 0); std::cout << "最大价值为:" << std::endl; std::cout << bag_v << std::endl; std::cout << "物品的编号依次为:" << std::endl; for(i = 1; i <= n; i++) if(bag[i] == 1) std::cout << i << " "; std::cout << std::endl; return 0; }
小明在去同学那前想了一想,准备顺便拜访各高校的高中同学。他打算从本校出发,途径高中同学所在的一些高校,最终回到自己学校。小明很懒,希望只走最短的路,同时不想在一个学校玩第二次,因为他们不是主要目标。如何制定一个旅行方案?
乍一看这个题目是不是和最短路径问题很像?但很可惜的是,最短路径不要求通过每一个点,还是有所不同。
排列树与子集树最大的区别在于,子集树的解是无序的子集,而排列树的解则包含整个集合的所有元素,我们从暴力的原则出发,将元素进行全排列。
{ } 外的数表示已经排好序,{ } 内的数表示尚未排序。
在排序树中,每一层选择一个数字排到队尾,因此对一个n元素的集合,树的第一层将有n个子结点,表示可选n个数放在队伍的第一个位置,一次分叉比前一次减少一个(因为已经确定了一个位置的元素);树共有n+1层(图中省略了最后一层),表示选择n次;叶结点共有 n! 个,表示组合数A,全排列共有 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[i],x[t]);
}
}
}
这里的swap是一个交换函数,对于一个排列,只要交换任意两数后就是一个新排列。constraint()和bound())分别是约束条件和限定函数(用于剪枝优化)。
为什么要用swap来交换,而不是把数据放入新数组呢?这是因为,当我们在原先存储数据的数组x内进行交换时,我们把排好序的元素放到了数组的前面,留下的数据则是未排序的。这样在我们进行for循环的时候就能从t开始,同时避免了重复遇到排过序的数,也不需要book记录等多余的代码。
// 旅行商问题-回溯法-排序树 #include <iostream> int n, t; int dis[100][100], x[100], bestroad[100]; int cur_dis, bestdis; const int INF=99999; void swap(int& a, int& b) //swap函数,交换 { int temp; temp = a; a = b; b = temp; } void backtrack(int t) { if (t == n) { //判断边界。很长的判断,不能到自己或到不了,要比当前最优解短 if (dis[x[n - 1]][x[n]] != 0 && dis[x[n]][1] != 0 &&(cur_dis + dis[x[n - 1]][x[n]] + dis[x[n]][1] < bestdis || bestdis == 0)) { //记录最优路径,最优距离 for (int j = 1; j <= n; j++) bestroad[j] = x[j]; bestdis = cur_dis + dis[x[n-1]][x[n]] + dis[x[n]][1]; return; } } else { for (int j=t;j<= n; j++) { if(dis[x[t]][x[j]]!=0&& (cur_dis + dis[x[t - 1]][x[t]] + dis[x[t]][1] < bestdis || bestdis == 0)) { swap(x[t], x[j]); cur_dis += dis[x[t]][x[t-1]]; backtrack(t+1); //回溯 cur_dis -= dis[x[t]][x[t-1]]; swap(x[t], x[j]); } } } } int main() { int i, j, m, a, b, c; std::cout << "输入城市数:" << std::endl; std::cin >> n; std::cout << "输入路径数:" << std::endl; std::cin >> m; //初始化邻接矩阵 for(i = 1; i <= n; i++) for(j = 1; j <= n; j++) dis[i][j] = 0; std::cout << "输入路径与距离:" << std::endl; //读入城市之间的距离 for(i = 1; i <= m; i++) { std::cin >> a >> b >> c; dis[a][b] = dis[b][a] = c; //无向图,两边都记录 } for(i = 1; i <= n; i++) x[i] = i; backtrack(2); std::cout << "最佳路径为:"; for (i = 1; i <= n; i++) std::cout << bestroad[i] << " --> "; std::cout << "1" << std::endl; std::cout << "最短距离为:" << bestdis; return 0; }
输出如下:
PS E:\Code\VSCode\Learning\build> ."E:/Code/VSCode/Learning/build/main.exe"
输入城市数:
4
输入路径数:
6
输入路径与距离:
1 2 30
1 3 6
1 4 4
2 3 5
2 4 10
3 4 20
最佳路径为:1 --> 4 --> 2 --> 3 --> 1
最短距离为:25
注意:
不同于最短路径,这里我们把 INF(即无路径连通)与0(即自身)放在一起处理,因为他们都不需要swap。
我们用t==n,而不是t>=n,是为了防止数组下表越界。
回溯法作为一种极暴力的搜索法,其时间复杂度是极高的,子集树大概是2n,排序树大概是n!,所以处理大的问题不太给力。但作为回报,它能给出真正的最优解。
回溯法的子集树和排序树,可以处理两类问题,求子集最优和排序最优。
想要利用剪枝函数优化是非常困难的。
参考文章:程序猿声:【算法学习】再谈回溯法
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。