本篇文章继续来学习广度优先搜索算法(Broad-First-Search,BFS)
1、本质
广度优先搜索本质上还是遍历整个搜索空间,找到给定问题的解
实际上也是一种暴力搜索算法,不过其中的实现细节和优化细节还是值得探讨的
与深度优先算法略有不同,广度优先搜索是同时推进各搜索路径的(雨露均沾)
下面以图的遍历作为例子,直观上感受下广度优先搜索是一个怎么样的搜索过程
假设初始节点为 1
,目标节点为 9
,则具体遍历步骤如下:
当前节点 | 当前节点的相邻节点 | 已被访问的节点 |
---|---|---|
1 (初始) | 2 、3 、4 | 1 |
2 、3 、4 | 5 、6 | 1 、2 、3 、4 |
5 、6 | 7 、8 、9 | 1 、2 、3 、4 、5 、6 |
7 、8 、9 (目标) | / | 1 、2 、3 、4 、5 、6 、7 、8 、9 |
2、核心
广度优先搜索的实现过程很容易理解,直观上感受就是每次从搜索边界向周围扩散一圈
更具体来说,我们需要维护一组节点,每次取出全部节点对其进行处理,然后将相邻的节点重新加入维护
直至遍历完所有节点、或者到达目标节点、或者满足结束条件时才停止上述过程
对标深度优先搜索,广度优先搜索也有几个核心的概念,分别是:
- 当前节点集合:目前维护的节点集合,是上一步的相邻节点
- 相邻节点集合:当前节点的相邻节点,是下一步的当前节点
- 结束条件:表示遍历到哪些节点停止
- 约束条件:表示哪些节点不可以遍历
对比深度优先搜索,广度优先搜索可以更快地找到抵达目标节点的最短路径
但是作为代价,广度优先搜索具有更高的空间复杂度,因此在使用过程中,需要根据实际情况进行选择
3、框架
广度优先搜索算法可以使用队列实现,大致过程描述如下
首先将初始节点加入队列,然后开始循环,直至队列为空
每次循环从队列取出所有节点并对其处理,然后再将每个节点的相邻节点加入队列
在这个过程中,还需要使用集合来标记已被处理过的元素,避免重复访问
具体的代码框架如下:
def bfs(初始节点):
初始化步数为零
初始化队列为空
初始化集合为空
将初始节点加入队列
将初始节点加入集合
while 队列不为空:
for _ in 队列.长度():
元素 = 队列.出队()
if 元素能满足结束条件:
表示当前元素合法,返回对应结果
if 元素不满足约束条件:
表示当前元素非法,跳过当前元素
for 相邻元素 in 元素.相邻元素():
if 相邻元素不在集合中:
队列.入队(相邻元素)
集合.加入(相邻元素)
步数 += 1
4、例题
(1)二叉树的层序遍历 | leetcode102
给定二叉树的根节点,返回节点值的层序遍历
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
// 特判
vector<vector<int>> ans;
if (!root) {
return ans;
}
// 初始化队列
queue <TreeNode*> q;
q.push(root);
// 每次取出队列中的所有节点进行处理
// 然后把这些节点的左右节点加入队列
// 直至队列为空时结束
while (!q.empty()) {
ans.push_back(vector<int>());
int num = q.size();
while (num-- > 0) {
// 当前元素出队
TreeNode* node = q.front(); q.pop();
// 当前元素加入结果
ans.back().push_back(node -> val);
// 相邻元素入队
if (node -> left ) { q.push(node -> left ); }
if (node -> right) { q.push(node -> right); }
}
}
// 返回结果
return ans;
}
};
(2)二叉树的最小深度 | leetcode111
给定二叉树的根节点,返回最小深度,最小深度是从根节点到最近叶子节点的最短路径上的节点数量
class Solution {
public:
int minDepth(TreeNode* root) {
// 特判
if (!root) {
return 0;
}
// 初始化步数
int step = 1;
// 初始化队列
queue <TreeNode*> q;
q.push(root);
// 每次取出队列中的所有节点进行处理
// 然后把这些节点的左右节点加入队列
while (!q.empty()) {
int num = q.size();
while (num-- > 0) {
// 当前元素出队
TreeNode* node = q.front(); q.pop();
// 如果满足结束条件,即到达叶节点
// 那么返回对应结果
if (
node -> left == nullptr &&
node -> right == nullptr
) {
return step;
}
// 相邻元素入队
if (node -> left ) { q.push(node -> left ); }
if (node -> right) { q.push(node -> right); }
}
// 步数加一
step++;
}
return -1;
}
};
(3)二叉树的右侧视图 | leetcode199
给定二叉树的根节点,想象自己站在树的右侧,按照从顶部到底部的顺序,返回从右侧看到的节点值
class Solution {
public:
vector<int> rightSideView(TreeNode* root) {
// 特判
vector<int> ans;
if (!root) {
return ans;
}
// 初始化队列
queue <TreeNode*> q;
q.push(root);
// 每次取出队列中的所有节点进行处理
// 然后把这些节点的左右节点加入队列
// 直至队列为空时结束
while (!q.empty()) {
int num = q.size();
while (num-- > 0) {
// 当前元素出队
TreeNode* node = q.front(); q.pop();
// 如果当前节点符合要求,即该节点为最右侧节点
// 那么将该节点加入结果
if (num == 0) {
ans.push_back(node -> val);
}
// 相邻元素入队
if (node -> left ) { q.push(node -> left ); }
if (node -> right) { q.push(node -> right); }
}
}
// 返回结果
return ans;
}
};
(4)围绕的区域 | leetcode130
给定一个 m × n
字符矩阵,由字符 X
和 O
组成,找到所有被 X
围绕的 O
,并且将这些 O
转变成 X
基本思路:
- 题目中说,任何边界上的
O
都不会填充为X
,因为这些O
没有被X
所围绕- 换句话说,与边界相连的
O
都继续保持为O
,而其它的O
则会被X
所填充
解题思路:
- 找出边界上的
O
- 找出与上述的
O
相连的O
- 将上述找到的
O
填充为A
- 遍历矩阵,将
O
修改为X
,将A
修改为O
注意:
- 上述的第二步,既可以使用深度优先搜索实现,也可以使用广度优先搜索实现
- 另外,本题可通过直接修改数组作为访问标志,因此无需额外的哈希集合记录
// 广度优先搜索
class Solution {
public:
const int dx[4] = {1, -1, 0, 0};
const int dy[4] = {0, 0, 1, -1};
void bfs(vector<vector<char>>& board, int x, int y, int m, int n) {
// 特判
if (board[x][y] != 'O') {
return;
}
// 初始化队列
queue<pair<int, int>> q;
q.emplace(x, y);
board[x][y] = 'A';
// 依次取出队列中的节点进行处理
// 把这些节点的相邻节点加入队列
// 直至队列为空时结束
while (!q.empty()) {
// 当前元素出队
int ax = q.front().first;
int ay = q.front().second;
q.pop();
// 相邻元素入队
for (int k = 0; k < 4; k++) {
int bx = ax + dx[k];
int by = ay + dy[k];
// 判断相邻元素是否合法
if (
bx < 0 ||
by < 0 ||
bx >= m ||
by >= n ||
board[bx][by] != 'O'
) {
continue;
}
// 如果相邻元素合法,则入队
q.emplace(bx, by);
board[bx][by] = 'A';
}
}
}
void solve(vector<vector<char>>& board) {
// 特判
int m = board.size();
if (m <= 2) {
return;
}
int n = board[0].size();
if (n <= 2) {
return;
}
// 1. 找出边界上的 'O'
// 2. 找出与上述的 'O' 相连的 'O' (广度优先搜索)
// 3. 将上述找到的 'O' 填充为 'A'
for (int i = 1; i <= m - 2; i++) {
bfs(board, i, 0, m, n);
bfs(board, i, n - 1, m, n);
}
for (int j = 0; j <= n - 1; j++) {
bfs(board, 0, j, m, n);
bfs(board, m - 1, j, m, n);
}
// 4. 遍历矩阵,将 'O' 修改为 'X',将 'A' 修改为 'O'
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (board[i][j] == 'O') {
board[i][j] = 'X';
}
if (board[i][j] == 'A') {
board[i][j] = 'O';
}
}
}
}
};
// 深度优先搜索
class Solution {
public:
void dfs(vector<vector<char>>& board, int x, int y, int m, int n) {
if (
x < 0 ||
y < 0 ||
x >= m ||
y >= n ||
board[x][y] != 'O'
) {
return;
}
board[x][y] = 'A';
dfs(board, x + 1, y, m, n);
dfs(board, x - 1, y, m, n);
dfs(board, x, y + 1, m, n);
dfs(board, x, y - 1, m, n);
}
void solve(vector<vector<char>>& board) {
// 特判
int m = board.size();
if (m <= 2) {
return;
}
int n = board[0].size();
if (n <= 2) {
return;
}
// 1. 找出边界上的 'O'
// 2. 找出与上述的 'O' 相连的 'O' (深度优先搜索)
// 3. 将上述找到的 'O' 填充为 'A'
for (int i = 1; i <= m - 2; i++) {
dfs(board, i, 0, m, n);
dfs(board, i, n - 1, m, n);
}
for (int j = 0; j <= n - 1; j++) {
dfs(board, 0, j, m, n);
dfs(board, m - 1, j, m, n);
}
// 4. 遍历矩阵,将 'O' 修改为 'X',将 'A' 修改为 'O'
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (board[i][j] == 'O') {
board[i][j] = 'X';
}
if (board[i][j] == 'A') {
board[i][j] = 'O';
}
}
}
}
};
(5)岛屿的数量 | leetcode200
给定一个 m × n
数字矩阵,由数字 0
和 1
组成,找到所有被 0
围绕的 1
,并统计数量
// 广度优先搜索
class Solution {
public:
const int dx[4] = {1, -1, 0, 0};
const int dy[4] = {0, 0, 1, -1};
void bfs(vector<vector<char>>& grid, int x, int y, int m, int n) {
// 初始化队列
queue<pair<int, int>> q;
q.emplace(x, y);
grid[x][y] = '0';
// 依次取出队列中的节点进行处理
// 把这些节点的相邻节点加入队列
// 直至队列为空时结束
while (!q.empty()) {
// 当前元素出队
int ax = q.front().first;
int ay = q.front().second;
q.pop();
// 相邻元素入队
for (int k = 0; k < 4; k++) {
int bx = ax + dx[k];
int by = ay + dy[k];
// 判断相邻元素是否合法
if (
bx < 0 ||
by < 0 ||
bx >= m ||
by >= n ||
grid[bx][by] != '1'
) {
continue;
}
// 如果相邻元素合法,则入队
q.emplace(bx, by);
grid[bx][by] = '0';
}
}
}
int numIslands(vector<vector<char>>& grid) {
int m = grid.size();
int n = grid[0].size();
// 遍历矩阵
// 发现陆地之后,岛屿数量加一,并使用广度优先搜索将相邻的陆地标记
int ans = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '1') {
ans += 1;
bfs(grid, i, j, m, n);
}
}
}
return ans;
}
};
// 深度优先搜索
class Solution {
public:
void dfs(vector<vector<char>>& grid, int x, int y, int m, int n) {
if (
x < 0 ||
y < 0 ||
x >= m ||
y >= n ||
grid[x][y] != '1'
) {
return;
}
grid[x][y] = '0';
dfs(grid, x + 1, y, m, n);
dfs(grid, x - 1, y, m, n);
dfs(grid, x, y + 1, m, n);
dfs(grid, x, y - 1, m, n);
}
int numIslands(vector<vector<char>>& grid) {
int m = grid.size();
int n = grid[0].size();
// 遍历矩阵
// 发现陆地之后,岛屿数量加一,并使用深度优先搜索将相邻的陆地标记
int ans = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '1') {
ans += 1;
dfs(grid, i, j, m, n);
}
}
}
return ans;
}
};
(6)打开转盘锁 | leetcode752
实际应用场景下的广度优先搜索,本质上是对字符串进行遍历
关键是找到当前节点的相邻节点,以及定义清楚元素要满足的约束条件
class Solution {
public:
int openLock(vector<string>& deadends, string target) {
// 初始化步数
int step = 0;
// 初始化队列
queue <string> q;
q.push("0000");
// 初始化集合
unordered_set <string> deadeds {deadends.begin(), deadends.end()};
unordered_set <string> visited {"0000"};
// 初始化辅助函数
auto addOne = [](char x) -> char {
return (x == '9' ? '0' : x + 1);
};
auto subOne = [](char x) -> char {
return (x == '0' ? '9' : x - 1);
};
// 每次取出队列中的所有节点进行处理
// 然后把这些节点的相邻节点加入队列
// 直至队列为空时结束
while (!q.empty()) {
int num = q.size();
while (num-- > 0) {
// 当前元素出队
string str = q.front(); q.pop();
// 若元素不满足约束条件
// 跳过当前元素
if (deadeds.find(str) != deadeds.end()) {
continue;
}
// 若元素能满足结束条件
// 返回对应结果
if (str == target) {
return step;
}
// 相邻元素入队
char chr;
for (int i = 0; i < 4; i++) {
// 备份
chr = str[i];
// 第 i 个字符加 1 得到相邻元素,如果相邻元素合法,将其加入队列
str[i] = addOne(chr);
if (visited.find(str) == visited.end()) {
q.push(str);
visited.insert(str);
}
// 第 i 个字符减 1 得到相邻元素,如果相邻元素合法,将其加入队列
str[i] = subOne(chr);
if (visited.find(str) == visited.end()) {
q.push(str);
visited.insert(str);
}
// 还原
str[i] = chr;
}
}
// 步数加一
step++;
}
// 遍历完没找到目标
return -1;
}
};
(7)解滑动谜题 | leetcode773
咋一看是二维数组的广度优先搜索,实际上可以将其转化为字符串形式,本质上与例题六相似
解题的关键在于预处理,一是将二维数组转字符串,二是找到找到当前节点的相邻节点
class Solution {
public:
int slidingPuzzle(vector<vector<int>>& board) {
// 预处理
string target = "123450";
int m = board.size();
int n = board[0].size();
string input;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
input += char(board[i][j] + '0');
}
}
vector<vector<int>> neighbors = {{1, 3}, {0, 2, 4}, {1, 5}, {0, 4}, {1, 3, 5}, {2, 4}};
// 初始化步数
int step = 0;
// 初始化队列
queue <string> q;
q.push(input);
// 初始化集合
unordered_set <string> visited;
visited.insert(input);
// 每次取出队列中的所有节点进行处理
// 然后把这些节点的相邻节点加入队列
// 直至队列为空时结束
while (!q.empty()) {
int num = q.size();
while (num-- > 0) {
// 当前元素出队
string str = q.front(); q.pop();
// 若元素能满足结束条件
// 返回对应结果
if (str == target) {
return step;
}
// 相邻元素入队
int x = str.find('0');
for (int y: neighbors[x]) {
// 交换
// 得到相邻元素
swap(str[x], str[y]);
// 如果相邻元素合法
// 将其加入队列
if (visited.find(str) == visited.end()) {
q.push(str);
visited.insert(str);
}
// 还原
swap(str[x], str[y]);
}
}
// 步数加一
step++;
}
// 遍历完没找到目标
return -1;
}
};
(8)单词接龙 | leetcode127
本质上也是字符串的遍历,与例题六相似,只不过增加了遍历的约束
class Solution {
public:
int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
// 初始化步数
int step = 1;
// 初始化队列
queue <string> q;
q.push(beginWord);
// 初始化集合
unordered_set<string> words {wordList.begin(), wordList.end()};
// 每次取出队列中的所有节点进行处理
// 然后把这些节点的相邻节点加入队列
// 直至队列为空时结束
while (!q.empty()) {
int num = q.size();
while (num-- > 0) {
// 当前元素出队
string str = q.front(); q.pop();
// 若元素能满足结束条件
// 返回对应结果
if (str == endWord) {
return step;
}
// 相邻元素入队
char b;
for (int i = 0; i < str.size(); i++) {
// 备份
b = str[i];
// 第 i 个字符修改为 a-z 得到相邻元素,如果相邻元素合法,将其加入队列
for (char c = 'a'; c <= 'z'; c++) {
str[i] = c;
if (words.find(str) != words.end()) {
q.push(str);
words.erase(str);
}
}
// 还原
str[i] = b;
}
}
// 步数加一
step++;
}
// 遍历完没找到目标
return 0;
}
};
5、优化
广度优先搜索还有一个常用的优化方法,那就是双向广度优先搜索
一般的广度优先搜索是从初始节点开始向周围扩散,直至遇到目标节点或满足结束条件时停止
而双向广度优先搜索是从初始节点和目标节点同时开始扩散,直至两者出现交集时停止
根据上面的描述,相信大家也能发现,双向广度优先搜索有其使用限制,那就是必须知道目标节点
如果一开始不知道目标节点,那么就无法使用这个优化方法
为什么双向广度优先搜索更好呢?或者说在什么场景下能够表现更好呢?
随着扩散步数的增加,所维护的节点集合也会随之增大,在这种情况下使用双向广度优先搜索更佳
双向广度优先搜索通过交替扩散初始节点和目标节点以减少维护的节点数量,大家看下图就能理解
实现上,双向广度优先搜索使用两个哈希集合分别维护两个从初始节点和目标节点扩散的节点集合
为什么不再使用队列了呢?这是为了方便判断两个节点集合是否存在交集
而在扩散两个节点集合时,可以选择每次扩散小的集合,而非轮流扩散,这也是一个小的优化策略
这里还是以例题六为例,给出双向广度优先搜索的代码
class Solution {
public:
int openLock(vector<string>& deadends, string target) {
// 初始化步数
int step = 0;
// 初始化队列
// 改为用集合,方便判断是否有交集
unordered_set <string> q1 {"0000"};
unordered_set <string> q2 {target};
// 初始化集合
unordered_set <string> deadeds {deadends.begin(), deadends.end()};
unordered_set <string> visited;
// 初始化辅助函数
auto addOne = [](char x) -> char {
return (x == '9' ? '0' : x + 1);
};
auto subOne = [](char x) -> char {
return (x == '0' ? '9' : x - 1);
};
// 每次取出队列中的所有节点进行处理
// 然后把这些节点的相邻节点加入队列
// 直至队列为空时结束
while (
!q1.empty() &&
!q2.empty()
) {
// 构造临时集合
unordered_set <string> q0;
// 扩散小的集合
if (q1.size() > q2.size()) {
unordered_set <string> q3 = q1;
q1 = q2;
q2 = q3;
}
// 取出当前元素
for (string str: q1) {
// 若元素不满足约束条件
// 跳过当前元素
if (deadeds.find(str) != deadeds.end()) {
continue;
}
// 若元素能满足结束条件
// 返回对应结果
if (q2.find(str) != q2.end()) {
return step;
}
// 更新集合
visited.insert(str);
// 相邻元素入队
char chr;
for (int i = 0; i < 4; i++) {
// 备份
chr = str[i];
// 第 i 个字符加 1 得到相邻元素,如果相邻元素合法,将其加入队列
str[i] = addOne(chr);
if (visited.find(str) == visited.end()) {
q0.insert(str);
}
// 第 i 个字符减 1 得到相邻元素,如果相邻元素合法,将其加入队列
str[i] = subOne(chr);
if (visited.find(str) == visited.end()) {
q0.insert(str);
}
// 还原
str[i] = chr;
}
}
// 步数加一
step++;
}
// 遍历完没找到目标
return -1;
}
};