dfs bfs基础算法

图 搜索

前言

本文章还是耗费了我不少心血,非常认真地整理。大致分两部分,首先是介绍建图和搜索部分,已经知道如何建图的可以直接看dfs bfs搜索部分。这两个搜索在算法中是比较基础但非常实用且用途广泛,并不局限于图,好多更高阶的算法也不少基于这两种搜索的延申。我尽量用通俗易懂的话去解释实现过程,相信如果同学们能够坚持下来读下来,一定会豁然开朗!当然,了解思想后,还需要做题来巩固记忆和理解。
我也有个关于这个的B站视频如果有兴趣的看一下
图和dfs

图的基础

该部分图的基础 加 建图方式部分在新文章
文章

搜索

简称全称中文
DFSDepth-first search深度优先搜索
BFSBreadth-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队列。
  • 每次的操作是重复的,那就是用循环。
  • 循环结束的条件:队列为空。

BFS动态演示

  • 伪代码:
放入起点到队列里。
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
  1. 图的遍历

  2. 二维平面问题
    要点:

    • 利用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];//返回从该点出发能够走到的最远距离
    }
    
  3. 枚举问题
    一般就是利用回溯的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
  1. 图的遍历
  2. 二维平面问题 绝大部分四向、八向迷宫的最短路问题

简单题单
做完的可以做kuangbin简单搜索专题

以上所讲的知识,也只是图论和搜索里面的入门知识,还需要自己的多加努力,做题巩固并进一步学习更加高深的知识。相信大家终会理解,并有所成就。

参考资料
  1. https://blog.csdn.net/WhereIsHeroFrom/article/details/111407529
  2. https://shentuzhigang.blog.csdn.net/article/details/82959089
  3. https://blog.csdn.net/weixin_41385912/article/details/105549124
  4. https://blog.csdn.net/weixin_40953222/article/details/80544928
  5. https://www.bilibili.com/video/BV1pE411E7RV?p=9
  6. https://www.cnblogs.com/wzl19981116/p/9397203.html
  • 4
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值