回溯法的基本策略
策略:回溯法在问题的解空间树中,按深度优先搜索,从根节点出发搜索解空间。
算法搜索至某一结点时,先判断该结点是否包含问题的解,如果肯定不包含,则跳过,对以该节点为根的子树的搜索,逐层向其祖先回溯。否则,进入该子树,继续按照深度优先搜索。
回溯法求解问题的所有解时,要回溯到根,且根节点的所有子树都已经搜索时遍历才结束。
回溯法求解问题的一个解时,只要搜索到问题的一个解就可以结束。这种以深度优先方式系统搜索问题解的算法称为回溯法,适合解组合数较大的问题
用回溯法解决问题时,应明确问题的解空间。问题的解空间至少应包含问题的一个最优解。例如对于有3种可选物品的01背包问题,解空间包含所有的01取值可能如下:
(0,0,0),(0,0,1),(0,1,0),(0,1,1),(1,0,0),(1,0,1),(1,1,0),(1,1,1),总共8种可以选择的解。相应的解空间树如下
确定了解空间的组织结构之后,回溯从根节点以深度优先搜索开始,根节点首先成为一个活结点,同时也成为当前的扩展节点。在当前扩展节点处,搜索向纵深方向移至一个新节点。这个新节点就成为一个新的活结点,并成为当前扩展节点。如果当前扩展节点不能再向纵深方向移动,则当前的扩展节点就成为死结点。此时,往回回溯至最近的一个活节点处,并使这个活结点成为当前的扩展节点。
回溯法以这种方式递归地在解空间中搜索,直至找到所有要求的解或解空间已无活结点为止。
剪枝:在搜索至树中任一结点时,先判断该结点对应的部分解是否满足约束条件(约束函数),或者是否超出目标函数的界(限界函数);也即判断该结点是否包含问题的解,如果肯定不包含,则跳过对以该结点为根的子树的搜索,即剪枝;否则,进入以该结点为根的子树,继续按照深度优先的策略搜索。
回溯法步骤:针对所给问题,定义问题的解空间;
确定易于搜索的解空间结构;
以深度优先方式搜索解空间,并在搜索过程中利用剪枝函数(约束函数和限界函数)剪去无效的搜索。
递归回溯和迭代回溯
回溯法具体使用的又可以使用递归回溯和迭代回溯:
void BackTrack(int t){
if(t > n)Output(x);
else {
for(int i = f(n,t); i <= g(n,t); i++){
x[i] = h(i);
if(Constraint(t) && Bound(t)) BackTrack(t+1);
}
}
}
上面的代码中,形式参数t表示递归深度,即当前扩展结点在解空间树中的深度。n控制递归深度,当t > n时,算法以及搜索到叶子结点,此时,由Output(x)记录或输出得到的可行解x。for循环中的f(n,t)和g(n,t)表示的是当前结点处未搜索过的子树的起始编号和终止编号。h(i)表示在当前结点处x[t] (t是层数)的第i个可选值。Constrain和Bound函数是约束函数和限界函数。
执行完算法的for循环之后,已经搜索遍当前扩展结点的所有未搜索过的子树。BackTrack(t)已经执行完毕,返回t-1层继续执行,对还没有测试过的x[t-1]的值继续搜索。
当t = 1时,若已经测试完x[1]的所有值,外层的调用就全部结束。
一开始调用BackTrack(1)即可完成这次深度优先遍历。
void BackTrack(int t){
if(t > n)Output(x);
else {
for(int i = f(n,t); i <= g(n,t); i++){
x[i] = h(i);
if(Constraint(t) && Bound(t)) BackTrack(t+1);
}
}
}
注意一般回溯算法只保存从根节点到当前扩展结点的路径,如果解空间树中从根节点到叶子结点的最长路径的长度未h(n),那么计算空间就是O(h(n)),如果需要显示的存储整个解,那么需要O(2^h(n))或者O(h(n!))内存空间。
两种解空间树:
子集树:当所给的问题是从n个元素的集合S中找出满足某种性质的子集时,相应的解空间是子集树。例如01背包问题的解空间树就是子集树。这类树通常有2^n个叶子结点,结点总数是2^(n+1) - 1个。时间复杂度O(2^n)。
排列树:当所给的问题是确定n个元素满足某种性质的排列的时候,相应的解空间树是排列树。排列树有n!个叶子结点,时间复杂度O(n!)。比较经典的有旅行售货员问题:售货员要去n个城市,已知各个城市之间的旅费,他要选定一条从驻地出发,经过每一个城市,最后回到驻地的路线使得总的消费最小。把这个问题组织成一颗排列树如下(1,2,3,4个城市):子集树和排列树的代码如下:
//子集树的一般算法
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);
}
}
}
//排列树的一般算法
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]);
}
}
}
问题描述:就是将n个集装箱装入2艘载重量为C1,C2的轮船,其中集装箱i的重量为w[i],问题要求如果可以装上去,求一个最优装载方案。
可以证明,先将第一个集装箱装满,剩余的装入第二个可以得到一个最优装载方案。然后使用回溯法设计装载问题。
普通的回溯法中如果不剪枝右子树的话,右子树可以直接进入,这样到达叶子结点的时候,要更新一下最优解,使用一个上界函数,用r表示剩余集装箱的重量,定义上界函数cw(当前的载重量)+r,如果cw+r <= bestw的话,就不用进入右子树。
import java.io.BufferedInputStream;
import java.util.Scanner;
/**
* 回溯法解决装载问题
* @author 郑鑫
*/
public class MaxLoading {
private int n,C;
private int cw,bestw,r;//当前载重量
private int[] w,x,bestx;
public MaxLoading(int n, int c, int cw, int bestw, int r, int[] w, int[] x, int[] bestx) {
super();
this.n = n;
C = c;
this.cw = cw;
this.bestw = bestw;
this.r = r;
this.w = w;
this.x = x;
this.bestx = bestx;
}
public void BackTrack(int i){
if(i >= n){
if(cw > bestw){
for(int j = 1; j <= n; j++)bestx[j] = x[j];
bestw = cw;
}
return;
}
r -= w[i]; //剩下的重量 r 一开始赋值为所有w[i]的和
if(cw + w[i] <= C){
x[i] = 1; //装入
cw += w[i];
BackTrack(i+1);
cw -= w[i];
x[i] = 0;
}
if(cw + r > bestw) { //只有大于才进入右子树
x[i] = 0;
BackTrack(i+1);
}
r += w[i]; //回溯
}
public static void main(String[] args) {
Scanner cin = new Scanner(new BufferedInputStream(System.in));
int n = cin.nextInt(),r = 0;
int C1 = cin.nextInt(); //第一艘轮船
int C2 = cin.nextInt(); //第二艘轮船
int[] w = new int [n+1];
int[] x = new int [n+1];
int[] bestx = new int [n+1];
for(int i = 1; i <= n; i++){
w[i] = cin.nextInt();
r += w[i];
}
MaxLoading ml = new MaxLoading(n, C1, 0, 0, r, w, x, bestx);
ml.BackTrack(1);
int w1 = ml.bestw;
int w2 = 0;
for(int i = 1; i <= n; i++)w2 += w[i]*(1-bestx[i]);
if(w2 > C2){
System.out.println("---无法将全部物品装入两个集装箱!---");
}else {
System.out.println("第一艘船装入的重量是 : " + w1);
System.out.println("第二艘船装入的重量是 : " + w2);
for(int i = 1; i <= n; i++){
if(bestx[i] == 1)System.out.println("物体" + i + "装入第一艘轮船!");
else System.out.println("物体" + i + "装入第二艘轮船!");
}
}
}
}
前面已经说过,01背包的解空间可以使用子集树表示。搜索解空间树时,只要其左儿子结点是一个可行的结点(背包已经装的重量+w[i] <= C),搜索就进入左子树。
当右子树中有可能包含最优解时才进入右子树进行搜索,否则将右子树减去。试想,如果当前所剩的价值(Vleft)加上当前已经获得价值(nowV)小于等于记录好的最大价值(bestV),右子树就没有必要搜索。
剪枝函数更好的设计方法是:将剩余物品按照其单位重量价值降序排列。然后依此装入物品,直到装不下,再装一部分(实际上不可能(因为是01背包)),由此得到的价值是右子树中解的上界。
举个栗子:n = 4,C = 7,v = [9,10,7,4],w = [3,5,2,1];
这四个物品的单位重量价值为[3,2,3.5,4]。按照递减的顺序装入物品,按照4,3,1物品序号装入后,背包容量仅剩1,这是我们再装0.2的物品2,此时,相应价值为22,解为[1,0.2,1,1],尽管这不是可行解,但是可以知道其价值是最优值的上界。也就是说右子树按照这样装都小于当前的bestV的话就肯定剪枝。看代码吧:
import java.io.BufferedInputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Scanner;
/*
// *排序的另一种方法
class UnitWSort implements Comparator{ //对单位重量的类按照单位重量d来排序
@Override
public int compare(UnitW o1,UnitW o2) {
return -(o1.getD() > o2.getD() ? 1 :(o1.getD() == o2.getD() ? 0: -1));
}
}
*/
class UnitW implements Comparable{
private double d;
private int id;
public UnitW(int id,double d) {
super();
this.id = id;
this.d = d;
}
public int getId() {
return id;
}
public double getD() {
return d;
}
@Override
public int compareTo(UnitW o) {
return -(this.d > o.d ? 1: (this.d == o.d ? 0: -1));
}
}
class Knapsack {
private int C; //背包容量
private int n; //物品数目
private int[] w; //物品重量
private int[] v; //物品价值
private int nowW; //当前重量
private int nowV; //当前价值
private int bestV; //当前最优价值
public int getBestV(){ //获得最优值
return bestV;
}
public Knapsack(int c, int n, int[] w, int[] v, int nowW, int nowV, int bestV) {
super();
C = c;
this.n = n;
this.w = w;
this.v = v;
this.nowW = nowW;
this.nowV = nowV;
this.bestV = bestV;
}
public void Backtrack(int i){
if(i >= n){ //达到叶子结点
bestV = nowV;
return ;
}
if(nowW + w[i] <= C){ //能装就装,进入左边叶子
nowW += w[i];
nowV += v[i];
Backtrack(i+1);
nowW -= w[i];
nowV -= v[i];
}
if(Bound(i+1) > bestV){ //如果往右边走,背包装满了都没有现在的最优值,就剪枝这颗子树
Backtrack(i+1);
}
}
//计算上界的函数
public double Bound(int i){
int Cleft = C - nowW; //剩余容量
int nowBest = nowV;
while( i < n && w[i] < Cleft ){ //计算整数的
Cleft -= w[i];
nowBest += v[i];
i++;
}
//把背包装满
if(i < n)nowBest += v[i]*Cleft/w[i];
return nowBest;
}
}
public class BackTrack01 {
public static void main(String[] args) {
Scanner cin = new Scanner(new BufferedInputStream(System.in));
int W = 0,V = 0,n,C;
n = cin.nextInt(); C = cin.nextInt();
int[] w = new int[n+1];
int[] v = new int[n+1];
ArrayListunit = new ArrayList();
for(int i = 0; i < n; i++)w[i] = cin.nextInt();
for(int i = 0; i < n; i++)v[i] = cin.nextInt();
for(int i = 0; i < n; i++){
unit.add(new UnitW(i,v[i]*1.0/w[i]));
W += w[i];
V += v[i];
}
if(W <= C){
System.out.println(V);
System.exit(0);
}
Collections.sort(unit); //按照单位重量进行降序排序
//Collections.sort(unit,new UnitWSort()); //按照单位重量进行降序排序
//for(int i = 0; i < unit.size(); i++)System.out.println(unit.get(i).getD());
int[] neww = new int[n+1];
int[] newv = new int[n+1];
for(int i = 0; i < n; i++){
neww[i] = w[unit.get(i).getId()];
newv[i] = v[unit.get(i).getId()];
}
Knapsack K = new Knapsack(C,n,neww,newv,0,0,0);
K.Backtrack(0); //从第0层开始调用
System.out.println(K.getBestV());
}
}
效果
问题描述:n*n格的棋盘上放置彼此不受攻击的n个皇后,要求任何2个皇后不放在同一行或同一列或同一斜线上。
用n元组C[1:n]表示问题的解:C[i]表示的是i行皇后所在的列。
由于不允许两个皇后在同一列上,所以解向量中的C[i]互不相同。
还有一个要注意的就是2个皇后不能在同意斜线上,所以很容易得到两点之间的连线的斜率不能为1或-1,即:C[i] ! =C[col] && abs(col-i) != abs(C[col] - C[i]);如图
import java.io.BufferedInputStream;
import java.util.Scanner;
public class NQueen {
private int n;
private int[] C; //i行C[i]列 -->代表的是解
private int sum; // 解的个数
private int[][] map; //输出解
//一开始都是0
public NQueen(int n,int sum,int[] C,int[][] map) {
super();
this.n = n;
this.sum = sum;
this.C = C;
this.map = map;
}
public int getSum(){
return sum;
}
public void BackTrack(int cur){
if(cur >= n){
sum++;
for(int i = 0; i < n; i++)map[i][C[i]] = 1;
for(int i = 0; i < n; i++){
for(int j = 0; j < n; j++)System.out.print(map[i][j] + " ");
System.out.println();
}
System.out.println();
for(int i = 0; i < n; i++)for(int j = 0; j < n; j++)map[i][j] = 0;
}
else for(int i = 0; i < n; i++){ //尝试在cur行的各列放置皇后
C[cur] = i; //cur行和i列
if(Constraint(cur))BackTrack(cur+1); //检查一下 -->可以的话就放置下一行
}
}
public boolean Constraint(int col){
for(int i = 0; i < col; i++){
if(C[i] == C[col] || (Math.abs(col - i) == Math.abs(C[col] - C[i])))return false;
}
return true;
}
public static void main(String[] args) {
Scanner cin = new Scanner(new BufferedInputStream(System.in));
int n; n = cin.nextInt();
int[] C = new int[n+1];
int[][] map = new int[n+1][n+1];
for(int i = 0; i < n; i++)for(int j = 0; j < n; j++)map[i][j] = 0; //赋初值0
int sum = 0;
NQueen nq = new NQueen(n,sum,C,map);
nq.BackTrack(0);
System.out.println(nq.getSum());
}
}
展示一下8皇后的运行效果(只显示了两种解)
最大团问题:
注意回溯的过程中:
设当前扩展结点Z位于解空间树的第i层:在进入左子树之前,必须确认从顶点i到以选入的顶点集中的每一个顶点有边相连。
在进入右子树之前,必须确认还有足够多的可选择顶点使得算法有可能在右子树中找到更大的团,也就是说剩下的点加上目前的点要比保存的最多的点要大才搜索。
import java.io.BufferedInputStream;
import java.util.Scanner;
/**
* 最大团问题
* @author 郑鑫
*/
public class MaxClique {
private int[][] map; //图的邻接矩阵
private int n; //图的顶点数
private int[] ans; //记录当前的解
private int[] bestAns; //记录当前的最优解
private int nowN; //记录当前的顶点数
private int bestN; //记录最大的顶点数
public int getBestN(){
return bestN;
}
public void getBestAns(){ //输出最优解
for(int i = 0; i < n; i++)System.out.print(bestAns[i] + " ");
System.out.println();
System.out.println("----最大团中的点---");
for(int i = 0; i < n; i++)if(bestAns[i] == 1)System.out.print(i+1 + " ");
System.out.println();
}
public MaxClique(int[][] map, int n, int[] ans, int[] bestAns, int nowN, int bestN) {
super();
this.map = map;
this.n = n;
this.ans = ans;
this.bestAns = bestAns;
this.nowN = nowN;
this.bestN = bestN;
}
public void BackTrack(int i){
if(i >= n){
for(int j = 0; j < i; j++)bestAns[j] = ans[j];
bestN = nowN;
return;
}
boolean flag = true;
for(int j = 0; j < i; j++){
if(map[i][j] == 0 && map[j][i] == 0&& ans[j] == 1){ //前面已经选的和这个不相连-->肯定不行(团的概念(完全图))
flag = false;
break;
}
}
if(flag){ //进入左子树
ans[i] = 1;
nowN++;
BackTrack(i+1);
nowN--; //记得回溯的时候减掉
ans[i] = 0; //回溯
}
if(nowN + n - i > bestN){
ans[i] = 0; //第i个不选
BackTrack(i+1);
}
}
public static void main(String[] args) {
Scanner cin = new Scanner(new BufferedInputStream(System.in));
int n,m; //顶点数,边数
n = cin.nextInt(); //顶点的序号是0~n-1
m = cin.nextInt();
int[] ans = new int[n+1] ;// 记录每一个顶点
for(int i = 0; i < n; i++) ans[i] = 0; //一开始都不在团里面
int[] bestAns = new int[n+1];
for(int i = 0; i < n; i++) bestAns[i] = 0; //一开始都不在团里面
int[][] map = new int[n+1][n+1];
for(int i = 0; i < n; i++)for(int j = 0; j < n; j++)map[i][j] = 0;
for(int i = 0; i < m; i++){
int a = cin.nextInt();
int b = cin.nextInt();
map[a-1][b-1] = map[b-1][a-1] = 1;
}
int bestN = 0;
MaxClique mC = new MaxClique(map, n, ans, bestAns, 0, bestN);
mC.BackTrack(0);
System.out.println(mC.getBestN());
mC.getBestAns();
}
}
看这个例子和运行效果
问题描述:
给定无向图G和m中不同的颜色,用这些颜色为图G的各个顶点着色,每个顶点着一种颜色。若一个图最少需要m中颜色才能使得图中每条边相连的2个顶点着不同的颜色。则称m为图的色数。
现在的问题是: 给你一个图G = (V,E)和m种颜色,如果这个图不是m可着色,给出否定答案,如果这个图是m可着色,找出所有的着色法。
例如下图四个顶点四条边,如果用三种(注意这题也可以用2种颜色,总的着色数是18,但是有三种颜色的着色法是12)颜色着色的12种情况
这个题目也是用一个ans数组保存解,ans[i] 表示的是 顶点i 用的颜色是ans[i],Ok函数的约束保证了相连的不是同一个颜色。
import java.io.BufferedInputStream;
import java.util.Scanner;
/**
* 图的m着色问题
* @author 郑鑫
*/
public class Color {
private int n; //图的顶点数
private int m;
private int sum;
private int[][] map;
private int[] ans; //记录解
public int getSum(){
return sum;
}
public Color(int n, int m, int sum, int[][] map, int[] ans) {
super();
this.n = n;
this.m = m;
this.sum = sum;
this.map = map;
this.ans = ans;
}
public void BackTrack(int t){
if( t >= n){
sum++; //达到叶子结点,解的个数加1
for(int i = 0; i < t; i++)System.out.print(ans[i] + " ");
System.out.println();
return;
}else for(int i = 0; i < m; i++){
ans[t] = i;
if(Ok(t))BackTrack(t+1);
}
}
//可行性约束
public boolean Ok(int i){
for(int j = 0; j < i; j++)
if(map[i][j] == 1 && ans[j] == ans[i])return false; //如果相连而且颜色相同则不行
return true;
}
public static void main(String[] args) {
Scanner cin = new Scanner(new BufferedInputStream(System.in));
int n = cin.nextInt(),edgeSum = cin.nextInt(),m = cin.nextInt(); //m是颜色数
int[] ans = new int[n+1];
for(int i = 0; i < n; i++)ans[i] = -1;
int[][] map = new int[n+1][n+1];
for(int i = 0; i < n; i++)for(int j = 0; j < n; j++)map[i][j] = 0;
for(int i = 0; i < edgeSum; i++){
int a = cin.nextInt();
int b = cin.nextInt();
map[a-1][b-1] = map[b-1][a-1] = 1;
}
Color c = new Color(n, m, 0, map, ans);
c.BackTrack(0);
System.out.println(c.getSum());
}
}
上面的例子输入:
4 4 3
1 2
1 4
2 3
3 4
1
2
3
4
5
上面的例子输出
0 1 0 1
0 1 0 2
0 1 2 1
0 2 0 1
0 2 0 2
0 2 1 2
1 0 1 0
1 0 1 2
1 0 2 0
1 2 0 2
1 2 1 0
1 2 1 2
2 0 1 0
2 0 2 0
2 0 2 1
2 1 0 1
2 1 2 0
2 1 2 1
18