实验四 回溯法
一、实验目的
1、理解回溯法的概念;
2、掌握回溯法的基本要素;
3、掌握回溯法的解题步骤与算法框架;
4、通过应用范例学习回溯法的设计技巧与策略。
二、实验内容和要求
实验要求:通过上机实验进行算法实现,保存和打印出程序的运行结果,并结合程序进行分析,上交实验报告和程序文件。
实验内容:
1、实现旅行售货员问题的回溯算法:某售货员要到若干城市去推销商品,已知各城市之间的路线(或旅费)。要选定一条从驻地出发,经过每个城市一遍,最后回到驻地的路线,使总的路程(或总旅费)最小。
2、使用回溯算法解决0-1背包问题。
3、使用回溯算法解决迷宫问题:以一个M×N的长方阵表示迷宫,0和1分别表示迷宫中的通路和障碍。设计一个程序,对任意设定的迷宫,求出一条从入口到出口的通路,或得出没有通路的结论。
(1)根据二维数组,输出迷宫的图形。
(2)探索迷宫的四个方向:RIGHT为向右,DOWN向下,LEFT向左,UP向上,输出从入口到出口的行走路径。
运行示例:
请输入迷宫的行数
9
请输入迷宫的列数
8
请输入9行8列的迷宫
0 0 1 0 0 0 1 0
0 0 1 0 0 0 1 0
0 0 1 0 1 1 0 1
0 1 1 1 0 0 1 0
0 0 0 1 0 0 0 0
0 1 0 0 0 1 0 1
0 1 1 1 1 0 0 1
1 1 0 0 0 1 0 1
1 1 0 0 0 0 0 0
有路径
路径以下:
三、算法思想分析
(一)回溯法
1. 基本思想
回溯法是一种“能进则进,进不了则换,换不了就退”的基本搜索方法。
首先需要为问题定义一个解空间,这个解空间必须至少包含问题的一个解(可能是最优的)。在问题的解空间树中,按深度优先策略,从根节点出发搜索解空间树。算法搜索至解空间树的任一结点时,先判断该结点是否包含问题的解。如果不包含,则跳过该结点为根的字数,逐层对其他祖先结点回溯。否则,进入该子树,继续按照深度优先策略搜索。
2. 相关概念
解空间:对问题的一个实例,所有满足显性约束条件的多元解向量组成了该实例的一个解空间。一方面有助于快速找到问题解,另一方面可以防止遗漏部分可行解。回溯法的解空间可以组织成一棵树,通常有两类典型的解空间树:子集树和排列树。
子集树:当所给的问题是从n个物体r集合中找出满足某种性质的子集时,相应的解空间树成为子集树。每增加一个新元素,都使子集个数加倍,因此对于n个元素有2n个子集。
排序树:当所给的问题是从n个元素的集合中找出满足某种性质的排列时,相应的解空间树成为排序树。对于n个元素,得到不同排列的总数为n!,即排序树中至少有n!个叶节点,因此任何算法遍历排列树所需运行时间为O(n!)
3. 基本步骤
运用回溯法解题通常包含以下三个步骤:
1)针对所给问题,定义问题的解空间;
2)确定易于搜索的解空间结构;
3)以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索;
其中,通过深度优先搜索思想完成回溯的完整过程如下:
- 设置初始化的方案;
- 变换方式去试探,若全部试完则转到⑦;
- 判断此法是否成功(通过约束函数),不成功则转②;
- 试探成功则前进一步再试探;
- 正确方案还未找到则转②;
- 已找到一种方案则记录并打印;
- 退回一步(回溯),若未退到头则转②;
- 已退到头则结束或打印无解。
(二)实验内容一
旅行售货员问题是寻求单一旅行者由起点出发,通过所有给定的需求点之后,最后再回到原点的最短路径(或旅费)。
1. 解空间
各城市间的路线是一个带权图。其中,权为各路线的长度(或旅费),端点为城市。旅行售货员的一条周游路线,就是连接图中所有顶点的一条回路。周游路线的路程(或费用)是这条回路上所有边的权值之和。
旅行售货员问题的解空间是一棵排序树。从树的根结点到任一叶子结点的路径就是一条周游路线,我们只需找出在图G中路程(或费用)最小的周游路线即可。
假设起点为1。算法开始时 x = [1, 2, 3, …, n]。x[1 : n]有两重含义 x[1 : i]代表前 i 步按顺序走过的城市, x[i + 1 : n]代表还未经过的城市。利用Swap函数进行交换位置。
2. 约束条件
- 路径中相邻城市之间有路径相连。即当搜索的层次i < n 时,判断从x[i - 1]到x[i] 之间是否存在一条边,若存在则x [1 : i ] 构成了图G的一条路径。
- 最后一个城市与第一个城市有路径相连。即当前搜索的层次i = n(处在排列树的叶节点的父节点上)时,判断此时图G是否存在从顶点x[n-1] 到顶点x[n] 的一条边,和从顶点x[n] 到顶点x[1] 的一条边。若两条边都存在,则求得一个旅行售货员的回路,继续判断这条回路的路程(或费用)是否优于已经找到的当前最优回路。若是,则更新当前最优值minRoad和当前最优解bestX。
3. 限界条件
若路径x[1: i] 走过的路径长度(或费用)小于当前最优解,则算法进入排列树下一层;否则没有必要继续搜索,剪掉相应的子树。
(三)实验内容二
0-1背包问题,给定一个容量为C的背包,n个物品,物品的体积为Ci,价值为Vi,1<=i<=n,要求使装入背包内的物品价值总量最大。
1. 解空间
解空间为用户输入的N个物品的子集,是一棵子集树。
2. 约束条件
规定放入背包的物品体积之和小于等于背包容量。
3. 限界条件
为了更好地计算上界,首先对剩余物品按单位价值进行从大到小的排序,先装价值大的,后装价值小的,直到不能装下时,再装入该物品的一部分,从而填满背包,获得上界。
在我的代码实现中,在完成输入背包的物品数量和容量以及的物品体积和价值后,通过sort()方法对物品进行价值排序,利用Backtrack()方法对子集树从根节点开始,采用深度优先算法遍历树,同时每一次遍历调用 Bound()限界函数判断是否剪枝。
(四)实验内容三
用一个二维数组来定义迷宫的初始状态,1表示为墙不能走,0表示路可以走。而后从入口(左上角)开始,顺着某一个方向前进,若能走通则继续往前进;否则沿着原路退回,换一个方向继续探索,直至出口位置,求得一条通路。假如所有可能的通路都探索到而未能到达出口,则所设定的迷宫没有通路。
四、程序代码
(一)实验内容一
#include <bits/stdc++.h>
using namespace std;
const int max_ = 0x3f3f3f; //定义一个最大值
int cityNum; //城市数
int edgeNum; //边数
int nowRoad; //记录当前的路程
int minRoad; //记录最小的路程(最优)
int Graph[100][100]; //图的边距记录
int x[100]; //记录行走顺序
int bestX[100]; //记录最优行走顺序
void InPut() {
cout << "请输入城市数量和道路数量:" << endl;
cin >> cityNum >> edgeNum;
memset(Graph, -1 , sizeof(Graph)); //初始化所有城市间没有道路
cout << "请输入两座城市之间的距离(格式:城市 城市 距离):" << endl;
int pos1, pos2, len;
for(int i = 1; i <= edgeNum; ++i) {
cin >> pos1 >> pos2 >> len;
Graph[pos1][pos2] = Graph[pos2][pos1] = len;
}
}
//初始化行走路线
void Init() {
nowRoad = 0;
minRoad = max_;
for(int i = 1; i <= cityNum; ++i) {
x[i] = i;
}
}
void Swap(int &a, int &b) {
int temp;
temp = a;
a = b;
b = temp;
}
//计算第i步去的城市
void BackTrack(int i) {
//判断到最后一个城市
if(i == cityNum) {
if(Graph[x[i - 1]][x[i]] != -1 && Graph[x[i]][x[1]] != -1
&& (nowRoad + Graph[x[i - 1]][x[i]] + Graph[x[i]][x[1]] < minRoad || minRoad == max_)) {
//最小距离 = 当前的距离 + 当前城市到叶子城市的距离 + 叶子城市到初始城市的距离
minRoad = nowRoad + Graph[x[i - 1]][x[i]] + Graph[x[i]][x[1]];
for(int j = 1; j <= cityNum; ++j) bestX[j] = x[j];
}
}
else {
for(int j = i; j <= cityNum; ++j) {
if(Graph[x[i - 1]][x[j]] != -1 && (nowRoad + Graph[x[i - 1]][x[j]] < minRoad || minRoad == max_)) {
Swap(x[i], x[j]);
nowRoad += Graph[x[i - 1]][x[i]];
BackTrack(i + 1); //递归判断下一步
nowRoad -= Graph[x[i - 1]][x[i]];
Swap(x[i], x[j]);
}
}
}
}
void OutPut() {
cout << "最短路程为:" << minRoad << endl;
cout << "具体路线为:" ;
for(int i = 1; i <= cityNum; ++i) cout << bestX[i] << "——> ";
cout << "1" << endl;
}
int main() {
InPut();
Init();
BackTrack(2);
OutPut();
}
(二)实验内容二
#include <iostream>
using namespace std;
int n; //物品数量
int bagC; //背包容量
double c[1000]; //物品体积
double v[1000]; //物品价值
double vc[1000]; //物品单位重量价值
int order[1000]; //物品编号1~n
int bestX[1000]; //记录回溯过程的最优情况
int flag[1000]; //记录当前物品是否装入背包
int maxV = 0; //最大价值
int nowC = 0; //当前体积
int nowV = 0; //当前价值
void InPut() {
cout << "请输入物品数量:";
cin >> n;
cout << "请输入背包容量:";
cin >> bagC;
cout << "请输入所有物品重量:" << endl;
for(int i=1; i<=n; ++i) cin >> c[i];
cout << "请输入所有物品价值:" << endl;
for(int i=1; i<=n; ++i) cin >> v[i];
for(int i=1; i<=n; ++i) order[i] = i;
}
void sort() {
for (int i = 1; i <= n; ++i) vc[i] = v[i] / c[i]; //单位重量价值
//将vc[]从大到小排序,并对应改变order[]、v[]、c[]
for (int i = 1; i <= n - 1; i++) {
for (int j = i + 1; j <= n; j++) {
if (vc[i] < vc[j]) {
{
double temp;
temp = vc[i];
vc[i] = vc[i];
vc[j] = temp;
temp = order[i];
order[i] = order[j];
order[j] = temp;
temp = v[i];
v[i] = v[j];
v[j] = temp;
temp = c[i];
c[i] = c[j];
c[j] = temp;
}
}
}
}
}
//限界函数:该函数返回装入所有剩余物品后(不能超过c的前提下)的价值
int Bound(int i) {
int cleft = bagC - nowC; //剩余容量
int value = nowV;
while(i <= n && c[i] <= cleft) {
cleft -= c[i];
value += v[i];
++i;
}
if(i<=n) value += v[i] * cleft / c[i];
return value;
}
void Backtrack(int i) {
if(i > n) {
//到达根节点且根节点处理完毕
for(int i = 1; i <= n; i++) {
bestX[i] = flag[i]; //记录回溯的最优情况
}
maxV=nowV;
} else {
if(nowC + c[i] <= bagC) {
//满足约束条件进入左子树,物品放入
flag[i] = 1;
nowC += c[i];
nowV += v[i];
Backtrack(i+1);
nowC -= c[i];
nowV -= v[i];
}
if(Bound(i+1) > maxV) {
//满足限界函数进入右子树,物品不装入
flag[i] = 0;
Backtrack(i+1);
}
}
}
void OutPut() {
cout<<"背包可装入的最大价值为:"<<maxV<<endl;
cout<<"放入背包的物品编号为:" ;
for(int i=1; i<=n; ++i)
if(bestX[i] == 1) cout << order[i] << " ";
}
int main() {
InPut();
sort();
Backtrack(1); //从根节点开始回溯
OutPut();
return 0;
}
(三)实验内容三
package migong;
import java.util.*;
class Position{
int row; //行数
int col; //列数
public Position() {}
public Position(int row, int col){
this.col = col;
this.row = row;
}
public String toString(){
return "(" + row + " ," + col + ")";
}
}
class Maze{
int maze[][];
private int row;
private int col;
Stack<Position> stack;
boolean p[][] = null;
public Maze(){
maze = new int[15][15];
stack = new Stack<Position>();
p = new boolean[15][15];
}
//构造迷宫
public void init(){
try (Scanner scanner = new Scanner(System.in)) {
System.out.println("请输入迷宫的行数:");
row = scanner.nextInt();
System.out.println("请输入迷宫的列数:");
col = scanner.nextInt();
System.out.println("请输入" + row + "行" + col + "列的迷宫:");
for(int i = 0; i < row; ++i) {
for(int j = 0; j < col; ++j) {
int temp = scanner.nextInt();
maze[i][j] = temp;
p[i][j] = false;
}
}
}
}
//回溯迷宫,查看是否有出路
public void findPath(){
//给原始迷宫周围加一圈围墙
int temp[][] = new int[row + 2][col + 2];
for(int i = 0; i < row + 2; ++i) {
for(int j = 0; j < col + 2; ++j) {
temp[0][j] = 1;
temp[row + 1][j] = 1;
temp[i][0] = temp[i][col + 1] = 1;
}
}
//将原始迷宫复制到新迷宫中
for(int i = 0; i < row; ++i) {
for(int j = 0; j < col; ++j) {
temp[i + 1][j + 1] = maze[i][j];
}
}
//从左上角开始按照顺时针开始查询
int i = 1;
int j = 1;
p[i][j] = true;
stack.push(new Position(i, j));
while (!stack.empty() && (!(i == (row) && (j == col)))) {
if ((temp[i][j + 1] == 0) && (p[i][j + 1] == false)) {
p[i][j + 1] = true;
stack.push(new Position(i, j + 1));
j++;
} else if ((temp[i + 1][j] == 0) && (p[i + 1][j] == false)) {
p[i + 1][j] = true;
stack.push(new Position(i + 1, j));
i++;
} else if ((temp[i][j - 1] == 0) && (p[i][j - 1] == false)) {
p[i][j - 1] = true;
stack.push(new Position(i, j - 1));
j--;
} else if ((temp[i - 1][j] == 0) && (p[i - 1][j] == false)) {
p[i - 1][j] = true;
stack.push(new Position(i - 1, j));
i--;
} else {
stack.pop();
if(stack.empty()) break;
i = stack.peek().row;
j = stack.peek().col;
}
}
Stack<Position> newPos = new Stack<Position>();
if (stack.empty()) {
System.out.println("没有路径");
} else {
System.out.println("有路径");
System.out.println("路径如下:");
while (!stack.empty()) {
Position pos = new Position();
pos = stack.pop();
newPos.push(pos);
}
}
//图形化输出路径
String resault[][]=new String[row+1][col+1];
for(int k=0;k<row;++k){
for(int t=0;t<col;++t){
resault[k][t]=(maze[k][t])+"";
}
}
while (!newPos.empty()) {
Position p1=newPos.pop();
resault[p1.row-1][p1.col-1]="#";
}
for(int k=0;k<row;++k){
for(int t=0;t<col;++t){
System.out.print(resault[k][t]+"\t");
}
System.out.println();
}
}
}
class miGong {
public static void main(String[] args){
Maze demo = new Maze();
demo.init();
demo.findPath();
}
}
五、结果运行与分析
(一)实验内容一
由于解是图顶点的一个排序,第1个顶点已经确定,因而一共有O((n-1)!)种可能,每次更新需要O(n)来更新bestX的值,因此总的时间复杂度为O(n!)
(二)实验内容二
回溯算法的运行时间取决于它在搜索过程中所生成的结点数,而限界函数可以大大减少生成结点的个数,避免无效搜索,加快搜索速度,由于解空间的子集树中叶子结点的数目为2^n,调用限界函数计算上界需要O(n)时间,在最坏情况下有O(2^n)个有儿子结点需要调用限界函数,因此总的时间复杂度为O(n2^n)。
(三)实验内容三
六、心得与体会
本次是算法分析与设计的第四次实验,主要是应用回溯法分别解决旅行售货员问题、0-1背包问题和迷宫问题。
回溯算法有一种不撞南墙不回头的感觉。其基本思想就是按选优条件向前搜索,去尝试所有的可能性,以达到目标。但当探索到某一步发现原先选择并不优或达不到目标时,就退回一步重新选择。它强调了深度优先遍历思想的用途,用一个不断变化的变量,在尝试各种可能的过程中,搜索需要的结果,强调回退操作对于搜索的合理性。
我觉得该算法的难在在于空间解的求解,因此我们可以在做题前尝试画树去帮助理清思路。对于旅行售货员问题,它是典型的TSP问题,通过上文中的分析其空间解是一棵排列树。对于0-1背包问题,我们已经在前几次实验多次用其他算法进行求解,问题背景不再重述,其空间解显然是一个子集树。对于迷宫问题,按照上右下左顺序判断走还是不走,空间解为排列树。
对于排列树和子集树,我们遍历其每个结点的遍历函数写法也有所不同。
对于排列树:
void backtrack(int t){
if(t>=n){
output(); //此时已到达叶子结点,用于输出一个结果
return;
}
//从第t个单元开始进行交换排列
for(int i=t;i<n;i++){
swap(x[t],x[i]); //交换两个排列顺序,得到了一个解
if(ok(t)) //ok是判断目前的一个解有可行的机会
backtrack(t+1);
swap(x[t],x[i]); //恢复原样,给后面的好继续交换判断
}
}
对于子集树:
void backtrack(int t){
if(t>=n){
output(); //此时已到达叶子结点,用于输出一个结果
return;
}
//对分叉点遍历0,1两种情况
for(int i=0;i<=1;i++){
x[t]=i; //给第t层代表的选择赋可能选择的值
if(ok(t)) //ok是判断目前的一个解有可行的机会
backtrack(t+1);
}
}
总结比较,排列树的遍历公式中,for循环中的i是所有可能取值的存放位置;在子集树的遍历公式中,for循环中的i是可能的取值。为了提高算法的效率,我们可以在判断目前的一个解是否可行的ok()函数中加入对不可行解的剪枝操作。
总之,通过本次三个实验内容的分析与实现,加深了我对回溯法概念和基本要素的理解,实现了利用回溯法进行解题。