回溯法
具有限界函数的深度优先生成法
回溯法的基本思想
从一条路往前走,能进则进,不能进则退回来,换一条路再试
回溯法是一种组织的井井有条的,能避免不必要搜索的穷举式搜索法,这种方法适合去解一些组合数相当大的问题
问题的解空间:回溯法希望一个问题的解能够表示成一个n元式 (x1, x2 … xn)
显约束:对分量xi的取值限定
隐约束:为满足问题的解而对不同分类之间施加的约束
解空间:对于问题的一个实例,解向量满足显示约束体哦阿健的所有多元组,可以用树的结构来表示
回溯法的求解步骤
- 针对所给的问题,定义问题的解空间
- 确定易于搜索的解空间结构
- 以深度优先方式搜索解空间,并在搜索的过程中使用剪枝函数避免无效搜索
常见的剪枝函数
① 用约束函数在扩展节点处剪去不满足约束的子树(如0-1背包问题中剪去装不下的)
② 用限界函数剪去得不到最优解的子树(如在0-1背包问题中剪去现在情况下肯定比最优解装的价值少的子树)
两种回溯方式
- 递归回溯
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
- 迭代回溯
解空间树的形式
-
子集树:从n个元素的集合s中找到某种性质的子集,有2n-1-1个总节点
-
排列树:确定n个元素满足某种性质的排列,n!个叶节点
羽毛球最佳配对问题
羽毛球队有男女运动员各n人。给定2个n×n 矩阵P和Q。P[i][j]是男运动员i和女运动员 j配对组成混合双打的男运动员竞赛优势;Q[i][j]是女运动员i和男运动员j配合的女运动员竞赛优势;由于技术配合和心理状态等各种因素影响,P[i][j]不一定等于Q[j][i]。男运动员i和女运动员j配对组成混合双打的男女双方竞赛优势为P[i][j]* Q[j][i]。设计一个算法,计算男女运动员最佳配对法,使各组男女双方竞赛优势的总和达到最大。
问题分析
可以把男运动员固定不动,只改变女运动员改变配对情况,也就是说,这个问题可以转换为对女运动员的全排列问题
每次排列结束后按照题目要求计算当前配对情况的竞赛优势总和,再所有的全排列结束后,竞赛优势总和最大的配对情况即为解
该问题的解空间结构是一个排列数且问题没有显示的约束函数
代码实现
public class sportMan {
//可以把男运动员固定住,然后对女运动员进行一次全排列
//取全排列后结果的最大值
//所以问题的解空间结构是一个排列树
static int n = 3;//运动员个数
static int[] x = new int[n];//x代表女运动员 x[i] = j表示 男运动员i和女运动员j配对
static int[] opt = new int [n];//最优解
static int maxValue = 0;//最优值
static int[][] P = {
{10,2,3},
{2,3,4},
{3,4,5}
};//男
static int[][] Q = {
{2,2,2},
{3,5,3},
{4,5,1}
};//女
public static void traceback(int i){
int temp = 0;
if(i >= n){//到达叶子节点
//比较
int tempValue = 0;
for(int m =0;m<n;m++){
tempValue += P[m][x[m]] * Q[x[m]][m];
}
if(tempValue>maxValue){
maxValue = tempValue;
for(int l =0; l<n; l++){
opt[l] = x[l];
}
}
}
//没有约束函数
for(int k = i; k<n; k++){
//swap()
temp = x[i];
x[i] = x[k];
x[k] = temp;
traceback(i+1);
temp = x[i];
x[i] = x[k];
x[k] = temp;
}
}
public static void main(String[] args) {
for(int i =0; i<n; i++){
x[i] = i;
}
traceback(0);
System.out.println("最大竞赛优势为:"+maxValue);
System.out.println("组合方式为:");
for(int i=0; i<n; i++){
System.out.println("男运动员"+(i+1)+"与女运动员"+(opt[i]+1)+"组队");
}
}
}
输入为
输出结果如下
0-1背包问题
解法1:
问题分析
- 问题的约束函数为:
- 问题的限界函数为:
用r表示剩余没装的总价值
如果剩余没装的总价值+当前装进去的总价值 < 之前求出装法的最大价值,则剪枝
代码实现
public class MaxBag {
static int c=30; //背包容量
static int n=3; //对象数目
static int w[]={20,15,15}; //对象重量数组
static int v[]={40,25,25}; //对象收益数组
static int cw; //当前背包重量
static int cv; //当前背包价值
static int bestv;//迄今最大的收益
static int[] path = new int [n] ; //记录在树中的移动路径,为1的时候表示选择该组数据,为0的表示不选择该组数据
static int r = 90;//未考虑的里面剩余的价值
public static void backtrace(int i){
//回溯结束条件
if(i>=n){//i在解空间树中是层数
if(cv>bestv){
bestv = cv;
}
return;
}
r-=v[i];
if(cw + w[i] <= c){//选择进入左子树 约束函数
path[i] = 1;
cw+=w[i];
cv+=v[i];
backtrace(i+1);
cw-=w[i];
cv-=v[i];
}
if(cv + r > bestv){//剩余的价值加上当前的价值比最优大才有可能是最优解
//进入右子树
path[i] = 0;
backtrace(i+1);
}
r+=v[i];
}
public static void main(String[] args) {
backtrace(0);
System.out.println(bestv);
for(int i =0; i<3; i++){
if(path[i]!=0){
System.out.println("选择物品"+(i+1));
}
}
}
}
解法2
参考
问题分析
这里的上界函数的剪枝方法不再是使用 剩余没装的总价值+当前装进去的总价值 < 之前求出装法的最大价 值则剪枝
而是 当前背包的总价值+剩余容量可容纳的最大价值<= 当前最优价值,这里的剩余容量可容纳的总价值指的是在当前的基础上,继续装,装到装不下,对装不下的那个物品,只装他能被装下的部分
//计算上界
public static double upBound(int i){
//i是第几个物品
//cw是当前背包已有重量
//cv是当前背包已有价值
double lw =c - cw;//当前背包剩余重量
double b = cv;//当前背包价值
for(int j = i; j<n; j++){
if(lw == 0){
break;
}
if(lw>=w[j]){//如果往下能装下,则装
b+=v[j];
lw-=w[j];
}else {//装不下则装入他能装入部分的百分比
b+=perp[j]*lw;//rerp[j]是物品j的单位重量的价值
lw = 0;
}
}
return b;
}
代码实现
public class BigBagProblem {
//第二种剪枝的上界函数的计算方式是 当前背包的总价值+剩余容量可容纳的最大价值<= 当前最优价值
static double c=50; //背包容量
static int n=5; //对象数目
//物品按照单位价值由大到小排序,便于剪枝,这里就直接排列好了
static double w[]={5,15,25,27,30}; //对象重量数组
static double v[]={12,30,44,46,50}; //对象收益数组
static double perp[]= new double[5];//单位价值,计算上界函数的时候用
static double cw; //当前背包重量
static double cv; //当前背包价值
static double bestv;//迄今最大的收益
static int[] path = new int [n] ; //记录在树中的移动路径,为1的时候表示选择该组数据,为0的表示不选择该组数据
//初始化perp
public static void initPerp(){
for(int i = 0; i<n; i++){
perp[i] = v[i]/w[i];
}
}
//计算上界
public static double upBound(int i){
double lw =c - cw;//当前背包剩余重量
double b = cv;//当前背包价值
for(int j = i; j<n; j++){
if(lw == 0){
break;
}
if(lw>=w[j]){
b+=v[j];
lw-=w[j];
}else {
b+=perp[j]*lw;
lw = 0;
}
}
return b;
}
public static void backTrace(int i){
if(i>=n){
//递归结束
if(cv>bestv){
bestv = cv;
}
return;
}
//约束函数,当这个物品可以装得下,进入左子树
if(cw + w[i] <= c){
path[i] = 1;
cv += v[i];
cw += w[i];
backTrace(i+1);
cv -= v[i];
cw -= w[i];
}
//限界函数,能装的最大价值比当前最优大则不剪枝
if(upBound(i+1)>bestv){
path[i] = 0;
backTrace(i+1);
}
}
public static void main(String[] args) {
initPerp();
backTrace(0);
System.out.println(bestv);
for(int i =0; i<5; i++){
if(path[i]!=0){
System.out.println("选择物品"+(i+1));
}
}
}
}
装载问题
有一批共n个集装箱要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为wi,且,装载问题要求确定是否有一个合理的装载方案可将这些集装箱装上这2艘轮船。如果有,找出一种装载方案。
解题思路
最优装载方案: 首先将第一艘轮船尽可能的装满,然后将剩余的集装箱装上第二艘轮船。
将第一艘轮船尽可能的装满等价于选取全体集装箱的一个子集,使该子集中集装箱重量之和最接近c1
所以这个问题实际上类似于0-1背包问题,求子集树
**约束函数:**对于物品i,如果能放得下,则进入左子树,放不下则进入右子树
**限界函数:**用r表示剩余没装的总重量,如果剩余没装的总重量+当前装进去的总重量 < 之前求出装法的最大重量,则剪枝
代码实现
public class ZYZZProblem {
static int c1=16,c2=50;//两个船的分别承重
static int[] w={7,8,12}; //货物重量
static int n = 3;
static int bestw;//当前的最优
static int cw;//当前放的最大所有重量
static int r = 27;//剩余物品重量
static int x[] = new int[n];
public static void backTrace(int i){
if(i >= n){//到达叶节点
if(bestw < cw){
bestw = cw;
}
return;
}
r-=w[i];
//约束函数
if(cw+w[i] <= c1){
//放
x[i] = 1;
cw += w[i];
backTrace(i+1);
cw -= w[i];
}
//限界函数
if(cw+r > bestw){
//不放
backTrace(i+1);
}
r+=w[i];
}
public static void main(String[] args) {
backTrace(0);
System.out.println(bestw);
for(int i =0; i<3; i++){
if(x[i]==1){
System.out.println("选择物品"+(i+1));
}
}
}
}
n皇后问题
皇后的攻击范围如下:
问题分析
从第0行开始判断该行的每一列判断皇后能否摆放(不在其他皇后的攻击范围之内)直到找到当前行能摆放的位置后,递归到下一行进行查找直到最后一行也摆放完成,之后进行回溯,删去上一个摆放的点,在递归寻找另外的可行解直到找完可行解;如果当前行找不到可以摆放的皇后,也会回溯,把上一个皇后删除重新摆放
代码实现
- 使用Location类表示每一个皇后
static class Location{
private int r;//皇后所在的行
private int c;//皇后所在的列
public Location(int r, int c){
this.r= r;
this.c = c;
}
public int getR() {
return r;
}
public void setR(int r) {
this.r = r;
}
public int getC() {
return c;
}
public void setC(int c) {
this.c = c;
}
}
- 用一个LinkedList来存已经摆放的皇后
static LinkedList<Location> queenList = new LinkedList<>();//存放皇后
- 判断当前皇后是否可以摆放
public static boolean isValid(LinkedList<Location> lists, Location queen){
for (Location listQueen : lists) {
//判断行列
if(listQueen.getC() == queen.getC() || listQueen.getR() == queen.getR()){
return false;
}
//判断对角线
if(Math.abs(listQueen.getC() - queen.getC()) == Math.abs(listQueen.getR()-queen.getR())){
return false;
}
}
return true;
}
- 回溯求解
public static void backTrace(int r){
if(queenList.size() == size){//size是棋盘大小
count++;
}
for(int m = 0; m<size; m++){
Location queen = new Location(r, m);
if(isValid(queenList,queen)){//如果能放则放
queenList.offer(queen);
backTrace(r+1);
queenList.pollLast();
}
}
}
完整代码如下
import java.util.LinkedList;
import java.util.Scanner;
public class NQueenProblem {
static LinkedList<Location> queenList = new LinkedList<>();//存放皇后
static int size;//棋盘大小
static int count;//摆法数
static class Location{
private int r;//皇后所在的行
private int c;//皇后所在的列
public Location(int r, int c){
this.r= r;
this.c = c;
}
public int getR() {
return r;
}
public void setR(int r) {
this.r = r;
}
public int getC() {
return c;
}
public void setC(int c) {
this.c = c;
}
}
//判断皇后是否可以放置
public static boolean isValid(LinkedList<Location> lists, Location queen){
for (Location listQueen : lists) {
//判断行列
if(listQueen.getC() == queen.getC() || listQueen.getR() == queen.getR()){
return false;
}
//判断对角线
if(Math.abs(listQueen.getC() - queen.getC()) == Math.abs(listQueen.getR()-queen.getR())){
return false;
}
}
return true;
}
public static void backTrace(int r){
if(queenList.size() == size){//size是棋盘大小
count++;
}
for(int m = 0; m<size; m++){
Location queen = new Location(r, m);
if(isValid(queenList,queen)){//如果能放则放
queenList.offer(queen);
backTrace(r+1);
queenList.pollLast();
}
}
}
public static void main(String[] args) {
System.out.println("输入棋盘大小");
Scanner scanner = new Scanner(System.in);
size= scanner.nextInt();
backTrace(0);
System.out.println("有"+count+"种摆法");
}
}
旅行售货员问题
某售货员要到4个城市去推销商品,已知各城市之间的路程,如下图所示。
请问他应该如何选定一条从城市1出发,经过每个城市一遍,最后回到城市1的路线,使得总的周游路程最小?并分析所设计算法的计算时间复杂度。
求解思路
从当前城市开始找,找一个他能连通的城市(约束函数),判断这个到这个城市后的当前消费是不是比之前的最优消费小(限界函数),如果是则递归去找下一个城市
代码实现
public static void backTrace(int i){
// path = {
// {0,0,0,0,0},
// {0,0,30,6,4},
// {0,30,0,5,10},
// {0,6,5,0,20},
// {0,4,10,20,0}
// };//path[i][j]表示从城市i到城市j的花费
// cityN = 4;//城市数
// static int[] currentPath = new int[cityN+1];//当前路径
// static int[] bestPath = new int[cityN+1];//最优路径
// static int bestC;//最优花费
// static int currentC;//当前花费
if(i == cityN){
//到达叶子节点要判断
//①前一个城市到最后一个城市是不是连通
//②最后一个城市和第一个城市是不是连通的
//③以及花费是不是最少的
if(path[currentPath[cityN-1]][currentPath[cityN]]>0
&&path[currentPath[cityN]][currentPath[1]]>0
&¤tC + path[currentPath[cityN-1]][currentPath[cityN]]+path[currentPath[cityN]][currentPath[1]]<bestC){
for(int j = 1; j<=cityN; j++){
bestPath[j] = currentPath[j];
}
bestC = currentC + path[currentPath[cityN-1]][currentPath[cityN]]+path[currentPath[cityN]][currentPath[1]];
}
}else {
for(int j = i; j<=cityN; j++){
//选择能通,且比最优短的路
if (path[currentPath[i - 1]][currentPath[j]] > 0
&& currentC + path[currentPath[i - 1]][currentPath[j]] < bestC ){
//在currentpath里面交换i,j的位置
swap(currentPath,i,j);
currentC = currentC + path[currentPath[i - 1]][currentPath[j]];
//递归找
backTrace(i+1);
//回溯
currentC-=path[currentPath[i - 1]][currentPath[j]];
swap(currentPath,i,j);
}
}
}
}
图的m着色问题
给定无向连通图G=(V, E)和m种不同的颜色,用这些颜色为图G的各顶点着色,每个顶点着一种颜色。是否有一种着色法使G中相邻的两个顶点有不同的颜色?
代码实现
public static boolean isOk(int c){
for(int i = 0; i<n;i++){
if(graph[c][i] == 1&&color[i]==color[c]){
//不符合着色条件
return false;
}
}
return true;
}
public static void backTrace(int i){
//n 顶点数
// static int color[] = new int[n];//当前着色情况
// static int graph[][] = new int[n][n];//表示图 graph[a][b]=1表示 ab顶点相邻
if(i >= n){
count++;//着色方法数
System.out.println("着色方法---"+count);
for(int k =0; k<n;k++){
System.out.println(k+"is color "+color[k]);
}
}else {
for(int c = 1; c<4; c++){
//3种颜色就够了
color[i] = c;
if(isOk(i)){
backTrace(i+1);
}
color[i] = 0;
}
}
}
批处理作业调度问题
给定n个作业的集合{J1,J2,…,Jn}。每个作业必须先由机器1处理,然后由机器2处理。作业Ji需要机器j的处理时间为tji。对于一个确定的作业调度,设Fji是作业i在机器j上完成处理的时间。所有作业在机器2上完成处理的时间和称为该作业调度的完成时间和
解题思路
这个问题的解空间是一个排列树,作业i一定要先在机器1 上完成且在作业 (i-1) 在机器2上完成的基础上再在机器2上进行
所以对于作业i来说,在机器1上的完成时间就是作业i自己的时间 + 前一个作业的完成时间,而在机器二上的完成时间是作业i在机器1上完成了且作业(i-1)在机器二上也完成了的等待时间 + 作业i在机器2上完成所需的时间
代码实现
- 几个变量的意思看注释
static int[][] M = {
{0,0,0},
{0,2,1},
{0,3,1},
{0,2,3}
};//M[i][j]作业i在机器j上的处理时间
static int[] x ={0,1,2,3};//当前作业调度顺序
static int[] bestx = new int[4];//当前最优作业调度
static int f1;//机器1上的完成时间
static int[] f2 = new int[4];//机器2上的完成时间 用数组是为了保留前一个的状态,用于回溯
static int f = 0;//当前总时间
static int bestf = Integer.MAX_VALUE;//最佳总时间
- swap()函数改变当前作业调度x的顺序
public static void swap(int i, int j){
int temp = x[i];
x[i] = x[j];
x[j] = temp;
}
- 算法核心
public static void backTrace(int i){
if(i >= 4){//到达叶节点
for(int m = 1; m<4; m++){
bestx[m] = x[m];
}
bestf = f;
}else {
for(int j = i; j<4; j++){
f1 += M[x[j]][1];
if(f2[i-1]>f1){//需要等待
f2[i] = f2[i-1] + M[x[j]][2];
}else {//不需要等待
f2[i] = f1+M[x[j]][2];
}
f += f2[i];
if(f<bestf){//剪枝
swap(i,j);
backTrace(i+1);
swap(i,j);
}
f1 -= M[x[j]][1];
f -= f2[i];
}
}
}
- 完整代码
public class PCLZYDDProblem {
static int[][] M = {
{0,0,0},
{0,2,1},
{0,3,1},
{0,2,3}
};//M[i][j]作业i在机器j上的处理时间
static int[] x ={0,1,2,3};//当前作业调度顺序
static int[] bestx = new int[4];//当前最优作业调度
static int f1;//机器1上的完成时间
static int[] f2 = new int[4];//用数组是为了保留前一个的状态,用于回溯
static int f = 0;//当前总时间
static int bestf = Integer.MAX_VALUE;//最佳总时间
public static void swap(int i, int j){
int temp = x[i];
x[i] = x[j];
x[j] = temp;
}
public static void backTrace(int i){
if(i >= 4){//到达叶节点
for(int m = 1; m<4; m++){
bestx[m] = x[m];
}
bestf = f;
}else {
for(int j = i; j<4; j++){
f1 += M[x[j]][1];
if(f2[i-1]>f1){//需要等待
f2[i] = f2[i-1] + M[x[j]][2];
}else {//不需要等待
f2[i] = f1+M[x[j]][2];
}
f += f2[i];
if(f<bestf){//剪枝
swap(i,j);
backTrace(i+1);
swap(i,j);
}
f1 -= M[x[j]][1];
f -= f2[i];
}
}
}
public static void main(String[] args) {
backTrace(1);
System.out.println(bestf);
for(int i =1; i<4; i++){
System.out.println(bestx[i]);
}
}
}