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


    背景

    三国杀近期搞了一个活动,其中一项内容就是完成一张 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 的时候,可以考虑用启发式算法减少状态数
  • 相关阅读:
    JSP 和Servlet 有有什么关系?
    转发(forward)和重定向(redirect)的区别?
    get和post请求的区别?
    软件的三大类型-单机类型、BS类型、CS类型
    Redis集群搭建
    Tomcat网站上的core和deployer的区别
    spring 事务处理
    mybatis ${}与#{}的区别
    Quartz--Spring 定时任务
    @JsonSerialize @JsonIgnoreProperties @JsonIgnore @JsonFormat
  • 原文地址:https://www.cnblogs.com/zhongju/p/13419818.html
Copyright © 2020-2023  润新知