目录
回溯算法简介
回溯法是一种类似枚举的搜索尝试过程,既然是枚举,那么就会遍历解空间树中的所有解(或者是“路径”),搜索的过程按照DFS原则,而尝试就意味着,在遍历的过程中,有可能到达某一个结点后,发现不能够满足约束条件,在这次尝试中,这条“路”是不优的,将走不通,即无法找到所求的解,那么就会回退到上一步的状态,重新作出选择。如果即满足约束条件,但是依然没有获得有效的解,那么我们就需要在此基础上做下一步选择,即将当前结点当做一个新的根结点。所以经常会使用递归的方法。如果一步步下来的选择结果正好满足我们所求的问题,那么就是一个有效的解。
回溯法思想:在包含问题所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度搜索解空间树。当搜索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯。(其实回溯法就是对隐式图的深度优先搜索算法)。若用回溯法求问题的所有解时,要回溯到根,且根结点的所有可行的子树都要已被检索一遍才结束。而若使用回溯法求任一个解时,只要搜索到问题的一个解就可以结束。
若用回溯法求问题的所有解时,要回溯到根,且根结点的所有可行的子树都要已被搜索遍才结束。
若使用回溯法求任一个解时,只要搜索到问题的一个解就可以结束。
回溯算法中的三个概念
(1)约束函数:约束函数是根据题意定出的。通过描述合法解的一般特征用于去除不合法的解(即使这个解并不是完整的解),从而避免继续搜索出这个不合法解的剩余部分,起到了剪值函数的作用。因此,约束函数是对于解空间树上的任何节点都有效、等价的。
(2)状态空间树:状态空间树是一个对所有解的图形描述。
(3)扩展节点:当前正求出它的子节点的节点,在DFS中,只允许有一个扩展节点。
活节点:通过与约束函数对照,节点本身和其父节点均满足约束函数要求的节点。
死节点: 与活节点正好相反,死节点是在与约束函数对照中,不满足约束函数的节点。那么就不需要求该节点的子节点情况了。
回溯算法的步骤
在回溯法执行时,应当:保存当前步骤,如果是一个解就输出;维护状态,使搜索路径(含子路径)尽量不重复。必要时,应该对不可能为解的部分进行剪枝(pruning)。
(1)定义一个解空间,它包含问题的解。明确问题的解空间树内容,比如,在求组合问题时,最后有效解中元素是否可重复的,元素个数是否是固定的等。且保证问题的解空间中应至少包含问题的一个解。
(2)利用适于搜索的方法组织解空间。确定结点的扩展搜索规则,在回溯法中,就是深度优先遍历规则。扩展节点就是当前正在求出它的子节点的节点,在DFS中,只允许有一个扩展节点。
(3)利用深度优先法搜索解空间。
(4)利用限界函数避免移动到不可能产生解的子空间。 构造约束条件。作为剪枝函数,避免无效搜索。
问题的解空间通常是在搜索问题的解的过程中动态产生的,这是回溯算法的一个重要特性。
具体流程
(1)设置初始化的方案(给变量赋初值,读入已知数据等);
(2)选择所到深度层(k)中其中的一个节点进行试探,如果k层的节点已经全部都试探完毕,则进入(7);
(3)如果试探不成功(不满足约束条件),则转入(2);
(4)如果试探成功,则进入下一个深度层进行试探。
(5)如果正确解还没找到,则进入(2);
(6)如果找到了正解,如果问题只求其中的一个解,那么可以退出程序了,如果是求一组解,那么将该种解存储(一般用一个类中的变量);
(7)退回上一步的状态(深度为k-1时,变量的状态),如果没有退到头,则进入(2);
(8)已退到头则结束或打印无解。
在使用回溯法解决实际问题时,往往会和递归算法结合,更容易解决问题,但是也可以使用非递归的方法实现,这就需要具体情况进行分析了。
代码实现框架
bool finished = FALSE; /* 是否获得全部解? */
backtrack(int a[], int k, data input)
{
int c[MAXCANDIDATES]; /*这次搜索的候选 */
int ncandidates; /* 候选数目 */
int i; /* counter */
if (is_a_solution(a,k,input))
process_solution(a,k,input);
else {
k = k+1;
construct_candidates(a,k,input,c,&ncandidates);
for (i=0; i<ncandidates; i++) {
a[k] = c[i];
make_move(a,k,input);
backtrack(a,k,input);
unmake_move(a,k,input);
if (finished) return; /* 如果符合终止条件就提前退出 */
}
}
}
对于其中的函数和变量,解释如下:
a[]表示当前获得的部分解;
k表示搜索深度;
input表示用于传递的更多的参数;
is_a_solution(a,k,input)判断当前的部分解向量a[1...k]是否是一个符合条件的解
construct_candidates(a,k,input,c,ncandidates)根据目前状态,构造这一步可能的选择,存入c[]数组,其长度存ncandidates
process_solution(a,k,input)对于符合条件的解进行处理,通常是输出、计数等
make_move(a,k,input)和unmake_move(a,k,input)前者将采取的选择更新到原始数据结构上,后者把这一行为撤销。
经典例子
选取数字集合
做一个白话版的描述,给你两个整数 n和k,从1-n中选择k个数字的组合。比如n=4,k=2,那么从1,2,3,4中选取两个数字的组合
可以看到上面的每个变量都出现了,用一个类变量保存结果
public class Solution {
List<List<Integer>> result=new ArrayList<List<Integer>>();
public List<List<Integer>> combine(int n, int k) {
List<Integer> list=new ArrayList<Integer>();
backtracking(n,k,1,list);
return result;
}
public void backtracking(int n,int k,int start,List<Integer>list){
if(k<0) return ;
else if(k==0){
result.add(new ArrayList(list));
}else{
for(int i=start;i<=n;i++){
list.add(i);
backtracking(n,k-1,i+1,list);
list.remove(list.size()-1);
}
}
}
}
皇后问题
N皇后问题是指在N*N的棋盘上放置N个皇后,使这N个皇后无法吃掉对方(也就是说两两不在一行,不在一列,也不在对角线上)。经典的是8皇后问题,这里我们为了简单,以4皇后为例。
首先利用回溯算法,先给第一个皇后安排位置,如下图所示,安排在(1,1)然后给第二个皇后安排位置,可知(2,1),(2,2)都会产生冲突,因此可以安排在(2,3),然后安排第三个皇后,在第三行没有合适的位置,因此回溯到第二个皇后,重新安排第二个皇后的位置,安排到(2,4),然后安排第三个皇后到(3,2),安排第四个皇后有冲突,因此要回溯到第三个皇后,可知第三个皇后也就仅此一个位置,无处可改,故继续向上回溯到第二个皇后,也没有位置可更改,因此回溯到第一个皇后,更改第一个皇后的位置,继续上面的做法,直至找到所有皇后的位置,如下图所示。
这里为什么我们用4皇后做例子呢?因为3皇后是无解的。同时我们也可以看到回溯算法虽然也是Brute-Force,但是它可以避免去搜索很多的不可能的情况,因此算法是优于Brute-Force的。
这里是每次确定第col行的棋子的位置
public class NQueensII {
int[] x;//当前解
int N;//皇后个数
int sum = 0;//当前已找到的可行方案数
public int totalNQueens(int n) {
N = n;
x = new int[N+1];
backTrace(1);
return sum;
}
/**
* col行这个点,x[col]列这个点,与已经存在的几个皇后,是否符合要求,放到这个位置上,
* @param col
* @return
*/
private boolean place(int col){
for(int i = 1; i < col; i++){
if(Math.abs(col - i)==Math.abs(x[col]-x[i])||x[col]==x[i]){
return false;
}
}
return true;
}
private void backTrace(int t) {
if(t>N){
sum++;
}else {
//第t行,遍历所有的节点
for(int j = 1; j <= N; j++) {
x[t] = j ;
//如果第j个节点可以放下皇后
if(place(t)){
//接着放下一个
backTrace(t+1);
}
}
}
}
public static void main(String[] args) {
NQueensII n = new NQueensII();
System.out.println(n.totalNQueens(8));
}
}
最大 k 乘积问题
设I是一个 n 位十进制整数。如果将 I 划分为 k 段,则可得到 k 个整数。这 k 个整数的乘积称为 I 的一个 k 乘积。试设计一个算法,对于给定的 I 和 k ,求出 I 的最大 k 乘积。
import java.util.Scanner;
public class 最大k乘积 {
private static long ans;
private static Scanner cin;
static{
cin = new Scanner(System.in);
}
static void work(int cur, int i, int k, long v){
//System.out.println("i = " + i + " cur = " + cur + " k = " + k);
if(i == k){
ans = Math.max(ans, v);
return;
}
if(cur == 0){
return;
}
int MOD = 1;
while(cur / MOD != 0){
work(cur % MOD, i + 1, k, v * (cur / MOD));
MOD *= 10;
}
}
public static void main(String[] args) {
int num, k;
System.out.println("请输入数字num和要分成的段数k: ");
while(cin.hasNext()){
num = cin.nextInt();
k = cin.nextInt();
ans = Long.MIN_VALUE;
work(num, 0, k, 1L);
if(ans == Long.MIN_VALUE){
System.out.println("整数" + num + "不能被分成" + k + "段");
}else{
System.out.println(num + "的最大" + k + "乘积是: " + ans);
}
System.out.println("请输入数字num和要分成的段数k: ");
}
}
}