Java常用算法——搜索(dfs) & 回溯(全排列、八皇后、分苹果问题的详细解析)

来源:互联网 发布:人员去向软件 编辑:程序博客网 时间:2024/05/18 00:26

dfs & 回溯

(1).定义

深度优先搜索算法(英语:Depth-First-Search,简称DFS)是一种用于遍历或搜索树或图的算法。沿着树的深度遍历树的节点,尽可能深的搜索树的分支。当节点v的所在边都己被探寻过,搜索将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。如果还存在未被发现的节点,则选择其中一个作为源节点并重复以上过程,整个进程反复进行直到所有节点都被访问为止。属于盲目搜索。
深度优先搜索是图论中的经典算法,利用深度优先搜索算法可以产生目标图的相应拓扑排序表,利用拓扑排序表可以方便的解决很多相关的图论问题,如最大路径问题等等。
————————————————————————————————————————来自维基百科深度优先搜索
回溯法(英语:backtracking)是暴力搜寻法中的一种。
对于某些计算问题而言,回溯法是一种可以找出所有(或一部分)解的一般性算法,尤其适用于约束满足问题(在解决约束满足问题时,我们逐步构造更多的候选解,并且在确定某一部分候选解不可能补全成正确解之后放弃继续搜索这个部分候选解本身及其可以拓展出的子候选解,转而测试其他的部分候选解)。

在经典的教科书中,八皇后问题展示了回溯法的用例。(八皇后问题是在标准国际象棋棋盘中寻找八个皇后的所有分布,使得没有一个皇后能攻击到另外一个。)
回溯法采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:
      找到一个可能存在的正确的答案;
      在尝试了所有可能的分步方法后宣告该问题没有答案。
在最坏的情况下,回溯法会导致一次复杂度为指数时间的计算。
————————————————————————————————————————来自维基百科回溯法

一个图示例子

这里写图片描述
  如上图,解析一下左图的深度优先搜索过程。假如我们从顶点A开始深度优先搜索,一开始沿着A -> B -> C -> D -> E -> F的方向一直往”深”处搜索,当搜索至顶点F时,继续往”深”处搜索,这时候他发现顶点A已经搜索过了,而它的相邻顶点G还没有搜索过,于是它继续往”深”处搜索,即F -> G。同样的,到G点之后,顶点G也还想继续往”深”处搜索,它开始检查自己的相邻顶点,发现B,D,F均已搜索过。而H点还没有搜索过,于是继续往”深”处搜索,即G -> H。同样的,H点重复同样的动作,但是它的相邻顶点D,E都已经搜索过了,无法再继续往”深”处搜索,所以它只好回到它的上一个顶点(也就是深搜路径上与之相邻的前一个顶点)。而这个就称之为回溯,那怎么才能回到上一个顶点呢?计算机不是人,人会思考,懂得转个身就能回去了,但是计算机可以模拟这个过程。即是回到它上一次的状态,也就是搜索至G点时的状态。
  而回到搜索至G点时的状态后,由于在这之前已经发现它的相邻顶点D,E都已经搜索过了,H点也已经试探过了,因此G点也无法再继续往”深”处搜索了,所以再次回溯至其上一个状态。因此回溯至顶点F,同样的,在这之前顶点F已经发现它的相邻顶点A已经搜索过了,G点也已经试探过了,因此还是继续回溯。重复该过程,直到回溯到D点时,由于是从E点回溯至顶点D,顶点D继续寻找可以往”深”处搜索的顶点。同样的,他发现HG点都已经搜索过了,而I点却没有被搜索过,因此D点继续往I点搜索,即D -> I。而到I点之后,同样的,顶点I继续寻找可以往”深”处搜索的顶点。可是,其相邻顶点BCD也都已经搜索过了,所以也只能回溯至D点,然后重复此过程,直至回到起始搜索点A,搜索过程完成。
  总结一下这个过程,即是:

   A -> B -> C -> D -> E -> F -> G -> H -> I

由于图的每个顶点一般都是1个或多个相邻顶点,所以深度优先搜索的顺序往往是不确定的,而往”深”处搜索的思想是一致的,我们根据以上模拟过程,很容易发现,其实dfs就是一个递归的过程,每一步往下一步的动作都是一致的,都是探索自己相邻的所有顶点,找寻有哪些顶点还没有搜索过,有的话则继续深挖,没有的话,则路不通,只好回到相邻的上一个顶点(回溯),走另外的路径继续寻找

(2).几个经典的小例子

1.全排列问题

从n个不同元素中任取m(m≤n)个元素,按照一定的顺序排列起来,叫做从n个不同元素中取出m个元素的一个排列。当m = n时所有的排列情况叫全排列。
————————————————————————————————————————来自百度百科全排列
如1,2,3三个元素的全排列为:
123
132
213
231
312
321
共3*2*1=6种 3!
公式
全排列数f(n)=n!(定义0 != 1)

  对于这样的一个全排列问题,我们思考:假定输入一个数字n,表示全排列的数字范围为1~n,首先对于每个数,很显然我们会想,遍历除这个数字以外的所有数字即可,但是这样做显然是行不通的,为什么呢?首先要想遍历除这个数字以外的所有数字,那么我要知道当前哪些数字已经用了(而且不一定是连续的),所以这样的想法用循环写,显然是行不通的。那我们换个角度,把遍历的范围扩大一点,对于每一次尝试,都遍历尝试所有的数字。然后只要每个数用过了之后,就对它进行标记。这样的话for循环的部分就比较好写了,然后只要for循环内部判断一下当前循环到的数字是否已经标记过了即可。有了这样的思路,自然想到可以递归实现,因为每一次尝试都是尝试所有的数字,进行的动作是相同的。那么,递归终止的条件是什么呢?很显然,根据全排列的要求,当数字个数到达n个时,即可输出了。那么递归终止条件则是进行第n+1次尝试的时候,我们就可以判断到前面的n次尝试成功了,则可以返回上一步,输出前面n次尝试的结果,则成功输出了一次全排列的结果,而这个过程也相当于”深”处探索,其实也就是dfs的过程,这个返回上一步的过程,则为回溯,表示不再继续往”深”处探索下去。而这里需要注意的是,由于我们是要输出所有的结果,因此当回溯至上一步之后,则要重新标记回来之前的那一步为未搜索状态,这样的话,方便下一次通过其他的路径来到这里。

示例代码:

package month12.day13;import java.util.Scanner;/** * Created by Administrator on 2016/12/13. */public class AllArrangedProblem {    private boolean isVisited[] = new boolean[10];    private StringBuilder sb = new StringBuilder();    public static void main(String args[]) {        Scanner scanner = new Scanner(System.in);        while (scanner.hasNextInt()) {            int number = scanner.nextInt();            new AllArrangedProblem().dfs(number, 1);        }    }    void dfs(int sum, int step) {        if (step == sum + 1) {            System.out.println(sb);            return;        }        for (int i = 1;i <= sum;i++) {            if (!isVisited[i]) {                sb.append(i);                isVisited[i] = true;                dfs(sum, step + 1);                isVisited[i] = false;                sb.deleteCharAt(sb.length()-1);            }        }    }}

程序运行结果:

31231322132313123214123412431324134214231432213421432314234124132431312431423214324134123421412341324213423143124321

2.八皇后问题

八皇后问题是一个以国际象棋为背景的问题:如何能够在8×8的国际象棋棋盘上放置八个皇后,使得任何一个皇后都无法直接吃掉其他的皇后?为了达到此目的,任两个皇后都不能处于同一条横行、纵行或斜线上。
————————————————————————————————————————来自维基百科八皇后问题

  对于八皇后问题,由于任两个皇后都不能处于同一条横行、纵行或斜线上。那么首先我们可以考虑,对于满足以上条件的八个皇后的放置情况,很显然八个皇后都必须位于不同的行或者不同的列,那么,我们可以从第一行开始考虑棋子的摆放,由于我们并不清楚满足条件的八个皇后放置情况中每个皇后分别位于哪一列,所以我们只能每一次放置皇后的时候,对每一列都进行尝试,每找到满足条件的列,就进行放置。然后再考虑下一行,按照同样的方式遍试要放在哪一列,如果当考虑到第i(i <= 8)行皇后的放置时,第i行所有列都不满足条件,那么则要回退(即回溯)到第i - 1行,重新尝试其他满足条件的列。(由于我们是从第一列开始考虑的,那么相当于向右移动),如果第i - 1行其他的列仍找不到满足条件的列,那么继续回溯,直到找到满足条件的列,在按照以上方式,继续寻找满足题目条件的八个皇后放置情况。那么还有一个问题没有解决,怎么样考虑两个皇后是否在同一斜线(即任一主对角线或任一副对角线)上呢?
  考虑棋盘上的任意两个棋子,棋子1的棋盘坐标为(x1,y1),棋子2的棋盘坐标为(x2,y2),如果两个棋子位于主对角线上,那么显然两个棋子连线的斜率为1,即(x1x2)==(y1y2),而如果两个棋子位于副对角线上,那么显然两个棋子连线的斜率为-1,即(x1x2)==(y2y1),所以到这里,要判断棋盘上的任意两个皇后是否在同一斜线上,只需要判断(x1x2)==abs(y2y1)是否满足即可,若满足,则说明在同一斜线上,若不满足,则说明不在同一斜线上。
  有了以上的考虑,我们可以整理一下思路:
  从第一行开始考虑棋子的摆放,每一行都从第一列开始尝试每一个满足条件的位置,当遇到一个满足条件的位置之后,则考虑下一行,重复上述过程,如果考虑到了第9行(说明前8行已经摆放好了皇后,这样的话,就可以将计数+1,并返回(回溯)到上一行,继续考虑其他列是否满足条件),起始时,棋盘位置均为空(用0标志),当棋子摆放成功之后,则标记该位置(用1表示)。因此,我们得出以下示例代码(行列均从0开始计数):

package month12.day13;/** * Created by Administrator on 2016/12/13. *//** * @see本程序仅用于计算八皇后问题可行解的种类数 */public class EightQueensProblem {    static private final int COL_ROW_NUM = 8;    static private int count = 0;    static private boolean maze[][] = new boolean[COL_ROW_NUM][COL_ROW_NUM];    public static void main(String args[]) {        dfs(0);        System.out.println("八皇后问题总可行摆放总数为: " + count);    }    //判断当前摆放的皇后与前r-1行是否有冲突    static boolean whetherConflict(int r,int c) {        for (int i = 0;i < r;i++) {            for (int j = 0;j < COL_ROW_NUM;j++) {                if (maze[i][j]) {                    if (c == j || r - i == Math.abs(c-j)) {                        return false;                  //同一列或者位于同一对角线,不满足                    }                }            }        }        return true;    }    static void dfs(int step) {        if (step == COL_ROW_NUM) {            count++;            return;        }        for (int c = 0;c < COL_ROW_NUM;c++) {            if (!maze[step][c]) {                if (whetherConflict(step, c)) {                    maze[step][c] = true;                    dfs(step + 1);                    maze[step][c] = false;                }            }        }    }}

程序运行结果:

八皇后问题总可行摆放总数为: 92

有了以上的代码,就满足了吗?以上代码的时间复杂度为O(n3)dfs,时间复杂度太高了。

十五皇后问题总可行摆放总数为: 2279184 计算时间为: 186452ms

以上代码测试十六皇后跑了三分钟完全没有反应,然后测试十五皇后,整整跑了186+s,可见效率之低!

  观察程序,效率低的地方在于whetherConflict方法,存在着大量重复的检验,每个位置可能被检验非常多次,这样的话效率很显然是十分低下的,那么能想出什么样的优化方式呢?

  首先,我们可以想这样的一个问题,是否能够优化掉哪个for循环呢?这样的话复杂度就直接降了一个维度,即为:O(n2)dfs,首先dfs中的for循环肯定是没办法优化掉的,因为我们无论是遍试行还是列,都需要遍历寻访每一行或者每一列,判断其是否满足条件。然后我们可以考虑whetherConflict检验方法。
  whetherConflict方法很显然是存在大量重复检验的,判断当前摆放的皇后与前r-1行是否有冲突,外层for循环也还是没办法优化掉,因为在现有的思路中,从第1行到r-1行,我们没办法确定哪一行与现有检验行是否存在斜线冲突,只能逐一判断。而内层for循环呢?内层for循环是判断有摆放皇后的位置,是否与现有检验行存在列与斜线冲突。那么我们试想,对于每一行摆放皇后的列位置,我们是否可以将其存储起来呢?这样的话,就省去了这个遍试的过程。有了这样的思路,我们就可以继续想下去,如果重新声明一个新的数组显得更麻烦,我们是否可以在原有数组上存储呢?这里有很多种存储方式,最简单可行的,我们可以利用每一行的下标0位置存储该行摆放皇后的列,那么我们n*n的皇后棋盘,则可用maze[n+1][n+1]存储。其中1~n行每一行下标0位置存储该行摆放皇后的列,这样的话复杂度就相比原来降了一个维度。所以在dfs的方法中,我们可以先假定每个位置都是满足条件的,然后再逐一检验,如果不满足则将其恢复为空的状态,如果满足的话,则用maze[i][0]存储该行摆放皇后的列。因此得出以下示例代码:

package month12.day13;/** * Created by Administrator on 2016/12/13. */public class BetterEightQueensProblem {    static private final int COL_ROW_NUM = 15;    static private int count = 0;    static private int maze[][] = new int[COL_ROW_NUM + 1][COL_ROW_NUM + 1];    public static void main(String args[]) {        long start = System.currentTimeMillis();        dfs(1);        long end = System.currentTimeMillis();        System.out.println("十五皇后问题总可行摆放总数为: " + count + " 计算时间为: " + (end - start) + "ms");    }    //判断当前摆放的皇后与前r-1行是否有冲突    static boolean whetherConflict(int r,int c) {        for (int i = 1;i < r;i++) {            int col = maze[i][0];            if (col == c || r - i == Math.abs(c - col)) {                return false;            }        }        return true;    }    static void dfs(int step) {        if (step == COL_ROW_NUM + 1) {            count++;            return;        }        for (int c = 1;c <= COL_ROW_NUM;c++) {            maze[step][c] = 1;            if (whetherConflict(step, c)) {                maze[step][0] = c;                //每行的第0个位置标记该行使用了哪一列(妙!)                dfs(step + 1);            }            maze[step][c] = 0;        }    }}

测试十五皇后运算结果所需时间:

十五皇后问题总可行摆放总数为: 2279184 计算时间为: 54118ms

只用了54+s,整整比原来的算法快了130+s!可见算法的重要性!
  是否这样就是最优了呢?我觉得这样还是有优化的地方,比如说在dfs方法中,在未找到合适的位置时,对于之前的每一列都需要用whetherConflict方法判断,而whetherConflict是整个程序中比较耗时的部分,所以可以思考一下怎么能够减少判断次数,我们看到whetherConflict方法中有这样的一句判断col == c,当看到这里的时候,很显然我觉得这个判断是不必要的,根据以上标记的思想,对于该列是否被占用显然也是可以标记的,用boolean数组即可,若该列已经有摆放元素,则标记为true,否则则为false。因此我们得出再进一步的优化程序:

package month12.day13;/** * Created by Administrator on 2016/12/13. */public class AnotherBetterEightQueensProblem {    static private final int COL_ROW_NUM = 15;    static private int count = 0;    static private int maze[][] = new int[COL_ROW_NUM + 1][COL_ROW_NUM + 1];    static private boolean colSign[] = new boolean[COL_ROW_NUM + 1];    public static void main(String args[]) {        long start = System.currentTimeMillis();        dfs(1);        long end = System.currentTimeMillis();        System.out.println("十五皇后问题总可行摆放总数为: " + count + " 计算时间为: " + (end - start) + "ms");    }    //判断当前摆放的皇后与前r-1行是否有冲突    static boolean whetherConflict(int r,int c) {        for (int i = 1;i < r;i++) {            int col = maze[i][0];            if (r - i == Math.abs(c - col)) {                return false;            }        }        return true;    }    static void dfs(int step) {        if (step == COL_ROW_NUM + 1) {            count++;            return;        }        for (int c = 1;c <= COL_ROW_NUM;c++) {            maze[step][c] = 1;            if (!colSign[c]) {                if (whetherConflict(step, c)) {                    colSign[c] = true;                    maze[step][0] = c;                //每行的第0个位置标记该行使用了哪一列(妙!)                    dfs(step + 1);                    colSign[c] = false;                }            }            maze[step][c] = 0;        }    }}

测试十五皇后运算结果所需时间:

十五皇后问题总可行摆放总数为: 2279184 计算时间为: 33145ms

具体还有更优化的版本,位运算及其他语言的奇淫技巧,参见:
如何用 C++ 在 10 行内写出八皇后?
位运算简介及实用技巧(三):进阶篇(2)

3.分苹果问题

问题描述:

把M个同样的苹果放在N个同样的盘子里,允许有的盘子空着不放,问共有多少种不同的分法?(用K表示)5,1,1和1,5,1 是同一种分法。

  首先可以我们可以思考,把M个同样的苹果放在N个同样的盘子里,允许有的盘子空着不放,那么很自然的可以想到,可以分类讨论,当0,1,2,...,n个盘子空着不放的情况。
那么很显然,问题就转换成了:
  把M个同样的苹果放在N个同样的盘子里,允许有的盘子空着不放 => 把M个同样的苹果放在1个盘子里 + 把M个同样的苹果放在2个盘子里 + 把M个同样的苹果放在3个盘子里 + … + 把M个同样的苹果放在n个盘子里(其中n的取值为: M > N ? N : M)。
  再进一步的转换,核心问题则等价于求”整数划分”的问题,即将整数M划分成n个正整数x1x2x3,…,xn,有多少种划分方法?(其中2,1,21,2,2算同一种划分)
  将整数M划分成n个正整数,有多少种划分方式?对于这个问题,仔细一想便知,核心在于n的大小,因为整数M最多也是划分成M个正整数(1,1,1...1M),因此当n > M时,显然结果为0。而当n == M或者n==1时,显然结果为1。有了这样的想法,我们就可以尝试对n的范围进行讨论,从而更好的分析问题。为了分析方便,我们记f(m,n)为整数m划分成n个正整数的问题。那么有:

    (1).当n > m时,f(m,n)=0
    (2).当n == mn == 1时,f(m,n)=1
    (3).当n < m时,那么要怎么考虑呢?
  当n < m时,很显然划分成M个正整数(1,1,1...1M)后,还剩余整数m - n尚未划分,那么,现在的核心问题就在于剩余整数m - n该怎么划分?回到我们的主问题:

将整数M划分成n个正整数,有多少种划分方式?

  那么是否剩下的问题即为将整数m - n划分成n个正整数,有多少种划分方式?显然不是的,因为一开始的时候每个划分块都已经有了一个数1,所以剩余整数m - n可以放入1个,2个…,n个划分块。那么主问题将整数M划分成n个正整数,则转换成了将整数m - n划分成1个正整数 + 将整数m - n划分成2个正整数 + … + 将整数m - n划分成n个正整数的问题。将我们的想法公式化即为:

f(m,n)=f(mn,1)+f(mn,2)+f(mn,3)+...+f(mn,n)

  对于这n个子问题,每个问题描述起来是将整数m - n划分成1,2,3...,n个正整数的问题。本质上还是将一个整数划分成n个正整数问题,因此依旧可以按照上述分三点的处理方式去做。那么从这里我们就可以看到,这是一个递归的问题,每一步的处理方式是一致的。完整代码示例:

package month12.day29;import java.util.Scanner;/** * Created by Administrator on 2016/12/29. */public class Main {    private static int getSolution(int m, int n) {        if (m < n) {            return 0;        }        if (m == n || n == 1) {            return 1;        }        int sum = 0;        for (int i = 1;i <= n;i++) {            sum += getSolution(m - n, i);        }        return sum;    }    public static void main(String args[]) {        Scanner scanner = new Scanner(System.in);        int T = scanner.nextInt();         //T表示共有几组测试数据        while (T-- != 0) {            int m = scanner.nextInt();            int n = scanner.nextInt();            System.out.println(getSolution(m, n));        }    }}

程序运行结果(可手算验证^-^):

510 2510 1110 385 2230 375
0 0