【数据结构与算法笔记04】对图搜索策略的一些思考(包括DFS和BFS)

图搜索策略

这里的“图搜索策略”应该怎么理解呢?

首先,是“图搜索”,所谓图无非就是由节点和边组成的,那么图搜索也就是将这个图中所有的节点和边都访问一遍。

其次是“策略”:

==> 如果就直接给你一个图,要怎么样才能将所有的节点和边都访问一遍呢?

这里可以考虑一个非常非常大并且结构复杂的图,那么当拿到这个图的时候信息庞杂无比,你不知道里面有多少个节点,有多少条边,不知道节点和边之间是怎样错综复杂的关系,不知道有多少连通子图......

对这样的图进行搜索会让人一下子摸不着头绪,这时候肯定会想到,得有一个入手点才行!这是一个很通用的解决问题的策略,当整体过于复杂的时候不如试着从一个很小的局部开始一点点梳理脉络。

这个入手点一般是某个节点。

==> 有了一个入手点之后,要怎样继续去搜索其他的节点和边呢?

这时候会考虑,从我已有的知识来说,我目前只知道一个搜索的起点(记为source),以及关于这个起点的一些信息:与source相连的边的集合,与source相邻的节点集合。不过这两个信息其实是重合的,如果我现在从source出发去访问一条与source相连的边,其实也就是访问了这条边另一头的节点(source的一个邻居)。

当我走了这一条边之后,到达source的这个邻居节点之后,又会得到与这个邻居节点相邻的边以及它的邻居......

也就是说,在每一个时间截面(或者说选择节点)上,我都有很多选择——我有一个可以访问但还没有访问的节点的集合,此时我会从这个节点集合中选出一个节点,想前走一步,每探索一步,我对这个图的了解就更深一步(如果说有一个已经访问过的节点的集合,那么这个集合中节点的个数就可以理解为深入的程度),但这样的深入并非永无止境,这个图总会遍历完(总会到达这样一个选择节点:此时没有任何可以继续访问的节点了),所以这个探索会像一个先膨胀再收缩的过程(或者说像一个回力镖?),总会回到已经访问过的节点上来,这时候就不必再继续下去了,再继续下去对 “遍历更多的节点和边” 这一目标没有任何价值。

于是我得到了一个初步的搜索思路

我有一个可以访问但还没有访问的节点的集合(记为Package),初始时这个集合中只有我一个节点。

每访问一个节点,都有可能会向这个Package中装入一些新的节点(当前访问的节点的邻居中,还没有访问过的那些节点),当然也可能不会。

我每次从这个Package中取一个节点,然后更新一下Package的内容,然后再取一个节点......就这样一直重复直到Package为空,这时就已经将从source所在的那个连通子图全部遍历完了。

==> 每次取出一个节点?

对于刚刚得出的搜索策略,还有一个值得思考的地方:每次取一个节点?每次取哪个节点?

如何决定每一次取出哪一个节点这个问题就是所谓的“策略”。

当确定了“策略”之后,还需要考虑Package用什么数据结构来存储,要选择一个最适合于搜索策略的数据结构。


画外音:不能狭隘地认为一说到图搜索或者遍历图,不是DFS就是BFS,虽然确实这两种是最最常用而且能高效地解决很多问题的搜索策略。但是我也可以简单地每次就随机取一个节点进行访问,或者说每次先把剩余未访问邻居最多的节点拿出来访问,或者说每次把距离source最近的节点拿出来访问等等。我这里只是随便举一些例子,我想说的是,图搜索的策略有千千万万种,我们不能局限自己的思路,当拿到一个问题时,应该想着,怎么样为这个问题服务,而不是直接就开始想“这题用DFS能不能做?不能就用BFS”。

深度优先搜素(DFS)& 广度优先搜索(BFS)

跟着上面的思路来考虑DFS和BFS的搜索策略:

  • DFS:每次从Package中选出距离source最远的节点进行访问。
  • BFS:每次从Package中选出距离source最近的节点进行访问。

如果在某一个选择节点有多个距离source最近/远的节点,选哪一个都可以,具体选哪个现在暂时无法决定,这个问题先留着之后再考虑。

在前面的“初步搜索思路”中有一个很重要的细节,回顾一下我们是如何更新Package的?

每访问一个节点,都有可能会向这个Package中装入一些新的节点(当前访问的节点的邻居中,还没有访问过的那些节点)...

如果在访问某个节点A时对Package进行了更新:

  • DFS:A离source的距离(记为k)大于等于当前Package中的所有节点

==> A的邻居中,还没有被访问过的那些节点离source的距离(k+1)一定大于当前Package中的所有节点

      ==> 下一次访问的节点一定是这次刚刚加入Package的节点中的某一个

      ==> 典型的“后入先出”

      ==> Package用栈来存

      ==> 每次从栈顶取出一个节点,对其进行访问,并将它的 邻居中还没有被访问过的节点 压栈

  • BFS: 假设当前Package中距离source最近的节点构成一个集合S_k,假设它们离source为k

==> 每次从这个集合中选出一个节点,并将该节点的邻居中还没有被访问过的那些节点(离source的距离为k+1)放入Package中

      ==> 在访问S_k时加入到Package中的节点构成集合 S_k+1,以此类推

      ==> 这就意味着节点是按照他们和source的距离加入Package的,也是按照他们和source的距离离开Package的

      ==> 加入的顺序 = 离开的顺序

      ==> 典型的“先入先出”

      ==> Package用队列来存

      ==> 每次从队首取出一个节点,对其进行访问,并将它的 邻居中还没有被访问过的节点 放入队尾

再回头看刚才遗留的那个问题:如果在某一个选择节点有多个距离source最近/远的节点时,应该选哪一个?

在DFS中,显然是选最后加入的那一个;在BFS中,是选最先加入的那一个。因为选哪一个其实都可以,按照这样的方式取是最方便的,所以这个问题也就解决了。

到这里就可以开始编码实现了。

编码实现

这里采用《算法(第4版)》中的图处理算法的设计模式:

1. DFS

因为DFS是用 “栈” 来控制顺序的,所以有两种写法:一种是显示地使用一个栈,用迭代的方式来实现;另一种是隐式地使用栈,用递归的方式实现。

 1 #pragma once
2 #include <stack>
3 #include "Graph.h"
4 using namespace std;
5
6 class DepthFirstSearch {
7 private:
8 bool* mymarked; //在递归写法中:某一个时间截面记录已经访问过的节点
9 //在迭代写法中:某一个时间截面记录已经压过栈的节点
10 //在dfs结束后记录与s节点连通的所有节点
11
12 public:
13 DepthFirstSearch(Graph G, int s) {
14 //初始化辅助数组mymarked
15 mymarked = new bool[G.v()];
16 for (int i = 0; i < G.v(); i++)
17 mymarked[i] = false;
18
19 //深度优先搜索
20 //dfs_recursion(G, s);
21 dfs_iteration(G, s);
22 }
23
24 void dfs_recursion(Graph G, int s) {
25 //Step1: 访问当前节点
26 mymarked[s] = true;
27
28 //Step2: 对于S的所有邻居,如果没有访问过的话,调用dfs自身来访问它
29 for (int i = 0; i < G.adj(s).size(); i++) {
30 if (mymarked[G.adj(s)[i]] == false)
31 dfs_recursion(G, G.adj(s)[i]);
32 }
33 }
34
35 void dfs_iteration(Graph G, int s) {
36 stack<int> S;
37
38 //Step0: 初始化栈:放入source节点
39 S.push(s);
40 mymarked[s] = true;
41
42 while (!S.empty()) {
43 //Step1: 取出栈顶元素进行访问
44 int cur = S.top();
45 S.pop();
46
47 //Step2: 将cur节点的邻居节点中,尚未被访问过的节点压栈
48 for (int i = 0; i < G.adj(cur).size(); i++) {
49 if (mymarked[G.adj(cur)[i]] == false) {
50 mymarked[G.adj(cur)[i]] = true;
51 S.push(G.adj(cur)[i]);
52 }
53 }
54 }
55 }
56
57 bool marked(int w) {
58 return mymarked[w];
59 }
60
61 };

2. BFS

 1 #pragma once
2 #include <queue>
3 #include "Graph.h"
4 using namespace std;
5
6 class BreadFirstSearch {
7 private:
8 bool* mymarked; //某一个时间截面记录已经进入过队列的节点
9 //在bfs结束后记录与s节点连通的所有节点
10
11 public:
12 BreadFirstSearch(Graph G, int s) {
13 //初始化辅助数组mymarked
14 mymarked = new bool[G.v()];
15 for (int i = 0; i < G.v(); i++) {
16 mymarked[i] = false;
17 }
18
19 //广度优先搜索
20 bfs(G, s);
21 }
22
23 void bfs(Graph G, int s) {
24 queue<int> Q;
25
26 //Step0: 初始化队列:放入source节点
27 Q.push(s);
28 mymarked[s] = true;
29
30 while (!Q.empty()) {
31 //Step1: 取出队首元素进行访问
32 int cur = Q.front();
33 Q.pop();
34
35 //Step2: 将cur节点的邻居节点中,尚未被访问过的节点放入队尾
36 for (int i = 0; i < G.adj(cur).size(); i++) {
37 if (mymarked[G.adj(cur)[i]] == false) {
38 mymarked[G.adj(cur)[i]] = true;
39 Q.push(G.adj(cur)[i]);
40 }
41 }
42
43 }
44
45 }
46
47 bool marked(int w) {
48 return mymarked[w];
49 }
50
51 };

值得注意的是

在两个迭代算法中,mymarked[i] == true的含义是:将该节点放入Package这个操作已经执行过了。

将节点i放入Package意味着它之后一定会被访问,而不是现在就被访问了。

从Package中取出节点i才意味着它被访问了。

所以将一个节点对应的mymarked[i]设为True的时机应该是将它放入Package时,而不是从Package中取出时。

DFS和BFS的对比

  “深度优先搜索和广度优先搜索是我们首先学习的几种通用的图搜索的算法之一。

  在搜索中我们都会先将起点存入数据结构中,然后重复以下步骤直到数据结构被清空:

  1. 取其中的下一个顶点并标记它;
  2. 将 v 的所有相邻而又未被标记的顶点加入数据结构。
       这两个算法的不同之处仅在于从数据结构中获取下一个顶点的规则(对于广度优先搜索来说是最早加入的顶点,对于深度优先搜索来说是最晚加入的顶点)。这种差异得到了处理图的两种完全不同的视角,尽管无论使用哪种规则,所有与起点连通的顶点和边都会被检查到。图 4.1.19 和 图 4.1.20 显 示了深度优先搜索和广度优先搜索处理样图 mediumG.txt 的过程,它们清晰地展示了两种方法中搜索路径的不同。
  1. 深度优先搜索不断深入图中并在栈中保存了所有分叉的顶点;广度优先搜索则像扇面一般扫描图,用一个队列保存访问过的最前端的顶点。
  2. 深度优先搜索探索一幅图的方式是寻找离起点更远的顶点,只在碰到死胡同时才访问近处的顶点;广度优先搜索则会首先覆盖起点附近的顶点,只在临近的所有顶点都被访问了之后才向前进。
  3. 深度优先搜索的路径通常较长而且曲折,广度优先搜索的路径则短而直接。
                                                               —— 《算法(第4版)》

【数据结构与算法笔记04】对图搜索策略的一些思考(包括DFS和BFS)


画外音:书中在介绍DFS之前还介绍了一个走迷宫的例子,这个例子对于理解DFS也很有价值。

上一篇:JAVA实现复制文件夹


下一篇:First Python script