DFS与BFS算法详解及应用

DFS与BFS

前言

DFS和BFS是搜索的两种基本方法。搜索是暴力算法的具体实现,即将可能的情况全部罗列出来,然后逐一检查,找出答案。

DFS和BFS的相同点:都能找到出口,且都需要暴力搜索所有的路口和道路。

区别:使用BFS能方便的找到最短路径,而使用DFS较困难;使用DFS能搜索到从入口到出口的所有路径,而使用BFS不行;DFS编程相对于BFS较简单。

DFS

基础思想

DFS编程一般用递归实现,写递归代码时,一般需要用记忆化搜索进行优化。

递归的算法思想主要是将大问题逐步缩小,直至成为最小的同类问题,达到最小时,再逐个回答更大的问题,直至解决最终的问题。所以递归有两个过程:递归前进、递归返回(回溯)。

代码模板

ans;  //答案,用全局变量表示
void dfs(层数,其他参数){
   if(出局判断){    //到达最底层,或者满足条件退出
       更新答案;    //答案一般用全局变量表示
       return;    //返回到上一层
   }
   (剪枝)         //在进一步DFS之前剪枝
   for(枚举下一层可能的情况)   //对每一种情况继续DFS
      if(used[i]==0){       //如果状态i没有用过,就可以进入下一层
          used[i]=1;        //标记状态i,表示已经用过,在更底层不能再使用
          dfs(层数+1,其他参数); //下一层
          used[i]=0;         //恢复状态,回溯时,不影响上一层对这个状态的使用
      }
    return;      //返回到上一层
}

在进行DFS时,“保存现场”和“恢复现场”非常重要,这关系到搜索的路径是否正确或重复。

  • “保存现场”的作用是禁止重复使用。当搜索一条从起点到终点的路径时,这条路径上经过的点不能重复经过,否则就会兜圈子,因此需要对路径上的点“保存现场”,禁止再经过它。没有经过的点,或者碰壁后退回的点,都不能保存现场,这些点可能后面会进入当前路径。

  • “恢复现场”的作用是允许重复使用。当重新搜索新路径时,方法是从终点(或碰壁点)沿着原路径逐步退回,每退回一个点,就对这个点“恢复现场”,允许新路径重新经过这个点。

DFS应用

生成排列组合

  • 排列

在某些场景下,系统排列函数next_permutation()并不适用,需要自行编写排列算法。

下面的代码能够按从小到大的顺序输出排列,前提是a[20]中数字是从小到大排列,否则需要先排序。

代码中,用b[]记录一个新的全排列,第一次进入dfs()时,b[0]在n个数中选一个数,第二次进入时,b[1]在剩下的n-1个数中选一个数…用vis[]记录某个数是否已经被选过,被选过的数不能在后面继续被选。

#include<bits/stdc++.h>
using namespace std;
int a[20]={1,2,3,4,5,6,7,8,9,10,11,12,13};
bool vis[20];  //记录第i个数是否用过
int b[20];  //生成的一个全排列
void dfs(int s,int t){
    if(s==t){   //递归结束,产生一个全排列
        for(int i=0;i<t;++i)cout<<b[i]<<" ";  //输出一个排列
        cout<<"; ";
        return;
    }
    for(int i=0;i<t;i++)
       if(!vis[i]){
           vis[i]=true;
           b[s]=a[i];
           dfs(s+1,t);
           vis[i]=false;
       }
}
int main(){
    int n=3
    dfs(0,n);  //前n个数的全排列
    return 0;
}

运行后输出:1 2 3; 1 3 2; 2 1 3; 2 3 1; 3 1 2; 3 2 1;

如果需要输出n个数中任意m个数的排列,例如在4个数中取任意三个数的排列,则将上述代码中21行改为n=4,然后dfs()中7,8行的t改为3即可。

  • 组合

用DFS输出组合。在进行DFS时,选或不选第k个数,就可以实现各种组合。

以三个数{1,2,3}为例:

#include<bits/stdc++.h>
using namespace std;
int a[]={1,2,3,4,5,6,7,8,9,10};
int vis[10];
void dfs(int k){
    if(k==3){
        for(int i=0;i<3;i++)
            if(vis[i])cout<<a[i];
        cout<<"-";
    }
    else {
       vis[k]=0; //不选中第k个数
       dfs(k+1); //继续搜下一个数
       vis[k]=1; //选这个数
       dfs(k+1); //继续搜下一个数
    }
}
int main(){
    dfs(0); //从第一个数开始
    return 0;
}

输出结果:-3-2-23-1-13-12-123-

迷宫问题

先看一道例题:

在这里插入图片描述

本题中某个点上的人,他沿着指示牌一直走,或者最后能走出去,或者没走出去,这种“一路到底”的走法是典型的DFS。

如果本题直接利用DFS求解,复杂度是O(n^4),当n较大时,代码会严重超时。

其实不用对每个点都执行一次dfs,例如从一个点出发,走过一条路径,最后走出了迷宫,那么以这条路径上所有的点为起点,都能走出迷宫;若走这条路径兜圈子了,则走过这条路径上所有的点都不能走出迷宫。因此关键是如何标记整个路径,从而大大减少计算量。优化后代码用solve[][]实现。

...
char mp[n+2][n+2];
bool vis[n+2][n+2];
int solve[n+2][n+2]; //solve[i][j]=1表示这个点能走出去,solve[i][j]=2表示这个点走不出去
int ans=0;
int cnt=0;
bool dfs(int i,int j){
    if(i<0||i>n-1||j<0||j>n-1)return true;
    if(solve[i][j]==1) return true; //点(i,j)已经算过,能走出去
    if(solve[i][j]==2) return false;//点(i,j)已经算过,走不出去
    if(vis[i][j])return false;
    cnt++; //统计DFS了多少次
    vis[i][j]=true;
    if(mp[i][j]=='L'){
        if(dfs(i,j-1)){solve[i][j]=1;return true;}  //回退,记录整条路径都能走出去
        else    {solve[i][j]=2;return false;}  //回退,记录整条路径都走不出去
    }
    if(mp[i][j]=='R'){
        if(dfs(i,j+1)){solve[i][j]=1;return true;}
        else    {solve[i][j]=2;return false;}
    }
    if(mp[i][j]=='U'){
        if(dfs(i-1,j)){solve[i][j]=1;return true;}
        else    {solve[i][j]=2;return false;}
    }
    if(mp[i][j]=='D'){
        if(dfs(i+1,j)){solve[i][j]=1;return true;}
        else    {solve[i][j]=2;return false;}
    }
}
...

由于只需要对迷宫内每个点的solve[][]赋值一次就可以得到答案,所以复杂度是O(n²)。

正则问题

正则表达式又称为规则表达式,通常用来被检索,替换符合某个模式(规则)的文本。

考虑一种简单的正则表达式:

只由 x ( ) | 组成的正则表达式。

小明想求出这个正则表达式能接受的最长字符串的长度。

例如 ((xx|xxx)x|(x|xx))xx 能接受的最长字符串是: xxxxxx,长度是 6。

在本题目中,括号“()”的优先级最高,或操作“()”次之。括号里面是一个整体,操作的两边保留更长的那个。

题目的主体是括号匹配,这是经典的栈的应用,但同时也可以采用DFS(递归)编程,会使解答更简单。

#include<bits/stdc++.h>
using namespace std;
string s;
int pos=0;  //当前的位置
int dfs(){
    int tmp=0,ans=0;
    int len=s.size();
    while(pos<len){
       if(s[pos]=='()') {pos++;tmp+=dfs();}  //左括号,继续递归,相当于入栈
       else if(s[pos]==')') {pos++;break;}   //右括号,递归返回,相当于出栈
       else if(s[pos]=='|') {pos++;ans=max(ans,tmp);tmp=0;}  //检查或操作
       else if(s[pos]=='x') {pos++;tmp++;}    //检查x,并统计x个数
    }
    ans=max(ans,tmp);
    return ans;
}
int main(){
    cin>>s;
    cout<<dfs();
    return 0;
}

BFS

基本思想

BFS的原理是“逐层扩散”,从起点出发按层次先后搜索。编程时,BFS用队列实现。由于BFS的特点是逐层搜索,先搜到的层离起点更近,所以BFS一般用于求解最短路径问题。

  • 从初始状态S开始,利用规则,生成下一层的状态。
  • 顺序检查下一层的所有状态,看是否出现目标状态G。否则就对该层所有状态节点,分别利用规则。生成再下一层的状态节点。
  • 继续按上面的思想生成再下一层所有状态节点,这样一层一层往下展开,直到出现目标状态为止。

按层次的顺序来遍历搜索树。

代码模板

通常用队列(先进先出,first in first out)实现

初始化队列Q.
Q={起点s};标记s为已访问;
while(Q非空){
   取Q队首元素u;
   u出队;
   if(u==目标状态){...}
   所有与u相邻且未被访问的点进入队列;
   标记u为已访问;
}

BFS应用

寻找最短路径

下面是一个用BFS寻找最短路径的过程示例。

在这里插入图片描述

队列的变化过程如下:
在这里插入图片描述

BFS是一种很好地查找最短路径的算法,不过它只适合一种情况:任意相邻两点之间的距离相等,一般把这个距离看成1。在这种情况下,要查找一个起点到一个终点的最短距离,BFS是最优的算法。如果1次走动的距离不是1,那么一条走动次数更多的路径反而可能比有更少走动次数的路径更短,此时就不能用BFS了,而是需要用Dijkstra、SPFA、Floyd等算法。

给出一段用BFS算法求解无权图最短路径的代码:

//定义图的数据结构
vector<int> adj[N]; //存储图中每个节点的相邻节点

//定义队列及访问标记
queue<int> q;
bool visited[N];

//源点s和目标节点t
int s, t;

//BFS求解最短路径
int bfs(int s, int t) {
    memset(visited, false, sizeof(visited)); //初始化所有节点均未访问过
    q.push(s); //将源点s入队
    visited[s] = true; //标记源点s已访问过

    int step = 0; //记录当前的层数,即走过的步数
    while (!q.empty()) {
        int size = q.size(); //当前层的节点数
        for (int i = 0; i < size; i++) {
            int cur = q.front(); //取出队首节点
            q.pop();

            if (cur == t) return step; //如果找到目标节点t,返回最短路径长度

            for (int j = 0; j < adj[cur].size(); j++) {
                int neighbor = adj[cur][j]; //取出相邻节点
                if (!visited[neighbor]) { //如果该节点未被访问过
                    visited[neighbor] = true; //标记已访问
                    q.push(neighbor); //将该节点入队
                }
            }
        }
        step++; //步数加1
    }

    return -1; //未找到最短路径,返回-1
}

其中,N表示图中节点的总数,adj表示存储图中每个节点的相邻节点的数据结构(通常使用邻接表),s表示源点,t表示目标节点。在算法中,我们使用一个队列来存储当前层的所有节点,遍历完当前层后,步数step加1,进入下一层的遍历,直到找到目标节点或者遍历完所有节点。

计算最短路径时存在以下问题:

  • 最短路径有多长:要注意最短路径的长度是唯一的。
  • 最短路径经过了哪些点。由于最短路径可能不止一条,所以题目一般不要求输出路径,如果要求输出,则一般输出字典序最小的那条路径。

连通性判断

连通性判断是图论中的一个简单问题,给定一张图,图由点和连接点的边组成,要求找到图中互相连通的部分。

连通性问题用BFS和DFS都可以解决。不过,如果N较大,用DFS可能会因递归深度太大而出错,此时应该用BFS。

BFS连通性判断步骤:

  1. 从图上任意一个点u开始遍历,把它放进队列中。
  2. 弹出队首u,标记点u已经被搜索过,然后搜索点u的邻居点,即与点u连通的点,将其放到队列中。
  3. 弹出队首,标记为已被搜索过,然后搜索与它连通的邻居点,放进队列。

继续以上步骤,直到队列为空,此时已经找到一个连通块。其他没有被访问的点,属于另外的连通块,按以上步骤继续处理这些点,最后所有点都被搜索过,所有连通块也都找到了。

比如下面这个例题,给出一个N*M的矩形区域和每 个区域的状态(有/没有石油),如果两个有石油的区域是相邻的(水 平、垂直、斜)则认为这是属于同一 个oil pocket。

求这块矩形区域一共有多少oil pocket。

思路:

对于每个有油区域,找出所有与它同属一个oil pocket的有油区域, 最后计算一共有多少个oil pocket。

怎样去找出所有与它同属一个oil pocket?

BFS:找到一个起点; 从这个点出发,枚举四周寻找有油区域; 顺序从找到的新的区域出发,循环上述过程,直到没有新的区域加 入。

怎样去标志同属一个oil pocket的有油区域 ?

设置一个访问标志代表此区域有没有被包含过,这样的话调用BFS的次数就=oil pocket的数目。

代码框架如下:

Void BFS(int i,int j)
{
     初始化队列Q;
     while(Q不为空)
     {
         取出队首元素u;
         枚举元素u的相邻区域, if (此区域有油)
         {
             入队;访问标记;
         }
     }
}
int main()
{
   …
   枚举所有区域,if (此区域有油&&没有被访问过)
   BFS(…,…)
}

剪枝

BFS、DFS是暴力法的直接实现,能把所有可能的状态都搜索出来。但很多时间可能浪费在了不必要的计算上。

剪枝是一种比喻:把不会产生答案的或不必要的分支剪去。关键在于判断:剪什么枝、在哪里剪。剪枝是搜索常用的优化手段,常常能把指数级的复杂度优化到近似多项式的复杂度。

BFS的主要剪枝技术是判重,如果搜索到某一层出现重复的状态,就剪枝。

DFS剪枝技术较多,总体思路就是“减少搜索状态”。

例如之前的迷宫问题(迷宫内每个点有一个人,问有多少人能走出来)

剪枝:可使用记忆化搜索,如果一个点已被搜索过,就不用再搜索。

还有方格分割问题(把6×6的方格分割成完全相同的两部分,问有多少种分法)

剪枝:从中心点开始分割,注意不能同时分割关于中心点对称的两个点,用到了可行性剪枝。

同样,判断也是剪枝,总之就是尽量减少搜索次数。

总结

DFS算法通常使用栈或递归实现。由于DFS算法在搜索的过程中只需要记录当前路径,因此在空间复杂度上表现优异,但在极端情况下,它可能会进入死循环,无法结束搜索。另外,DFS算法通常没有办法保证找到的解是最优解,因为它只会搜索到第一个解

BFS算法通常使用队列实现。由于BFS算法需要遍历所有节点才能找到最短路径,因此在空间复杂度和时间复杂度上都比DFS算法高。但是BFS算法可以保证找到的解一定是最短的,这是DFS算法无法保证的。

因此,我们在选择算法时需要根据具体问题的特点和要求,权衡时间复杂度和空间复杂度的关系,选择合适的算法。同时,在实现算法时,我们还需要注意边界条件、算法优化等问题,以获得更好的效果。

当今,DFS和BFS算法已经成为计算机科学中最重要的算法之一,得到了广泛的应用。例如,深度学习中的反向传播算法可以看作是一种基于DFS的算法,而在计算机网络中,路由算法的实现也大量地运用了DFS和BFS算法。

此外,由于DFS和BFS算法具有计算时间复杂度低、实现简单等特点,它们也成为了很多算法的基础,如图像处理中的连通性分析算法和人工智能中的搜索算法等等。熟练理解掌握DFS和BFS,能帮助我们更好地深入学习其他领域。

猜你喜欢

转载自blog.csdn.net/m0_61443432/article/details/130015075