Loading

为了三国杀的一个破活动,我又复习了一次广搜和深搜……

背景

三国杀近期搞了一个活动,其中一项内容就是完成一张 3×3 的滑动拼图。移动需要消耗步数,步数每天可以获得两次。拼图完成后即获得奖励,消耗步数最少的还会得到额外奖励。我自然是懒得手撕的那种人(实际上我也搞不懂这种滑动拼图的策略),于是想写程序解决该问题。

目标

这里的滑动拼图,与人们所熟知的数字华容道是一个东西。简单起见,我们不妨把每个拼图块的内容抽象为一个数字,空位用 0 代替。我们的目标有以下两点:

  1. 输入一个拼图的初始状态,计算完成该拼图所需的最少步数。
  2. 在 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 的时候,可以考虑用启发式算法减少状态数
posted @ 2020-08-02 17:40  Zhongju.copy()  阅读(259)  评论(0编辑  收藏  举报