BFS Derivation
借着一道我愿称之为究极 BFS 的题目总结一下 BFS, Leetcode 2258. 逃离火灾, 顺便延伸至 dijkstra
BFS(宽度优先搜索)
和这道题的背景非常契合的, 火焰从开始点向四周扩散, 就是一个典型的 BFS 搜索过程. 我们每次都访问同一层(同一时刻)燃烧的点, 并将周遭可燃点推入队列以供下次访问.
BFS 相较于 DFS 能够得到当前点至起始点的最短路径(包含边, 即遍历层数最少)
遍历连通块及其变种
我们可以将一般的 BFS 过程都视为图的遍历, 线性时间( $O(N + M)$, 边数与点数)内可以求出所有连通块.
- 针对无环图来说, 我们不会重复遍历同一个点. 而涉及到有环图, 我们又不希望重复遍历, 我们可以存放遍历过的点(用 hash 或 布尔数组都行)
- 在遍历的过程中, 我们可以记录每个点至起点的最小路径, 此时必须保证是无权图(或路径权重都为 1), 这才能够将遍历层数, 视作路径长度(权重为 1 或 0)
你要是想求单源最短路径, 你就用 dijkstra 呗
我们可以写出下列代码来解决上述描述到的所有问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
vector<int> bfs(vector<vector<int>> &G, int start)
{
// 记录最小路径大小
vector<int> dis(G.size(), 0);
// 记录最小路径
vector<vector<int>> path(G.size(), 0);
int step = 0;
queue<int> q;
q.push(start);
visit.insert(start);
// 防止重复访问(进入环), 你借助这个甚至能求有环无权图的最小环(每次重复时, 当前的 step 与即将重复点的最小路径大小 dis[curNode] 的差)
unordered_set<int> visit;
// 记录当前遍历的路径
vector<int> curPath;
while(!q.empty())
{
int sz = q.size();
++step;
for(int i = 0; i < sz; ++i)
{
auto curNode = q.front();
q.pop();
path[curNode].push_back(curNode);
for(auto &child : G[curNode])
{
if(visit.find(child) != visit.end())
continue;
dis[child] = step;
q.push(child);
path[child].insert(path[child].begin(), path[curNode].begin(), path[curNode].end());
}
visit.insert(curNode);
}
}
return dis;
}
2258. 逃离火灾
分析题意我们可以知道火的扩散与人的逃跑都可以看作一次 BFS 的扩散, 那么我们就用两个队列存当前人物可出现的位置(かげぶんしんのじゅつ!)与火蔓延至的最外延. 至于如何确定最多等待的时间, 很显然答案具有二段性, 小于等于结果的一定能逃离, 大于结果的一定不能逃离, 可用二分搜索.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class Solution
{
public:
static constexpr int dirs[4][2] = { {0, 1}, {0, -1}, {1, 0}, {-1, 0} };
int m, n;
void spreadFire(queue<int> &fire, vector<vector<int>> &grid, vector<bool> &isFire)
{
int sz = fire.size();
for (int idx = 0; idx < sz; ++idx)
{
auto pos = fire.front();
fire.pop();
for (int i = 0; i < 4; ++i)
{
int x = pos / m + dirs[i][0], y = pos % m + dirs[i][1];
if (x >= 0 && x < n && y >= 0 && y < m && !isFire[x * m + y] && grid[x][y] != 2)
fire.push(x * m + y), isFire[x * m + y] = true;
}
}
}
bool check(vector<vector<int>> &grid, int time, queue<int> &fire, vector<bool> &isFire)
{
while (time-- && !fire.empty())
spreadFire(fire, grid, isFire);
if (isFire[0])
return false;
vector<bool> visit(n * m, false);
visit[0] = true;
queue<int> people;
people.push(0);
while (!people.empty())
{
int sz = people.size();
for (int idx = 0; idx < sz; ++idx)
{
auto pos = people.front();
people.pop();
if (!isFire[pos])
{
for (int i = 0; i < 4; ++i)
{
int x = pos / m + dirs[i][0], y = pos % m + dirs[i][1];
if (x >= 0 && x < n && y >= 0 && y < m && !isFire[x * m + y] && grid[x][y] != 2 && !visit[x * m + y])
{
if (x == n - 1 && y == m - 1)
return true;
else
people.push(x * m + y), visit[x * m + y] = true;
}
}
}
}
spreadFire(fire, grid, isFire);
}
return false;
}
int maximumMinutes(vector<vector<int>> &grid)
{
n = grid.size(), m = grid[0].size();
int left = 0, right = n * m;
// init
vector<bool> initIsFire(n * m, false);
queue<int> initFire;
for (int row = 0; row < n; ++row)
for (int col = 0; col < m; ++col)
if (grid[row][col] == 1)
initIsFire[row * m + col] = true, initFire.push(row * m + col);
while (left <= right)
{
int mid = left + (right - left) / 2;
auto fire = initFire;
auto isFire = initIsFire;
if (check(grid, mid, fire, isFire))
left = mid + 1;
else
right = mid - 1;
}
return right == m * n ? 1e9 : right;
}
};
双端队列 BFS( 0-1 BFS )
起因, Leetcode 2290. 到达角落需要移除障碍物的最小数目, 一道典型例题. 适用于一个图中, 边权值为 0 或 value
的最短路径问题.
主要思想是使用双端队列 deque
替换传统队列, 权值为 0 的边入队首, 其他权值的边入队尾. 可以将该算法看作是从 BFS 至 Dijkstra 的一份过渡算法. 即, 适用范围从 无权图->无权混单一权重图->非负权图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
vector<int> bfs0_1(vector<vector<int>> &G, int start)
{
// 记录起点至其他结点最小路径大小
vector<int> dis(G.size(), 0);
deque<int> q;
q.push_back(start);
dis[start] = 0;
while (!q.empty())
{
auto curNode = q.front();
q.pop_front();
for (auto &child : G[curNode])
{
// relax dis
if (dis[curNode] + weight(curNode, child) < dis[child])
{
dis[child] = dis[curNode] + weight(curNode, child);
if (weight(curNode, child) == 0)
q.push_front(child);
else
q.push_back(child);
}
}
}
return dis;
}
说到底, 0-1 BFS 已经更偏向于 Dijkstra 了(因为遍历深度已经不代表路径长度, 路径长度和边权有关了), 针对一条边已经需要多次松弛最短路. 但同时, 它又保有 BFS 的一定特征, 即, 队首插入可以使得遍历优先走最短路, 这使得原本偏向暴力 Dijkstra 的时间复杂度获得了优化
2290的题解如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
static constexpr int dirs[4][2] = { {0, 1}, {0, -1}, {1, 0}, {-1, 0} };
int minimumObstacles(vector<vector<int>>& grid)
{
vector<vector<int>> dist(grid.size(), vector<int>(grid[0].size(), 0x3f3f3f3f));
deque<pair<int,int>> q;
q.emplace_back(0, 0);
dist[0][0] = 0;
while(!q.empty())
{
auto [x, y] = q.front();
q.pop_front();
for(auto &[dx, dy] : dirs)
{
int nextX = x + dx, nextY = y + dy;
if(nextX >= 0 && nextX < grid.size() && nextY >= 0 && nextY < grid[0].size())
{
if(dist[x][y] + grid[nextX][nextY] < dist[nextX][nextY])
{
dist[nextX][nextY] = dist[x][y] + grid[nextX][nextY];
if(grid[nextX][nextY])
q.emplace_back(nextX, nextY);
else
q.emplace_front(nextX, nextY);
}
}
}
}
return dist[grid.size() - 1][grid[0].size() - 1];
}
};
优先队列 BFS
如果将原始 BFS 的普通队列换成优先队列, 我们可以按照一定优先度遍历有权重的点(边的仍然无权), 而这就和 Dijkstra 中的优先队列优化版本是一模一样的.
Dijkstra
Dijkstra 解决正权重有向图中的单源最短路径问题, 主要思路可以通过以下伪代码阐述
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void dijkstra(vector<vector<int>> &G, int s)
{
set<int> S, Q;
// Q 初始化为图中所有顶点
initial(Q, G);
while(!Q.empty())
{
// Q 中最小权重的结点
int u = minNode(Q);
S.insert(u);
// 对最小权重结点, 松弛其邻接点
for(int &child : G[u])
relax(u, child, G)
}
}
上述的松弛操作指的是更新两点的最短距离, 将原有预估值(INT_MAX)松弛至最终值(最短路径)
暴力法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
vector<int> dijkstra(vector<vector<pair<int, int>>> &G, int s)
{
int n = G.size();
vector<int> dis(n, INT_MAX);
vector<bool> visit(n, false);
dis[s] = 0;
for(int i = 0; i < n; ++i)
{
// min weight in Q
int u = n, minWeight = INT_MAX
for(int j = 0; j < n; ++j)
if(!visit[j] && dis[j] < minWeight)
u = j, minWeight = dis[j];
// Q -> S
visit[u] = true;
// relax
for(auto next : G[u])
if(dis[next.first] > dis[u] + next.second)
dis[next.first] = dis[u] + next.second;
}
return dis;
}
要小心
dis[u] + next.second
这个如果真的用 INT_MAX 初始化的话, 可能会爆 INT. 可以初始化为0x3f3f3f3f 来近似一个无限大
遍历整个图的每个顶点, 时间复杂度 $O(V)$, 找最小元素 $O(V)$, 遍历点的总时间复杂度 $O(V^2)$. 我们对每条边都进行了遍历(松弛), 时间复杂度 $O(E)$, 因此暴力 dijkstra 的总时间复杂度为 $O(V^2+E)$
优先队列 Dijkstra
找最小结点时通过优先队列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
vector<int> dijkstra(vector<vector<pair<int, int>>> &G, int s)
{
int n = G.size();
auto cmp = [&](const pair<int, int> &left, const pair<int, int> &right)
{ return left.first < right.first; };
priority_queue<pair<int, int>, vector<pair<int, int>>, decltype(cmp)> pq(cmp);
vector<int> dis(n, INT_MAX);
dis[s] = 0;
vector<bool> visit(n, false);
pq.emplace(s, 0);
while(!pq.empty())
{
auto u = pq.front();
pq.pop();
if(visit[u.first])
continue;
visit[u.first] = true;
for(auto next : G[u.first])
if(dis[next.first] > dis[u] + next.second)
dis[next.first] = dis[u] + next.second, pq.emplace(next.first, dis[next.first]);
}
return dis;
}
每条边都会遍历到以保证正确松弛得到最小路径(多次松弛), 因此最外层遍历时间复杂度 $O(E)$, 针对优先队列的 push/pop , 时间复杂度为 $O(\log{E})$, 总时间复杂度为 $O(E\log{E})$
除此之外, 还有通过二叉堆或斐波那契堆的方法优化, 目前用不上就不管了