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++) {
this.tiles[i][j] = tiles[i][j];
// 反正這裏都是要遍歷一遍的,不如直接把空格位置記錄下來,方便後面查找,就不需要再遍歷去找那個 0 了
if (tiles[i][j] != 0) {
// 這裏根據定義,空位 0 是不需要再加到距離上的
// 順便也一起做了 cache
// 這是 hamming 的,計算 shouldAt 和 nowAt 是不是相等
// 應該在的位置就是自己的數值(由於下標從 0 開始,減 1),如果是空位,就在最後
int targetAt = tiles[i][j] - 1;
// 這是現在在的位置,把二維的轉化爲一維的
int nowAt = i * n + j;
hammingSum += targetAt != nowAt ? 1 : 0;
// 這是 manhattan 的,計算橫縱座標距離差的絕對值的和
int vertical = Math.abs(i - targetAt / n);
int horizontal = Math.abs(j - targetAt % n);
manhattanSum += vertical + horizontal;
}
}
}
hamming = hammingSum;
manhattan = manhattanSum;
}
// string representation of this board
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(n).append("\n");
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
sb.append(tiles[i][j]).append(" ");
}
sb.append("\n");
}
return sb.toString();
}
// board dimension n
public int dimension() {
return n;
}
// number of tiles out of place
public int hamming() {
return hamming;
}
// sum of Manhattan distances between tiles and goal
public int manhattan() {
return manhattan;
}
// is this board the goal board?
public boolean isGoal() {
return hamming() == 0;
}
// does this board equal y?
@Override
public boolean equals(Object y) {
// The equals() method is inherited from java.lang.Object, so it must obey all of Java’s requirements.
if (y == null) {
return false;
}
if (this == y) {
return true;
}
if (y.getClass() != this.getClass()) {
return false;
}
Board board = (Board) y;
// 這裏二維數組的相等做 deepEquals
return Arrays.deepEquals(tiles, board.tiles);
}
// 本題不允許重寫 hashCode()
// all neighboring boards
public Iterable<Board> neighbors() {
ArrayList<Board> neighbors = new ArrayList<>();
int x = 0, y = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (tiles[i][j] == 0) {
x = i;
y = j;
}
}
}
int[][] directions = {{-1, 0}, {0, -1}, {0, 1}, {1, 0}};
for (int[] direction : directions) {
int xx = x + direction[0];
int yy = y + direction[1];
if (isValid(xx, yy)) {
neighbors.add(new Board(swap(x, y, xx, yy)));
}
}
return neighbors;
}
// 判斷是否越界
private boolean isValid(int x, int y) {
return x >= 0 && x < n && y >= 0 && y < n;
}
// 複製數組並交換指定位置
private int[][] swap(int x, int y, int xx, int yy) {
int[][] newTiles = new int[n][n];
for (int i = 0; i < n; i++) {
System.arraycopy(tiles[i], 0, newTiles[i], 0, n);
}
int tmp = newTiles[x][y];
newTiles[x][y] = newTiles[xx][yy];
newTiles[xx][yy] = tmp;
return newTiles;
}
// a board that is obtained by exchanging any pair of tiles
public Board twin() {
Board b = null;
// 隨便找兩個相鄰的位置就可以了,只要不越界,只要不是 0,就可以交換
for (int i = 0; i < n * n - 1; i++) {
int x = i / n;
int y = i % n;
int xx = (i + 1) / n;
int yy = (i + 1) % n;
if (tiles[x][y] != 0 && tiles[xx][yy] != 0) {
b = new Board(swap(x, y, xx, yy));
break;
}
}
return b;
}
// unit testing (not graded)
public static void main(String[] args) {
int[][] t = {{1, 2, 3}, {4, 5, 0}, {8, 7, 6}};
Board b = new Board(t);
// System.out.println(b.dimension());
// System.out.println(b);
// System.out.println(b.hamming());
// System.out.println(b.manhattan());
// System.out.println(b.isGoal());
// System.out.println(b.twin());
// System.out.println(b.equals(b.twin()));
for (Board bb : b.neighbors()) {
System.out.println(bb);
}
}
}
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.MinPQ;
import edu.princeton.cs.algs4.StdOut;
import java.util.ArrayList;
import java.util.Collections;
public class Solver {
// 定義一個搜索樹,方便進行 A* 搜索
// 搜索樹的結點,遞歸的定義
private static class GameTreeNode implements Comparable<GameTreeNode> {
private final Board board; // 結點
private final GameTreeNode parent; // 父親
private final boolean twin;
private final int moves;
// Caching the Hamming and Manhattan priorities.
// To avoid recomputing the Manhattan priority of a search node from scratch each time during various priority queue operations, pre-compute its value when you construct the search node;
// save it in an instance variable; and return the saved value as needed.
// This caching technique is broadly applicable:
// consider using it in any situation where you are recomputing the same quantity many times and for which computing that quantity is a bottleneck operation.
//
// rejecting if doesn't adhere to stricter caching limits
private final int distance;
// The efficacy of this approach hinges on the choice of priority function for a search node.
// We consider two priority functions:
//
// The Hamming priority function is the Hamming distance of a board plus the number of moves made so far to get to the search node.
// Intuitively, a search node with a small number of tiles in the wrong position is close to the goal, and we prefer a search node if has been reached using a small number of moves.
//
// The Manhattan priority function is the Manhattan distance of a board plus the number of moves made so far to get to the search node.
private final int priority;
// 初始節點,parent 爲 null,需要區分是不是雙胞胎
public GameTreeNode(Board board, boolean twin) {
this.board = board;
parent = null;
this.twin = twin;
moves = 0;
distance = board.manhattan();
priority = distance + moves;
}
// 之後的結點,twin 狀態跟從 parent
public GameTreeNode(Board board, GameTreeNode parent) {
this.board = board;
this.parent = parent;
twin = parent.twin;
moves = parent.moves + 1;
distance = board.manhattan();
priority = distance + moves;
}
public Board getBoard() {
return board;
}
public GameTreeNode getParent() {
return parent;
}
public boolean isTwin() {
return twin;
}
@Override
public int compareTo(GameTreeNode node) {
// 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
if (priority == node.priority) {
return Integer.compare(distance, distance);
} else {
return Integer.compare(priority, node.priority);
}
}
@Override
public boolean equals(Object node) {
if (node == null) {
return false;
}
if (this == node) {
return true;
}
if (node.getClass() != this.getClass()) {
return false;
}
GameTreeNode that = (GameTreeNode) node;
return getBoard().equals(that.getBoard());
}
@Override
public int hashCode() {
return 1;
}
}
private int moves;
private boolean solvable;
private Iterable<Board> solution;
private final Board initial;
// find a solution to the initial board (using the A* algorithm)
public Solver(Board initial) {
if (initial == null) {
throw new IllegalArgumentException();
}
this.initial = initial;
cache();
}
// is the initial board solvable? (see below)
public boolean isSolvable() {
return solvable;
}
// min number of moves to solve initial board
public int moves() {
return moves;
}
// sequence of boards in a shortest solution
public Iterable<Board> solution() {
return this.solution;
}
// 構造的時候直接跑出結果,然後緩存,否則沒有 solution 的話,moves 和 solvable 也拿不到
private void cache() {
// 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;
}
}
// test client (see below)
public static void main(String[] args) {
// create initial board from file
In in = new In(args[0]);
int n = in.readInt();
int[][] tiles = new int[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
tiles[i][j] = in.readInt();
}
}
Board initial = new Board(tiles);
// solve the puzzle
Solver solver = new Solver(initial);
// print solution to standard output
if (!solver.isSolvable()) {
StdOut.println("No solution possible");
} else {
StdOut.println("Minimum number of moves = " + solver.moves());
for (Board board : solver.solution()) {
StdOut.println(board);
}
}
}
}
歡迎關注我的個人博客以閱讀更多優秀文章:凝神長老和他的朋友們(https://www.jxtxzzw.com)
也歡迎關注我的其他平臺:知乎( https://s.zzw.ink/zhihu )、知乎專欄( https://s.zzw.ink/zhuanlan )、嗶哩嗶哩( https://s.zzw.ink/blbl )、微信公衆號( 凝神長老和他的朋友們 )