Princeton Algorithm 8 Puzzle
普林斯顿大学算法课第 4 次作业,8 Puzzle 问题。
这道题目使用了 A* 算法,题目本身就是有点难度的,但是 Specification 里面已经把该算法的步骤都列出来了,基本就是一个优先级队列的使用。而优先级队列也可以使用提供的 MinPQ
完成,所以基本没有难度。
本题的难点依旧在于优化。
Board 的代码还是相对容易的,主要是注意 euqals 必须满足几个特性,并且距离可以做一次缓存。
Solver 我写了一个内部类来用作搜索结点,用结点之间的父亲节点来表示搜索的路径,这样当出现目标局面的时候,逐层沿着 parent 向上就可以得到整条操作路径。
注意 distance
和 priority
必须缓存,这是一个多达 25 个测试点的优化项目。
另外通过实测可以发现 Manhattan Priority
更加好,所以直接采用这个方案就可以了。
有一个 Breaking tie 的技巧:
-
Using Manhattan() as a tie-breaker helped a lot.
-
Using Manhattan priority, then using Manhattan() to break the tie if two boards tie, and returning 0 if both measurements tie.
Solver 可以在构造的时候直接跑出结果,然后缓存,否则没有执行过 solution()
的话,moves()
和 solvable
也拿不到。
有一个非常关键的地方在于不要添加重复的状态进入 PQ。
node.getParent() == null || !bb.equals(node.getParent().getBoard())
对于判断是不是可解的,可以将 board
和 board.twin()
一起加入 PQ,两个状态一起做 A* 搜索,要么是棋盘本身,要么是棋盘的双胞胎,总有一个会做到 isGoal()
。
一旦有任何一者达到目标局面,就说明这一个情况是可解的,那么另一方就是不可解的。通过判断可解的是自己,还是自己的双胞胎,可以得到 solvable
。
注意当且仅当 solvable
的时候才会有 moves()
和 solution()
,所以对于不可解的状态,注意不要把它的双胞胎的 moves
和 solution
赋值过来。
// To implement the A* algorithm, you must use the MinPQ data type for the priority queue.
MinPQ<GameTreeNode> pq = new MinPQ<>();
// 把当前状态和双胞胎状态一起压入队列,做 A* 搜索
pq.insert(new GameTreeNode(initial, false));
pq.insert(new GameTreeNode(initial.twin(), true));
GameTreeNode node = pq.delMin();
Board b = node.getBoard();
// 要么是棋盘本身,要么是棋盘的双胞胎,总有一个会做到 isGoal()
while (!b.isGoal()) {
for (Board bb : b.neighbors()) {
// The critical optimization.
// A* search has one annoying feature: search nodes corresponding to the same board are enqueued on the priority queue many times.
// To reduce unnecessary exploration of useless search nodes, when considering the neighbors of a search node, don’t enqueue a neighbor if its board is the same as the board of the previous search node in the game tree.
if (node.getParent() == null || !bb.equals(node.getParent().getBoard())) {
pq.insert(new GameTreeNode(bb, node));
}
}
// 理论上这里 pq 永远不可能为空
node = pq.delMin();
b = node.getBoard();
}
// 如果是自己做出了结果,那么就是可解的,如果是双胞胎做出了结果,那么就是不可解的
solvable = !node.isTwin();
if (!solvable) {
// 注意不可解的地图,moves 是 -1,solution 是 null
moves = -1;
solution = null;
} else {
// 遍历,沿着 parent 走上去
ArrayList<Board> list = new ArrayList<>();
while (node != null) {
list.add(node.getBoard());
node = node.getParent();
}
// 有多少个状态,减 1 就是操作次数
moves = list.size() - 1;
// 做一次反转
Collections.reverse(list);
solution = list;
}
这段代码得了 99 分,应该已经秒杀了 Coursera 上绝大多数的提交了。
这次 Assignment 的及格线是 80 分,应该说只要正确性达标,内存和时间做的差些,90 分还是可以有的。
主要可能还是有些细节的地方没有优化到,MinPQ Operation Count
和 Board Operation Count
这两个测试有部分测试数据没过,应该是哪里还能省掉几次调用。但是在整体的运行时间上,只有 2 个测试数据超过了 1 秒,分别为 1.25 秒和 1.29 秒,其余测试点均在 0.X 秒就完成了,远小于测试规定的 5 秒以内。
Compilation: PASSED
API: PASSED
Spotbugs: PASSED
PMD: PASSED
Checkstyle: PASSED
Correctness: 51/51 tests passed
Memory: 22/22 tests passed
Timing: 116/125 tests passed
以下代码获得 99 分
import java.util.ArrayList;
import java.util.Arrays;
public class Board {
private final int[][] tiles;
private final int n;
// 缓存每一个位置的距离,需要的时候可以不用每次都重新遍历计算
private final int hamming;
private final int manhattan;
// create a board from an n-by-n array of tiles,
// where tiles[row][col] = tile at (row, col)
public Board(int[][] tiles) {
n = tiles.length;
this.tiles = new int[n][n];
int hammingSum = 0;
int manhattanSum = 0;
// 复制值,而不是令 this.tiles = tiles,确保 Immutable
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++)