在开始前,让我们先玩个小游戏:
“小游戏”
不知道大家能走多少步呢?又是否发现了些规律或是能多走几步的技巧呢?
什么是骑士周游问题?
他是一道著名的谜题,用“马”尽可能多的走遍棋盘上的格子,但是不能重复。
如何解决?
尝试解决1——暴力递归(DFS)
我们会发现,要解决这个问题无非就是将各种可能路径都尝试一下。那么如何尝试?怎样才能不遗漏任何一个可能的路径呢?会不会有优化?这些都是我们可能要面临的问题。当我们逐一解决这些问题那么问题的答案也就浮出水面了!
- 需要达到怎样的效果?
- 如何尝试?
- 啥时候结束?
- 如何判断当前路径可行?不可行又该如何尝试下一条路径?
- ...........
以上是我在着手写代码前想到的一些问题,大家也可以这样列举出一下或具体或抽象的问题出来;便于我们发散的思考问题。
明确目标:
咱们的骑士可以从棋盘的任意一个格子出发开始周游,棋盘是一个正方形——当然也可以不是,我们这里假设置它只会是6*6或8*8的棋盘;然后我们假设从任意点出发都可以走遍棋盘。
然后我们来思考一下我们写代码时的输入输出。
输入:
输入一个起点坐标和棋盘大小,对于起点坐标要求是非负整数;(0,0)表示棋盘的左上角。棋盘的横坐标向右增长,纵坐标向下增长。
输出:
输出棋盘上每一格骑士所走过时的步数是多少,每一行的每一格用一个空格分隔。
输入输出样例:
样例1:
输入:
1 5 6
输入说明:1 5是骑士的起始位置6是棋盘的大小。
输出:
24 13 34 1 22 11
33 2 23 12 27 0
16 25 14 35 10 21
3 32 17 26 7 28
18 15 30 5 20 9
31 4 19 8 29 6
理论结束实践开始:
这里我选用Java语言来求解这个问题。
先构建一个horseTreadsOnTheChessboard类
public class horseTreadsOnTheChessboard {
public static void main(String[] args) {
//得到答案
new horseTreadsOnTheChessboard(1, 5, 6).getAnswer();
System.out.println("end......");
}
//需要起始位置,棋盘,visited标注骑士去过的地方
private boolean isFinished;//求解过程是否结束
private int beginRow;//骑士起始位置的行坐标
private int beginCol;//骑士起始位置的列坐标
private int chessSize;//棋盘的大小
private int[][] chess;//棋盘 记录走过的位置 每一格的数字代表当前步数
private int[][] visited;//0 表示当前位置没有走过 1 表示已经走过
public horseTreadsOnTheChessboard(int beginRow, int beginCol, int chessSize) {
this.beginRow = beginRow;
this.beginCol = beginCol;
this.chessSize = chessSize;
this.chess = new int[chessSize][chessSize];
this.visited = new int[chessSize][chessSize];
}
//求解的主方法
public void getAnswer(){
}
}
后续我们通过不断完善getAnswer()方法就好啦。
先添加一个方法用于打印chess和visited,它既可以用于我们测试也可以用于我们输出结果。
//用于打印 chess 和 visited
private void printDeepArr(int[][] arr) {
for (int[] row : arr) {
for (int elem : row) {
System.out.print(elem + "\t");
}
System.out.println();
}
}
我们再来添加一个获取路径的方法,也就是标记骑士走过的路,具体的暴力搜索的代码。
//从当前位置出发,当走遍所有格子便结束
private void tryPath(int curRow, int curCol, int step) {
//记录当前位置已经走过了
visited[curRow][curCol] = 1;
chess[curRow][curCol] = step;
//尝试接下来要走的位置
//获取接下来要走的位置
ArrayList<Integer[]> nextStepPoses = getNextStepPoses(curRow, curCol);
while (!nextStepPoses.isEmpty()) {
Integer[] nextPos = nextStepPoses.remove(0);
if (visited[nextPos[0]][nextPos[1]] == 0) {
tryPath(nextPos[0], nextPos[1], step + 1);
}
}
if (isFinished || step == chessSize * chessSize - 1) {
//已经走到了最后一步
isFinished = true;
} else {
//不是最后一步,当前路径不是有效解
visited[curRow][curCol] = 0;
}
}
这里还涉及到另外一个方法getNextStepPoses(),用于获取下一步可以走的所有位置
//获取下一步可以走的位置
private ArrayList<Integer[]> getNextStepPoses(int row, int col) {
ArrayList<Integer[]> ret = new ArrayList<>();
//遍历八个方向
int[] derTaRow = {-2, -1, +1, +2, +2, +1, -1, -2};//行的改变量
int[] derTaCol = {+1, +2, +2, +1, -1, -2, -2, -1};//列的改变量
for (int i = 0; i < 8; i++) {
int newRow = row + derTaRow[i];
int newCol = col + derTaCol[i];
if (isRightPos(newRow, newCol)) {
ret.add(new Integer[]{newRow, newCol});
}
}
return ret;
}
这里我采用从右上角出发顺时针的顺序来遍历获取八个位置的坐标。isRightPos()用来判断当前位置是否合法,即是骑士不能到棋盘以外的地方去。
//当前位置是否合法
private boolean isRightPos(int row, int col) {
return !(row < 0 || col < 0 || row >= chessSize || col >= chessSize);
}
如果在浏览完代码后,感觉还是有点懵,那就需要好好看看后面的描述了。
思路梳理:
- horseTreadsOnTheChessboard类用于组织求解过程中的各种相关属性和方法。
- getAnswer()方法被public修饰对外暴露,通过该方法可以得到求解的答案。
- tryPath()方法被private修饰不对外暴露,该方法用于暴力尝试,枚举各种可能的走法,仅当骑士走完棋盘后才会结束。
- tryPath()方法首先将当前位置在棋盘和visited进行标记,由于需要在棋盘上保存骑士走过的路径(也就是某一步在哪儿一个格儿上)所以需要step这个参数来记录当前走的步数同时也用于判断骑士是否走完了棋盘。
- tryPath()方法中的循环用于递归的求解这个问题,它会不停的去走下一步,直到当前位置走不了下一步了,也就是下一步的位置都已经被走过了的时候(不是下一步的位置都越界了),就会结束本循环——“撞到南墙”了;后面的if-else语句用于判断是走到最后一步了该结束递归了还是当前路径不是有效解该回溯了。
- 走下一步之前必须保证下一步的位置没有被走过,不然会造成死递归——栈溢出。
- 因为step == 0时骑士已经在棋盘上了,也就是有一个格子已经被骑士走过了;所以当step == 所有格子数 - 1时就已经走到最后一步了(0~35总共36个数)。
- 递归使得只需要当前位置对应的visited被跟新成0之前未被尝试的路径(nextStepPoses中还没有被取出进行尝试的点)就可以再走到这个位置上来,这也是能够正确回溯和不会遗漏每条可能路径的关键。
通过思路梳理相信大家对代码有了更深入的了解,下面我们在getAnswer()方法中加入些额外的代码来看看求解的效率如何。
//求解的主方法
public void getAnswer() {
System.out.println("开始求解( " + beginRow + " , " + beginCol + " )");
long beginTime = System.currentTimeMillis();
tryPath(beginRow, beginCol, 0);
long endTime = System.currentTimeMillis();
printDeepArr(chess);
System.out.println("耗时:" + (endTime - beginTime) + " ms");
}
运行后会得到类似这样的输出:
开始求解( 1 , 5 )
22 31 18 29 20 5
17 28 21 6 11 0
32 23 30 19 4 7
27 16 25 10 1 12
24 33 14 3 8 35
15 26 9 34 13 2
耗时:13 ms
end......
看着还不错,不过当我尝试某些特殊的点时发现程序半天没有输出,等了一会儿才有输出;这让我意识到这个效率肯定还不够。
开始求解( 1 , 0 )
35 22 1 26 9 20
0 29 8 21 2 27
23 34 25 28 19 10
30 7 32 13 16 3
33 24 5 18 11 14
6 31 12 15 4 17
耗时:20672 ms
end......
继续优化:
比较容易想到的是从最耗时的地方也就是就是暴力递归尝试的部分下手。如果尝试优先走哪些递归深度比较浅的位置会不会提升效率呢?将哪些“从一开始就是错的”的路径放在后面尝试尽可能减少回溯浪费的时间是不是可以提示效率呢?这些是大家在思考过程中能够想到的吗?
如何实现呢?
由于下一步最多只可能会尝试8个位置所以我们可以直接将nextStepPoses进行排序即可。
//将nextStepPoses进行排序
nextStepPoses.sort(new Comparator<Integer[]>() {
@Override
public int compare(Integer[] p1, Integer[] p2) {
return getNextCanMovePosCnt(p1[0], p1[1]) - getNextCanMovePosCnt(p2[0], p2[1]);
}
});
只需要在tryPath()方法中的while循环前面加上这样几行代码,并添加getNextCanMovePosCnt()方法来获取下一步可以移动的步数。
//获取下一步可以移动的步数
private int getNextCanMovePosCnt(int curRow, int curCol) {
ArrayList<Integer[]> nextStepPoses = getNextStepPoses(curRow, curCol);
int cnt = 0;
for (Integer[] pos : nextStepPoses) {
if (visited[pos[0]][pos[1]] == 0) {
cnt++;
}
}
return cnt;
//return getNextStepPoses(curRow, curCol).size();//这个优化效果还不够明显
}
这时候运行代码我们会发现即使是将棋盘大小改为10*10的大小也能很快的求解出答案,可见这个优化方向是正确的!
最后附上完整代码:
import java.util.ArrayList;
import java.util.Comparator;
/**
* @author DCSGO
* @version 1.0
*/
public class horseTreadsOnTheChessboard {
public static void main(String[] args) {
//得到答案
//new horseTreadsOnTheChessboard(1, 0, 6).getAnswer();
int s = 10;
for (int i = 0; i < s; i++) {
for (int j = 0; j < s; j++) {
new horseTreadsOnTheChessboard(i, j, s).getAnswer();
}
}
//System.out.println("end......");
}
//需要起始位置,棋盘,visited标注骑士去过的地方
private boolean isFinished;//求解过程是否结束
private final int beginRow;//骑士起始位置的行坐标
private final int beginCol;//骑士起始位置的列坐标
private final int chessSize;//棋盘的大小
private final int[][] chess;//棋盘 记录走过的位置 每一格的数字代表当前步数
private final int[][] visited;//0 表示当前位置没有走过 1 表示已经走过
public horseTreadsOnTheChessboard(int beginRow, int beginCol, int chessSize) {
this.beginRow = beginRow;
this.beginCol = beginCol;
this.chessSize = chessSize;
this.chess = new int[chessSize][chessSize];
this.visited = new int[chessSize][chessSize];
}
//用于打印 chess 和 visited
private void printDeepArr(int[][] arr) {
for (int[] row : arr) {
for (int elem : row) {
System.out.print(elem + "\t");
}
System.out.println();
}
}
//获取下一步可以移动的步数
private int getNextCanMovePosCnt(int curRow, int curCol) {
ArrayList<Integer[]> nextStepPoses = getNextStepPoses(curRow, curCol);
int cnt = 0;
for (Integer[] pos : nextStepPoses) {
if (visited[pos[0]][pos[1]] == 0) {
cnt++;
}
}
return cnt;
//return getNextStepPoses(curRow, curCol).size();//这个优化效果还不够明显
}
//从当前位置出发,当走遍所有格子便结束
private void tryPath(int curRow, int curCol, int step) {
//记录当前位置已经走过了
visited[curRow][curCol] = 1;
chess[curRow][curCol] = step;
//尝试接下来要走的位置
//获取接下来要走的位置
ArrayList<Integer[]> nextStepPoses = getNextStepPoses(curRow, curCol);
//将nextStepPoses进行排序
nextStepPoses.sort(new Comparator<Integer[]>() {
@Override
public int compare(Integer[] p1, Integer[] p2) {
return getNextCanMovePosCnt(p1[0], p1[1]) - getNextCanMovePosCnt(p2[0], p2[1]);
}
});
while (!nextStepPoses.isEmpty()) {
Integer[] nextPos = nextStepPoses.remove(0);
if (visited[nextPos[0]][nextPos[1]] == 0) {
tryPath(nextPos[0], nextPos[1], step + 1);
}
}
if (isFinished || step == chessSize * chessSize - 1) {
//已经走到了最后一步
isFinished = true;
} else {
//不是最后一步,当前路径不是有效解
visited[curRow][curCol] = 0;
}
}
//当前位置是否合法
private boolean isRightPos(int row, int col) {
return !(row < 0 || col < 0 || row >= chessSize || col >= chessSize);
}
//获取下一步可以走的位置
private ArrayList<Integer[]> getNextStepPoses(int row, int col) {
ArrayList<Integer[]> ret = new ArrayList<>();
//遍历八个方向
int[] derTaRow = {-2, -1, +1, +2, +2, +1, -1, -2};//行的改变量
int[] derTaCol = {+1, +2, +2, +1, -1, -2, -2, -1};//列的改变量
for (int i = 0; i < 8; i++) {
int newRow = row + derTaRow[i];
int newCol = col + derTaCol[i];
if (isRightPos(newRow, newCol)) {
ret.add(new Integer[]{newRow, newCol});
}
}
return ret;
}
//求解的主方法
public void getAnswer() {
System.out.println("开始求解( " + beginRow + " , " + beginCol + " )");
long beginTime = System.currentTimeMillis();
tryPath(beginRow, beginCol, 0);
long endTime = System.currentTimeMillis();
printDeepArr(chess);
System.out.println("耗时:" + (endTime - beginTime) + " ms");
}
}
此时我们就可以借助上述代码,来重玩一下原来的那个“小游戏”,然后你会发现——当最后一步可以走回起点时他能多走一步!
那么这个不知道各位能否自己解决呢?
这个不难我就不卖关子了,直接在tryPath()方法里面的if-else判断那儿加一个判断就好,这里了我依旧是添加一个方法来解决这个问题。
//判断下一步能否走到起点
private boolean canMoveToLastStep(int curRow, int curCol) {
ArrayList<Integer[]> nextStepPoses = getNextStepPoses(curRow, curCol);
for (Integer[] pos : nextStepPoses) {
if (pos[0] == beginRow && pos[1] == beginCol) {
return true;
}
}
return false;
}
把这个方法加到if里的最后面用与连接就好了,但是不是所有的点都能满足这个结束条件哦。