Java求解迷宫问题:栈与回溯算法
摘要: 使用栈的数据结构及相应的回溯算法实现迷宫创建及求解,带点JavaGUI 的基础知识。
难度: 中级
迷宫问题是栈的典型应用,栈通常也与回溯算法连用。 回溯算法的基本描述是:
(1) 选择一个起始点;
(2) 如果已达目的地, 则跳转到 (4); 如果没有到达目的地, 则跳转到 (3) ;
(3) 求出当前的可选项;
a. 若有多个可选项,则通过某种策略选择一个选项,行进到下一个位置,然后跳转到 (2);
b. 若行进到某一个位置发现没有选项时,就回退到上一个位置,然后回退到 (2) ;
(4) 退出算法。
在回溯算法的实现中,通常要使用栈来保存行进中的位置及选项。本文给出自己写的迷宫回溯算法实现及简要说明。
1. 首先给出栈的抽象数据结构 StackADT<T> : 主要是入栈、出栈、判断空、长度、展示操作;
package zzz.study.javagui.maze;
public interface StackADT<T> {
/* 判断是否为空栈;若为空,返回TRUE, 否则返回FALSE */
public boolean isEmpty();
/* 入栈操作: 将元素 e 压入栈中 */
public void push(T e);
/* 出栈操作: 若栈非空,将栈顶元素弹出并返回;若栈空,则抛出异常 */
public T pop();
/* 返回栈顶元素,但并不将其弹出 */
public T peek();
/* 返回栈长度,即栈中元素的数目 */
public int size();
/* 遍历操作: 若栈非空,遍历栈中所有元素 */
public String toString();
}
2. 可变长的栈的实现 DyStack<T>: 借助 ArrayList 及一个指针来实现。注意到这里使用泛型来保证栈的通用性。
package zzz.study.javagui.maze;
import java.util.ArrayList;
public class DyStack<T> implements StackADT<T> {
private final int INIT_STACK_SIZE = 20;
ArrayList<T> ds; // 栈元素列表
private int top; // 栈顶索引:当前栈顶元素的下一个元素的索引
/*
* 构造器:
* 使用默认容量创建一个空栈
*
*/
public DyStack() {
top = 0;
ds = new ArrayList<T>(INIT_STACK_SIZE);
}
/*
* 构造器:
* 使用指定容量创建一个空栈
*
*/
public DyStack(int capacity) {
top = 0;
ds = new ArrayList<T>(capacity);
}
public boolean isEmpty() {
if (top == 0)
return true;
else
return false;
}
public void clear() {
top = 0;
ds.clear();
}
public void push(T e) {
ds.add(top, e);
top++;
}
public T pop() {
if (ds.isEmpty())
throw new StackEmptyException("The stack has been empty!");
top--;
T result = ds.get(top);
ds.set(top, null);
return result;
}
public T peek() {
if (ds.isEmpty())
return null;
return ds.get(top - 1);
}
public int size() {
return top;
}
public String toString() {
StringBuilder content = new StringBuilder(" ");
for (int i = 0; i < top; i++) {
content.append(" --> ");
content.append(ds.get(i));
}
return content.toString();
}
public int getTop() {
return top;
}
}
class StackEmptyException extends RuntimeException {
public StackEmptyException(String msg) {
super(msg);
}
}
3. 迷宫位置的数据结构 Position: 这里为了"节约内存"方向选择了 byte 类型,实际上在小型程序里是不必要的,带来了繁琐的类型转换,也带来了一些隐藏的问题。比如 Set<Byte> dirs 包含 byte 1; 但 dirs.contains(1) 会返回 false , 而 dirs.contains((byte)1) 才会返回 true. Position 要在集合中使用,最好实现 equals 和 hashCode 方法。注意 equals 不要写成了 equal, hashCode 不要写成 hashcode , 这些我都犯过错的 :)
package zzz.study.javagui.maze;
/*
* 迷宫中的通道位置模型:
* row: 所处位置的行坐标
* col: 所处位置的列坐标
* dir: 将要进行的搜索方向: 正东 1; 正南 2; 正西3; 正北 4;
*/
public class Position {
private int row;
private int col;
private byte dir;
public Position() {
row = 0;
col = 0;
dir = 0;
}
public Position(int row, int col, byte dir) {
this.row = row;
this.col = col;
this.dir = dir;
}
public Position(int row, int col) {
this(row, col, 0);
}
public Position(int row, int col, int dir) {
this(row, col, (byte)dir);
}
public int getRow() {
return row;
}
public int getCol() {
return col;
}
public short getDir() {
return dir;
}
public String toString() {
String dirStr = "";
switch (dir) {
case 1: dirStr = "正东"; break;
case 2: dirStr = "正南"; break;
case 3: dirStr = "正西"; break;
case 4: dirStr = "正北"; break;
default: dirStr = "UNKNOWN"; break;
}
return "(" + row + "," + col + "," + dirStr + ")";
}
public boolean equals(Object obj)
{
if (obj == this) { return true; }
if (obj instanceof Position) {
Position p = (Position) obj;
if (p.row == this.row && p.col == this.col && p.dir == this.dir) {
return true;
}
}
return false;
}
public int hashCode()
{
int result = 17;
result = result * 31 + row;
result = result * 31 + col;
result = result * 31 + dir;
return result;
}
}
4. 迷宫的核心实现 Maze :
里面注释说的比较清楚了,有五点说明一下:
(1) 由于要使用回溯算法,必须使用一个 Map<Position, List<triedDirs>> 来记录每个位置已经尝试过的方向,使用一个栈 stackForCreate 记录当前行进的位置轨迹; 当回溯到前面的位置时,不再重复已经尝试过的方向,避免重复尝试陷入无限循环;
(2) 方向选择上,耗费了一点空间,简单地实现了方向选择的概率设置;也就是将未尝试的方向列表按概率次数扩展成新的方向列表,然后随机从这个新的方向列表中选择;
(3) 在抵达迷宫边界时,对方向加以限制,只允许往出口方向走;否则,回走会形成环路,由于回溯的特性,会将环路里面的墙全部"吃掉"!
(4) 在迷宫展示上,为了简便使用了字符 IIII 完美等于 5 个空格完美等于 2 个 G, 实现了对齐问题; 虽然使用等宽字体,但似乎未起作用, 也尝试过 T, [T], [I], 这样的符号,但与空格难以对齐。写这个程序还是费了不少心思的 ^_^ 注意到 Maze 继承了 Observable , 支持 GUI 展示, 可以展示迷宫生成的过程, 也可以看到空格是如何一步步"吃掉"由 IIII 组成的墙的, interesting ~~
(5) 为了展示出误导路径, 采用分治策略将迷宫切分成若干个子迷宫矩阵分别求解, 并将上一个子迷宫矩阵的终止点与下一个子迷宫矩阵的起始点衔接起来确保一定有一条通路从入口抵达出口。
(6) 为什么创建迷宫的代码比求解迷宫的代码更多呢?因为求解迷宫可以尽可能地朝正东或正南即出口方向走,但创建迷宫必须选择随机方向。
package zzz.study.javagui.maze;
import java.util.*;
import java.util.concurrent.TimeUnit;
public class Maze extends Observable {
// 定义迷宫大小:行数 rows 和列数 cols
private final int rows;
private final int cols;
// 定义迷宫出口点位置: 行坐标 EXIT_ROW 和 列坐标 EXIT_COL
private final int EXIT_ROW;
private final int EXIT_COL;
// 定义迷宫矩阵mazeMatrix 和 标记数组 mark
private boolean[][] mazeMatrix; // true: 可通行; false: 不可通行
private short[][] mark;
private String mazeStr = ""; // 迷宫的字符串表示
private String solution = ""; // 迷宫的解的字符串表示
// 定义移动方向表
private byte[][] move = {
{0, 1}, // 正东 , move[0] 方向一
{1, 0}, // 正南 , move[1] 方向二
{0, -1}, // 正西 , move[2] 方向三
{-1, 0}, // 正北 , move[3] 方向四
};
// 存放所有方向, 使用该集合与某个位置已尝试方向的差集来获取其未尝试的方向
private static final Set<Byte> allDirs = new HashSet<Byte>(Arrays.asList(new Byte[] {0, (byte)1, (byte)2, (byte)3}));
private DyStack<Position> stack; // 使用栈存放迷宫通路路径
private boolean isCreatedFinished; // 迷宫是否创建完成
private Random rand = new Random();
public Maze(int rows, int cols) {
this.rows = rows;
this.cols = cols;
EXIT_ROW = rows - 1;
EXIT_COL = cols - 1;
mazeMatrix = new boolean[rows][cols];
mark = new short[rows][cols];
}
/**
* 迷宫求解:求解迷宫并设置解的表示
*/
public void solve() {
if (hasPath()) {
setSolutionStr();
} else {
noSolution();
}
}
/**
* 迷宫矩阵的字符串表示
*/
public String toString() {
StringBuilder mazeBuf = new StringBuilder("\n");
String mazeCell = "";
for (int i = 0; i < rows; i++) {
if (i == 0) {
mazeBuf.append("Entrance => ");
} else {
// the width of "Entrance => " is Equal to the width of 20 spaces.
mazeBuf.append(indent(20));
}
mazeBuf.append('|');
for (int j = 0; j < cols; j++) {
if (mazeMatrix[i][j] == false) {
mazeCell = String.format("%4s", "IIII");
} else { // 存在通路
if (mark[i][j] == 1) {
mazeCell = String.format("%2s", "GG");
}
else {
mazeCell = String.format("%5s", "");
}
}
mazeBuf.append(mazeCell);
}
if (i == rows - 1) {
mazeBuf.append("| => Exit\n");
} else {
mazeBuf.append("|\n");
}
}
mazeStr = mazeBuf.toString();
return mazeStr;
}
/**
* 监听按钮事件后发生改变,并通知观察者此变化的发生
*/
public void change() {
setChanged();
notifyObservers();
}
public String getSolution() {
return solution;
}
public boolean isCreatedFinished() { return isCreatedFinished; }
/**
* 将迷宫还原为初始状态
*/
public void reset() {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
mazeMatrix[i][j] = false;
}
}
isCreatedFinished = false;
}
public void createMaze() {
for (int i = 0; i <= EXIT_ROW; i++) {
for (int j = 0; j <= EXIT_COL; j++) { // 初始无通路
mazeMatrix[i][j] = false;
}
}
if (rows < 10 && cols < 10) {
StackADT<Position> createPaths = new DyStack<Position>(rows+cols);
createMaze(0,0, EXIT_ROW, EXIT_COL, createPaths);
isCreatedFinished = true;
change();
}
else {
StackADT<Position> createPaths = new DyStack<Position>(rows+cols);
List<int[][]> smallParts = divideMaze(rows, cols, 4);
for (int[][] parts: smallParts) {
createMaze(parts[0][0], parts[0][1], parts[1][0], parts[1][1], createPaths);
if (parts[0][1] != 0) {
// 衔接点打通, 保证总是有一条从入口到出口的通路
mazeMatrix[parts[0][0]][parts[0][1]-1] = true;
}
}
isCreatedFinished = true;
change();
}
}
/*
* divide [1:rows-1] into n sectors
*/
private static List<Integer> divideN(int rows, int n) {
int each = rows/n;
int start = 0;
List<Integer> divs = new ArrayList<Integer>();
for (int i=0; i<n;i++) {
divs.add(start + i*each);
}
divs.add(rows-1);
return divs;
}
private static List<int[][]> divideMaze(int rows, int cols, 
