和动态规划系列一样,我们还是来用几道题目来详细剖析一下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
算法解决这道题了。
给出题解前我们要思考这三个问题:
-
怎么把问题抽象成图的问题?图的节点怎么表示?
我们可以把n * n 矩阵中的每一个元素抽象成节点,这n * n 个节点共同构成一个全局图,之后我们也是在这个图上进行广度优先搜索(
BFS
)。对于怎么表示图的节点,我们1对1转化过去就行,利用pair<int,int>
来存储这个二维网格坐标。 -
当走到一个节点时,怎么对这个节点进行扩散呢?也就是说怎么将这个节点的邻接节点加入到遍历队列之中呢?
很简单,走到节点
(i,j)
时,我们有八种方向可以走,所以我们直接建立一个8 * 2 的二维数组来存储八个方向即可。int directions[8][2] = {{-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1}};
-
遍历图的过程中可能会走回头路,该怎么防止走回头路呢?
按照之前
BFS
框架的思路,我们建一个哈希表visited
存储已经访问过的节点就行啦。但是unordered_set
哈希表只能存储基本数据类【int
,double
,float
等基本类型】,也就是说pair<int,int>
不能用unordered_set
存储,除非对unordered_set
进行改造,我会在评论区给出改造方法。哈希表改造多麻烦,还能浪费时间和空间,我们换种思维,既然不改造哈希表,那我们就改造节点的表示形式嘛,怎么改呢?我们先想想之前的
visited
是怎么存数据的,基本上是存int
等数值类型对吧。而i
和j
其实就是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;
}
这段代码是可以解决问题的,但是经过测试,它竟然超时了!!!,我们得继续优化。
代码优化:
我们参照一下密码锁的优化方案,密码锁有两处优化:
-
将访问过的节点直接加入死亡密码集合
deads
中。二进制矩阵最短畅通路径的这道题也可以,将走过的节点元素在
grid
中对应的grid[i][j]
改为1就行了。这样可以起到不遍历访问过的节点的作用。 -
利用双向遍历的技巧进行优化
类似的,二进制矩阵最短畅通路径这道题也是在一开始就知道起点和终点位置,所以可以使用双向遍历。
直接看一下优化之后的代码就很清楚这两处优化的思路了:
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:
输入: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
集合,现在麻烦了,优化小妙招都使完了,还能怎么优化呢?
那我们只能考虑更深层,有关于计算机内存方面的优化咯:
-
我们在存储节点时,把
(i,j)
转化为amount
,使用节点(即访问节点时),又将amount
转化为(i,j)
,大量的编码和解码操作会浪费时间,所以我们不进行编码,直接有pair<int,int>
存储二维坐标。**其实我们之前提到过,用pair<int,int>
存,需要对哈希表visited
进行改造,但是我们这里把哈希表visited
给优化了,也不用麻烦改造,直接将队列存储的数据类型改为pair<int,int>
即可。 -
同时,我们注意到,上段代码在访问节点时才将这个节点加入到
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;
}