对于八数码问题,我们首先要判断当前的状态能不能到达目的状态,这里需要用到奇排列和偶排列的概念。八数码虽然是个二维数组,但也可以展开看成是一个一维序列。奇排列只能转换成奇排列,偶排列只能转换成偶排列。判断奇偶排列的方法就是:对于每个数,求出排在它之前的比它大的数的个数,然后将这些个数加起来,得到的数是奇数就是奇排列,是偶数就是偶排列,若起始状态和目标状态一个是奇排列一个数偶排列,那么肯定到达不了目标状态。
对于八数码问题,我们一般能想到的解法是广度优先搜索,但是由于广搜扩展了很多没有用的状态,导致复杂度非常高。而A*算法是一种启发式图搜索算法,其特点在于对估价函数的定义上,但本质还是个广度优先搜索。对于一般的启发式图搜索,总是选择估价函数 f 值最小的节点作为扩展节点。估价函数的设置多种多样,好的估价函数可以大幅缩短程序运行时间,下面介绍两种常见的解决八数码问题的估价函数:
1、走到当前状态已付出的代价与到达目的状态仍需的代价之和。
到达当前状态所付出的代价其实就是从初始状态到达当前状态所用的步数,这是我们可以确定的。但是到达目的状态仍需的代价却是未知的,是我们到达目的状态之后才能知道的。所以我们就用近似的方法,用一个可以求得出的量来近似代替到达目的状态仍需的代价。这个量就是,当前状态下的每个数字,走到它在目的状态中的位置所需的步数之和。例如当前状态是:
2 1 3
0 8 4
6 7 5
目的状态是:
2 1 3
0 4 5
6 7 8
那么0~9走到它在目的状态中的位置所需的步数分别是:0,0,0,0,1,1,0,0,2。其中0,1,2,3,6,7不需要移动,4和5需要移动一步,8需要移动两步。总和就是4。
明确了估价函数之后,就可以用优先队列来进行广度优先搜索,代码如下:
import java.util.*;
public class Code {
// 用于保存初始状态和目标状态
static int N = 0;
static int[][] MT = new int[3][3];
static int[][] endMT = new int[3][3];
// 用于保存目标状态中每个数字所在的位置
static HashMap<Integer, int[]> map = new HashMap<>();
static int[][] dir = { { 0, 1 }, { 0, -1 }, { -1, 0 }, { 1, 0 } };
// 用于保存所有出现过的状态
static List<int[][]> marke = new ArrayList<int[][]>();
static public class node implements Cloneable {
// 当前结点状态中空格的位置
int x;
int y;
// 估价函数g和h
int g;
int h;
// 记录步数
int step;
// 当前状态各位置的数字
int[][] mt = new int[N][];
// 保存路径
List<int[][]> path = new ArrayList<int[][]>();
// 构造函数
public node(int x, int y, int g, int h, int step, int[][] mt, List<int[][]> path) {
super();
this.x = x;
this.y = y;
this.g = g;
this.h = h;
this.step = step;
this.mt = mt;
this.path = path;
}
// 用于克隆对象,这样在扩展时,下一个状态可以保留上一个状态的路径
public Object clone() {
node nd = null;
try {
nd = (node) super.clone();
} catch (CloneNotSupportedException e) {
// TODO 自动生成的 catch 块
e.printStackTrace();
}
nd.mt = new int[3][];
for (int r = 0; r < N; r++) {
nd.mt[r] = this.mt[r].clone();
}
nd.path = new ArrayList<int[][]>();
nd.path.addAll(this.path);
return nd;
}
}
static Comparator<node> cmp = new Comparator<node>() {
@Override
public int compare(node o1, node o2) {
// TODO Auto-generated method stub
return (o1.g + o1.h) - (o2.g + o2.h);
}
};
static boolean input_date() {
@SuppressWarnings("resource")
Scanner in = new Scanner(System.in);
N = 3;
// 求奇偶排列的变量
int[] startNum = new int[N * N];
int[] endNum = new int[N * N];
int cnt1 = 0;
int cnt2 = 0;
// 输入初始状态和目标状态
System.out.println("请输入初始状态(0代表空白位置):");
for (int i = 0; i < N; i++) {
MT[i][0] = in.nextInt();
MT[i][1] = in.nextInt();
MT[i][2] = in.nextInt();
for (int j = 0; j < N; j++)
if (MT[i][j] != 0)
startNum[cnt1++] = MT[i][j];
}
System.out.println("请输入目标状态(0代表空白位置):");
for (int i = 0; i < N; i++) {
endMT[i][0] = in.nextInt();
endMT[i][1] = in.nextInt();
endMT[i][2] = in.nextInt();
// 将默认的map覆盖掉,用于计算估价函数h
for (int j = 0; j < N; j++) {
int[] temp = { i, j };
map.put(endMT[i][j], temp);
if (endMT[i][j] != 0)
endNum[cnt2++] = endMT[i][j];
}
}
//判断问题是否有解
int st = 0;
int et = 0;
for (int i = N * N - 2; i >= 0; i--) {
for (int j = i - 1; j >= 0; j--) {
if (startNum[i] > startNum[j])
st++;
if (endNum[i] > endNum[j])
et++;
}
}
if (st % 2 == et % 2)
return true;
return false;
}
static int A_star(int[][] MT) {
// 找到空格所在的位置
int x0 = 0, y0 = 0;
for (x0 = 0; x0 < N; x0++) {
boolean flag = false;
for (y0 = 0; y0 < N; y0++) {
if (MT[x0][y0] == 0) {
flag = true;
break;
}
}
if (flag)
break;
}
// 优先队列
Queue<node> q = new PriorityQueue<node>(cmp);
int[][] curmt = new int[N][];
int[][] markemt = new int[N][];
// clone方法用于复制一个对象,在内存中开辟同样大小的空间
for (int r = 0; r < N; r++)
curmt[r] = MT[r].clone();
for (int r = 0; r < N; r++)
markemt[r] = MT[r].clone();
List<int[][]> path = new ArrayList<int[][]>();
// path加入初始状态
path.add(MT);
// 创建一个结点,表示空格,估价函数初始化为0
node cur = new node(x0, y0, 0, 0, 0, curmt, path);
// 将出现过的所有状态都加入marke集合中
marke.add(markemt);
// 入队并遍历
q.add(cur);
while (!q.isEmpty()) {
// 队首元素出队
cur = (node) q.poll().clone();
boolean tag = false;
// 判断当前状态是不是目标状态
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
if (cur.mt[i][j] != endMT[i][j]) {
tag = true;
}
}
}
// 如果是,输出结果
if (!tag) {
System.out.println("共扩展了" + marke.size() + "个结点");
return cur.step;
}
// 遍历四种方向上的移动
for (int i = 0; i < 4; i++) {
node next = (node) cur.clone();
next.x = cur.x + dir[i][0];
next.y = cur.y + dir[i][1];
// 如果空格位置不合法就忽略这个状态
if (next.x >= 0 && next.x < N && next.y >= 0 && next.y < N) {
// 因为上面next定义时clone了cur,所以在这里更新空格的位置
next.mt[cur.x][cur.y] = next.mt[next.x][next.y];
next.mt[next.x][next.y] = 0;
boolean mark = false;
// 判断当前状态有没有出现过
for (int c = 0; c < marke.size(); c++) {
int x = 0, y = 0;
for (x = 0; x < N; x++) {
for (y = 0; y < N; y++)
if (marke.get(c)[x][y] != next.mt[x][y])
break;
if (y < N)
break;
}
if (x == N && y == N)
mark = true;
}
// 若出现过则忽略这个状态
if (!mark) {
// 更新next的属性值step和估价函数g
next.step++;
next.g++;
// 将当前状态加入到结点的path中,因为程序中定义结点时,clone了上一个结点,所以在当前结点添加的状态也会clone到下一个结点中。
next.path.add(next.mt);
// 计算估价函数h,获取每个位置的数字,到达目标状态中对应数字的位置,所需要的步数
int count = 0;
for (int row = 0; row < N; row++) {
for (int cow = 0; cow < N; cow++) {
if (cow != 0 && next.mt[row][cow] != endMT[row][cow]) {
count += Math.abs(row - map.get(next.mt[row][cow])[0])
+ Math.abs(cow - map.get(next.mt[row][cow])[1]);
}
}
}
next.h = count;
// 将扩展状态入队
int[][] newmt = new int[N][];
for (int r = 0; r < N; r++)
newmt[r] = next.mt[r].clone();
marke.add(newmt);
q.add((node) next.clone());
}
}
}
}
return 0;
}
public static void main(String[] args) {
System.out.println("-------------------------八数码A*算法实现------------------------");
boolean flag = input_date();
if (!flag) {
System.out.println("问题无解!");
} else {
int ans = A_star(MT);
System.out.println("移动步数:" + Integer.toString(ans));
}
}
}
运行结果:
初始状态:
2 1 3
0 8 4
6 7 5
目标状态:
2 1 3
0 4 5
6 7 8
估价函数二:当前状态与目标状态位置不符的数码数目,例如当前状态为:
2 1 3
0 8 4
6 7 5
目标状态为:
2 1 3
0 4 5
6 7 8
在初始状态中,8、4、5这三个数与目标状态不符,所以估价函数值就是3。程序代码与估价函数一的代码几乎相同,只需要修改优先队列的优先策略函数即可。与上面代码的区别为:
static Comparator<node> cmp = new Comparator<node>() {
@Override
public int compare(node o1, node o2) {
//修改估价函数一的优先策略
//return (o1.g + o1.h) - (o2.g + o2.h);
return o1.g - o2.g;
}
};
还是用上面的例子运行一下试试:
可见这两种估价函数都可以得到正确的结果,只是第二种估价函数的扩展结点更多,运行速度肯定也更慢。那我们现在换一个例子看看他们各自的表现如何:
初始状态:
2 1 3
0 8 4
6 7 5
目的状态:
2 1 3
0 4 5
6 7 8
估价函数一:
估价函数二:
广搜:
在这个例子中差距已经非常明显了,估价函数二扩展了187个结点,广搜扩展了467个结点,这也就意味着更长的运行时间。
更新
中间过程其实就是从队列中取出的各个结点,输出这个过程就是输出每次从队列中取出来的结点,再设置一个变量来记录取出结点的个数。所以我们首先在对队列进行遍历的while循环之前设置变量:
int count_step = 0;
然后在取出结点的语句后将该节点输出:
// 队首元素出队
cur = (node) q.poll().clone();
System.out.println("----------第" + Integer.toString(count_step++) + "步----------");
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++)
System.out.print(cur.mt[i][j] + " ");
System.out.println();
}
System.out.println();