图 搜索
前言
本文章还是耗费了我不少心血,非常认真地整理。大致分两部分,首先是介绍建图和搜索部分,已经知道如何建图的可以直接看dfs bfs搜索部分。这两个搜索在算法中是比较基础但非常实用且用途广泛,并不局限于图,好多更高阶的算法也不少基于这两种搜索的延申。我尽量用通俗易懂的话去解释实现过程,相信如果同学们能够坚持下来读下来,一定会豁然开朗!当然,了解思想后,还需要做题来巩固记忆和理解。
我也有个关于这个的B站视频如果有兴趣的看一下
图和dfs
图的基础
该部分图的基础 加 建图方式部分在新文章
文章
搜索
简称 | 全称 | 中文 |
---|---|---|
DFS | Depth-first search | 深度优先搜索 |
BFS | Breadth-first search | 广度优先搜索 |
前言
在暴力枚举的基础上引出了搜索算法,包括深度优先搜索和广度优先搜索,从起点开始,逐渐扩大寻找范围,直到找到需要的答案为止。
严格来说,搜索算法也算是一种暴力枚举策略,但是其算法特性决定了效率比直接的枚举所有答案要高,因为搜索可以跳过一些无效状态,降低问题规模。
小声叨叨大体思想:
图的遍历就是把所有的点都数一遍。然后我们一般会有两种策略,就是我一条路数下去,数完这一条路所有点,后退走其他路;或者数一个点周围直接相连的点,然后数那些点相邻的点。这两种策略分别就是dfs bfs。
dfs 深搜
引入
-
思路:
- 一直往下走,如果走到尽头,就返回到上一个交叉路口选择另一条没走过的路。
- 就是暴力把所有的路径都搜索出来,它运用了回溯,保存这次的位置,深入搜索,都搜索完了便回溯回来,搜下一个位置,直到把所有最深位置都搜一遍
-
举个栗子:
假定数字小的优先。这样顺序是:1 2 4 6 3 5 7 8相应顺序,括号内表示向上返回 1→2→4 (4→2) 2→6 2→6 (6→2→1) 1→3→5 (5→3) 3→7 (7→3→1) 1→7? 不走(7已经走过了) 1→8
相应的,对于下面的树,dfs访问顺序为蓝色标点
-
伪代码:
到达(P点){ for(从P点出发能去的相邻的点Q){ if(没有来过Q点){ 记录Q点到过了 到达(Q点) } } 返回P点前的一步//自动执行 }
-
图的遍历实现
#include<iostream>
#include<vector>
using namespace std;
const int maxN = 10;//根据需要改
int n,m; //n点的个数 m边的个数
int cnt = 0; //记录已经访问过的
bool vis[maxN]; //默认为false
vector<int> G[maxN];
void dfs(int x){
vis[x]=true; //记录操作
cnt++;
cout<<x<<" ";
if(cnt==n) return; // 遍历完所有点,退出
for(int i =0;i < G[x].size();i++){ //G[x]为一个数组
if(!vis[ G[x][i] ]){
dfs(G[x][i]);
}
}
}
int main(){
int x,y;
cin>>n>>m;
for(int i = 0;i<m;i++){
cin>>x>>y;
G[x].push_back(y);
G[y].push_back(x);//有向图的话不能有这句
}
dfs(0);
return 0;
}
/*测试数据1
7 8
0 2
0 4
0 5
1 5
2 3
2 4
3 6
4 5
*/
我们输入测试数据得到结果0 2 3 6 4 5 1
注意:我们是从图的遍历引出的这两种搜索方式。我们要清楚这两个搜索的思想不只是在图的搜索中的策略,也可以在很多非图的情况中用到。这个时候就考验我们从一个状态抽象出来的能力。
dfs搜索回溯
因为dfs本来就是一个搜索到尽头,然后回到前一步的环节,这个环节就是在回溯。其实我们要实现回溯法只需要在做完一个尝试后,取消刚刚的标记,然后去做另一个尝试标记。
就好比我去打几个boss中的一个,我一铁头娃果断试试第一个(标记去过),然后发现打不过,连忙认错:“我错了我错了,你大人不记小人过,就当我没来过”,然后溜了(取消标记),跑去尝试另一个。
说了这些,其实在模板中只需要改一步,就是dfs之后取消标记,相当于标记环节的对称操作。
伪代码:
到达(P点){
for(从P点出发能去的相邻的点Q){
if(没有来过Q点){
记录Q点到过 //回溯就相当于这一步的逆操作
到达(Q点)
取消记录Q到过 //回溯操作
}
}
返回P点前的一步 //自动执行
}
注意:回溯取消标记操作也不是所有的情况都要用,有的时候不需要用。我的这次标记回影响到下一次尝试才需要回溯。比如二维找连通块(连着的一片相同的东西)的操作中,我的每一次尝试都有独特的坐标(x,y)并不影响其他尝试,不需要回溯。
dfs搜索剪枝
搜索的过程可以看作是从树根出发,遍历一棵倒置的树——搜索树的过程。而剪枝,顾名思义,就是通过某种判断,避免一些不必要的遍历过程,形象的说,就是剪去了搜索树中的某些“枝条”,故称剪枝(原话取自1999年OI国家集训队论文《搜索方法中的剪枝优化》(齐鑫))。
之前说过对于搜索是比较暴力的枚举所有情况,其实有的时候走到图的当前节点就可以判断出往下走不可行,我也就没必要往下走(结束函数),就也就是去除这个节点以下的分支。
对于上图,它是一棵利用深度优先搜索遍历的搜索树,可行解(或最优解)位于黄色的叶子结点,那么根结点的最左边的子树完全没有必要搜索(因为不可能出解)。如果我们在搜索的过程中能够清楚地知道哪些子树不可能出解,就没必要往下搜索了,也就是将连接不可能出解的子树的那根“枝条”剪掉,图中红色的叉对应的“枝条”都是可以剪掉的。
好的剪枝可以大大提升程序的运行效率,我们来看需要满足的剪枝原则:
- 正确性
剪枝的前提必须是要正确,不会剪掉正确结果 - 准确性
剪枝要“准”。就是要在保证在正确的前提下,尽可能多得剪枝 - 高效性
剪枝一般是通过一个函数来判断当前搜索空间是否是一个合法空间,在每个结点都会调用到这个函数,所以这个函数的效率很重要
举例:
-
搜索时有可能进行了远远多于正确结果的递归次数,当函数还是可以继续运行,我们在这种情况下会给定一个步数deep用于记录搜索的深度,当深度远超过正常可能,直接退出函数。这是一种比较简单的剪枝。例题:马的遍历(dfs实现)
由于这个层数限制是我们自己预估设定的,并不是一个准确的数,可能在设定的深度内不没有搜索到结果的情况。那程序如果对于当前迭代深度没有解就将深度增加1,知道搜到结果为止。这就是所谓的迭代加深。 -
可行性剪枝:一般是处理可行解的问题
如 N ( N < = 25 ) 根长度不一的木棒,问能否选取其中几根,拼出长度为 K的木棒,具体就是枚举取木棒的过程,每根木棒都有取或不取两种状态,所以总的状态数为 2 25 ,需要进行剪枝。用到的是剩余和不可达剪枝(随便取的名字,即当前 S根木棒取了 S1 根后,剩下的 N − S根木棒的总和,加上 之前取的 S1 根木棒总和如果小于 K,那么必然不满足,没必要继续往下搜索了)。这个问题其实是个01背包,当 N 比较大的时候就是动态规划了。
dfs搜索记忆化
对于经典的斐波那契数列我们可以直接递归实现。
int dfs(unsigned int n) {
if(n <= 1) {
return 1;
}
return dfs(n-1) + dfs(n-2);
}
回过头来想,g(n) 的计算需要用到g(n-1)和g(n−2) ,而 g(n−1) 的计算需要用到 g(n−2) 和 g(n−3),所以我们发现 g(n−2) 被用到了两次,而且每个结点都存在这个问题,这样就使得整个算法的时间复杂度变成指数级了。
int dfs(int x) {
if(x<=1){
g[x] = 1;
return g[x];
}
if(g[x]!=0) return g[x];//记忆化
g[x] = dfs(x-1)+dfs(x-2);
return g[x];
}
为了避免重复的运算,我们可以将每一次的运算结果在数组G中保存下来,下一次再次访问时,直接返回值。这就是记忆化搜索。
这种思想类似动态规划的思想,每次将已经计算出来的状态的值存储到数组或者哈希表中,下次需要的时候直接记录的值,避免重复计算。
bfs 广搜
- 思路:
先访问最近层的节点,访问完最近的层就访问次近的依次类推,直到访问完所有层。也就是一层一层地搜,可以想象成水波的扩散。就是数一个点连着点(朋友),然后数连着的点连着的点(朋友的朋友)
- 还是上面的栗子:下两图等价,环圈起来的是层数。
这样,例子的BFS序列:(假设多个选择,优先选数字小的)
队列内 |
---|
1 |
2 3 7 8 |
3 7 8 4 6 |
7 8 4 6 5 |
8 4 6 5 1? 3? |
4 6 5 |
6 5 |
5 |
访问顺序:12378465
贴上另一张网上动图,相同情况优先遍历小的,感受一下思路。
那怎么实现呢?
- 需要一个容器,这具有先进先出,后进后出的特点,可以联想到FIFO队列。
- 每次的操作是重复的,那就是用循环。
- 循环结束的条件:队列为空。
- 伪代码:
放入起点到队列里。
while(队列不为空){
取出队列一点A
弹出A
for(遍历A点所有相连的点){
把相连且没访问过的点放入队列
}
}
- 图的遍历实现
queue<int> q;
void bfs(int t){
vis[t]=true;
q.push(t);
while(!q.empty()){
int x = q.front();
q.pop();
cout<<x<<" ";
for(int i = 0; i<G[x].size(); i++){
if(!vis[ G[x][i] ]){
q.push(G[x][i]);
vis[G[x][i]] =true;
}
}
}
}
//主函数和测试数据参考前面dfs遍历代码
我们搜索0点,得到结果0 2 4 5 3 1 6
bfs基础应用
a. 最短路
- 绝大部分四向、八向迷宫的最短路问题。就是后面会讲到的二维平面问题。
- bellman-ford最短路的优化算法SPFA,主体是利用BFS实现的。
自行搜索
b. 拓扑排序
首先找入度为0的点入队,弹出元素执行“减度”操作,继续将减完度后入度为0的点入队,循环操作,直到队列为空,经典BFS操作。自行搜索
c. FloodFill
经典洪水灌溉算法。
区分
以上就是dfs bfs的思想,有没有理解呢?总的就是:
dfs 一往无前,递归实现
bfs 先近后远,队列实现
有时候两个都可以用,不过需要其他的东西来记录什么的,各自有各自的优势
图和树
图的遍历是从图中某点出发,然后按照某种方法对图中所有顶点进行访问,且仅访问一次。
前面过,树是一种特殊的图,相对于树,一般图可能成环。
所以说图的遍历相对树而言要更为复杂。因为图中的任意顶点都可能与其他顶点相邻(存在环),所以在图的遍历中需要记录已被访问的顶点,避免重复访问。
如何进行判环操作?
对该点进行搜索,如果一个有向图上的点能够从自身走到自身时,就说明经过该点存在环。
/*color代表每个结点的状态,-1代表还没被访问,0代表正在被访问,1代表访问结束
如果一个状态为“0”的结点,与他相连的结点状态也为0的话就代表有环,这个可以用dfs实现*/
const int N = (int)1e5 + 10;
vector <int> vec[N];
int color[N];
bool dfs(int u) {
color[u] = 0; // 0表示正在访问
for (int v: vec[u]) {
if (color[v] == 0) { //如果正在访问的点又被访问到则代表有环
return false;
} else if (color[v] == -1) { // -1代表还没有访问
if (!dfs(v)) {
return false;
}
}
}
color[u] = 1; // 1代表访问结束
return true;
dfs优点
dfs应用比较广泛,用起来比较简单
如果要搜索全部的解,在记录路径的时候会简单一点,只需要把每一次找的点,放进去答案中就好;相对而言dfs在很多题目可以用上,就是递归思想。
bfs优点
bfs是用来搜索最短径路的解法是比较合适的
比如求最少步数的解,走出迷宫最短路等。因为bfs搜索过程中遇到的第一个解一定是离最初位置最近的,所以遇到第一个解,一定就是最优解,此时搜索算法可以终止。
而如果用dfs,会搜一些其他的位置,需要花相对比较多的时间,需要搜很多次,然后如果找到还不一定是最优解,就比较麻烦
bfs是浪费空间节省时间,dfs是浪费时间节省空间。
应用
dfs
-
图的遍历
-
二维平面问题
要点:- 利用dx[],dy[]这种数组来高效的写4方向、6方向、8方向的移动;
- DFS或BFS可以解决一些判断联通、可达的问题;各自有各自的用处
水洼的大小问题
油田问题
滑雪问题
记忆化的一道二维平面问题,数据不大直接搜索。
需要四方向移动,用dx dy存为
int dx[4] = {0,0,1,-1};
int dy[4] = {1,-1,0,0};
将所有点的能走多远的最大值找出来就可以。就是对于每一个点去搜的能走多远,结果时通过四个方向的点对应的值+1 的最大值得到。
然而这样很多点就会像之前斐波那契数列一样重复求。所以我们找一个二维数组D,初始值为0,每当搜完(x,y)点,将D[x][y]赋值。这样当运行dfs(x,y)时,如果D[x][y]的值不为0,说明已经计算过一次,我们直接返回D[x][y]的值即可。主要代码:
int dfs(int x,int y) { if(D[x][y] != 0) return D[x][y];//记忆化 D[x][y] = 1;//题目结果距离包括了本身1 for(int i = 0; i<4; i++) { //列举四个方向相邻的点的坐标 int xx = x + dx[i]; int yy = y + dy[i]; if(xx<1||yy<1||xx>r||yy>c) continue;//去除越界情况 if(a[x][y]<a[xx][yy]) { //满足单增条件才去搜索 D[x][y] = max(D[x][y],dfs(xx,yy)+1);//最重要 递归调用 同时得到所有情况中的最大值 } } return D[x][y];//返回从该点出发能够走到的最远距离 }
-
枚举问题
一般就是利用回溯的dfs,数据范围较小的的排列、组合的穷举。
要点:- 常用到vis数组做标记
- 灵活掌握递归函数的传参很重要
例题:
全排列问题
给定 n,按字典序输出 1 到 n 的所有全排列;
要求按照字典序排列,就需要保证在运行至从小到大尝试。void dfs(int deep) { // deep参数用来计数,表明本次遍历了多少个结点 if(deep == MAXN) { // 元素个数满足要求MAXN个,输出结果并退出 dfs_print(); //输出结果函数 return; } for(int i = 1; i <= MAXN; i++) { int v = i; if(!vis[v]) { // vis[v]用来判断 v这个元素是否有访问过 // 将结点加入列表 ans[deep] = v; vis[v] = true; dfs(deep+1); // 撤销加入操作(回溯) vis[v] = false; } } }
bfs
- 图的遍历
- 二维平面问题 绝大部分四向、八向迷宫的最短路问题
以上所讲的知识,也只是图论和搜索里面的入门知识,还需要自己的多加努力,做题巩固并进一步学习更加高深的知识。相信大家终会理解,并有所成就。
参考资料
- https://blog.csdn.net/WhereIsHeroFrom/article/details/111407529
- https://shentuzhigang.blog.csdn.net/article/details/82959089
- https://blog.csdn.net/weixin_41385912/article/details/105549124
- https://blog.csdn.net/weixin_40953222/article/details/80544928
- https://www.bilibili.com/video/BV1pE411E7RV?p=9
- https://www.cnblogs.com/wzl19981116/p/9397203.html