三道关于图论搜索的智力题

492 阅读5分钟

一:Water Puzzle

有两个没有刻度的桶,一个可以盛装 9 升的水,一个可以盛装 4 升的水,问:如何利用这两个桶得到 6 升的水?

Water Puzzle 是一道经典的智力题,想必大家肯定接触或了解过。这道题的解决步骤如下:

  1. 将 9 升桶倒满水;
  2. 将 9 升桶里的水倒向 4 升桶,使 4 升桶装满水,9 升桶中还剩下 5 升的水;
  3. 将 4 升桶里的水倒空;
  4. 将 9 升桶中的水全部倒向 4 升桶中,此时,9 升桶中有 1 升的水,4 升桶盛满水;
  5. 将 4 升桶里的水倒空;
  6. 将 9 升桶中的水全部倒向 4 升桶中,此时,9 升桶为空,4 升桶中有 1 升的水;
  7. 将 9 升桶倒满水;
  8. 将 9 升桶里的水倒向 4 升桶,使 4 升桶装满水,此时,9 升桶中剩下 6 升的水。

为了大家能够清晰地看到每个桶盛水量的变化,我给出下表,左侧代表 9 升桶的盛水量,右侧代表 4 升桶的盛水量:

9 升桶的盛水情况4 升桶的盛水情况
00
90
54
50
14
10
01
91
64

对于这样一道问题,大家是否有想过使用程序来自动求解呢?

其实,这是完全可行的。Water Puzzle 就是一道图论算法领域的问题。从本质上来讲,它就是一个求解无向图的最短路径问题。

关键在于,我们如何将这个问题进行图论的建模?

我的图论建模方式如下图所示:

其中,X 和 Y 分别代表 9 升的桶所盛的水和 4 升的桶所盛的水,我们以当前两桶的盛水状态 (X,Y) 作为图的一个顶点,从一个状态指向一个状态的箭头作为图的一条边。最终,我们要从 (0,0) 这个状态达到 (6,Y) 或 (X,6) 这个状态。

从一个状态 (X,Y) 到另一个状态有以下几种变化方法:

  1. 将 9 升桶接满,或将 4 升桶接满,此时到达状态 (9,Y) 或 (X,4)
  2. 将 9 升桶倒空,或将 4 升桶倒空,此时到达状态 (0,Y) 或 (X,0)
  3. 将 9 升桶的水倒向 4 升桶中,或是将 4 升桶的水倒向 9 升桶中

Java 代码如下:

public class WaterPuzzle {
    private int end = -1;
    private boolean[] visited;
    private int[] pre;

    public WaterPuzzle() {
        Queue<Integer> q = new LinkedList<>();
        visited = new boolean[100];
        pre = new int[100];
        q.offer(0);
        visited[0] = true;
        pre[0] = 0;


        while (!q.isEmpty()) {
            int cur = q.poll();
            int x = cur / 10;
            int y = cur % 10;
            List<Integer> nexts = getNexts(x, y);

            for (int next : nexts)
                if (!visited[next]) {
                    q.offer(next);
                    visited[next] = true;
                    pre[next] = cur;
                    if (next / 10 == 6 || next % 10 == 6) {
                        end = next;
                        return;
                    }
                }
        }
    }

    private List<Integer> getNexts(int x, int y) {
        List<Integer> nexts = new ArrayList<>();
        nexts.add(9 * 10 + y);
        nexts.add(x * 10 + 4);
        nexts.add(0 * 10 + y);
        nexts.add(x * 10 + 0);
        // x = 9 y = 2   -> x = 7 y = 4
        // x = 1 y = 0   -> x = 0 y = 1
        int x2y = Math.min(x, 4 - y);
        nexts.add((x - x2y) * 10 + y + x2y);

        // x = 8 y = 3   -> x = 9 y = 2
        // x = 0 y = 3   -> x = 3 y = 0
        int y2x = Math.min(9 - x, y);
        nexts.add((x + y2x) * 10 + y - y2x);
        return nexts;
    }

    public Iterable<Integer> result() {
        List<Integer> res = new ArrayList<>();
        if (end == -1return res;
        int cur = end;
        while (cur != 0) {
            res.add(cur);
            cur = pre[cur];
        }
        res.add(0);
        Collections.reverse(res);
        return res;
    }
    public static void main(String[] args) {
        WaterPuzzle waterPuzzle = new WaterPuzzle();
        System.out.println(waterPuzzle.result());
    }
}  

因为,这道题的本质就是求解无向图的最短路径,所以,我使用了 BFS 遍历。

对于图的顶点的表示,我使用了 10X + Y 这种方式,因为 X 最大为 9,Y 最大为 4,所以,visited 数组和 pre 数组,我都开辟了 100 的空间。

该代码运行的结果为:

[0, 90, 54, 50, 14, 10, 1, 91, 64]  

我们可以对比在上面的表格:

9 升桶的盛水情况4 升桶的盛水情况
00
90
54
50
14
10
01
91
64

结果是完全一致的。

二:River Crossing Puzzle

River Crossing Puzzle 也是一道非常著名的智力题,题目如下:

农夫需要把狼,羊,菜和自己运输到河到对岸去。只有农夫可以划船,并且船只能承载农夫和另外一样东西。还有一个棘手的问题是,如果没有农夫看着,羊会偷吃菜,狼会吃羊。请考虑一种方法,让农夫可以安全地安排这些东西和他自己过河。

这一道题也是图论领域的问题,并且有了上一个问题的铺垫,我们就明确了应该使用状态信息对这个问题进行图论建模。

我们定义一个长度为 4 的字符串用来表示对岸的状态,从左向右起, 0 位置表示农夫,1 位置表示狼,2 位置表示羊,3 位置表示菜。初始状态为 "0000",目标状态为 "1111",我们求解的就是从 "0000" 这个起始状态到 "1111" 这个目标状态的过程。

我们知道,狼和羊无法单独在一起,羊和菜无法单独在一起,所以,可以将所有不可能的情况列出,定义到一个数组 deadends 中,该数组表示会出现狼吃羊或羊吃菜的所有情况,deadends 数组如下所示:

{"0111","0110","0011","1000","1001","1100"}  

Java 代码如下:

/**
 * River Crossing Puzzle:
 * <p>
 * 农夫需要把狼,羊,菜和自己运输到河到对岸去。
 * 只有农夫可以划船,并且船只能承载农夫和另外一样东西。
 * 还有一个棘手的问题是,如果没有农夫看着,羊会偷吃菜,狼会吃羊。
 * 请考虑一种方法,让农夫可以安全地安排这些东西和他自己过河。
 * <p/>
 */
public class RiverCrossingPuzzle {

    // 我们定义一个长度为 4 的字符串来表示对岸的状态,其中,0 位表示农夫,1 位置表示狼,2 位置表示羊,3 位置表示菜
    // 求解的就是从 0000 这个状态到 1111 这个状态的最短路径
    private String initState = "0000";
    private String finalState = "1111";
    private String[] deadends = {"0111""0110""0011""1000""1001""1100"};
    // visited,key:表示该状态是否被遍历过,value:key 的上一状态
    private Map<String, String> visited;

    public RiverCrossingPuzzle() {
        visited = new HashMap<>();
        HashSet<String> deadset = new HashSet<>();
        for (String deadend : deadends)
            deadset.add(deadend);

        Queue<String> q = new LinkedList<>();
        q.offer(initState);
        visited.put(initState, initState);

        while (!q.isEmpty()) {
            String cur = q.poll();
            List<String> nexts = getNexts(cur);
            for (String next : nexts) {
                if (!visited.containsKey(next) && !deadset.contains(next)) {
                    q.offer(next);
                    visited.put(next, cur);
                    if (next.equals(finalState))
                        return;
                }
            }
        }
    }

    private List<String> getNexts(String cur) {
        List<String> res = new ArrayList<>();
        char[] chars = cur.toCharArray();

        // 说明农夫在岸的左边,农夫可以带一个为 0 的其他东西过河,也可以自己过河
        if (chars[0] == '0') {
            chars[0] = '1';
            res.add(new String(chars));
            chars[0] = '0';
            for (int i = 1; i < chars.length; i++) {
                if (chars[i] == '0') {
                    chars[i] = '1';
                    chars[0] = '1';
                    res.add(new String(chars));
                    chars[0] = '0';
                    chars[i] = '0';
                }
            }
        } else {
            // 说明农夫在岸的右边,农夫可以带一个为 1 的其他东西过河,或者是自己过河
            chars[0] = '0';
            res.add(new String(chars));
            chars[0] = '1';
            for (int i = 1; i < chars.length; i++) {
                if (chars[i] == '1') {
                    chars[i] = '0';
                    chars[0] = '0';
                    res.add(new String(chars));
                    chars[0] = '1';
                    chars[i] = '1';
                }
            }
        }
        return res;
    }

    public Iterable<String> result() {
        List<String> res = new ArrayList<>();
        String cur = finalState;
        while (!cur.equals(initState)) {
            res.add(cur);
            cur = visited.get(cur);
        }
        res.add(initState);
        Collections.reverse(res);
        return res;
    }
    public static void main(String[] args) {
        System.out.println(new RiverCrossingPuzzle().result());
    }
}  

该代码执行的结果为:

[0000, 1010, 0010, 1110, 0100, 1101, 0101, 1111]  

这个结果表示的含义为:

  1. 初始状态,农夫,狼,羊,菜都在河岸的左侧
  2. 农夫带着羊过河
  3. 农夫自己回来
  4. 农夫带着狼过河
  5. 农夫带着羊回来
  6. 农夫带着菜过河
  7. 农夫自己回来
  8. 农夫带着羊过河,所有物品和农夫都成功到达对岸

三:Sliding Puzzle

这道题目是 LeetCode 上一道 Hard 级别的问题:滑动谜题

题目给定一个 2 x 3 的板子(board),在板子上有 5 块砖瓦,用数字 1 ~ 5 来表示,以及一块空缺用 0 来表示。

一次移动定义为选择 0 与一个相邻的数字(上下左右)进行交换。

最终当板 board 的结果是 [[1,2,3],[4,5,0]] 谜板被解开。

给出一个谜板的初始状态,返回最少可以通过多少次移动解开谜板,如果不能解开谜板,则返回 -1 。

题目给定了一个初始状态,并且给定了最终状态([[1,2,3],[4,5,0]]) ,从一个初始状态到最终状态的问题,我们现在已经可以很自然地想到使用图论建模的方式来进行求解。

我的建模方式如下:

题目本身并不难,代码复杂的部分在于如何将谜板这个二维数组的信息表示为一种状态。

该状态可以是 Integer 类型的数字或 String 字符串。

譬如,谜板的二维数组信息是:[[1,2,3],[4,0,5]],我们可以将对应的状态表示为 123405 这个数字;或者,我们也可以将该状态表示为 “123405” 这个字符串。

无论是哪种转换方法,都应该保证谜板二维数组的信息和状态能够保证一致性和正确性。

本题我使用了 Integer 数字来表示谜板的状态,有兴趣的朋友可以尝试一下使用字符串来表示状态信息。

Java 代码如下:

class Solution {
    private boolean[] visited;
    private int[] pre;
    private int[][] dirs = {{-10}, {01}, {10}, {0, -1}};

    public int slidingPuzzle(int[][] board) {
        visited = new boolean[550000];
        pre = new int[550000];

        Queue<Integer> q = new LinkedList<>();
        int initState = board2num(board);
        if (initState == 123450return 0;

        q.offer(initState);
        visited[initState] = true;
        pre[initState] = 0;

        while (!q.isEmpty()) {
            int cur = q.poll();
            List<Integer> nexts = getNexts(cur);
            for (int next : nexts) {
                if (!visited[next]) {
                    q.offer(next);
                    visited[next] = true;
                    pre[next] = pre[cur] + 1;
                    if (next == 123450)
                        return pre[next];
                }
            }
        }
        return -1;
    }

    private List<Integer> getNexts(int cur) {
        List<Integer> res = new ArrayList<>();
        int[][] board = num2board(cur);
        int zeroX = -1;
        int zeroY = -1;
        for (int i = 0; i < 2; i++)
            for (int j = 0; j < 3; j++)
                if (board[i][j] == 0) {
                    zeroX = i;
                    zeroY = j;
                }

        for (int d = 0; d < 4; d++) {
            int nextX = zeroX + dirs[d][0];
            int nextY = zeroY + dirs[d][1];
            if (isValid(nextX, nextY)) {
                swap(board, zeroX, zeroY, nextX, nextY);
                res.add(board2num(board));
                swap(board, zeroX, zeroY, nextX, nextY);
            }
        }
        return res;
    }

    private boolean isValid(int x, int y) {
        return x >= 0 && x < 2 && y >= 0 && y < 3;
    }

    private void swap(int[][] board, int x1, int y1, int x2, int y2) {
        int tmp = board[x1][y1];
        board[x1][y1] = board[x2][y2];
        board[x2][y2] = tmp;
    }

    private int[][] num2board(int num) {
        int[][] res = new int[2][3];
        for (int i = 0; i < 2; i++)
            for (int j = 0; j < 3; j++)
                if (i == 0) {
                    res[i][j] = getDigit(num, i * 2 + j);
                } else {
                    res[i][j] = getDigit(num, i * 2 + j + 1);
                }

        return res;
    }

    private int getDigit(int num, int index) {
        String s = String.valueOf(num);

        if (num > 100000) {
            return s.charAt(index) - '0';
        } else {
            if (index == 0)
                return 0;
            return s.charAt(index - 1) - '0';
        }
    }

    private int board2num(int[][] board) {
        return board[0][0] * 100000
                + board[0][1] * 10000
                + board[0][2] * 1000
                + board[1][0] * 100
                + board[1][1] * 10
                + board[1][2] * 1;
    }
}