详细研究BFS(广度优先搜索)的使用与优化技巧

310 阅读11分钟

和动态规划系列一样,我们还是来用几道题目来详细剖析一下BFS算法的使用以及优化策略。

LeetCode1091 二进制矩阵中的最短路径


给你一个 n x n 的二进制矩阵 grid 中,返回矩阵中最短 畅通路径 的长度。如果不存在这样的路径,返回 -1

二进制矩阵中的 畅通路径 是一条从 左上角 单元格(即,(0, 0))到 右下角 单元格(即,(n - 1, n - 1))的路径,该路径同时满足下述要求:

  • 路径途经的所有单元格的值都是 0
  • 路径中所有相邻的单元格应当在 8 个方向之一 上连通(即,相邻两单元之间彼此不同且共享一条边或者一个角)。

畅通路径的长度 是该路径途经的单元格总数。

示例 1:

输入:grid = [[0,1],[1,0]]
输出:2

以上是题目的简要介绍,友友们可自行前往官网查看题目


问题分析:

对这个问题初步一看,最短畅通路径从左上角单元格到右下角单元格在 n * n 的矩阵范围内活动,这三个元素都在,再回想一下之前BFS框架提到的**BFS问题本质**, 自然而然地就会想到使用BFS算法解决这道题了。

给出题解前我们要思考这三个问题:

  1. 怎么把问题抽象成图的问题?图的节点怎么表示?

    我们可以把n * n 矩阵中的每一个元素抽象成节点,这n * n 个节点共同构成一个全局图,之后我们也是在这个图上进行广度优先搜索(BFS)。对于怎么表示图的节点,我们1对1转化过去就行,利用pair<int,int> 来存储这个二维网格坐标。

  2. 当走到一个节点时,怎么对这个节点进行扩散呢?也就是说怎么将这个节点的邻接节点加入到遍历队列之中呢?

    很简单,走到节点(i,j)时,我们有八种方向可以走,所以我们直接建立一个8 * 2 的二维数组来存储八个方向即可。

    int directions[8][2] = {{-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1}};
    
  3. 遍历图的过程中可能会走回头路,该怎么防止走回头路呢?

    按照之前BFS框架的思路,我们建一个哈希表visited 存储已经访问过的节点就行啦。但是unordered_set哈希表只能存储基本数据类【int,double,float等基本类型】,也就是说pair<int,int>不能用unordered_set存储,除非对unordered_set进行改造,我会在评论区给出改造方法。

    哈希表改造多麻烦,还能浪费时间和空间,我们换种思维,既然不改造哈希表,那我们就改造节点的表示形式嘛,怎么改呢?我们先想想之前的visited是怎么存数据的,基本上是存int等数值类型对吧。而ij其实就是int型数据,而且题目也给了限制条件 1<= n <= 100,所以我们利用 amount = i * 101 + j 来表示节点,这样可以快速在哈希表中查找amount,当需要使用节点的i,j时,我们利用下面公式进行转化即可:

    int i = amount / 101;
    int j = amount - 101 * i;
    

解决这三个问题后,我们就可以给出具体代码了:

具体代码:

int shortestPathBinaryMatrix(vector <vector<int>> &grid) {
    int n = grid.size();
    queue<int> q;
    //unordered_set<int> visited; //建立哈希表visited 存储已经访问过的节点,避免走回头路
    if (grid[0][0] == 1) return -1;
    q.push(0);
    visited.insert(0);
    //利用大小为8* 2 的一维数组存储八个方向
    int directions[8][2] = {{-1, -1},
                            {-1, 0},
                            {-1, 1},
                            {0,  -1},
                            {0,  1},
                            {1,  -1},
                            {1,  0},
                            {1,  1}};
    int step = 1;
    while (!q.empty()) {
        int sz = q.size();
        for (int k = 0; k < sz; k++) {
            int cur = q.front();
            q.pop();
            //将cur转换为(i,j)
            int i = cur / 101;
            int j = cur - i * 101;
            //结束条件
            if (i == n - 1 && j == n - 1) return step;

            //将相邻节点加入到队列q中
            for (auto direction: directions) {
                int ni = i + direction[0], nj = j + direction[1];
                if (ni >= 0 && ni < n && nj >= 0 && nj < n && grid[ni][nj] == 0) {
                    int amount = ni * 101 + nj;
                    q.push(amount);
                    visited.insert(amount);
                }
            }
        }
        step++;
    }
    return -1;
}

这段代码是可以解决问题的,但是经过测试,它竟然超时了!!!,我们得继续优化。

代码优化:

我们参照一下密码锁的优化方案,密码锁有两处优化:

  1. 将访问过的节点直接加入死亡密码集合deads中。

    二进制矩阵最短畅通路径的这道题也可以,将走过的节点元素在grid 中对应的grid[i][j]改为1就行了。这样可以起到不遍历访问过的节点的作用。

  2. 利用双向遍历的技巧进行优化

​ 类似的,二进制矩阵最短畅通路径这道题也是在一开始就知道起点和终点位置,所以可以使用双向遍历。

直接看一下优化之后的代码就很清楚这两处优化的思路了:

int shortestPathBinaryMatrix(vector <vector<int>> &grid) {
    int n = grid.size();
    unordered_set<int> q_start, q_target;
    if (grid[0][0] == 1) return -1;
    if (grid[n - 1][n - 1] == 1) return -1;
    q_start.insert(0);
    q_target.insert((n - 1) * 101 + (n - 1));
    //利用大小为8 * 2 的一维数组存储八个方向
    int directions[8][2] = {{-1, -1},
                            {-1, 0},
                            {-1, 1},
                            {0,  -1},
                            {0,  1},
                            {1,  -1},
                            {1,  0},
                            {1,  1}};
    int step = 1;
    //先从q_start开始,再到q_target,依次切换,循环下去
    while (!q_start.empty() && !q_target.empty()) {
        unordered_set<int> temp; //存储扩散节点
        for (int cur: q_start) {
            //将cur转换为(i,j)
            int i = cur / 101;
            int j = cur - 101 * i;
            //结束条件:如果两边遍历集合出现交集,则遍历结束
            if(q_target.count(cur) != 0) return step;
            grid[i][j] = 1;
            //对当前节点进行扩散
            for (auto direction: directions) {
                int ni = i + direction[0], nj = j + direction[1];
                if (ni >= 0 && ni < n && nj >= 0 && nj < n && grid[ni][nj] == 0) {
                    int amount = ni * 101 + nj;
                    temp.insert(amount);
                }
            }
        }
        step++;
        //交替q_start,q_target
        q_start = q_target;
        q_target = temp;
    }
    return -1;
}

在这段代码里我再说明一点,就是节点应该在什么时候加入到visited【严格来说已,应该是什么时候把节点(i,j)对应的grid[i][j]变为1,为了和框架对应上,我直接说成visited数组,大家明白我的意思就行】已遍历节点集合中呢?

在未优化的代码,即单向遍历中,你刚搜索到这个节点,将这个节点加入到队列之后马上在visited集合中也加入,这种方式是可以的。当然,我们也可以在访问这个节点【对这个节点进行处理】时在visited集合中加入,也是可以的。

但是在双向遍历中呢?如果我们刚搜索到这个节点时就在visited中加入,就会导致两个遍历集合永远不会出现交集,所以我们只能选择访问节点时在visited中加入此节点。

LeetCode1926 迷宫中里入口最近的出口


给你一个 m x n 的迷宫矩阵 maze下标从 0 开始),矩阵中有空格子(用 '.' 表示)和墙(用 '+' 表示)。同时给你迷宫的入口 entrance ,用 entrance = [entrancerow, entrancecol] 表示你一开始所在格子的行和列。

每一步操作,你可以往 或者 移动一个格子。你不能进入墙所在的格子,你也不能离开迷宫。你的目标是找到离 entrance 最近 的出口。出口 的含义是 maze 边界 上的 空格子entrance 格子 不算 出口。

请你返回从 entrance 到最近出口的最短路径的 步数 ,如果不存在这样的路径,请你返回 -1

示例 1:

img

输入:maze = [["+","+",".","+"],[".",".",".","+"],["+","+","+","."]], entrance = [1,2]
输出:1
解释:总共有 3 个出口,分别位于 (1,0),(0,2) 和 (2,3) 。
一开始,你在入口格子 (1,2) 处。
- 你可以往左移动 2 步到达 (1,0) 。
- 你可以往上移动 1 步到达 (0,2) 。
从入口处没法到达 (2,3) 。
所以,最近的出口是 (0,2) ,距离为 1 步。

这道题和二进制矩阵中的最短路径几乎一模一样,我们直接给出代码:

具体代码:

int nearestExit(vector <vector<char>> &maze, vector<int> &entrance) {
    int m = maze.size();
    int n = maze[0].size();
    queue<int> q;
    int amount = entrance[0] * 101 + entrance[1];
    q.push(amount);
    int directions[4][2] = {{0,  -1}, //正左
                            {-1, 0},  //正上
                            {0,  1},  //正右
                            {1,  0}}; //正下
    int step = 0;
    while (!q.empty()) {
        int sz = q.size();
        for (int k = 0; k < sz; k++) {
            int cur = q.front();
            q.pop();
            int i = cur / 101;
            int j = cur - 101 * i;
            maze[i][j] = '+';
            //判断结束条件
            if ((i == 0 || i == m - 1 || j == n - 1 || j == 0 )&& !(i == entrance[0] && j == entrance[1])) {
                return step;
            }
            //对当前节点进行扩散
            for (auto direction: directions) {
                int ni = i + direction[0];
                int nj = j + direction[1];
                if (0 <= ni && ni <= m - 1 && 0 <= nj && nj <= n - 1 && maze[ni][nj] == '.') {
                    int amount = ni * 101 + nj;
                    q.push(amount);
                }
            }
        }
        step++;
    }
    return -1;
}

超时分析与优化:

但很抱歉,这段代码虽然能够解决问题,但是它超时了!!!,我们该怎么优化呢?

试试【双向遍历】?但是这道题我们并不知道【target】在哪个位置,显然不能双向遍历?并且我们已经优化了visited集合,现在麻烦了,优化小妙招都使完了,还能怎么优化呢?

那我们只能考虑更深层,有关于计算机内存方面的优化咯:

  1. 我们在存储节点时,把(i,j)转化为amount,使用节点(即访问节点时),又将amount转化为(i,j),大量的编码和解码操作会浪费时间,所以我们不进行编码,直接有pair<int,int>存储二维坐标。其实我们之前提到过,用pair<int,int>存,需要对哈希表visited进行改造,但是我们这里把哈希表visited给优化了,也不用麻烦改造,直接将队列存储的数据类型改为pair<int,int>即可。
  2. 同时,我们注意到,上段代码在访问节点时才将这个节点加入到visited(其实是相应的maze位置改为‘+’,为了大家更好理解框架,我用哈希表visited来描述)中,这样会导致一个节点在同一层出现多次,被搜索到了多次(加入到队列q中多次),这也会浪费时间和空间。

看下面的最终代码,你就会明白我说的是什么意思了:

int nearestExit(vector <vector<char>> &maze, vector<int> &entrance) {
    int m = maze.size();
    int n = maze[0].size();
    queue <pair<int, int>> q;
    q.push({entrance[0], entrance[1]});
    maze[entrance[0]][entrance[1]] = '+';

    int directions[4][2] = {{0,  -1},
                            {-1, 0},
                            {0,  1},
                            {1,  0}}; // 左上右下四个方向
    int step = 0;
    while (!q.empty()) {
        int sz = q.size();
        for (int k = 0; k < sz; k++) {
            auto [i, j] = q.front();
            q.pop();

            // 检查是否为出口且不为入口位置
            if ((i == 0 || i == m - 1 || j == 0 || j == n - 1) && !(i == entrance[0] && j == entrance[1])) {
                return step;
            }

            // 四个方向扩散
            for (auto &direction: directions) {
                int ni = i + direction[0];
                int nj = j + direction[1];
                if (ni >= 0 && ni < m && nj >= 0 && nj < n && maze[ni][nj] == '.') {
                    maze[ni][nj] = '+'; // 标记已访问
                    q.push({ni, nj});
                }
            }
        }
        step++;
    }
    return -1;
}

LeetCode286 墙与门


286墙与门.png


一眼看出解题方案:

一眼望去,这个墙与门的问题与之前两道题简直一模一样嘛,以一个空房间为起点开始进行BFS遍历,target是门,遍历到门则结束遍历就好了。但是不知道起点有多少个,所以我们需要遍历一下整个二维数组,并穷举所有起点,对所有起点都来一次BFS

这是我们第一眼的思路框架:

for(遍历二维矩阵的所有节点){
	如果是起点,则开始`BFS`
	如果不是,即节点值为-1,则continue进入下一个节点
	
	开始BFS{
		这里BFS的代码逻辑我就不写了,和前两题几乎一模一样,有疑惑的朋友可以在评论区问我
	}
	
	利用BFS得到的最短路径数来更新原来节点的值
}

我们假设这道题BFS的时间复杂度为O(A)【A至少是mnm*n,因为BFS需要遍历所有节点】,外层循环是遍历二维矩阵所有节点,那解法的总时间复杂度达到了惊人的O(Amn)O(A*m*n)​,我们先给出这种思路的解法:

vector<vector<int>> problem(vector<vector<int>>& grid){
	int m = grid.size();
    int n = grid[0].size();
    int INF = 2^31 - 1;
	
    //遍历所有节点
    for(int i = 0;i < m ;i++){
        for(int j = 0;j < n;j++){
            vector<vector<int>> grid_cur = grid;
            if(grid[i][j] == -1 || grid[i][j] == 0)  continue; //遇到墙和障碍物 或者遇到门,直接跳过
            queue<pair<int,int>> q;
            q.push({i,j});
            grid_cur[i][j] = -1;
            int directions[4][2] = {{0,-1},
                                    {-1,0},
                                    {0,1},
                                    {1,0}};
            int step = 0;
            while(!q.empty()){
                auto [cur_i,cur_j] = q.front();
                q.pop();
                
               //结束条件 题目给的所有测试点都有门存在,所以不会出现遍历完所有节点仍不能为grid[cur_i][cur_j]赋值的情况
                if(grid[cur_i][cur_j] == 0){
                    grid[i][j] = step;
                    break;
                } 
               //对当前节点进行扩散
                for(auto direction : directions){
                    int ni = cur_i + direction[0];
                    int nj = cur_j + direction[1];
                    if(ni >=0 && ni < m && nj >=0 && nj < n && grid_cur[ni][nj] != -1){
                        q.push({ni,nj});
                        grid_cur[ni][nj] = -1;
                    }
                }
                step++;
            }  
        }
    }
	return grid;
    
    
}

这道墙与门的题由于是LeetCode付费题目,我不好过多描述,有兴趣优化和改进这道题代码的朋友可以私信我,我会为你详细解答。