题目地址:
https://www.lintcode.com/problem/sliding-puzzle-ii/description
给定一个三行三列的整型矩阵,每一位恰好是 0 ∼ 8 0\sim 8 0∼8,每一步允许将其中的 0 0 0与四个方向的数字进行交换。给定初始状态和终止状态,问从起始状态开始要经过多少步可以达到终止状态。若达不到则返回 − 1 -1 −1。
法1:双向BFS。题目本质上是隐式图求最短路,可以用双向BFS来做。为了判重以及存储方便,我们将矩阵的每个状态序列化为一个字符串进行存储(序列化的方式是第一行拼成的字符串接上第二行再接上第三行)。
先证明一道命题:两个(序列化后的)状态
s
s
s和
t
t
t互相可达当且仅当它们的逆序数奇偶性相同。
证明:必要性:由于状态转换的时候逆序数只会增减
2
2
2或不变,所以逆序数奇偶性维持不变,所以如果两个状态相互可达,它们的逆序数奇偶性一定一样。
充分性:以后再补。
代码如下:
import java.util.*;
public class Solution {
/**
* @param init_state: the initial state of chessboard
* @param final_state: the final state of chessboard
* @return: return an integer, denote the number of minimum moving
*/
public int minMoveStep(int[][] init_state, int[][] final_state) {
// # write your code here
// 将状态序列化为字符串
String start = stateToString(init_state), end = stateToString(final_state);
// 如果起点终点相等,就不用走了,返回0
if (start.equals(end)) {
return 0;
}
// 如果逆序对数奇偶性不同则说明无解,返回-1;否则一定有解
if ((computeInversePairs(start) ^ computeInversePairs(end) & 1) == 1) {
return -1;
}
int m = init_state.length, n = init_state[0].length;
// 开两个队列,分别代表从开始状态和终止状态走
Queue<String> beginQueue = new LinkedList<>(), endQueue = new LinkedList<>();
// 开两个哈希表,分别存储从开始状态走过的状态,和从终止状态走过的状态
Set<String> beginVisited = new HashSet<>(), endVisited = new HashSet<>();
beginQueue.offer(start);
beginVisited.add(start);
endQueue.offer(end);
endVisited.add(end);
// 初始化步数为0
int res = 0;
while (!beginQueue.isEmpty() && !endQueue.isEmpty()) {
res++;
// 如果从beginQueue扩展一步,能找到合法路径,则直接返回res;否则继续扩展
if (oneStep(beginQueue, beginVisited, endVisited, m, n)) {
return res;
}
res++;
// 注意这里是从endQueue扩展一步,所以需要把begin和end对应的队列和哈希表换一下
if (oneStep(endQueue, endVisited, beginVisited, m, n)) {
return res;
}
}
return -1;
}
// 这个方法用于从beginQueue开始扩展状态,返回值是指是否寻找到了一条合法路径
private boolean oneStep(Queue<String> beginQueue, Set<String> beginVisited, Set<String> endVisited, int m, int n) {
int beginSize = beginQueue.size();
for (int i = 0; i < beginSize; i++) {
String cur = beginQueue.poll();
List<String> nexts = getNexts(cur, beginVisited, endVisited, m, n);
if (nexts == null) {
return true;
}
for (String next : nexts) {
beginVisited.add(next);
beginQueue.add(next);
}
}
return false;
}
// 从cur开始扩展状态,如果扩展到了endVisited标记过的状态,则返回null;否则返回可以到达的状态的列表
private List<String> getNexts(String cur, Set<String> beginVisited, Set<String> endVisited, int m, int n) {
List<String> nexts = new ArrayList<>();
StringBuilder sb = new StringBuilder(cur);
int idx = 0;
for (int i = 0; i < sb.length(); i++) {
if (sb.charAt(i) == '0') {
idx = i;
break;
}
}
int x1 = idx / n, y1 = idx % n;
int[] d = {1, 0, -1, 0, 1};
for (int i = 0; i < 4; i++) {
int nextX = x1 + d[i], nextY = y1 + d[i + 1];
if (0 <= nextX && nextX < m && 0 <= nextY && nextY < n) {
int swapIdx = nextX * n + nextY;
// 交换0和别的数字,得到一个新的状态
swapSb(sb, idx, swapIdx);
String next = sb.toString();
// 扩展到了endVisited的地盘了,说明找到了合法路径,返回null
if (endVisited.contains(next)) {
return null;
}
// 如果扩展到的状态以前未访问过,则加入nexts列表以供扩展
if (!beginVisited.contains(next)) {
nexts.add(next);
}
// 扩展完了还需要换回来,得到原先的初始状态
swapSb(sb, idx, swapIdx);
}
}
return nexts;
}
private void swapSb(StringBuilder sb, int i, int j) {
char tmp = sb.charAt(i);
sb.setCharAt(i, sb.charAt(j));
sb.setCharAt(j, tmp);
}
private String stateToString(int[][] state) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < state.length; i++) {
for (int j = 0; j < state[0].length; j++) {
sb.append(state[i][j]);
}
}
return sb.toString();
}
// 算逆序对的数量
private int computeInversePairs(String state) {
int count = 0;
for (int i = 0; i < state.length(); i++) {
for (int j = i + 1; j < state.length(); j++) {
if (state.charAt(i) > state.charAt(j)) {
count++;
}
}
}
return count;
}
}
时间复杂度 O ( V + E ) O(V+E) O(V+E),空间 O ( V ) O(V) O(V)。
法2:A*算法。设目标状态为 u u u。这个算法的思想是,先对每个状态 v v v设置一个估价函数 f ( v ) f(v) f(v),设 v v v与源点的真实最短路距离是 g ( v ) g(v) g(v),我们需要对每个状态定义一个启发函数 h ( v ) h(v) h(v),则估价函数 f ( v ) = g ( v ) + h ( v ) f(v)=g(v)+h(v) f(v)=g(v)+h(v),并且需要满足 h ( v ) ≥ 0 h(v)\ge 0 h(v)≥0并且 f ( v ) = g ( v ) + h ( v ) ≤ g ( u ) f(v)=g(v)+h(v)\le g(u) f(v)=g(v)+h(v)≤g(u)。这样在BFS的时候,要开一个最小堆,每个状态按照估价函数较小者优先出堆,扩展状态的时候和SPFA类似做即可。这里的 h ( v ) h(v) h(v)可以选择 v v v与 u u u的相同数字所在位置的曼哈顿距离之和(显然 h ( v ) h(v) h(v)非负,而一个数字要移到 u u u对应的位置至少要曼哈顿距离那么多步,而且每次移动并不影响别的数字的曼哈顿距离。所以 g ( v ) + h ( v ) ≤ g ( u ) g(v)+h(v)\le g(u) g(v)+h(v)≤g(u)这一条也成立)。需要注意的是,A*算法是不能记录visited与否的,每次更新完了都要进堆。但是A*算法的确能保证当目标状态出堆的时候,算出来的距离一定是最近的。代码如下:
import java.util.*;
public class Solution {
// 这个类的state指矩阵的状态(也就是矩阵序列化后的字符串),evaluation指该状态的估价,
// 两个Pair的大小关系由估价决定,估价小者小
class Pair implements Comparable<Pair> {
private String state;
private int evaluation;
public Pair(String state, int evaluation) {
this.state = state;
this.evaluation = evaluation;
}
@Override
public int compareTo(Pair o) {
return Integer.compare(evaluation, o.evaluation);
}
}
/**
* @param init_state: the initial state of chessboard
* @param final_state: the final state of chessboard
* @return: return an integer, denote the number of minimum moving
*/
public int minMoveStep(int[][] init_state, int[][] final_state) {
// # write your code here
String start = stateToString(init_state), end = stateToString(final_state);
if (start.equals(end)) {
return 0;
}
// 无解则直接返回-1
if ((computeInversePairs(start) ^ computeInversePairs(end) & 1) == 1) {
return -1;
}
List<int[]> pos = position(final_state);
// 记录每个状态离出发点最短路距离
Map<String, Integer> disFromStart = new HashMap<>();
disFromStart.put(start, 0);
PriorityQueue<Pair> minHeap = new PriorityQueue<>();
minHeap.offer(new Pair(start, manhattanDis(start, pos)));
while (!minHeap.isEmpty()) {
Pair cur = minHeap.poll();
// 如果目标状态出堆了,那一定是最近的,直接返回距离
if (cur.state.equals(end)) {
return disFromStart.get(cur.state);
}
for (String next : getNexts(cur.state, 3, 3)) {
int newDis = disFromStart.get(cur.state) + 1;
// 如果能更新邻接点的最短路距离,则更新,并入堆
if (!disFromStart.containsKey(next) || disFromStart.get(next) > newDis) {
disFromStart.put(next, newDis);
// 入堆的时候要将启发函数加到与出发点距离上去
minHeap.offer(new Pair(next, disFromStart.get(next) + manhattanDis(next, pos)));
}
}
}
return -1;
}
private List<String> getNexts(String cur, int m, int n) {
List<String> nexts = new ArrayList<>();
StringBuilder sb = new StringBuilder(cur);
// 找0的位置
int idx = 0;
for (int i = 0; i < sb.length(); i++) {
if (sb.charAt(i) == '0') {
idx = i;
break;
}
}
// 求出0的横纵坐标
int x1 = idx / n, y1 = idx % n;
int[] d = {1, 0, -1, 0, 1};
for (int i = 0; i < 4; i++) {
int nextX = x1 + d[i], nextY = y1 + d[i + 1];
if (0 <= nextX && nextX < m && 0 <= nextY && nextY < n) {
int swapIdx = nextX * n + nextY;
swapSb(sb, idx, swapIdx);
String next = sb.toString();
nexts.add(next);
swapSb(sb, idx, swapIdx);
}
}
return nexts;
}
private void swapSb(StringBuilder sb, int i, int j) {
char tmp = sb.charAt(i);
sb.setCharAt(i, sb.charAt(j));
sb.setCharAt(j, tmp);
}
// 计算cur和终点位置的对应每个数字曼哈顿距离之和
private int manhattanDis(String cur, List<int[]> pos) {
int res = 0;
for (int i = 0; i < cur.length(); i++) {
int x = i / 3, y = i % 3;
int[] chPos = pos.get(cur.charAt(i) - '0');
res += Math.abs(x - chPos[0]) + Math.abs(y - chPos[1]);
}
return res;
}
private String stateToString(int[][] state) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < state.length; i++) {
for (int j = 0; j < state[0].length; j++) {
sb.append(state[i][j]);
}
}
return sb.toString();
}
// 算逆序对个数
private int computeInversePairs(String state) {
int count = 0;
for (int i = 0; i < state.length(); i++) {
for (int j = i + 1; j < state.length(); j++) {
if (state.charAt(i) > state.charAt(j)) {
count++;
}
}
}
return count;
}
// 返回每个数字所处的横纵坐标
private List<int[]> position(int[][] state) {
List<int[]> res = new ArrayList<>();
int m = state.length, n = state[0].length;
for (int i = 0; i < m * n; i++) {
res.add(new int[2]);
}
for (int i = 0; i < state.length; i++) {
for (int j = 0; j < state[0].length; j++) {
res.get(state[i][j])[0] = i;
res.get(state[i][j])[1] = j;
}
}
return res;
}
}
时空复杂度与上面一样。但实际上大部分情况下,A*算法要比普通的BFS效率高非常多。
算法正确性证明:
以后再补。
注解:一般而言, h ( v ) h(v) h(v)这个函数取的越大,算法效率就越高,也即无效的状态搜索的越少。但具体 h h h的选择,则需要参考一些已有的结论,或者先猜一个然后再对其进行证明。