为了三国杀的一个破活动,我又复习了一次广搜和深搜……
背景
三国杀近期搞了一个活动,其中一项内容就是完成一张 3×3 的滑动拼图。移动需要消耗步数,步数每天可以获得两次。拼图完成后即获得奖励,消耗步数最少的还会得到额外奖励。我自然是懒得手撕的那种人(实际上我也搞不懂这种滑动拼图的策略),于是想写程序解决该问题。
目标
这里的滑动拼图,与人们所熟知的数字华容道是一个东西。简单起见,我们不妨把每个拼图块的内容抽象为一个数字,空位用 0 代替。我们的目标有以下两点:
- 输入一个拼图的初始状态,计算完成该拼图所需的最少步数。
- 在 1 的基础上,给出完成这一拼图的步骤。
对于问题 1,可以通过广度优先搜索来解决,而问题 2 则可以在问题 1 的状态里进行回溯来解决。不过这触及到我的知识盲区了,所以对于问题 2,采用回溯法(深度优先搜索)解决,考虑到拼图的状态可能很大,为了减小搜索范围,同时满足最少步骤的需求,采用限制深度的回溯法。
步骤
问题 1:完成该拼图所需的最少步骤
笔者通过网络搜索,发现 LeetCode 上有类似的题目,即 773. Sliding Puzzle,这部分的解答步骤参考了这篇帖子。
状态的表示部分,与帖子中的一致,将九宫格中的数字从左到右,从上到下,组成一串,组成拼图板的状态信息。拼图板的移动,则可以理解为其中的数字 0 与其周围的数字交换位置,我们定义以下数组,来表示 0 在每个位置上可以移动的地方:
// 0, 1, 2
// 3, 4, 5
// 6, 7, 8
// all the positions 0 can be swapped to
private static final int[][] MOVABLE_INDEX = new int[][] {
{ 1, 3 },
{ 0, 2, 4 },
{ 1, 5 },
{ 0, 4, 6 },
{ 1, 3, 5, 7 },
{ 2, 4, 8 },
{ 3, 7 },
{ 4, 6, 8 },
{ 5, 7 }
};
然后我们定义一个 FINAL_STATE
,赋值为 "123456780"
表示拼图的最终状态。最少步骤的求解使用广度优先搜索。在该算法中,有一个状态的队列,每次循环,从队首里取出状态,产生若干个新状态,将这些新状态入队,如此往复直到抵达 FINAL_STATE
。对于已经搜索过的状态,需要将其排除,代码如下:
public static int findMinimumPathLength(String start) {
// 已遍历过的状态
Set<String> visited = new HashSet<>();
// 状态队列
Queue<String> queue = new ArrayDeque<>();
queue.offer(start);
visited.add(start);
// 步长
int result = 0;
while (!queue.isEmpty()) {
// 对于当前层的所有状态进行遍历
int size = queue.size();
for (int i = 0; i < size; i++) {
String cur = queue.poll();
if (Objects.equals(cur, FINAL_STATE)) {
// 抵达最终状态,返回长度
return result;
}
// 寻找空位的位置
// 这里用 Objects.requireNonNull 包围住 cur 是因为 Solarlint 插件提示我这里的 cur 可能为 null,实际上就本代码来说,这种情况不会发生
int zero = Objects.requireNonNull(cur).indexOf('0');
// swap if possible
for (int dir : MOVABLE_INDEX[zero]) {
// 生成新状态
String next = swap(cur, zero, dir);
if (visited.contains(next)) {
continue;
}
// 出现未遍历过的状态,加入队列
visited.add(next);
queue.offer(next);
}
}
// 完成当前层的遍历,步长+1
result++;
}
return -1;
}
private static String swap(String str, int i, int j) {
StringBuilder result = new StringBuilder(str);
result.setCharAt(i, str.charAt(j));
result.setCharAt(j, str.charAt(i));
return result.toString();
}
问题 2:寻找具体路径
在问题 1 中,我们已经得到了完成拼图板所需要的总步数,接下来就是路径搜索了。笔者这里采用回溯法,并且利用问题 1 中已经得到的条件来限制深度。思路简单来说就是从一个状态出发,先寻找可能的下一个状态,并从该状态递归验证是否能到达目标状态,若已经超出搜索范围则退到上一状态,若该状态下的所有子状态都不可达,则退回至该状态的上一状态,以此类推直到找到路径。数据的表示与问题 1 中的相同,具体代码如下:
/**
* 返回从初始状态到目标状态间一条可达路径
* @param start 初始状态
* @param maxDepth 搜索深度
* @return 可达路径,若该路径不存在,则返回空列表
*/
public static List<String> findMinimumPath(String start, int maxDepth) {
if (maxDepth == -1) {
return Collections.emptyList();
}
List<String> result = new ArrayList<>();
Set<String> visited = new HashSet<>();
result.add(start);
visited.add(start);
boolean found = search(result, visited, 0, maxDepth + 1);
return found ? result : Collections.emptyList();
}
private static boolean search(List<String> path, Set<String> visited, int depth, int maxDepth) {
if (depth == maxDepth) {
// 到达最大深度,返回 false
return false;
}
// 取出上一状态
String lastState = path.get(path.size() - 1);
if (Objects.equals(lastState, FINAL_STATE)) {
// 达到最终状态,返回 true,此时的 path 即为所求
return true;
}
// 找到空位的位置
int zero = lastState.indexOf('0');
// 遍历每一个空位可移动的位置
for (int movableIndex : MOVABLE_INDEX[zero]) {
// 生成新状态
String newState = swap(lastState, zero, movableIndex);
if (visited.contains(newState)) {
continue;
}
// 新状态不在之前的状态中出现时,深入下一层搜索
path.add(newState);
visited.add(newState);
boolean found = search(path, visited, depth + 1, maxDepth);
// 没找到则回溯到之前的状态
if (!found) {
path.remove(path.size() - 1);
visited.remove(newState);
} else {
return found;
}
}
return false;
}
验证
编写以下验证程序进行验证:
public static void main(String[] args) {
String start = "285174306";
long start1 = System.currentTimeMillis();
int minimumLength = findMinimumPathLength(start);
long end1 = System.currentTimeMillis();
long start2 = System.currentTimeMillis();
List<String> path = findMinimumPath(start, minimumLength);
long end2 = System.currentTimeMillis();
System.out.println("结果:");
System.out.printf("所需最少步数:%d,搜索时间:%dms%n", minimumLength, (end1 - start1));
System.out.printf("路径搜索耗时:%dms,路径如下%n", (end2 - start2));
System.out.println(path);
}
在笔者尚未着手编写代码时,舍友友用手撕的方式给出他的拼图最少需 19 步拼完,他的拼图数据为主程序中的 start
变量。运行该代码,显示结果为:
结果:
所需最少步数:19,搜索时间:59ms
路径搜索耗时:13ms,路径如下
[285174306, 285104376, 205184376, 025184376, 125084376, 125384076, 125384706, 125304786, 125034786, 025134786, 205134786, 235104786, 235140786, 230145786, 203145786, 023145786, 123045786, 123405786, 123450786, 123456780]
将 start
变量改为笔者本人的拼图数据,运行该代码,结果为:
结果:
所需最少步数:20,搜索时间:86ms
路径搜索耗时:106ms,路径如下
[813467052, 813467502, 813467520, 813460527, 813406527, 813426507, 813426057, 813026457, 013826457, 103826457, 123806457, 123856407, 123856470, 123850476, 123805476, 123085476, 123485076, 123485706, 123405786, 123450786, 123456780]
可以看出,只是多了一步,代码运行的时间就有了明显的差异,这点在搜索的状态数目上也有所体现。笔者在解决第 1 个问题时,有在结果返回前打印了 queue.size()
,第一组数据比第二组数据少遍历了三万多个状态,由此不难想象,如果在深搜时不对搜索深度加以限制,则会造成堆栈溢出。
可改进的地方
- 能不能直接在解决问题 1 的基础上解决问题 2
- 在解决问题 1 的时候,可以考虑用启发式算法减少状态数