问题1: 什么是搜索?
搜索,是一个动态的,收集信息,分析信息,保存信息的循环过程。在循环的过程中,我们根据已知的信息,对探索方向进行调整。根据选择探索方向的策略,我们将搜索大致划分为“广度优先搜索”(Breadth-First Search,简称BFS)和“深度优先搜索”(Depth-First Search,简称DFS),而本文主要介绍关于广度优先搜索(BFS)的相关知识和刷题总结。
问题2:什么是广度优先搜索?
广度优先搜索,是搜索的一种策略,我们从某一个位置出发,仅向当前所能到达的位置迈进,之后我们再从这些可以到达的位置出发,继续向下一个能到达的位置迈进。这样“由一个点向周围一圈一圈地扩散搜索”的过程,宛如一颗石头砸入一片平静的湖水,激起一圈一圈向外扩散的波浪一般,波浪所及之处,就是我们到达之处,因此得名“广度”。生活当中,我们也经常能“应用”广度优先搜索。比如我们想要找到某个人,必定先询问我们周围的人是否有认识ta的人存在,然后周围的人再继续问他们周围的人是否有认识ta的人存在…这样从身边开始一圈一圈向外扩散地寻找答案,正是广度优先搜索。
更为书面地讲,广度优先搜索的过程,就是判断哪些位置可以迈进,并记录下来作为下次出发的候选位置,并在此过程中判断是否找到了答案的过程。
问题3:为什么需要广度优先搜索?
我们来想象一个场景:我们想找一个人,且除了问朋友没有别的手段(排除查户口,查社交账号,查监控摄像头等手段),如何在最短的时间内找到ta呢?答案是:我先问一圈自己的朋友,看看有没有认识这个人的,如果没有,那就拜托我的朋友们再去问问他们的朋友,并且我会告诉我的朋友我已经问过哪些人了,这样我的朋友们就不会再彼此询问浪费时间了。人多力量大,几轮询问下来,很快我们就能知道结果,要么是找到了这个人,要么就是大家都不认识这个人。这样的搜索方式,肯定比我一个接一个朋友的“击鼓传花”式的寻找要快的多。而这,就是广度优先搜索的优势,它能从一个点开始以最快的速度向外扩散,且到达的区域会越来越广,获得的信息量也越来越多,就更容易找到问题的答案。
书面地讲,广度优先搜索解决的是“无权图寻找中最短路径”问题。所谓无权图,即边与边之间没有长短的区别,只包含两个点之间是否相连的信息的图。因为搜索策略的特性,只有我们到达了距离起点为d的位置,才能到达距离起点为d+1的位置,而d+1一定是距离d最近的位置。根据“两点之间,线段最短”,所有距离最短的线段组合起来,就是某两点之间的最短路径,所以BFS很适合解决一些“最值/极值”问题,比如:最小距离,最大距离,最少操作数等。
问题4:如何实现广度优先搜索?
我们再回想上面说的“最少询问圈数内找到人”的过程(所谓圈数,就是间接询问的次数,我们询问自己身边的朋友统一算一圈,朋友问他们身边的朋友算第二圈,以此类推):
- 首先,我们要问一圈自己的朋友,因此,我们需要一个“笔记本”来记录我们都问过谁了;
- 其次,我们需要让朋友们去问他们的朋友,如果我们把自己的朋友和他们的朋友都记在同一个“笔记本”里,难免会忘记谁是我们直接询问的,谁是朋友帮我们询问的。当然了,以人类的智慧,这种事情还不至于记不住,大不了在名字旁边写个标记也行,但计算机毕竟没有智力,只能处理我们给它的信息,因此,我们需要想办法把我们自己询问的,和朋友询问的,以及之后朋友的朋友询问的…区分开。
对于第一个需求,我们可以使用一个哈希表或者数组,来记录谁被问过,谁没被问过。
对于第二个需求,我们可以用一种数据结构来实现——队列。
现实生活中的队列就是一群人排成一排,然后最前头的人优先办理业务,结束后离开,下一个最前头的人接着办业务。而算法中的队列也是如此,它只能从末尾添加数据,从队首取出数据。
我们将自己的朋友加入队列,并记录一个人数size,我们每次都从队列的最前端拉出一个人询问ta知不知道我们要找的人,此时队列里少了一个人,因此size减1。如果被我们拉出来的这个人知道我们要找的人在哪儿,那就停止搜索;如果ta不知道,那我们就让ta去询问ta的朋友们,并把ta的朋友们加入队列的末尾(注意,这里我们会告诉ta,我们已经问过谁了,所以如果ta的朋友中那些已经被问过的人将不被加入队列)。一直重复这个过程,直至size变成0。当size变成0,意味着我们直接询问的朋友们已经都检查光了,剩下的都是朋友帮忙询问的,因此我们询问的圈数+1。循环搜索,直到我们找到那个要找的人,或者没有更多的可以询问的朋友为止。需要注意的是,在我们将某个人加入队列时,一定要立刻把ta的名字记录到笔记中,避免其他人也是ta的好友,导致将ta反复加入队列。
伪代码实现:
unordered_set<int> hash; //用来记录谁问过谁没问过的笔记本(哈希表)
queue<int> q; //用来保存接下来需要询问的人的队列
q.push(me); //将我自己添加到队列中,因为需要从我开始向身边询问
hash.insert(me); //将我自己添加到笔记本中,证明我自己已经问过了,让朋友的朋友们不要跑来问我
int round = 0; //圈数
while(!q.empty()){ //当队列中还有人可以询问的时候,继续询问
int size = q.size(); //当前这一轮需要询问的人数
while(size--){
auto person = q.front(); //从队列开头拉出一个人
q.pop(); //队列少了一个人
if(person==target) return round; //如果这个人就是我们要找的目标,则返回圈数
for(auto& friend: person.friends){ //如果不是,则拜托他询问他的朋友们
if(hash.find(friend)!=hash.end()) continue; //如果朋友的朋友已经被问过了,则跳过
hash.insert(friend); //如果没有被问过,就加入笔记本,这一点非常重要!!!
q.push(friend); //同时将那个人加入队列中
}
}
round++; //一轮询问结束,圈数+1
}
return -1;//-1表示我们最终未能找到那个人
5. 广度优先搜索与树
树,是一种数据结构,它包含了一个根节点,以及一些子节点。树,也是一种特殊的图,它是无权无环图。因此,在搜索树的节点时,我们可以使用广度优先搜索来实现搜索过程。因为搜索的过程是一层一层地将节点加入队列,因此在树中的广度优先搜索又被称为“逐层遍历”或“层序遍历”。
代码实现 ( LeetCode102. 二叉树的层序遍历 ):
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
if(!root) return {}; //节点为空,则直接返回
queue<TreeNode*> q; //保存树节点的队列
vector<vector<int>> ans; //答案数组
q.push(root); //将根节点加入队列
while(!q.empty()){ //当队列中还有节点未访问时继续循环
int size = q.size(); //当前层的节点数
vector<int> temp; //用于保存当前层节点数据的数组
while(size--){
TreeNode* current = q.front(); //取出队列最前面的节点
temp.push_back(current->val); //将数据加入temp数组
//如果这个节点存在左子节点,则将左子节点加入队列
if(current->left) q.push(current->left);
//如果这个节点存在右子节点,则将右子节点加入队列
if(current->right) q.push(current->right);
q.pop();
}
ans.push_back(temp); //将当前层的数据加入到答案数组中
}
return ans;
}
};
6. 无权图的广度优先搜索
二维数组就是一个最常见的无权图,我们可以利用广度优先搜索在二维数组构成的无权图中进行搜索。为了实现这个过程,除了广度优先搜索中需要用到的队列和记录是否访问过的数组外,我们还需要明确三个要素:起点,方向,终点:
- 起点,决定了我们开始扩散的位置
- 方向,决定了我们扩散的方向和幅度
- 终点,决定了我们什么时候停止搜索
代码实现 ( LeetCode529. 扫雷游戏 ):
class Solution {
public:
int dx[8] = {0,0,1,-1,1,1,-1,-1}; //X轴的移动幅度
int dy[8] = {1,-1,0,0,1,-1,1,-1}; //Y轴的移动幅度
vector<vector<int>> visit; //用于标记哪些点已经访问了的数组
vector<vector<char>> updateBoard(vector<vector<char>>& board, vector<int>& click) {
int m = board.size(); //棋盘的X轴方向长度
int n = board[0].size(); //棋盘的Y轴方向长度
visit.resize(m,vector<int>(n));
queue<pair<int,int>> q; //保存需要访问节点的坐标的队列
q.emplace(click[0],click[1]); //将起点加入队列
visit[click[0]][click[1]]=1; //将起点标记为已访问
while(!q.empty()){ //当还有未检查的点存在于队列时,继续循环
int size = q.size(); //当前一轮队列的大小
while(size--){
auto [x,y] = q.front(); //取队列最前面的元素
q.pop();
if(board[x][y]=='M'){ //如果这个元素是地雷,则导致爆炸,游戏结束
board[x][y]='X';
return board;
}
int count=0; //如果不是地雷,则开始统计周围是否存在地雷
for(int i=0;i<8;i++){ //检查当前位置的周围八个方向
int nx = x+dx[i];
int ny = y+dy[i];
if(nx>=0&&nx<m&&ny>=0&&ny<n&&!visit[nx][ny]){ //在可访问范围内+尚未被访问
if(board[nx][ny]=='M') count++; //如果是雷,则计数+1
}
}
if(count==0){ //如果计数为零,说明周围没有雷
board[x][y]='B'; //按照游戏规则标记
for(int i=0;i<8;i++){ //并将周围8个方向的位置加入队列,准备检查
int nx = x+dx[i];
int ny = y+dy[i];
if(nx>=0&&nx<m&&ny>=0&&ny<n&&!visit[nx][ny]){
if(board[nx][ny]=='E'){
q.emplace(nx,ny);
visit[nx][ny]=1;
}
}
}
}
else board[x][y] = '0'+count; //如果周围有雷,则按照规则标记
}
}
return board; //最终返回结果
}
};
7. 抽象无权图中的广度优先搜索遍历
当我们遇到一些“最值/极值”问题,又不是用二维数组表示的时候,我们可以尝试将问题转化为求无权图中的最短路径问题。这么说感觉很抽象,但也确实没有办法…我们用下面的两个例子来说明。
例子1: LeetCode279. 完全平方数
题目中,给定一个正整数n,找到若干个完全平方数(比如1,4,9,16,。。。)使得他们的和等于n。求使总和达到n的最少完全平方数个数。比如,n=12,它可以由4,4,4构成,也可以是1,1,1,9构成,但因为4,4,4的完全平方数个数是最少的,因此答案为3。
常见的做法是动态规划,从1开始枚举数字i,然后枚举不超过数字i的完全平方数j,计算和为i的最少完全平方数个数,最后一步一步推到n。在这里,我们提供另一套思路,使用广度优先搜索解决这个问题:
此问题可以抽象成,起点为n,终点为0,需要求从n到0的最短路径(最少的完全平方数个数)的无权图最短路径问题。起点有了,终点有了,那么方向呢?
对于n到0之间的任何一个数字i(节点),它都有不超过 i \sqrt{i} i个完全平方数可以选择。为了找到最少,我们需要将所有选择都试一遍,因此我们枚举不超过i的完全平方数j,并用i减去j,将剩下的部分保存到队列中,进行之后的搜索(注意,加入队列之前,需要先检查剩余的部分是否已经在队列中,如果已经在队列中则直接跳过,不需要重复加入队列)。而这些操作都统一成“一轮操作”,当到达0时,我们返回操作的轮数,就是到达n的最少完全平方数的个数。
代码实现:
class Solution {
public:
int numSquares(int n) {
queue<int> q; //用于保存计算结果的队列
q.push(n); //将起点放入队列
int ans = 0; //轮数初始化为0
vector<int> visit(n+1); //标记数组
visit[n]=1; //标记n为已访问
while(!q.empty()){
int size = q.size();
while(size--){
auto p = q.front(); //从队列最前端取出一个数字
q.pop();
if(p==0) return ans; //如果是0,说明我们已经找到最短路径,直接返回
for(int i=1;i*i<=p;i++){ //枚举不大于p的完全平方数
if(visit[p-i*i]) continue; //如果已经在队列或已访问,直接跳过
q.push(p-i*i); //将剩余部分加入队列
visit[p-i*i]=1; //并标记
}
}
ans++; //没有找到0,操作轮数+1
}
return ans;
}
};
例子2:LeetCode365. 水壶问题
有两个容量分别为 x升 和 y升 的水壶以及无限多的水。请判断能否通过使用这两个水壶,从而可以得到恰好 z升 的水?可以执行的操作有:将一个水壶装满;将一个水壶清空;将一个水壶的水倒入另一个水壶,直至装满或者倒空。
问题分析:首先,当z等于0或者两个壶全装满正好等于z的时候,显然是可以得到恰好z升水的(z=0 or x+y=z)。然后,当两个壶全都装满也无法达到z升水时,显然是不可能得到恰好z升水的(x+y<z)。
当0<z<x+y时,我们可以开始探索,起点为0,终点为z,求从0到z的是否存在路径(广度优先搜索不仅能解决“极值问题”,也可以解决“存在问题”)。
方向:假设当前的总水量为total,我们可以考虑一下几种操作:
- 将x壶灌满,那么只要total+x不超过总容量上限x+y,且之前总水量没有到达过total+x,我们就可以将total+x加入队列
- 同理,我们也可以将y壶灌满,满足不超上限+之前没有到达过total+y,就可以将total+y加入队列
- 将x壶清空,那么只要total-x不会低于0,且之前总水量没有达到过total-x,就可以将total-x加入队列
- 同理,我们也可以将y壶清空,只要保证不低于0且之前没有到达过total-y,既可以将total-y加入队列
也许你会问,那将一个壶里得水倒入另一个壶的操作呢?其实,这个操作是毫无意义的。
- 因为任何时刻,两个壶的状态只有双满,双空,一满一空,不存在两个同时都不满的情况,这是由题目给的操作造成的。
- 如果对一个不满的桶加水:若另一个桶为空,那么相当于灌满这个不满的桶;如果另一个桶为满,那么相当于将两个桶都灌满
- 如果将一个不满的桶的水倒掉:若另一个桶为空,那么相当于回到初始状态;如果另一个桶为满,相当于开始的时候就把另一个桶灌满…
代码实现:
class Solution {
public:
bool canMeasureWater(int x, int y, int z) {
if(z==0||x+y==z) return true;
if(z<0||x+y<z) return false;
queue<int> q;
vector<int> visit(x+y+1,0);
q.push(0);
while(!q.empty()){
int total = q.front();
q.pop();
if(total+x<=x+y&&!visit[total+x]) q.push(total+x),visit[total+x]=1;
if(total+y<=x+y&&!visit[total+y]) q.push(total+y),visit[total+y]=1;
if(total-x>=0&&!visit[total-x]) q.push(total-x),visit[total-x]=1;
if(total-y>=0&&!visit[total-y]) q.push(total-y),visit[total-y]=1;
if(visit[z]) return true;
}
return false;
}
};
8. 拓扑排序与广度优先搜索
拓扑排序(Topological Sorting)是一种应用在“有向无环图”上,给出节点输出先后顺序的算法。拓扑忽略了节点的大小关系,而是给出节点的先后顺序。拓扑排序存在的条件为:
- 每个节点输出且仅输出一次;
- 在有向无环图中,如果存在一条从u到v的路径,那么再拓扑排序的结果中,u必须在v之前(未必相邻,但必须保证先后)
拓扑排序可以用来检测有向图是否存在环,以及得到节点的拓扑排序。经典的应用是:课程安排和任务安排。比如,在学习某个课程A之前,我们要求学生必须先掌握另外一些课程B和C的知识。那么拓扑序就是BCA或者CBA,表示先学习B和C,再学习A。假如存在环,比如学习A必须学习B,学习B之前必须学习C,学习C之前必须学习A,那么就不存在拓扑序,因为环的存在,无法确定谁在谁的前面。
而拓扑排序是可以通过广度优先搜索来实现的,步骤如下:
- 首先需要构建图:包括但不限于使用哈希表,邻接表,邻接矩阵,保存每个节点的连接情况
- 构建图的过程中,我们需要记录每个节点的入度(和出度,入度最常用,出度不是很常用)。所谓入度,就是指向自己的边的条数,而出度就是从自己指向别的节点的边的边数
- 检查每个节点的入度,当某个节点入度为0时,说明它没有前驱节点,因此可以作为我们探索的起点,将它加入队列
- 依照广度优先搜索对队列进行处理,每次从队列中取出一个点,这个点一定是满足拓扑序的,记录到答案中,同时将与它连接的所有的点的入度都减一,因为我们把这个点加入答案后,要将这个点删除,所以需要消除它对其他点的影响。如果有节点在入度减一后入度变成了0,说明它也可以作为下一轮搜索的起点,加入队列
- 搜索完毕后,检查答案中节点的数量与全图节点数量是否一致,如果一致,说明拓扑序完成;如果不一致,说明存在环
例子:LeetCode310. 最小高度树
题目大意:有一个有n个节点的树,可以任选其中一个节点称为根,求使得树的高度最小的根的标签列表。
问题分析:一个朴素的想法就是,对每个点都遍历一次全树,记录最低高度,然后将达到最低高度的节点都加入答案中。然而节点量为 2 × 1 0 4 2 \times 10^{4} 2×104个,时间复杂度为O(N^2)的算法显然会超时。因此必须要想别的办法
思路:虽然暴力不可取,但是在上面的尝试中,我们会发现一个规律,那就是越是靠近中间的节点,构成的树的高度越低,因此,我们可以反向拓扑,从所有入度为1的节点(也就是边缘节点)开始向内拓扑,并保存每一层的节点到数组中,当遍历结束后,返回最后一层的节点,就是答案
class Solution {
public:
vector<int> findMinHeightTrees(int n, vector<vector<int>>& edges) {
vector<vector<int>> adj(n);
vector<int> outdegrees(n);
for(auto& e: edges){
adj[e[0]].push_back(e[1]);
adj[e[1]].push_back(e[0]);
outdegrees[e[0]]++;
outdegrees[e[1]]++;
}
queue<int> q;
for(int i=0;i<n;i++){
if(outdegrees[i]<=1) q.push(i);
}
vector<int> ans;
while(q.size()!=n){
int size = q.size();
n-=size;
while(size--){
auto p = q.front();
q.pop();
for(auto& v: adj[p]){
if(outdegrees[v]>1){
outdegrees[v]--;
if(outdegrees[v]==1){
q.push(v);
}
}
}
}
}
while(!q.empty()) ans.push_back(q.front()), q.pop();
return ans;
}
};
9. 双向BFS
双向BFS可以应用于这样的特殊场景:无向图,且有明确的搜索终点。朴素的BFS是从起点出发,逐层遍历,最后到达终点,而双向BFS则是从起点和终点同时出发,交替逐层遍历。下图中,蓝色部分为单向BFS探索的区域范围,绿色为双向BFS探索的区域范围,显然比蓝色面积小,因此双向BFS的效率比单向BFS更高。
例子:LeetCode127. 单词接龙
题目大意:给定一个起始单词和一个终点单词,以及一本字典,每次可以修改单词的一个位置的字符,但修改后得到的单词也必须是字典中的单词。求最少需要操作几次才能从起始单词变成终点单词。
思路:
单向BFS,我们以起始单词为起点,每次修改它的一个位置,然后检查是否在字典内,如果存在,且之前没有加入过队列,则加入队列,否则继续尝试修改成别的字符或者别的位置。
双向BFS,我们将起始单词和终点单词分别加入两个不同的队列,然后交替做单向BFS,检查修改后的单词是否在字典中以及是否在对方的笔记里出现过,如果出现过,那么就找到了起点到终点的最短路径,返回路径长度即可
代码实现
class Solution {
public:
int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
unordered_set<string> hash;
for(auto& w: wordList){
hash.insert(w);
}
if(!hash.count(endWord)) return 0;
queue<string> start;
queue<string> end;
start.push(beginWord);
end.push(endWord);
unordered_set<string> seen_start;
unordered_set<string> seen_end;
seen_start.insert(beginWord);
seen_end.insert(endWord);
int step=0;
while(!start.empty()&&!end.empty()){
int size = start.size();
while(size--){
auto p = start.front();
start.pop();
int n = p.size();
for(int i=0;i<n;i++){
char ch = p[i];
for(int j=0;j<26;j++){
if(j==ch-'a') continue;
p[i] = 'a'+j;
// cout<<p<<endl;
if(hash.count(p)){
if(seen_end.count(p)) return step+2;
if(!seen_start.count(p)){
seen_start.insert(p);
start.push(p);
}
}
}
p[i]=ch;
}
}
step++;
size=end.size();
while(size--){
auto p = end.front();
end.pop();
int n = p.size();
for(int i=0;i<n;i++){
char ch = p[i];
for(int j=0;j<26;j++){
if(j==ch-'a') continue;
p[i] = 'a'+j;
// cout<<p<<endl;
if(hash.count(p)){
if(seen_start.count(p)) return step+2;
if(!seen_end.count(p)){
seen_end.insert(p);
end.push(p);
}
}
}
p[i]=ch;
}
}
step++;
}
return 0;
}
};
10. 多源BFS
其实我们在拓扑排序中已经接触过多源BFS了,即起点不止一个的单向BFS。在此不再赘述