队列和广度优先搜索

Posted 清梅煮酒

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了队列和广度优先搜索相关的知识,希望对你有一定的参考价值。

广度优先搜索(BFS)的一个常见应用是找出从根结点到目标结点的最短路径。

思路

1. 结点的处理顺序

在第一轮中,我们处理根结点。在第二轮中,我们处理根结点旁边的结点;在第三轮中,我们处理距根结点两步的结点;等等等等。

与树的层序遍历类似,越是接近根结点的结点将越早地遍历。

如果在第 k 轮中将结点 X 添加到队列中,则根结点与 X 之间的最短路径的长度恰好是 k。也就是说,第一次找到目标结点时,你已经处于最短路径中。

2. 队列的入队和出队顺序

首先将根结点排入队列。然后在每一轮中,我们逐个处理已经在队列中的结点,并将所有邻居添加到队列中。值得注意的是,新添加的节点不会立即遍历,而是在下一轮中处理。

结点的处理顺序与它们添加到队列的顺序是完全相同的顺序,即先进先出(FIFO)。这就是我们在 BFS 中使用队列的原因。

模版

标准模版

int BFS(Node root, Node target) {
    Queue<Node> queue = new LinkedList<>();  // 存储所有等待处理的节点
    int step = 0;       // 深度
    // 初始化,将根节点存入队列
    queue.offer(root);
    // BFS
    while (!queue.isEmpty()) {
        step = step + 1;
        // 迭代
        int size = queue.size();
        for (int i = 0; i < size; ++i) {
            Node cur = queue.peek();
            // 第一次找到该节点,将深度返回
            if (cur == target)
                return step
            for (Node next : the neighbors of cur) 
{
                queue.offer(next);
            }
            queue.remove();
        }
    }
    return -1;
}

1.如代码所示,在每一轮中,队列中的结点是等待处理的结点。 
2.在每个更外一层的 while 循环之后,我们距离根结点更远一步。变量 step 指示从根结点到我们正在访问的当前结点的距离。

避免重复访问节点

有时,确保我们 永远不会访问一个结点两次 很重要。否则,我们可能陷入无限循环。如果是这样,我们可以在上面的代码中添加一个哈希集来解决这个问题。这是修改后的伪代码:

int BFS(Node root, Node target) {
    Queue<Node> queue = new LinkedList<>();  // 存储所有等待处理的节点
    Set<Node> used = new HashSet<>();   // 存储已经访问了的节点
    int step = 0;       // 深度

    // 初始化,将根节点存入队列和 HashSet 中
    queue.offer(root);
    used.add(root);

    // BFS
    while (!queue.isEmpty()) {
        step = step + 1;
        // 迭代
        int size = queue.size();
        for (int i = 0; i < size; ++i) {
            Node cur = queue.peek();
            // 第一次找到该节点,将深度返回
            if (cur == target)
                return step
            for (Node next : the neighbors of cur) 
{
              if(!used.contains(next)) {
                queue.offer(next);
                used.add(next);
              }
            }
            queue.remove();
        }
    }
    return -1;
}

有两种情况你不需要使用哈希集:
1.你完全确定没有循环,例如,在树遍历中;
2.你确实希望多次将结点添加到队列中。

例题

1.岛屿数量

  • 难度:Medium

题目描述

给定一个由 '1'(陆地)和 '0'(水)组成的的二维网格,计算岛屿的数量。一个岛被水包围,并且它是通过水平方向或垂直方向上相邻的陆地连接而成的。你可以假设网格的四个边均被水包围。

示例 1:

输入:
11110
11010
11000
00000
输出: 1

示例 2:

输入:
11000
11000
00100
00011
输出: 3

解题思路及实现

这道题思路如下:线性扫描整个二维网格,如果一个结点包含 1,则以其为根结点启动广度优先搜索。将其放入队列中,并将值设为 0 以标记访问过该结点。迭代地搜索队列中的每个结点,直到队列为空。

实现代码:

class Solution {
    public int numIslands(char[][] grid) {
       int result = 0;
        if (grid == null || grid.length == 0 || grid[0].length == 0return result;

        int nr = grid.length;
        int nc = grid[0].length;

        for (int c = 0; c < nc; c++) {
            for (int r = 0; r < nr; r++) {
                // 如果是岛,返回结果加1,并将默认的坐标点标记为0
                if (isIsland(grid, c, r)) {
                    result++;
                    makeVisited(grid, c, r);
                    // 开始广度优先搜索算法
                    final LinkedList<Integer> queue = new LinkedList<>();
                    queue.add(r * nc + c);
                    while (!queue.isEmpty()) {
                        final int removed = queue.remove();
                        final int removedCol = removed % nc;
                        final int removedRow = removed / nc;
                        //         top
                        //  left  removed  right
                        //        bottom

                        // top
                        final int removeTop = removedRow - 1;
                        if (removeTop >= 0 && isIsland(grid, removedCol, removeTop)) {
                            queue.add(removeTop * nc + removedCol);
                            makeVisited(grid, removedCol, removeTop);
                        }

                        // left
                        final int removeLeft = removedCol - 1;
                        if (removeLeft >= 0 && isIsland(grid, removeLeft, removedRow)) {
                            queue.add(removedRow * nc + removeLeft);
                            makeVisited(grid, removeLeft, removedRow);
                        }

                        // bottom
                        final int removeBottom = removedRow + 1;
                        if (removeBottom <= nr - 1 && isIsland(grid, removedCol, removeBottom)) {
                            queue.add(removeBottom * nc + removedCol);
                            makeVisited(grid, removedCol, removeBottom);
                        }

                        // right
                        final int removeRight = removedCol + 1;
                        if (removeRight <= nc - 1 && isIsland(grid, removeRight, removedRow)) {
                            queue.add(removedRow * nc + removeRight);
                            makeVisited(grid, removeRight, removedRow);
                        }
                    }
                }
            }
        }
        return result;
    }

    // 返回二维数组对应的位置是否为岛
    private boolean isIsland(char[][] grid, int c, int r) {
        return grid[r][c] == '1';
    }

    // 标记二维数组对应的位置非岛
    private void makeVisited(char[][] grid, int c, int r) {
        grid[r][c] = '0';
    }
}

2.打开转盘锁

  • 难度:Medium

题目描述

你有一个带有四个圆形拨轮的转盘锁。每个拨轮都有10个数字:'0', '1', '2', '3', '4', '5', '6', '7', '8', '9' 。每个拨轮可以自由旋转:例如把 '9' 变为  '0''0' 变为 '9' 。每次旋转都只能旋转一个拨轮的一位数字。

锁的初始数字为 '0000' ,一个代表四个拨轮的数字的字符串。

列表 deadends 包含了一组死亡数字,一旦拨轮的数字和列表里的任何一个元素相同,这个锁将会被永久锁定,无法再被旋转。

字符串 target 代表可以解锁的数字,你需要给出最小的旋转次数,如果无论如何不能解锁,返回 -1

解题思路及实现

我们可以将 0000999910000 状态看成图上的 10000 个节点,两个节点之间存在一条边,当且仅当这两个节点对应的状态只有 1 位不同,且不同的那位相差 1(包括 09 也相差 1 的情况),并且这两个节点均不在数组 deadends 中。那么最终的答案即为 0000target 的最短路径。

我们用广度优先搜索来找到最短路径,从 0000 开始搜索。对于每一个状态,它可以扩展到最多 8 个状态,即将它的第 i = 0, 1, 2, 3 位增加 1 或减少 1,将这些状态中没有搜索过并且不在 deadends 中的状态全部加入到队列中,并继续进行搜索。注意 0000 本身有可能也在 deadends 中。

public class B752OpenLock {

    public int openLock(String[] deadends, String target) {
        Set<String> dead = new HashSet<>();
        for (String d : deadends) dead.add(d);

        // 用于bfs的队列
        Queue<String> queue = new LinkedList<>();
        queue.add("0000");
        queue.add(null);

        // 额外的一个哈希集,确保我们永远不会访问一个结点两次。
        // 否则,我们可能陷入无限循环
        Set<String> seen = new HashSet<>();
        seen.add("0000");
        int depth = 0;

        while (!queue.isEmpty()) {
            final String node = queue.remove();

            if (node == null) {
                // null 作为单次广度搜索的尾巴标记
                depth++;
                if (queue.peek() != null) {
                    // 这意味着单轮搜索结束,队列末尾继续追加一个null
                    // 当轮训到下一个null时,意味着下轮的搜索结束
                    queue.offer(null);
                }
            } else if (node.equals(target)) {
                return depth;
            } else if (!dead.contains(node)) {  // 如果某个节点不是死亡数字
                // 对于每一个状态,它可以扩展到最多 8 个状态,即将它的第 i = 0, 1, 2, 3 位增加 1 或减少 1
                for (int i = 0; i < 4; i++) {           // 四个位置
                    for (int j = -1; j <= 1; j += 2) {  // 每个位置分别 -1 , + 1
                        // 这里用到一个小技巧避免对字符进行额外的负数判断
                        // 这里统一加10求余,值一定是期望的值,否则结果会返回负数
                        // 比如,当node.charAt(i) = '0', j = -1 时,这时候结果仍然是期望的9,而不是 -1
                        int y = ((node.charAt(i) - '0') + j + 10) % 10;
                        // 取得对应的邻居节点,比如 i = 1, j = 1 时, '0000' -> '0100'
                        String nei = node.substring(0, i) + String.valueOf(y) + node.substring(i + 1);
                        if (!seen.contains(nei)) {
                            seen.add(nei);
                            queue.offer(nei);
                        }
                    }
                }
            }
        }
        return -1;
    }
}

3.完全平方数

  • 难度:Medium

题目描述

给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, ...)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。

示例 1:

输入: n = 12
输出: 3
解释: 12 = 4 + 4 + 4.

示例 2:

输入: n = 13
输出: 2
解释: 13 = 4 + 9.

解题思路及实现

如下图,详细实现参考代码:


class Solution {
    public int numSquares(int n) {
        boolean[] visited = new boolean[n];
        int step = 1;

        Queue<Integer> queue = new LinkedList<>();
        queue.add(n);
        queue.add(null);

        while (!queue.isEmpty()) {
            Integer removed = queue.remove();
            // bfs
            for (int i = 1; ; i++) {
                if (removed == null) {
                    step++;
                    if (queue.peek() != null) {
                        queue.offer(null);
                    }
                    break;
                }

                int nextValue = removed - (i * i);

                if (nextValue == 0)
                    return step;

                if (nextValue < 0) {
                    break;
                }

                // 关键点!
                // 当再次出现时没有必要加入,因为在该节点的路径长度肯定不小于第一次出现的路径长
                if (!visited[nextValue]) {
                    visited[nextValue] = true;
                    queue.add(nextValue);
                }
            }
        }
        return -1;
    }
}

参考 & 感谢

文章绝大部分内容节选自LeetCode,概述:

  • https://leetcode-cn.com/explore/learn/card/queue-stack/217/queue-and-bfs/869/

例题:

  • https://leetcode-cn.com/problems/number-of-islands

  • https://leetcode-cn.com/problems/open-the-lock

  • https://leetcode-cn.com/problems/perfect-squares

以上是关于队列和广度优先搜索的主要内容,如果未能解决你的问题,请参考以下文章

Python数据结构-队列与广度优先搜索(Queue)

深度优先搜索和广度优先搜索

队列和广度优先搜索

如何用队列实现广度优先算法

广度优先搜索算法

广度优先搜索