20 | 广度优先搜索:如何快速解决迷宫问题?

20 | 广度优先搜索:如何快速解决迷宫问题?

转载:https://time.geekbang.org/column/article/292638

 

 

你好,我是胡光。

上节课,我们一起学习了深度优先搜索和状态树,以及一些简单的剪枝技巧。这节课,我们继续学习搜索算法,解决另外一种常见的问题:迷宫问题。

迷宫问题是这样的,现在有一个 N×M 大小的迷宫,我们可以从迷宫的某一个格子出发,向上、下、左、右四个方向走。但是,迷宫中存在一些障碍格子,我们不能走上去。比如下图中,白色的格子代表可以走,黑色的格子代表障碍不能走。假设迷宫的起点是左上角,终点是右下角,那我们从起点走到终点,最短的路径是多长,又是哪一条呢?

针对这个问题,我们可以使用上节课学过的深度优先搜索去解决吗?

我们尝试之后发现,使用深度优先搜索的时候,就像我们独自身处在这个迷宫中,从起点出发之后,碰到了岔路就一条一条去尝试。如果走到了死路,就原路返回,接着尝试下一条路,再碰到了岔路还是用同样的方法继续尝试。而当我们终于走到终点的时候,走的却又未必是最短的那一条路。如果想要确认走的是不是最短的那一条,我们只能回到前面,继续尝试,直到走完所有的路才能确认。

这样做显然非常浪费时间,于是我们就开始设想,我们在迷宫中遇到岔路的时候,能不能像漩涡鸣人一样,用一次分身术生出许多分身,让它们同时走所有的路径。当某一个分身再遇到岔路的时候,就再使用一次分身术,让这些分身继续走新的岔路,并且让走到死路上的分身直接解除掉。最后,所有走到终点的分身来报告走的路径长度,这样我们就能找到最短的那个路径了。

当然了,这个想法在现实中是没有办法实现的,但在搜索算法中却可以做到,这就是我们今天要讲的搜索方法,广度优先搜索。

广度优先搜索:搜索中的“影分身术”

还是以刚才的迷宫为例,首先我们从起点 [0, 0] 出发。不巧,起点就是一个岔路,它的右边和下边都可以走,那我们只能派出两个分身分别走到 [0, 1] 和 [1, 0] ,为了方便,我们先分别称它们为分身 1 号和分身 2 号。

然后我们先看分身 1 号,它的前面没有岔路了,所以只能往右边再走一步。同时,分身 2 号面前也没有岔路,只能向下再走一步。

这之后,我们再去看分身 1 号,发现分身 1 号面对一个岔路。这个时候,它只能再使用一次分身术,分别代替它往下和往右走……就这样,直到所有的分身都走到终点。

这里你要注意了,我们刚才描述的过程虽然是同时进行的,但在程序实现中我们做不到同时,它们总会有一个先后关系。比如说,当分身 1 号向右边走了一步之后,我们就需要知道分身 2 号走到哪儿了,然后才能让分身 2 号继续走下一步。同样,在分身 2 号走完了之后,我们还要回去更新分身 1 号,知道分身 1 号走到哪儿了,再去更新分身 1 号的状态。也就是说,我们每一次的搜索,所有的分身都得向前走一步。

如果是这样的话,我们就得能够依次取出所有分身的位置才能更新。

想要实现这个功能,我们就必须使用一个先进先出的数据结构来维护每个节点上的状态,所以我们需要借助队列来实现这一算法。接下来,我们就来正式描述一下解决迷宫问题的算法是什么样的。

首先,我们在每个格子上记录的是,目前从起点到当前格子的最短路径长度。那我们每次到达这个节点的时候,如果走过的路径比它现在记录的值短就更新它。同时,我们还要记录,从哪个格子到的当前格子,才能得到这个最小值。因为到达每个格子的最短路径一定是由上一个格子的最短路径而来,那依次回溯回去,我们就能找到这条最短路径了。

那么问题来了,我们该怎么结合队列来实现呢?

最开始,我们当然是把起点的路径长度置为 0,再把起点放到队列中,然后开始搜索过程。

在每一次搜索更新过程中,我们都会先取出队首,分别检查它的上、下、左、右四个格子。检查分两步,一是查看它们是不是障碍,如果不是障碍,再看它们有没有被走过,如果没被走过,再更新格子上的值和前驱格子,入队列。如果被走过了,我们就看由当前格子出发到这个格子是不是最短的路,如果是最短的路,就跟新格子上的值和前驱格子入队列,反之就不管它。

我们一直重复这个过程,直到这个队列为空。最后,我们再从终点出发一步步地向回找,就能找出这条最短的路径了。

在我们刚才进行的搜索过程中,检查从当前格子出发到下一个格子是否构成最短路径,并且更新的操作,叫做松弛操作。正因为这个操作的存在,我们搜索的过程中才不会走回头路,也就不会出现在两个格子上反复横跳这种事情了。代码如下:


void bfs(int n, int m) {
    int pos_x, pos_y;
    queue<pair<int, int> > que;

    // 起点入队
    que.push(pair<int, int>(0, 0));
    dis[0][0] = 0;

    while (!que.empty()) {
        pair<int, int> cur_pos = que.front();
        que.pop();
        pos_x = cur_pos.first;
        pos_y = cur_pos.second;

        // 判断向下走一步
        if (pos_x < n - 1 && a[pos_x + 1][pos_y] != -1) {
            if (dis[pos_x + 1][pos_y] == -1 || (dis[pos_x][pos_y] + 1 < dis[pos_x + 1][pos_y])) {
                dis[pos_x + 1][pos_y] = dis[pos_x][pos_y] + 1;
                father[pos_x + 1][pos_y] = cur_pos;
                que.push(pair<int, int>(pos_x + 1, pos_y));
            }
        }

        // 判断向上走一步
        if (pos_x > 0 && a[pos_x - 1][pos_y] != -1) {
            if (dis[pos_x - 1][pos_y] == -1 || (dis[pos_x][pos_y] + 1 < dis[pos_x - 1][pos_y])) {
                dis[pos_x - 1][pos_y] = dis[pos_x][pos_y] + 1;
                father[pos_x - 1][pos_y] = cur_pos;
                que.push(pair<int, int>(pos_x - 1, pos_y));
            }
        }

        // 判断向右走一步
        if (pos_y < m - 1 && a[pos_x][pos_y + 1] != -1) {
            if (dis[pos_x][pos_y + 1] == -1 || (dis[pos_x][pos_y] + 1 < dis[pos_x][pos_y + 1])) {
                dis[pos_x][pos_y + 1] = dis[pos_x][pos_y] + 1;
                father[pos_x][pos_y + 1] = cur_pos;
                que.push(pair<int, int>(pos_x, pos_y + 1));
            }
        }

        // 判断向左走一步
        if (pos_y > 0 && a[pos_x][pos_y - 1] != -1) {
            if (dis[pos_x][pos_y - 1] == -1 || (dis[pos_x][pos_y] + 1 < dis[pos_x][pos_y - 1])) {
                dis[pos_x][pos_y - 1] = dis[pos_x][pos_y] + 1;
                father[pos_x][pos_y - 1] = cur_pos;
                que.push(pair<int, int>(pos_x, pos_y - 1));
            }
        }
    }
}

如果我们要输出这条最短的路径,一步步找回去就好了。代码如下:

void dfs_output(int x, int y) {
    if (x == 0 && y == 0) {
        printf("(%d, %d)\n", x, y);
        return;
    }
    dfs_output(father[x][y].first, father[x][y].second);
    printf("(%d, %d)\n", x, y);
}

由于在这个过程中,我们每进行一次操作,都会关注当前状态的所有可能的下一个状态,非常注重广度,所以这个算法就叫做广度优先搜索,也叫做宽度优先搜索,简称就是广搜、宽搜。

总的来说,虽然我们不断地派出分身去走每一条岔路,可实际上整个广搜的过程,我们还是走了迷宫中所有可能的节点,所以广度优先搜索的时间复杂度是 O(N×M) 的,也是一种盲目搜索算法。

广度优先搜索图结构相关算法中占有非常重要的地位,很多相关算法其实都是广度优先搜索思想的延续,例如在图结构上求解单源最短路的 Dijkstra 算法、SPFA 算法,都是利用广度优先思想进行的。那广度优先思想到底是怎么应用在这些算法中的呢?

Dijkstra 算法:城市路程问题

接下来,我就以实际的城市路程问题为例,来和你详细讲讲 Dijkstra 算法的思路。

假设,现在有 N 个城市,各个城市相互之间修了一条路,这些路的长度都不相等。如果张三想要从 A 城市开车出发到达 B 城市,请问:他怎么走,距离才最短?

我们依据广度优先搜索的思想,先从 A 出发,而从 A 出发能直接到达的节点是 C 和 F,那在当前阶段,从 A 出发到 C 和 F 的最短距离就得到了。提前说下,在这个过程中,我会用绿色表示当前在处理的节点,黄色表示待处理的节点,红色表示已经处理过的节点。

然后,按照正常广搜的思路,我们要依次从 C 城市和 F 城市出发,再去摸索接下来能到达什么城市,对吧?但到了这里,我们的做法就有变化了,我们会从已经知道的城市中,找到距离 A 城市最近的那个城市,从它出发去向下找。

为什么要这么做呢?仔细想想就知道了,因为我们要求的是最短路程,那从目前路程最短的城市出发去摸索新的路径,就是一个非常直观的优化想法了。其实,我们还可以从另一个角度来证明这个做法是正确的。那就是从 A 城市出发,到达某一个城市的最短路程,例如 B 城市,一定是从 A 城市到其相邻的城市,也就是 E 城市和 I 城市的最短路径获得的。

我们可以看到,下图中 A 城市到 C 城市的路程是最短的,那我们就从 C 城市出发,更新它相邻的城市的路程。我们发现,D 城市我们没有访问过,所以我们可以更新 A 城市到它的路程,而 F 节点我们已经访问过了,并且发现如果从 C 城市到 F 城市的话,距离反倒是更远了,那我们就不去更新它。

当 C 城市处理完了之后,我们再从 A 城市出发,这个时候路程最短的城市就变成了 F 城市,那我们继续从 F 城市出发,看看会发生什么。就这样,我们依次到达 H 和 I 城市。

接下来,我们再来看 D 城市。这个时候,我们发现,从 D 城市出发,相邻的两个城市都被访问过了。但是呢,从 D 城市出发,到 G 城市的路程,比之前计算出来的 A 城市到 G 城市的路程要小,那我们就利用松弛操作,更新 G 城市的路程。而且,我们也发现,从 D 城市出发到 E 城市的距离也比之前的路程要短,所以我们同样利用松弛操作,来更新 E 城市的路程。

我们就继续这样的操作,最终走到 B 节点。这样,我们就可以求出,从 A 节点到 B 节点的最短路程是 21。

好了,我刚才讲的就是 Dijkstra 算法的基本思想,它利用了广度优先搜索当做基础,但在每次更新的时候,都有一个关键的筛选条件,就是找到当前已经更新的节点中路程最短的那个节点。Dijkstra 算法使用了这样的技巧避免了多次搜索,提升了效率。

关于这个最短路程的节点,你还能想到什么手段来进一步加速这个查找过程呢?没错,就是堆。也就是说,Dijkstra 算法是有专门的堆优化技巧,从而很快得到所有节点的单源最短路径。这里,你可以尝试结合堆来解决一下,我就不展开说了。

课程小结

这节课,我们又学习了一种非常重要的基础搜索算法,广度优先搜索算法。并且利用广度优先搜索解决了迷宫问题。广度优先搜索算法利用“分身术”,不断地派出分身走遍迷宫中所有可能的节点,并且,每一次更新都会关注当前状态的所有可能的下一个状态,从中选出最短的路径。因此,广度优先搜索的时间复杂度是 O(N×M) 的,是一种盲目搜索算法。

不过,在使用广搜算法的实际应用中,我们可以通过增加一些筛选条件,来提升它的效率,这让它的应用非常广泛。比如我们最后讲到的 Dijkstra 算法,就是一种重要的寻路算法,在很多图结构的应用中都会用到它。

同时广搜算法也广泛应用于游戏之中,比如,基于启发式搜索的寻路算法,它和 Dijkstra 一样,都是在每次更新的次序上做文章。另外,在人工智能领域相关应用中,搜索也是重要的解码算法。

因此,在接下来的课程中,我们会继续学习搜索算法的优化技巧,遇到的题目也都非常有趣,你可以期待一下!

课后练习

解决了初级版的迷宫问题之后,我们再来解决进阶的迷宫问题,现在有一个非常大的迷宫,长宽量级 105 那么大,迷宫里面还是有一些不能走的障碍,但是这些障碍的数量要远小于迷宫中的格子数。请问,如果我们从起点出发,能不能走到终点。

欢迎在留言区分享你的答案,也希望你能把这节课的内容转发出去。那今天就到这里了,我是胡光,我们下节课见!

  • 4
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
智慧校园整体解决方案是响应国家教育信息化政策,结合教育改革和技术创新的产物。该方案以物联网、大数据、人工智能和移动互联技术为基础,旨在打造一个安全、高效、互动且环保的教育环境。方案强调从数字化校园向智慧校园的转变,通过自动数据采集、智能分析和按需服务,实现校园业务的智能化管理。 方案的总体设计原则包括应用至上、分层设计和互联互通,确保系统能够满足不同用户角色的需求,并实现数据和资源的整合与共享。框架设计涵盖了校园安全、管理、教学、环境等多个方面,构建了一个全面的校园应用生态系统。这包括智慧安全系统、校园身份识别、智能排课及选课系统、智慧学习系统、精品录播教室方案等,以支持个性化学习和教学评估。 建设内容突出了智慧安全和智慧管理的重要性。智慧安全管理通过分布式录播系统和紧急预案一键启动功能,增强校园安全预警和事件响应能力。智慧管理系统则利用物联网技术,实现人员和设备的智能管理,提高校园运营效率。 智慧教学部分,方案提供了智慧学习系统和精品录播教室方案,支持专业级学习硬件和智能化网络管理,促进个性化学习和教学资源的高效利用。同时,教学质量评估中心和资源应用平台的建设,旨在提升教学评估的科学性和教育资源的共享性。 智慧环境建设则侧重于基于物联网的设备管理,通过智慧教室管理系统实现教室环境的智能控制和能效管理,打造绿色、节能的校园环境。电子班牌和校园信息发布系统的建设,将作为智慧校园的核心和入口,提供教务、一卡通、图书馆等系统的集成信息。 总体而言,智慧校园整体解决方案通过集成先进技术,不仅提升了校园的信息化水平,而且优化了教学和管理流程,为学生、教师和家长提供了更加便捷、个性化的教育体验。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值