「双端队列BFS」李白斗酒诗百篇

本题为12月25日22寒假集训每日一题题解

题目来源:(未知)

题面

题目描述

李白斗酒诗百篇,长安市上酒家眠,天子呼来不上船,自称臣是酒中仙。

出自唐代杜甫的《饮中八仙歌》
知章骑马似乘船,眼花落井水底眠。
汝阳三斗始朝天,道逢麴车口流涎,恨不移封向酒泉。
左相日兴费万钱,饮如长鲸吸百川,衔杯乐圣称避贤。
宗之潇洒美少年,举觞白眼望青天,皎如玉树临风前。
苏晋长斋绣佛前,醉中往往爱逃禅。
李白斗酒诗百篇,长安市上酒家眠,(斗酒 一作:一斗)
天子呼来不上船,自称臣是酒中仙。
张旭三杯草圣传,脱帽露顶王公前,挥毫落纸如云烟。
焦遂五斗方卓然,高谈雄辩惊四筵。

且说李白喝了酒,吟完诗,准备回自己的房间睡觉。但已经喝醉酒的他不能转太多的弯。

给你一个N*N(2≤N≤100)的方格中,‘x’表示障碍,‘.’表示没有障碍(可以走),李白可以从一个格子走到他相邻的四个格子,但是不能走出这些格子。问李白从A点到B点最少需要转90度的弯几次。

输入

第一行一个整数:N,下面N 行,每行N 个字符,只出现字符:‘.’,‘x’,‘A’,‘B’;表示上面所说的矩阵格子,字符之间没有空格。

输出

一个整数:最少转弯次数。如果不能到达,输出”zui dao le”。

样例输入

3
.xA
...
Bx.

样例输出

2

提示

开始和结束时的方向任意。


思路分析

本题需要较多的思考,对思维要求比较大,希望各位阅读时多多思考.

本题要求的是转弯数的最小值,在图论中我们常常可求路径的最小值,所以我们尝试把转弯数定义为”路径长”,那么本题题意可以转化为,当沿着同一方向前进时,边权为0,否者边权为1,且起点可以向任意方向,求起点到终点的最短路径.

转化后的此题类似于洛谷上第4554的升级版,我们可以使用同样的思路解决此问题.

第一种思路是直接使用最短路算法,不过要注意朝向会影响边权,所以需要额外对朝向进行记录和处理,感兴趣的可以自己尝试一下,这里就不详细展开说了(因为这类题用双端队列BFS其实更优).

第二种思路就是双端队列BFS.众所周知,BFS(广度优先搜索)是可以用来处理边权均相等的最短路问题的,此处虽然有两个权重,但是我们可以把这些边权为0的边所连接的点合起来看成是同一个点,把它们分别引出的边权为1的边看成是由这个合起来的点引出的一些边权为1的边,这样图上又只有边权为1的边了,就可以使用BFS了.

如何把边权为0的边所连接的点合起来看成是同一个点?这里要说到为什么BFS能够且只能处理边权均相等的最短路问题了.BFS可以看成是一批一批进行搜索的,在搜索当前批的时候同时把下一批的那些点放入了队列当中,当找到终点时只需要看当前是第几批的即可,因为每批之间差的路径长是相等的(边权相等).如果边权不相等,那么每批的路径长是不同的,所以这种方法就失效了.但是,如果存在权重为0的边,由于0边权相当于没有边权,可以看成是当前批的,所以我们就可以通过把边权为0的边所连接的这些点也加入当前批中,来使得BFS依旧适用,这和前面所说的把这些边权为0的边所连接的点合起来看成是同一个点是同一个意思.

普通BFS使用的是普通的队列,此处由于我们需要将新的元素添加到当前批中,因而需要使用双端队列,即头和尾都可以插入和删除的队列(此处不需要在尾部删除,只需要在头尾插入和头部删除即可).当我们搜到边权为0的边时,我们把它所连接的那个点放入队列的头部,即放入队列中当前批的头部(不可能添加到队列中当前批的末尾,因为末尾在队列中间的一个不确定的位置,是无法访问的),和当前批一起处理;而搜到正常边权的边时,正常放入尾部,在下一批中处理即可.这种BFS也称01BFS双端队列BFS,在除了0权边以外的边都是相等权重的图中,其效率可以优于使用 dijkstra.

这题可以直接使用双端队列BFS解决,不过同样的由于朝向会影响边权,也需要额外对朝向进行记录和处理(此处有个小坑,需要特殊处理路径交叉的情况,具体解决方法在下面有写),感兴趣的可以自己尝试一下,这里我们直接对此法继续优化(不会优化代码性能,如果你看不懂下面的内容,直接这么写其实就可以了).

由于我个人做的时候嫌记录朝向太麻烦了(不能用可爱的pair了),于是我就想,能否对此题中的双端队列BFS进行进一步的优化,让其不用记录方向呢?答案是在本题中是可以的.

根据前面的分析,在此题所使用的双端队列BFS中,我们每次都会把同方向的放入双端队列的头部,且显然方向是一维的,一次只会往头部放一个点,且由于是放入头部,所以这个放入的点会在当前点处理完后立刻被连续处理,然后往队列头部放入一下个同样也会被立即处理的同方向的点.由此可见,此处的双端队列放入头部的操作,其实是让我们统一且连续地处理这些方向相同的点,也就是在挨个遍历这些点.所以我们完全可以把放入头部的操作简单用一个循环来代替,即我们不把点放入头部,而是开启一个循环,在循环中逐个遍历这些同方向的点,遍历直到遇到已经入队的点(这样其实是有问题的,后面再谈)或者是边界、障碍为止,同时把每个点剩下三个方向连接的点入队尾即可.这样之后也不必用双端队列了,可以重新使用普通的队列.

不过这样依然是要存放方向的,因为在遍历之前我们需要知道第一个点的方向.那有没有办法可以不用存第一个点的方向捏?有的,那就是提前处理.我们在处理一个点的时候,直接向上下左右四个方向进行一次遍历,将这些点全部入队.即我们在让当前点四周的点入队的同时,提前把与其四周点同方向的点全部提前,在此时一并处理.而且,由于在之前也是这么操作的,所以和这个点同方向的点此时已经全部入队过了,被打上入队标记了(次数标记为已记录最小转弯数),是不会被再次启动遍历的(遍历会在开始时直接因为循环条件不满足中断),所以剩下的能被启动遍历的方向自然就是需要转弯的方向,直接将其转弯数设为当前加一即可.局部代码如下(省去):

    int dict[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; // 搜索方向
    typedef pair<int, int> Node; // (x,y)坐标对

    while (!que.empty())
    {
        Node now = que.front();
        que.pop();

        // 搜索该点周围的四个点,同时将与其四周点同方向的点全部提前在此时一并处理并入队
        for (int i = 0; i < 4; i++)
        {
            // 取出当前方向前进后点的坐标
            int x = now.first + dict[i][0];
            int y = now.second + dict[i][1];

            // g存放图,非负数表示到当前点的最小转弯数(即当前点已入队),-1表示障碍物(以及起点,后面会写到),-2表示当前点不是障碍物且还未处理,-3表示终点
            // 遍历此方向的点,直到越界、碰到障碍物或已入队,此处循环的条件是不完整的,后面会补充
            while (x >= 0 && x < n && y >= 0 && y < n && g[x][y] <= -2)
            {
                if (g[x][y] == -3) // 到达终点
                {
                    cout << g[now.first][now.second] + 1 << "\n";
                    return 0;
                }

                // 被遍历到的点一定是要转弯一次的,因为同一方向的在前一次的提前处理中,已经全部入队了
                g[x][y] = g[now.first][now.second] + 1;
                que.push(Node{x, y});

                // 同方向继续前进,提前在此时一并处理并入队
                x += dict[i][0];
                y += dict[i][1];
            }
        }
    }

如果你运气好,把到此为止的思路写好交上去,可能能够AC(我本人AC了,后来才发现其实是有漏洞的).不过此时其实还是存在一个问题.在同一批操作中,如果一条水平的路径和一条竖直的路径发生了交叉,由于不是同时进行处理的,先走过的路径会把交叉点标记成已处理,而后走过的路径会因为这一点已处理而结束循环.但实际上此处循环提前结束了,因为经过这个点之后还有很多同方向的点没有被加入队列中,导致答案出现错误.

此问题同样出现在直接使用双端队列BFS的方法中,本质原因是这个路径交叉点其实向四个方向的边权都是0,因而无论从哪个方向经过都不会使得”路径”变长,可以经过多次,并不像别的点只能走过一次.

这个问题的解决方法很简单,只需要特判这个路径交叉的点即可,即如果发现遍历到的这个点记录的转角次数正好比当前点大一(因为此处为了不记录方向,采用了提前处理,所以是大一;如果直接用双端队列BFS,那就是记录的转角次数与当前相等),我们依旧继续进行迭代.所以上面代码中while循环的条件应该写成:

x >= 0 && x < n && y >= 0 && y < n && (g[x][y] <= -2 || g[x][y] == g[now.first][now.second] + 1)

至此主要的问题就全部解决了,只需要再编写一段输入的代码来对输入进来的图进行编码即可.这里我以-3代表终点,-2代表路,-1代表障碍,非负数表示经过这个点的最小转弯次数.然后只需要从起点开始启动我们所编写的这段BFS即可.不过此处还有个小问题,由于题目规定起点的方向是任意的,所以起点周围点的转角次数应该都是0,这和我们写的这段BFS是不能直接兼容的(会标记成1),需要特别处理.但是,如果我们假定起点的转弯次数是-1,由于-1在加1后正好是0,此时便会变成我们想要的情况.这种方法即是所谓的把起点看成一个超级源点,常用来省去对起点的特判.同时-1也代表障碍物,可以用来标记起点已经入队过(其实我想的时候是反过来的,正是因此我才用-1代表障碍物),且从题目描述中显然可以看出起点和终点一定是不重合的,所以这样的写法是没有问题的.输入部分的局部代码如下:

    typedef pair<int, int> Node; // (x,y)坐标对

    int n;
    cin >> n;
    
    vector<vector<int>> g(n, vector<int>(n, -2)); // -2代表空
    queue<Node> que; // BFS所用队列
    for (int i = 0; i < n; i++)
    {
        for (int j = 0; j < n; j++)
        {
            char c;
            cin >> c;
            if (c == 'A')
            {
                g[i][j] = -1; // 这里我把起点看成是一个超级源点,-1不仅可以代表这个点不能走,而且还能使得其周围的点在+1之后记录的转弯次数正好是0
                que.push(Node{i, j});
            }
            else if (c == 'B')
            {
                g[i][j] = -3; // 终点
            }
            else if (c == 'x')
            {
                g[i][j] = -1; // -1代表障碍物(都代表当前这个点不能经过)
            }
        }
    }

至此,本题解答完毕.

参考代码

#pragma GCC optimize(1)
#pragma GCC optimize(2)
#pragma GCC optimize(3, "Ofast", "inline")

#include <iostream>
#include <vector>
#include <queue>
using namespace std;


int main()
{
    ios::sync_with_stdio(false);
    int dict[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; // 搜索方向
    typedef pair<int, int> Node; // (x,y)坐标对

    int n;
    cin >> n;
    vector<vector<int>> g(n, vector<int>(n, -2)); // -2代表空
    queue<Node> que; // BFS所用队列
    for (int i = 0; i < n; i++)
    {
        for (int j = 0; j < n; j++)
        {
            char c;
            cin >> c;
            if (c == 'A')
            {
                g[i][j] = -1; // 起点看成超级源点,下一个点在+1之后正好是转0次
                que.push(Node{i, j});
            }
            else if (c == 'B')
            {
                g[i][j] = -3; // 终点
            }
            else if (c == 'x')
            {
                g[i][j] = -1; // -1除了代表超级源点,也可以代表障碍物(都代表当前这个点不能经过)
            }
        }
    }

    // BFS(广度优先搜索)
    while (!que.empty())
    {
        Node now = que.front();
        que.pop();

        // 搜索该点周围的四个点,同时将与其四周点同方向的点全部提前在此时一并处理并入队
        for (int i = 0; i < 4; i++)
        {
            // 取出当前方向前进后点的坐标
            int x = now.first + dict[i][0];
            int y = now.second + dict[i][1];

            // 未越界且未走过,注意由于无法同时进行水平和竖直方向,会导致交叉时的路径被截断,所以在同批处理中的标记不应当视为障碍
            while (x >= 0 && x < n && y >= 0 && y < n && (g[x][y] <= -2 || g[x][y] == g[now.first][now.second] + 1))
            {
                if (g[x][y] == -3) // 到达终点
                {
                    cout << g[now.first][now.second] + 1 << "\n";
                    return 0;
                }

                if(g[x][y] != g[now.first][now.second] + 1)
                {
                    // 被遍历到的点一定是要转弯一次的,因为同一方向的在前一次的提前处理中,已经全部入队了
                    g[x][y] = g[now.first][now.second] + 1;
                    que.push(Node{x, y});
                }

                // 同方向继续前进,提前在此时一并处理并入队
                x += dict[i][0];
                y += dict[i][1];
            }
        }
    }

    cout << "zui dao le\n";
    
    return 0;
}

“正是我们每天反复做的事情,最终造就了我们,优秀不是一种行为,而是一种习惯” —亚里士多德