按理来讲,回溯算法和动态规划算法是面试中笔试相对考的比较多的两个算法,区别在于一个可以重头再来一个可以无限读档,反正都是很bug的,所以就从这篇文章来看看回溯算法是怎么做到无限重来的吧。
先说一下常见的回溯算法考的体型有:数独,八皇后,0-1背包,图的着色,旅行商问题和全排列等等。
下面就来介绍一下回溯算法
如何理解回溯算法
我们之前所介绍的这些贪心分治算法无非都是让策略最优,贪心算法可以让我们每次进行选择的时候选择看起来最优的选择,分治算法可以将大问题化成小问题,逐一破解,降低难度,而回溯算法则类似遍历所有的解法选出最优的解法。
还是举一个例子来说:
有一个8*8的棋盘,希望往里面放8个棋子,每个棋子所在的行列对角线都不能有另一个棋子。
图1就是满足条件的,图2就是不满足条件的,那么解决问题,一共需要这么几个步骤:
- 首先将第一个棋子放在第一行某个位置
- 然后放第二个棋子在满足条件的第二行某个位置
- 不断循环,然后就可以满足条件了
回溯算法适合用递归来实现,然后用代码来实现以下这个题目。
public class Eight {
static int[] result = new int[8];//全局或者成员变量,下标代表行,值代表queen在哪一列
public static void main(String[] args) {
new Eight().cal8queens(0);
}
public void cal8queens(int row) {
//调用方式:cal8queens(0);
if (row == 8) {
// 8 个棋子都放置好了,打印结果
printQueens(result);
return;//8行棋子都放置好了,就不递归了
}
for (int column = 0; column < 8; column++) {//每一行都有八种放法
if (isOk(row, column)) {//有些方法不满足要求
result[row] = column;//第row行的棋子放到了column列
cal8queens(row+1);//考察下一行
}
}
}
private boolean isOk(int row,int column) {//判断row行column列放置是否合适
int leftup = column - 1;
int rightup = column + 1;
for (int i = row - 1; i >= 0; i--) {//逐行往上考察每一行
if (result[i]==column) return false;//第i行的column行有棋子吗?
if (leftup >= 0) {//考察左上对角线,第i行leftup列有棋子吗?
if (result[i] == leftup) return false;
}
if (rightup < 8) {//考察右上对角线:第i行rightup列有棋子吗?
if (result[i] == rightup) return false;
}
--leftup;
++rightup;
}
return true;
}
private void printQueens(int[] result) {//打印一个二维矩阵
for (int row = 0; row < 8; ++row) {
for (int column = 0; column < 8; ++column) {
if (result[row]==column) System.out.println("Q ");
else System.out.println("* ");
}
System.out.println();
}
System.out.println();
}
}
理解了代码之后然后再来说两个例子联系一个回溯算法的应用和实现。
0-1背包问题
0-1背包问题是经典的dp算法题,但是用回溯也可以解决。0-1背包有很多多变体,这里先介绍一种比较基础的。
假设我们有一个背包,背包的承载重量有Wkg,我们现在有n个物品,每个物品的重量不相等,并且不可分割,那么我们现在需要选择装载到背包,如何在不超过装载重量的前提,让背包中的物品总重量最大。
可能这时会有人联想到使用贪心算法解决问题,但是贪心算法解决的问题是东西有重复且可以将东西拆分放入,以获取最大价值,但是0-1背包问题不是这样设计的,之所以叫0-1背包问题,是因为这个东西只能装或者不装。
实际上,对于每种物品来说,都有两种选择,装或者不装。所以我们对于n个物品共有2^n种选择,我们只需要在策略中选择最接近Wkg的即可。
首先将物品依次排列,这样问题就分为了n个阶段没每个阶段操作一个物品,也就是装和不装,然后递归处理剩下的物品。
然后看一下代码:
public class BagZeroOne {
public int maxW = 0;//存储背包中物品总重量的最大值‘
//cw表示已经装进去的物品的重量和,i表示考察到哪个物品了
//w背包重量 items表示每个物品的重量 n表示物品数
//假设背包可承受重量为100,物品个数为10,物品重量存储在数组a中
//然后这样调用函数f(0,0,a,10,100)
public void f(int i, int cw, int[] items, int n, int w) {
if (cw == w || i == n) {//cw == w表示装满;i==n表示已经考察完所有物品
if (cw>maxW) maxW = cw;
return;
}
f(i + 1, cw, items, n, w);
System.out.println(cw);
if (cw + items[i] <= w) {//已经超过可以背包承受的重量的时候,就不装了
f(i + 1, cw+items[i], items, n, w);
}
}
public static void main(String[] args) {
int[] a = {1,2,2,4,5,6,7,8,9,14};
new BagZeroOne().f(0, 0, a, 10, 100);
}
}
正则表达式
作为一个开发工程师来说,最重要的就是通配符,将一些符号串在一起表达丰富的语意。假设正则表达式中只包含*和?两种通配符,并且对这两种通配符的语意稍微做些改变:
其中 “ * ”可以匹配任意多个(大于等于0个)任意字符,“?”匹配零个或者一个任意字符。然后根据定死的文本,能否给定正则表达式。
我们依次考察正则中的每个字符,非通配符就直接和文本进行匹配,如果相同就继续往下处理,如果不同就回溯。
如果遇到特殊字符,也就是岔路口,我们有很多种方案,去组合使用两种通配符,当无法继续匹配就从最近的断点重新来。
然后来看代码:
public class Pattern {
private boolean matched = false;
private char[] pattern;//正则表达式
private int plen; //正则表达式长度
public Pattern(char[] pattern, int plen) {
this.pattern = pattern;
this.plen = plen;
}
public boolean match(char[] text, int tlen) {//文本串及其长度
matched = false;
rmatch(0, 0, text, tlen);
return matched;
}
private void rmatch(int ti, int pj, char[] text, int tlen) {
if (matched) return;//如果已经匹配好了,就不要继续递归了
if (pj == plen) {//正则表达式到结尾了
if (pj == plen) matched = true;//文本串也到结尾了
return;
}
if (pattern[pj] == '*') {//匹配任意个字符
for (int k = 0; k <= tlen - ti; ++k) {
rmatch(ti + k, pj + 1, text, tlen);
}
} else if (pattern[pj] == '?') {//匹配0或者1个字符
rmatch(ti,pj+1,text,tlen);
rmatch(ti+1,pj+1,text,tlen);
} else if (ti < tlen && pattern[pj] == text[ti]) {//纯字符匹配才行
rmatch(ti+1,pj+1,text,tlen);
}
}
public static void main(String[] args) {
char[] chars = {'*', '?'};
char[] chars1 = {'z', 'h'};
System.out.println(new Pattern(chars, 2).match(chars, 2));
}
}
总结
回溯思想在于大部分情况下都是用广义的去搜索问题,也就是从一组可能的解中选择出一个满足要求的解。而施加更具体的条件可以让我们的复杂度降低。