【数据结构与算法基础】代码仓库:https://github.com/jinrunheng/datastructure-and-algorithm
一:算法笔试面试中的 BFS 问题
我们知道比起 DFS 遍历,BFS 能够解决无权图的最短路径问题。接下来我们依旧以 LeetCode 上面的问题为例,来看一下 BFS 在图论算法题中的应用。
如果你对求解无向图的最短路径还不太熟悉,可以先回顾一下第四章【图的广度优先遍历】中的内容。
求解无向图的最短路径,我们只能使用 BFS 遍历,像题目给定的二维矩阵,我们有两种处理方式,第一种是对二维矩阵进行图的建模,然后对建模后的图进行 bfs 求解;第二种方式类似于 FloodFill 算法一样,直接在矩阵 grid 上进行 bfs 操作。

题目要求我们返回的结果并不是真正的“最短路径”的长度,而是达成一条最短路径经过的顶点的个数,这一点还需要大家留意。
第一种方法:将二维矩阵进行图的建模
class Solution {private int[][] grid;private int r,c;private boolean[] visited;private int[] dis;private int[][] dirs = {{-1,0},{-1,1},{0,1},{1,1},{1,0},{1,-1},{0,-1},{-1,-1}};private HashSet<Integer>[] G;public int shortestPathBinaryMatrix(int[][] grid) {r = grid.length;c = grid[0].length;if(grid[0][0] == 1) return -1;if(r == 1 && c == 1) return 1;visited = new boolean[r * c];dis = new int[r * c];this.grid = grid;G = constructGraph();// bfsQueue<Integer> q = new LinkedList<>();q.offer(0);visited[0] = true;dis[0] = 1;while(!q.isEmpty()){int cur = q.poll();int curX = cur / c;int curY = cur % c;for(int d = 0; d < 8; d++){int nextX = curX + dirs[d][0];int nextY = curY + dirs[d][1];int next = nextX * c + nextY;if(isValid(nextX,nextY) && !visited[next] && grid[nextX][nextY] == 0) {q.offer(next);visited[next] = true;dis[next] = dis[cur] + 1;if(nextX == r - 1 && nextY == c - 1) {return dis[next];}}}}return -1;}private HashSet<Integer>[] constructGraph() {HashSet<Integer>[] g = new HashSet[r * c];for(int v = 0; v < g.length; v++)g[v] = new HashSet<>();for(int v = 0; v < g.length; v++) {int x = v / c;int y = v % c;if(grid[x][y] == 0){for(int d = 0; d < 8; d++){int nextX = x + dirs[d][0];int nextY = y + dirs[d][1];if(isValid(nextX,nextY) && grid[nextX][nextY] == 0) {int next = nextX * c + nextY;g[v].add(next);g[next].add(v);}}}}return g;}private boolean isValid(int x,int y){return x >= 0 && x < r && y >= 0 && y < c;}}
第二种方法:直接在 grid 二维数组上进行 bfs
class Solution {private int r,c;private int[][] dirs = {{-1,0},{-1,1},{0,1},{1,1},{1,0},{1,-1},{0,-1},{-1,-1}};public int shortestPathBinaryMatrix(int[][] grid) {r = grid.length;c = grid[0].length;if(grid[0][0] == 1) return -1;if(r == 1 && c == 1) return 1;boolean[][] visited = new boolean[r][c];int[][] dis = new int[r][c];Queue<Integer> q = new LinkedList<>();q.offer(0);visited[0][0] = true;dis[0][0] = 1;while(!q.isEmpty()){int cur = q.poll();int curX = cur / c;int curY = cur % c;if(grid[curX][curY] == 0) {for(int d = 0; d < 8; d++){int nextX = curX + dirs[d][0];int nextY = curY + dirs[d][1];if(isValid(nextX,nextY) && !visited[nextX][nextY] && grid[nextX][nextY] == 0){int next = nextX * c + nextY;q.offer(next);visited[nextX][nextY] = true;dis[nextX][nextY] = dis[curX][curY] + 1;if(nextX == r - 1 && nextY == c - 1)return dis[nextX][nextY];}}}}return -1;}private boolean isValid(int x,int y){return x >= 0 && x < r && y >= 0 && y < c;}}
我们看到,同样是 BFS 遍历,第二种方式较第一种方式要好很多。
二:转盘锁问题
现在让我们看一下 LeetCode 上的第 752 号问题:752. 打开转盘锁
对于这道题目,看似和图论没有任何的关系,但是其本质就是求解无权图的最短路径。我们之前接触到的几个问题,都可以很明显地和图论联系起来,但是这个问题我们该如何构建图的模型呢?
题目给定了一个字符串 target,代表可以解锁的数字,锁的初始数字为 “0000”,一个代表四个拨轮的数字的字符串;我们每次可以旋转一个拨轮的一位数字,旋转规则题目已经给出了。
我们将每一次拨动后的状态作为图的一个顶点,从一个状态到达另一个状态记作一条边,题目出了给定解锁的数字 target 还给定了一个列表 deadends,deadends 数组中锁的状态即不构成一条边。
如下面的示例:

“6666” 是 target 值,”0901” 是 deadends 数组中的一个值,我们可以看到,每次旋转一个拨轮后的状态即可以表示为这个图的一个顶点,这个问题的本质就是求解从 “0000” 这个顶点到 “6666” 这个顶点的最短路径。
代码如下:
class Solution {public int openLock(String[] deadends, String target) {HashSet<String> deadset = new HashSet<>();for(String deadend : deadends)deadset.add(deadend);if(deadset.contains("0000")) return -1;if(deadset.contains(target)) return -1;if(target.equals("0000")) return 0;// BFS// visited key:字符串是否被遍历过,value:如果字符串被遍历过,value 存储从 "0000" 到该字符串的路径长度HashMap<String,Integer> visited = new HashMap<>();Queue<String> q = new LinkedList<>();q.offer("0000");visited.put("0000",0);while(!q.isEmpty()){String cur = q.poll();List<String> nexts = getNexts(cur);for(String next : nexts) {if(!deadset.contains(next) && !visited.containsKey(next)){q.offer(next);visited.put(next,visited.get(cur) + 1);if(next.equals(target))return visited.get(next);}}}return -1;}private List<String> getNexts(String s){List<String> res = new ArrayList<>();char[] chars = s.toCharArray();for(int i = 0; i < chars.length; i++){char o = chars[i];chars[i] = Character.forDigit((chars[i] - '0' + 1) % 10,10);res.add(new String(chars));chars[i] = o;chars[i] = Character.forDigit((chars[i] - '0' + 9) % 10,10);res.add(new String(chars));chars[i] = o;}return res;}}
四:Water Puzzle
Water Puzzle
有两个没有刻度的桶,一个可以盛装 5 升的水,一个可以盛装 3 升的水,问:如何利用这两个桶得到 4 升水?

这个问题想必大家都有接触过。这道智力题的解决步骤如下:
- 将 5L 的桶倒满水,然后倒进 3L 的桶中,这样 3L 的桶装满了水,5L 的桶中还剩下 2L 的水;
- 将 3L 的桶里面的水倒掉,5L 的桶里面 2L 的水倒进 3 L 桶中;
- 将 5L 的桶倒满水,然后往 3L 的桶里面倒水,直至 3L 的桶被倒满,此时 5L 桶中就正好盛装了 4L 的水。
其实,Water Puzzle 也可以将其转化为一个图论领域的无向图最短路径问题,进而使用 BFS 来解决。那么,这样一个问题该如何对其进行图的建模呢?和转盘锁问题类似,Water Puzzle 也是使用状态表达来标记图中的一个顶点的:

如上图所示,X,Y 分别代表 5L 的桶所盛的水和 3L 的桶所盛的水。我们从 (X,Y) 这个状态到下一个状态有三种变化方法:
- 第一种是将 5L 桶接满,或者是将 3L 桶接满
- 第二种是将 5L 桶清空,或者是将 3L 桶清空
- 第三种是将 5L 桶的水倒向 3L 的桶中,或者是将 3L 桶的水倒向 5L 的桶中
对于顶点的表示,我们可以使用 10X + Y 这种方式。
我们的代码执行结果如下:
[0, 50, 23, 20, 2, 52, 43]
这个结果表示的含义:
- [0,0]
- [5,0] 代表将 5L 的桶装满水
- [2,3] 代表将 5L 的桶里面的水倒向 3L的桶中,将 3L 桶装满
- [2,0] 代表将 3L 的桶里面的水倒空
- [0,2] 代表将 5L 桶里面的 2L 水全部倒向 3L 的桶中
- [5,2] 代表将 5L 的桶装满水
- [4,3] 代表将 5L 的桶里面的水倒向 3L 的桶中,将 3L 的桶装满后,5L 的桶中还剩余 4L 的水,整个过程结束
其实这个题目和 LeetCode 上的一个问题 365. 水壶问题 相类似,如果感兴趣的同学可以自己尝试做一下,在我的代码仓中,也有这道问题的题解。
五:River Crossing Puzzle
River Crossing Puzzle
River Crossing Puzzle 也是一道非常著名的,可以使用图论算法解决的智力题。
题目如下:
农夫需要把狼,羊,菜和自己运输到河到对岸去。只有农夫可以划船,并且船只能承载农夫和另外一样东西。还有一个棘手的问题是,如果没有农夫看着,羊会偷吃菜,狼会吃羊。请考虑一种方法,让农夫可以安全地安排这些东西和他自己过河。
有了上一道题目的经验,我们可以轻松地将这个问题转换为图模型。
我们定义一个长度为 4 的字符串来表示对岸的状态其中,0 位表示农夫,1 位置表示狼,2 位置表示羊,3 位置表示菜。
我们知道,狼和羊无法单独在一起,羊和菜无法单独在一起,我们可以像转盘锁这个问题一样定义一个数组 deadends,该数组表示会出现狼吃羊或羊吃菜的所有情况,deadends 数组如下:
{"0111","0110","0011","1000","1001","1100"}
我们求解的就是从初始状态 “0000” 到结束状态 “1111” 的过程。
代码请点击链接进行查看,运行测试程序,测试程序输出结果如下:
[0000, 1010, 0010, 1110, 0100, 1101, 0101, 1111]
这个结果表示的含义为:
- 初始状态,农夫,狼,羊,菜都在河岸的左侧
- 农夫带着羊过河
- 农夫自己回来
- 农夫带着狼过河
- 农夫带着羊回来
- 农夫带着菜过河
- 农夫自己回来
- 农夫带着羊过河,所有物品和农夫到达对岸
六:滑动谜题
我们这一次来看一个 LeetCode 上困难级别的问题:滑动谜题。
题目给定的二维数组 board 是谜板的初始状态,最终要到达的状态为:[[1,2,3],[4,5,0]]。
从初始状态到达一个最终状态,这一类问题我们很自然地想到图论。该问题的本质属于无权图的最短路径求解。

题目其实并不难,代码复杂的部分在如何将谜板这个二维数组的信息表示为一种状态。
该状态可以是 Integer 类型的数字或 String 字符串。
譬如,谜板的二维数组信息是:[[1,2,3],[4,0,5]],我们可以将对应的状态表示为 123405 这个数字;或者,我们也可以将该状态表示为 “123405” 这个字符串。
无论是哪种转换方法,都应该保证谜板二维数组的信息和状态保证一致性,正确性。
本题我使用了 Integer 数字来表示谜板的状态,有兴趣的朋友可以尝试一下使用字符串来表示状态信息。
代码如下:
class Solution {private boolean[] visited;private int[] pre;private int[][] dirs = {{-1, 0}, {0, 1}, {1, 0}, {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 == 123450) return 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;}}
那么该问题的时间复杂度是多少呢?
我们知道无论是 BFS/DFS 这两种算法的时间复杂度均为 O(V + E),对于滑动谜题这个问题来说,每一个状态相当于一个顶点,一个顶点最多有上下左右四条边,所以 O(V + E) = O(V + 4V) = O(V)。
那么一共有多少种状态呢?题目给定了 2 * 3 个格子,对应了 6!个状态,所以,该算法的时间复杂度为 O(n!)。
七:图论搜索和人工智能
通过这一章的学习,我们知道 BFS 可以求解无向图的最短路径问题,这实际上就是一种最简单的基于搜索的人工智能。
这里给出一本书籍的链接:《Artificial Intelligence A Modern Approach》,感兴趣的朋友可以阅读。
