搜索之DFS和BFS

本博客原文地址:https://www.cnblogs.com/BobHuang/p/12749804.html
本博客提供的代码仅供参考、查错用,务必不要直接复制粘贴代码在平台上提交,一经发现,严肃处理。
搜索是计算机常用的一种方法,它是利用计算机的高性能来有目的地穷举一个问题,从而求出问题的解。

一、复习枚举

在此之前我们其实已经学习了两种搜索算法,二分和枚举。二分在此不再赘述,我们这里复习下枚举搜索,如经典题目TZOJ5270: 百钱买百鸡,题意为“公鸡五文钱一只,母鸡三文钱一只,小鸡三只一文钱,用n文钱买n只鸡,公鸡、母鸡、小鸡各买多少只”。
我们可以列方程求解。设x为公鸡只数,y为母鸡只数,z为小鸡只数。

\[\left\{ \begin{array}{l} 5x+3y+\frac{1}{3}z=n \\ x+y+z=n \end{array} \right. \]

没有学过方程的话,可以这样理解:公鸡的单价*公鸡的只数+母鸡的单价*母鸡的只数+小鸡的单价*小鸡的只数=总价n ,公鸡的只数+母鸡的只数+小鸡的只数=总只数n。方程是数学式子的一个符号化表达,能帮我们更清楚的描述问题。
这是一个三元(未知数)一次(最高次数为1,没有出现二次方、三次方等等)方程,可能存在多组解(答案)或者无解。是不是有点像TZOJ1499: 鸡兔同笼问题,鸡和鸭共有n只,且脚数为m。鸡兔同笼是二元一次方程,且鸡兔同笼一定存在解(答案),但是不一定存在自然数(0和正整数)解。(这个解与《线性代数》里的线性方程组的解有关)
这个问题我们可以怎么解决呢,因为n比较小,我们可以设置三重循环,第一重循环为公鸡只数x,第二重为母鸡只数y,第三重为小鸡只数z。最少是0只,循环应该从0开始,但是他要怎么才能结束循环呢,我们应该考虑最多只数,公鸡最多n/5只,母鸡最多n/3,小鸡最多n*3。由于不能有余数,小鸡循环的必须是3的倍数,我们可以在小鸡的循环里对他每次+3。但是由于需要先求出正整数解的个数来判断是否存在,我们可以先让他进行一次三重循环进行统计,然后再进行一次三重循环进行输出。所以我们可以写出如下代码:

#include <bits/stdc++.h>
using namespace std;

int main()
{
    int n;
    //多组输入
    while (cin >> n)
    {
        //用来统计解的个数
        int num = 0;
        //可能没有公鸡,[0,n/5](表示0<=i<=n/5)
        for (int i = 0;i<=n/5;i++)
        {
            //母鸡[0,n/3]
            for(int j=0;j<=n/3;j++)
            {
                //小鸡[0,n*3],每次+3,保证是3的倍数
                for(int k=0;k<=n*3;k+=3)
                {
                    //只数满足&&钱也满足(k可以被整除)
                    if(i+j+k==n&&i*5+j*3+k/3==n)
                    {
                        num++;
                    }
                }
            }
        }
        //如果解不存在
        if(num==0)
        {
            cout<<"No Answer\n";
            //当前循环不再执行
            continue;
        }
        //重新再来一次进行答案输出
        for (int i = 0;i<=n/5;i++)
        {
            for(int j=0;j<=n/3;j++)
            {
                for(int k=0;k<=n*3;k+=3)
                {
                    if(i+j+k==n&&i*5+j*3+k/3==n)
                    {
                        cout<<i<<" "<<j<<" "<<k<<"\n";
                    }
                }
            }
        }
    }
}

这样通过循环对所有可能解进行一一验证的方法叫枚举,针对这种求解的过程我们往往会使用这种做法,因为计算机对重复的事情不会厌烦,我们只要循环运行的总次数在规定时间内即可。
当然我们可以对以上代码进行优化,既然我们已经枚举出了公鸡和母鸡的只数,那么小鸡的只数就可以用n-公鸡母鸡之和求得了。由于我们也不需要先输出num,那么插旗子来通过这个题目会更快。

#include <bits/stdc++.h>
using namespace std;

int main()
{
    int n;
    //多组输入
    while (cin >> n)
    {
        //初始化为解不存在,即旗子没有插上
        int flag = 0;
        //可能没有公鸡,[0,n/5](表示0<=i<=n/5)
        for (int i = 0; i <= n / 5; i++)
        {
            //母鸡[0,n/3]
            for (int j = 0; j <= n / 3; j++)
            {
                //小鸡的个数为n-(i+j)
                //钱数为n&&且小鸡个数为3的倍数
                int k = n - (i + j);
                if (i * 5 + j * 3 + k / 3 == n && k % 3 == 0)
                {
                	//满足条件,进行输出
                    cout << i << " " << j << " " << k << "\n";
                    //插上旗子
                    flag = 1;
                }
            }
        }
        //如果旗子没有被插上,即没有解
        if (flag == 0)
        {
            cout << "No Answer\n";
        }
    }
}

二、DFS(深度优先搜索)

百钱买鸡的问题两重循环可以解决,且解我们可以通过循环求得。但是如果需要多重循环呢,如果解的验证比较麻烦呢。我们可以通过搜索来尝试解决下上个题目。

#include <bits/stdc++.h>
using namespace std;
//把n定义在int main上面
int n;
//初始化为解不存在,即旗子没有插上
int flag;
//分别对应之前循环的i和j,即公鸡只数和母鸡只数
void dfs(int i, int j)
{
    //当前的i和j是否有存在的k
    //先算出小鸡个数
    int k = n - (i + j);
    //如果可以满足
    if (i * 5 + j * 3 + k / 3 == n && k % 3 == 0)
    {
        //满足条件,进行输出
        cout << i << " " << j << " " << k << "\n";
        //插上旗子
        flag = 1;
    }
    //如果j到了最大值就给i+1,j从头开始
    if (j == n / 3)
    {
        //不是最后一行,才能让i到下一行,然后j从0开始
        if (i != n / 5)
            dfs(i + 1, 0);
    }
    else
    {
        //j+1进行下一列
        dfs(i, j + 1);
    }
}
int main()
{
    //多组输入
    while (cin >> n)
    {
        //初始化为不存在,即旗子没有插上
        flag = 0;
        //从0开始,且不输出
        dfs(0, 0);
        //如果旗子没有被插上,即没有解
        if (flag == 0)
        {
            cout << "No Answer\n";
        }
    }
}

看起来它仅仅是把我们的循环变成了递归的形式,可是如果我们遇到TZOJ3095: 玉树搜救行动要怎么做呢,题目是这样的,有一个二维矩阵代表现在的现场情况,搜救狗只能上下左右走,不能越过障碍物,去救人,问他最多可以救到的人。我们可以搜索他可以走到的点,和走迷宫一样,你可以选择四个方向进行走,走不过去要走回来。如果用深度优先搜索来解决这个问题,我们是怎么进行搜索的呢?我们可以从狗开始,让他走四个方向中的一个,然后一直走下去,直到不能走就退回来看看其他方向是否可走。我们再走的过程中需要注意:如果当前点可走,那么我就不用倒回到最后一步,这样这个二维数组的每个点我们可以只访问一次。

#include <bits/stdc++.h>
using namespace std;
//定义成全局变量,这样可以在任何地方访问
//且数组的值全为0,单组可以省去vis数组初始化
int r, c;
char s[105][105];
int vis[105][105];
int ans;
void dfs(int x, int y)
{
    //如果当前步下标越界,停止搜索
    if (x < 0 || y < 0 || x == r || y == c)
        return;
    //已经访问过,或者是墙,也停止搜索
    if (vis[x][y] || s[x][y] == '#')
        return;
    //当前点已经被搜索到
    vis[x][y] = 1;
    //搜索到是人,答案需要+1
    if (s[x][y] == 'p')
        ans++;
    //进行上下左右的搜索
                    dfs(x - 1, y);
    dfs(x, y - 1);                  dfs(x, y + 1);
                    dfs(x + 1, y);
}
int main()
{

    while (cin >> r >> c)
    {
        //如果满足r和c都为0,退出循环
        if (r == 0 && c == 0)
            break;
        //初始化把vis的所有值都清0
        memset(vis, 0, sizeof vis);
        //初始化能救到的人为0
        ans = 0;
        //读入r行
        for (int i = 0; i < r; i++)
        {
            cin >> s[i];
        }
        //对r行c列的每个点进行遍历
        for (int i = 0; i < r; i++)
            for (int j = 0; j < c; j++)
            {
                //如果当前是救援狗,那么他可以继续走
                if (s[i][j] == 'd')
                {
                    //去搜索i行j列这个可达点
                    dfs(i, j);
                }
            }
        //输出答案
        cout << ans << "\n";
    }
    return 0;
}

对于搜图像的题目存在一个定式,我们先用二维数组保存这个图像,之后步骤如下:

  1. 选择其中一个可以开始的点作为起点开始搜索
  2. 判断当前点是否可以走,不能走结束当前搜索
  3. (当前点可能是最终目的地,要输出解,或者结束搜索)
  4. 判断当前点是否已经走过,走过结束当前搜索
  5. 标记当前点已经走过
  6. 根据可以走的方向搜索其他没有访问的点,回到步骤2

我们再来尝试一个题目,TZOJ5948: 排列型枚举:把 1~n 这 n 个整数排成一行后随机打乱顺序,输出所有可能的次序。即全排列,全排列我们可以用搜索去生成。首先我们需要确定我们搜索所用函数的参数。是n个数字,所以我们不能简单的用x、y、z十个变量来代表,我们需要使用数组。所以我们需要建立一个a[10]的数组来存储答案,我们的参数应该有i,代表当前为第i个数字。然后还需要什么呢,够了不需要了,n和a数组我们都可以定义为全局变量。但是1~n我们都只能使用一次,我们可以使用上面的vis数组,可以标记下当前的数字有没有被使用。。、然后我们每次都遍历下十个数字,选择那个没用的,​所以我们可以写出如下代码。

#include <bits/stdc++.h>
using namespace std;
//开全局变量,避免传递参数
//且数组的值全为0,省去vis数组初始化
int n,a[10],vis[11];
void f(int i)
{
    //[0,n)已经n个
    if (i == n)
    {
        //进行输出
        for (int i = 0; i < n - 1; i++)
            cout<<a[i]<<" ";
        //最后一个特殊输出
        cout<<a[n - 1]<<"\n";
    }
    //传递的参数是i,这里循环变量可以使用j
    for (int j = 1; j <= n; j++)
    {
        //当前这个数字没有填写
        if (vis[j] == 0)
        {
            //把它填上
            a[i] = j;
            //进行标记
            vis[j] = 1;
            //已经扩充了一个,下一个是i+1
            f(i+1);
            //清除标记
            vis[j] = 0;
        }
    }
}
int main()
{
    cin>>n;
    //从第0个开始
    f(0);
}

在枚举全排列的过程中有一个很明显的回溯过程,即这次这个数字用过之后,下次搜索是可以继续使用的。我们需要在搜索的前后进行标记,在搜索前进行标记,让之后不再选上;在搜索后进行标记,上一搜索进行之后,剩下的如果还没有选择是可以选的。比如3的全排列,先生成了1 2 3,退回来到1,2和3的标记被清除,那么这时候我选择3是可以的,下一次为1和3,且均被标记,所以1 3 2就会被得到,之后回到空重新填写。
接下来我们来看TZOJ6247: 学长的象棋加强版,这个题目为给定马的起点位置和终点位置,问马能不能从起点6步内到达终点。这个和玉树搜救行动是有点类似的,我们可以尝试解决下。先分析两者的不同:

  1. 没有给我们二维图像
  2. 而且我们是有起点和终点的,所以需要3. (当前点可能是最终目的地,要输出解,或者结束搜索) 这个步骤的
  3. 而且他每次可以有八个方向可以跳,我们应该用循环和数组来替代之前的写法
  4. 题目多了一个条件为最小步数,那超过这个步数都return掉,我们还需要记录当前路径下的步数。我们还需要和排列型枚举一样进行回溯,我们不知道之后搜索的路径是否比他小。

相同点呢

  1. 我可以从起点开始搜索,如果到了边界就结束搜索
  2. 访问过不再进行搜索,并标记当前点已经被访问
  3. 可以搜索就搜索很多个方向

那我们就可以实现下这个题目

#include <bits/stdc++.h>
using namespace std;
//定义成全局变量,这样可以在任何地方访问
//且vis数组的值全为0,代表还没访问
int r = 9, c = 10;
int vis[9][10];
int sx, sy, ex, ey;
int ans;
//定义方向数组,8行每行2个数分别对应x和y的变化
//我是从左上顺时针开始的,只要方向不漏即可
int dir[8][2] = {
    -1, -2,
    -2, -1,
    -2, 1,
    -1, 2,
    1, 2,
    2, 1,
    2, -1,
    1, -2};
void dfs(int x, int y, int step)
{
    //直接输出进行debug也是很快的方法
    //cout<<"debug: 当前访问坐标为("<<x<<","<<y<<")\n";
    //如果下一步下标越界,停止搜索
    if (x < 0 || y < 0 || x >= r || y >= c)
        return;
    //若当前步数比答案大,而且已经能到达,停止搜索
    if (step > 6)
        return;
    //如果是目的地,标记答案并退出
    if (x == ex && y == ey)
    {
        //若以前不可达或当前步数比较小,可以进行更新
        if (ans == -1 || step < ans)
            ans = step;
        return;
    }
    //已经访问过停止搜索
    if (vis[x][y])
        return;
    //当前点已经被搜索到
    vis[x][y] = 1;
    //进行8个方向的搜索
    for (int i = 0; i < 8; i++)
    {
        //搜索下一步,步数+1
        dfs(x + dir[i][0], y + dir[i][1],step+1);
    }
    //之后可以继续访问
    vis[x][y] = 0;
}
int main()
{
    //freopen("data1.in", "r", stdin);
    //freopen("data1.out", "w", stdout);
    while (cin >> sx >> sy >> ex >> ey)
    {
        //对ans进行初始化
        ans = -1;
        //对vis数组进行初始化
        memset(vis, 0, sizeof(vis));
        //从(sx,sy)进行搜索
        dfs(sx, sy, 0);
        if (ans != -1)
        {
            cout << "Yes " << ans << "\n";
        }
        else
        {
            cout << "No\n";
        }
    }
    return 0;
}

那如果这个图像变大,且不要求最多6步了呢,我们就需要广度优先了,因为回溯的代价实在太高。

三、BFS(广度优先搜索)

BFS是为了解决最短问题的,当然他还附带解决了一个问题爆栈,程序在运行时空间(栈内存)已经分配好了,但是如果我们不加控制得使用,我们的程序就出现了一个bug,StackOverFlow(栈溢出,它也是一个非常著名的程序员问答网站),我们可以通过手写栈来解决,当然很多题目也可以直接使用BFS。
BFS的步骤和DFS有些类似,但是却不完全一样。

  1. 选择其中一个可以开始的点作为起点开始搜索
  2. 设置两个变量分别表示当前的点和总共需要访问的点数
  3. 把起点放进我们定义好的数组里,并标记已经访问过
  4. 执行循环直到访问完所有的点,执行过程如下:
    1)当前点是否为终点,是终点就结束
    2)对8个方向数组进行遍历得到下一个要访问的位置,判断每个位置是否越界、访问情况,如果不越界且可以被访问表明这个点可以之后访问,那么把它加在最后一个点后面。
    3)进行下一个点

为什么用了这样一个循环呢,这样我们可以把满足条件的点都放在最后面,这就有点像我们排队,先来的人先吃,后到的人后吃,BFS把1分钟到的人全部安排在了一起,只有相同时间(如1分钟)到的人可能存在不公平(也就是方向的次序),1分钟到的人肯定比2分钟到的人先吃上饭。但是深搜是一条路走到黑了,不撞南墙不回头。我们可以根据上面的思路实现下学长的象棋加强版的代码。

#include <bits/stdc++.h>
using namespace std;
//定义成全局变量,这样可以在任何地方访问
//且vis数组的值全为0,代表还没访问
int r = 9, c = 10;
int sx, sy, ex, ey;
int ans;
//定义方向数组,8行每行2个数分别对应x和y的变化
//我是从左上顺时针开始的,只要方向不漏即可
int dir[8][2] = {
    -1, -2,
    -2, -1,
    -2, 1,
    -1, 2,
    1, 2,
    2, 1,
    2, -1,
    1, -2};
//定义一个结构体来存储
struct T
{
    //结构体需要存储当前的坐标(x,y)和步数
    int x, y, step;
};
//最多有90个点可以走
T a[90];
int vis[9][10];
void bfs()
{
    //定义tot代表当前的总个数,cur代表当前进行到哪个了
    int tot = 0, cur = 0;
    //信奥不支持C++11,要一个一个元素进行赋值
    a[tot].x = sx;
    a[tot].y = sy;
    //初始步数为0,tot在执行时为原值,执行后+1
    a[tot++].step = 0;
    //起始点被访问过了
    vis[sx][sy] = 1;
    //直到执行到最后一个
    while (cur < tot)
    {
        //直接输出进行debug也是很快的方法
        //cout<<"debug: 这次访问坐标为("<<a[cur].x<<","<<a[cur].y<<")\n";
        //如果到达了终点
        if (a[cur].x == ex && a[cur].y == ey)
        {
            //保存答案
            ans = a[cur].step;
            return;
        }
        for (int i = 0; i < 8; i++)
        {
            //根绝方向数组得到下一个位置
            int tx = a[cur].x + dir[i][0];
            int ty = a[cur].y + dir[i][1];
            //越界的不考虑
            if (tx < 0 || ty < 0 || tx >= r || ty >= c)
                continue;
            //访问过的不考虑
            if (vis[tx][ty])
                continue;
            //满足把当前点放进去
            a[tot].x = tx;
            a[tot].y = ty;
            //步数为上一个点+1,tot在执行时为原值,执行后+1
            a[tot++].step = a[cur].step + 1;
            //加过之后当前点就被访问过了
            vis[tx][ty]=1;
        }
        //这个点访问过了,访问下一个
        cur++;
    }
}
int main()
{
    //freopen("data1.in", "r", stdin);
    //freopen("data1.out", "w", stdout);
    while (cin >> sx >> sy >> ex >> ey)
    {
        //对ans进行初始化
        ans = -1;
        //对vis数组进行初始化
        memset(vis, 0, sizeof(vis));
        //从(sx,sy)进行搜索
        bfs();
        if (ans >=0 && ans <= 6)
        {
            cout << "Yes " << ans << "\n";
        }
        else
        {
            cout << "No\n";
        }
    }
    return 0;
}

你有没有看出来深搜和广搜的区别呢,我们可以举个例子,比如说面临的是迷宫,深搜会摸一条路走不过去再回来,广搜是看一下周围的格子,这些格子都可以走啊,那我都走一遍,再看一下刚才走的那些格子,把可以走的都走了,广搜存在一个层的概念,所以最先访问到的可以保证是最短的。
我们再来解决一个TZOJ3533: 黑白图像,这个题目是让我们统计八连块的个数,他和玉树搜救行动有点类似,由于整个图700*700,如果全是1,那么所需的栈空间太大超过了系统规定的空间,我们可以用BFS来解决。其实就是把我们DFS的过程改造成BFS。

#include <bits/stdc++.h>
using namespace std;
//定义成全局变量,这样可以在任何地方访问
//且vis数组的值全为0,代表还没访问
int n;
int ans;
//定义一个结构体来存储
struct T
{
    //结构体需要存储当前的坐标(x,y)
    int x, y;
};
//最多有700*700个点可以走
//数组太大,必须全局
T a[700 * 700];
int vis[700][700];
char mat[700][700];
//可以开始的点太多,改成函数,可以传入坐标(x,y)
void bfs(int x, int y)
{
    //定义tot代表当前的总个数,cur代表当前进行到哪个了
    int tot = 0, cur = 0;
    //信奥不支持C++11,要一个一个元素进行赋值
    a[tot].x = x;
    a[tot++].y = y;
    //起始点被访问过了
    vis[x][y] = 1;
    //直到执行到最后一个
    while (cur < tot)
    {
        //直接输出进行debug也是很快的方法
        //cout<<"debug: 这次访问坐标为("<<a[cur].x<<","<<a[cur].y<<")\n";
        //不存在终点问题,我们只负责每次标记有没有访问过,一次标记的为一块
        //这种八个方向其实为[-1,0,1]的组合,可以直接循环
        for (int i = -1; i <= 1; i++)
        {
            for (int j = -1; j <= 1; j++)
            {
                //根绝方向数组得到下一个位置
                int tx = a[cur].x + i;
                int ty = a[cur].y + j;
                //越界的不考虑
                if (tx < 0 || ty < 0 || tx >= n || ty >= n)
                    continue;
                //访问过的或者是0的不能访问
                if (vis[tx][ty] || mat[tx][ty] == '0')
                    continue;
                //满足把当前点放进去
                a[tot].x = tx;
                a[tot++].y = ty;
                //加过之后当前点就被访问过了
                vis[tx][ty] = 1;
            }
        }
        //这个点访问过了,访问下一个
        cur++;
    }
}
int main()
{
    cin >> n;
    //读取n行字符串
    for (int i = 0; i < n; i++)
        cin >> mat[i];
    ans = 0;
    //循环访问二维矩阵每一个点
    for (int i = 0; i < n; i++)
    {
        for (int j = 0; j < n; j++)
        {
            //是黑色的,而且没有被访问过
            if (mat[i][j] == '1' && vis[i][j] == 0)
            {
                bfs(i, j);
                //八连块+1
                ans++;
            }
        }
    }
    cout << ans << "\n";
    return 0;
}

但是为什么n皇后这样的题目还需要DFS回溯呢,不是用BFS呢?因为他没有一个通解,只能靠我们枚举去解决,是一个不断试错的过程,而且通过回溯修改并保存了状态,如果BFS带上这个状态你消耗的无用空间将非常大。如果但是如果面临是最短的问题我们是一定会使用广度优先搜索,或者说你可以看到广度优先搜索的影子。

四、题目练习

这类题目可以说非常之多,很多题目都可以用搜索+剪枝的方法(剪枝技巧在接下来的文章会介绍),比赛中如果题目不会做,我们也可以使用时间复杂度稍差一点的搜索去拿到步骤分,所以这个专题请大家好好练习。入门初期你可以使用我上面的代码进行拼凑修改完成下面的题目以体会这个算法逻辑,有点感觉了包括例题在内的题目请自己动手完成。
1.TZOJ5948: 排列型枚举
2.TZOJ5797: 递归实现指数型枚举
3.TZOJ5798: 组合型枚举
4.TZOJ5720: 组合的输出
5.TZOJ2777: Hero in Maze 简单版
6.TZOJ3305: Hero In Maze II
7.TZOJ2816: 勘探油田
8.TZOJ3095: 玉树搜救行动
9.TZOJ5726: 循环比赛日程表
10.TZOJ5727: 黑白棋子的移动
11.TZOJ3091: CB数列
12.TZOJ3104: 自然数的拆分
13.TZOJ5809: 小猫爬山
14.TZOJ6247: 学长的象棋加强版
15.TZOJ5730: 最少转弯问题
16.TZOJ3128: 简单版贪吃蛇
17.TZOJ5777: 分糖果
18.TZOJ6194: jump and jump
19.TZOJ5761: 最少步数
20.TZOJ5943: 八皇后问题
21.TZOJ4435: n皇后问题
22.TZOJ5164: 2n皇后问题
23.TZOJ1344: 速算24点
24.TZOJ2778: 数据结构练习题――分油问题
25.TZOJ5811: 电路维修
26.TZOJ4893: 生日蛋糕
以下为全英文题面
27.TZOJ3432: Meteor Shower
28.TZOJ3709: Number Maze
29.TZOJ2481: Knight Moves
30.TZOJ4367: Watashi's BG
31.TZOJ2799: Counting Sheep
32.TZOJ1334: Oil Deposits
33.TZOJ1272: Red and Black
34.TZOJ1335: 营救天使
35.TZOJ3031: Multiple
36.TZOJ3834: Space Exploration
37.TZOJ4004: Nikhil's Dungeon

posted @ 2020-04-22 09:23  暴力都不会的蒟蒻  阅读(744)  评论(0编辑  收藏  举报