BFS题目总结

431 阅读2分钟

基本介绍

我们先举例一下 BFS 出现的常见场景好吧,问题的本质就是让你在一幅「图」中找到从起点start到终点target的最近距离,这个例子听起来很枯燥,但是 BFS 算法问题其实都是在干这个事儿。

把枯燥的本质搞清楚了,再去欣赏各种问题的包装才能胸有成竹嘛。

这个广义的描述可以有各种变体,比如走迷宫,有的格子是围墙不能走,从起点到终点的最短距离是多少?如果这个迷宫带「传送门」可以瞬间传送呢?

再比如说两个单词,要求你通过某些替换,把其中一个变成另一个,每次只能替换一个字符,最少要替换几次?

再比如说连连看游戏,两个方块消除的条件不仅仅是图案相同,还得保证两个方块之间的最短连线不能多于两个拐点。你玩连连看,点击两个坐标,游戏是如何判断它俩的最短连线有几个拐点的?

再比如……

净整些花里胡哨的,这些问题都没啥奇技淫巧,本质上就是一幅「图」,让你从一个起点,走到终点,问最短路径。这就是 BFS 的本质,框架搞清楚了直接默写就好。

记住下面这个框架就 OK 了:

// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
    Queue<Node> q; // 核心数据结构
    Set<Node> visited; // 避免走回头路

    q.offer(start); // 将起点加入队列
    visited.add(start);
    int step = 0// 记录扩散的步数

    while (q not empty) {
        int sz = q.size();
        /* 将当前队列中的所有节点向四周扩散 */
        for (int i = 0; i < sz; i++) {
            Node cur = q.poll();
            /* 划重点:这里判断是否到达终点 */
            if (cur is target)
                return step;
            /* 将 cur 的相邻节点加入队列 */
            for (Node x : cur.adj())
                if (x not in visited) {
                    q.offer(x);
                    visited.add(x);
                }
        }
        /* 划重点:更新步数在这里 */
        step++;
    }
}

队列q就不说了,BFS 的核心数据结构;cur.adj()泛指cur相邻的节点,比如说二维数组中,cur上下左右四面的位置就是相邻节点;visited的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要visited

111. 二叉树的最小深度

题目链接:leetcode.cn/problems/mi…

image.png

怎么套到 BFS 的框架里呢?首先明确一下起点start和终点target是什么,怎么判断到达了终点?

显然起点就是root根节点,终点就是最靠近根节点的那个「叶子节点」嘛,叶子节点就是两个子节点都是null的节点:

if (cur.left == null && cur.right == null) 
    // 到达叶子节点

那么,按照我们上述的框架稍加改造来写解法即可。

代码如下:

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode() {}
 *     TreeNode(int val) { this.val = val; }
 *     TreeNode(int val, TreeNode left, TreeNode right) {
 *         this.val = val;
 *         this.left = left;
 *         this.right = right;
 *     }
 * }
 */
class Solution {
    public int minDepth(TreeNode root) {
        if(root == null) {
            return 0;
        }
        LinkedList<TreeNode> queue = new LinkedList<>();
        queue.add(root);
        int result = 1;
        while(queue.size() > 0) {
            int size = queue.size();
            for(int i = 0; i < size; i++) {
                TreeNode node =  queue.poll();
                if(node.left == null && node.right == null){
                    return result;
                }
                if(node.left != null) {
                    queue.add(node.left);
                }
                if(node.right != null) {
                    queue.add(node.right);
                }
            }
            result++;
        }
        return result;
    }
}

752. 打开转盘锁

题目链接:leetcode.cn/problems/op…

image.png

题目中描述的就是我们生活中常见的那种密码锁,若果没有任何约束,最少的拨动次数很好算,就像我们平时开密码锁那样直奔密码拨就行了。

但现在的难点就在于,不能出现deadends,应该如何计算出最少的转动次数呢?

第一步,我们不管所有的限制条件,不管deadendstarget的限制,就思考一个问题:如果让你设计一个算法,穷举所有可能的密码组合,你怎么做

穷举呗,再简单一点,如果你只转一下锁,有几种可能?总共有 4 个位置,每个位置可以向上转,也可以向下转,也就是有 8 种可能对吧。

比如说从"0000"开始,转一次,可以穷举出"1000", "9000", "0100", "0900"...共 8 种密码。然后,再以这 8 种密码作为基础,对每个密码再转一下,穷举出所有可能…

仔细想想,这就可以抽象成一幅图,每个节点有 8 个相邻的节点,又让你求最短距离,这不就是典型的 BFS 嘛,框架就可以派上用场了,先写出一个「简陋」的 BFS 框架代码再说别的:

// 将 s[j] 向上拨动一次
String plusOne(String s, int j) {
    char[] ch = s.toCharArray();
    if (ch[j] == '9')
        ch[j] = '0';
    else
        ch[j] += 1;
    return new String(ch);
}
// 将 s[i] 向下拨动一次
String minusOne(String s, int j) {
    char[] ch = s.toCharArray();
    if (ch[j] == '0')
        ch[j] = '9';
    else
        ch[j] -= 1;
    return new String(ch);
}

// BFS 框架,打印出所有可能的密码
void BFS(String target) {
    Queue<String> q = new LinkedList<>();
    q.offer("0000");

    while (!q.isEmpty()) {
        int sz = q.size();
        /* 将当前队列中的所有节点向周围扩散 */
        for (int i = 0; i < sz; i++) {
            String cur = q.poll();
            /* 判断是否到达终点 */
            System.out.println(cur);

            /* 将一个节点的相邻节点加入队列 */
            for (int j = 0; j < 4; j++) {
                String up = plusOne(cur, j);
                String down = minusOne(cur, j);
                q.offer(up);
                q.offer(down);
            }
        }
        /* 在这里增加步数 */
    }
    return;
}

这段 BFS 代码已经能够穷举所有可能的密码组合了,但是显然不能完成题目,有如下问题需要解决

1、会走回头路。比如说我们从"0000"拨到"1000",但是等从队列拿出"1000"时,还会拨出一个"0000",这样的话会产生死循环。

2、没有终止条件,按照题目要求,我们找到target就应该结束并返回拨动的次数。

3、没有对deadends的处理,按道理这些「死亡密码」是不能出现的,也就是说你遇到这些密码的时候需要跳过。

完整代码如下:

class Solution {
    public int openLock(String[] deadends, String target) {
        //记录需要跳过的死亡密码
        Set<String> deads=new HashSet<>();
        for(String s: deadends){
            deads.add(s);
        }

        //记录已经穷举过的密码,防止走回头路
        Set<String> visited = new HashSet<>();
        LinkedList<String> q = new LinkedList<>();
        //定义起点开始启动广度优先搜索
        int step=0;
        q.offer("0000");
        visited.add("0000");
        while(!q.isEmpty()){
            int size = q.size();
            for(int i = 0; i < size; i++){
                String cur = q.poll();
                //判断是否达到终点
                if(deads.contains(cur)){
                    continue;
                }
                if(cur.equals(target)){
                    return step;
                }

                //将一个结点的未遍历的相邻节点加入到队列
                for(int j = 0; j < 4; j++){
                    String up = plusOne(cur, j);
                    if(!visited.contains(up)){
                        q.offer(up);
                        visited.add(up);
                    }

                    String down = minusOne(cur, j);
                    if (!visited.contains(down)) {
                        q.offer(down);
                        visited.add(down);
                    }
                }
            }
            step++;
        }
        return -1;
    }
    
    public String plusOne(String s,int j) {
        char[] ch=s.toCharArray();
        if(ch[j] =='9'){
            ch[j] = '0';
        } else {
            ch[j] += 1;
        }
        return new String(ch);
    }

    public String minusOne(String s,int j) {
        char[] ch=s.toCharArray();
        if(ch[j] == '0') {
            ch[j] = '9';
        }else{
            ch[j] -= 1;
        }
        return new String(ch);
    }
}

773. 滑动谜题

题目链接:leetcode.cn/problems/sl…

image.png

题目的简单意思:给你一个 2x3 的滑动拼图,用一个 2x3 的数组board表示。拼图中有数字 0~5 六个数,其中数字 0 就表示那个空着的格子,你可以移动其中的数字,当board变为[[1,2,3],[4,5,0]]时,赢得游戏。

请你写一个算法,计算赢得游戏需要的最少移动次数,如果不能赢得游戏,返回 -1。

比如说输入的二维数组board = [[4,1,2],[5,0,3]],算法应该返回 5:

image.png

如果输入的是board = [[1,2,3],[5,4,0]],则算法返回 -1,因为这种局面下无论如何都不能赢得游戏。

这个题目转化成 BFS 问题是有一些技巧的,我们面临如下问题:

1、一般的 BFS 算法,是从一个起点start开始,向终点target进行寻路,但是拼图问题不是在寻路,而是在不断交换数字,这应该怎么转化成 BFS 算法问题呢?

2、即便这个问题能够转化成 BFS 问题,如何处理起点start和终点target?它们都是数组哎,把数组放进队列,套 BFS 框架,想想就比较麻烦且低效。

首先回答第一个问题,BFS 算法并不只是一个寻路算法,而是一种暴力搜索算法,只要涉及暴力穷举的问题,BFS 就可以用,而且可以最快地找到答案。

你想想计算机怎么解决问题的?哪有那么多奇技淫巧,本质上就是把所有可行解暴力穷举出来,然后从中找到一个最优解罢了。

明白了这个道理,我们的问题就转化成了:如何穷举出board当前局面下可能衍生出的所有局面?这就简单了,看数字 0 的位置呗,和上下左右的数字进行交换就行了:

image.png

这样其实就是一个 BFS 问题,每次先找到数字 0,然后和周围的数字进行交换,形成新的局面加入队列…… 当第一次到达target时,就得到了赢得游戏的最少步数。

对于第二个问题,我们这里的board仅仅是 2x3 的二维数组,所以可以压缩成一个一维字符串。其中比较有技巧性的点在于,二维数组有「上下左右」的概念,压缩成一维后,如何得到某一个索引上下左右的索引

很简单,我们只要手动写出来这个映射就行了:

//初始化一维数据的相邻索引
List<List<Integer>> neighbor = new ArrayList<>();
neighbor.add(Arrays.asList(1, 3));
neighbor.add(Arrays.asList(0, 4, 2));
neighbor.add(Arrays.asList(1, 5));
neighbor.add(Arrays.asList(0, 4));
neighbor.add(Arrays.asList(3, 1, 5));
neighbor.add(Arrays.asList(4, 2));

这个含义就是,在一维字符串中,索引i在二维数组中的的相邻索引为neighbor[i] :

image.png

完整代码如下:

class Solution {
    public static int slidingPuzzle(int[][] board) {
        int m = 2;
        int n = 3;
        String start = "";
        String target = "123450";

        //初始化状态的字符串
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                start += board[i][j];
            }
        }
        //初始化一维数据的相邻索引
        List<List<Integer>> neighbor = new ArrayList<>();
        neighbor.add(Arrays.asList(1, 3));
        neighbor.add(Arrays.asList(0, 4, 2));
        neighbor.add(Arrays.asList(1, 5));
        neighbor.add(Arrays.asList(0, 4));
        neighbor.add(Arrays.asList(3, 1, 5));
        neighbor.add(Arrays.asList(4, 2));

        LinkedList<String> queue = new LinkedList<>();
        Set<String> visited = new HashSet<>();
        visited.add(start);
        queue.offer(start);
        int step = 0;
        while (!queue.isEmpty()) {
            int sz = queue.size();
            for (int i = 0; i < sz; i++) {
                String cur = queue.poll();
                if (target.equals(cur)) {
                    return step;
                }
                int idx = cur.indexOf('0');
                for (int adj : neighbor.get(idx)) {
                    StringBuilder sb = new StringBuilder(cur);
                    //交换0的位置
                    sb.setCharAt(idx, cur.charAt(adj));
                    sb.setCharAt(adj, '0');
                    //产生新的序列
                    String newStart = sb.toString();
                    //防止重复
                    if (!visited.contains(newStart)) {
                        queue.offer(newStart);
                        visited.add(newStart);
                    }
                }
            }
            step++;
        }
        return -1;
    }    
}