Week2Day1B:图和深度优先搜索【2023 安全创客实践训练|笔记】

内容为武汉大学国家网络安全学院2022级大一第三学期“996”实训课程中所做的笔记,仅供个人复习使用,如有侵权请联系本人,将与15个工作日内将博客设置为仅粉丝可见。
 


图和搜索

深度优先搜索除了可以在迷宫上边进行以外也可以在图上进行,或者对于任何的深度优先搜索我们都可以把深度优先搜索的图画出来。

如果我们研究深度优先搜索的过程,把每一个经过的状态都画成一个点,把从这个状态去另一个状态这个递归的调用看成一条从这个状态到那个状态的边,就会画出一张图。

比如最简单的迷宫问题,状态就是目前所在的位置,那就可以画一个图,点就是每个位置,从这个位置可能可以往周围四个方向走,走到另一个位置,也就是到达另一种状态,如果可以从一个状态走到另一个状态,就给这两个点连上边。

这样就画出了一个图,执行搜索的时候也就是在这个图上走,回溯的时候就是退回上一个点。

如果我们不考虑那么多,就给一个图,那自然也可以在图上搜索。

比如下图是一个无向图,如果我们从 A 点开始深度优先搜索(以下的访问次序并不是唯一的,第二个点既可以是 B 也可以是 C、D),则我们可能得到如下的一个访问过程:A -> B -> E,回溯到 A,继续访问 C -> F -> H -> G -> D,回溯到 A,A 此时已经没有未访问的相邻顶点,本次搜索结束。最终通过 A -> B -> E -> C -> F -> H -> G -> D 的顺序搜索了图中的所有顶点。

那这样的搜索,状态是什么,其实就是当前所在的顶点,我们现在在这个顶点,然后看它连着的所有点,如果某一个点没访问过,就去访问。

在有向图上也是可以进行 DFS 搜索的,也是去看当前点连着的所有点就可以了(有向图的邻接矩阵或邻接表中存的本来就是有向边)。

深度优先搜索也可以借助栈来理解。实际上,当访问一个状态的时候,相当于把这个状态入栈。而从一个状态回溯的时候,相当于弹出栈顶元素。栈顶元素总是当前所在的状态。后面的演示课我们将会更直观地看到这一点。

在连通图上遍历,我们总能得到一个访问的顺序,如果我们使用 DFS 进行搜索,得到的顺序就叫做 DFS 序,从刚才的图也可以看出来一个图的 DFS 序是不唯一的,而且根据一个 DFS 序也没法还原出整张图的样子。不过 DFS 序还是有很大的应用价值的,在以后的学习中我们会见到 DFS 序的应用。

图上 dfs 参考程序:

#include <stdio.h>
#include <string.h>
struct edge {
    int v, next;
} e[1000];
int p[100], eid;
void insert(int x, int y) {
    e[eid].v = y;
    e[eid].next = p[x];
    p[x] = eid++;
}
void insert2(int x, int y) {
    insert(x, y);
    insert(y, x);
}
int vis[100];
void dfs(int u) {
    vis[u] = 1;
    printf("%d\n", u);
    for (int i = p[u]; ~i; i = e[i].next) {
        int v = e[i].v;
        if (!vis[v]) {
            dfs(v);
        }
    }
}
int main() {
    memset(p, -1, sizeof(p));
    int n, m;
    scanf("%d%d", &n, &m);
    for (int i = 0; i < m; i++) {
        int u, v;
        scanf("%d%d", &u, &v);
        insert(u, v);
    }
    dfs(1);
    return 0;
}

图上的深度优先搜索


选出正确的 DFS 序

请选出下图正确的 DFS 序:


字典序最小的方案

我们刚才说图的 DFS 不唯一,在方案不唯一的情况下我们经常会面临求字典序最小的方案的问题。

那先来看字典序是什么,对于字符串大家都知道字典序比较,两个字符串从头开始比,遇到第一个不同的位置,谁小谁字典序就小。特殊地,当一直都是一样,直到某个字符串后边没有字符了,那就是没有字符的小,还有字符的大。

对于方案,字典序的意思和我们谈论字符串时基本相同。把两种方案每一次的选择列出来,从第一个位置开始比,遇到某个位置两个方案选择不同,哪个方案选择的小,字典序就小。

比如

1 2 5 3 4
1 2 4 3 5

从第一个数开始比,会发现到第 3 个位置,上边的方案是 5 ,下边是 4 ,不一样了,那下边的方案这个数小,就是下边的方案字典序小。

那对于一张连通图,我们如何能够求出字典序最小的 DFS 序,首先肯定要从 11 号点开始,然后要求每一次走的都是当前能走的编号最小的点,因为字典序是一旦当前位置小了,不管后边怎么大,这个字典序都小,这就是一个贪心的思想。

实际写代码的时候我们只需要把邻接表的每一行都从小到大排序,按照这样的顺序进行图的遍历,得到的 DFS 序就是最小的了。

比如这个图,字典序最小的 DFS 序是 1,2,4,5,3,6 。

那回顾之前学习的迷宫问题,如果约定往上走一步是'U',往下走一步是'D',往左走一步是'L',往右走一步是'R',一个从起点走到终点的方案就可以写成一个字符串,现在希望求解字典序最小的方案,如何去做?

其实对于当前所在的点,下一步就按照下('D'),左('L'),右('R'),上('U')的顺序依次来尝试就可以了,相当于每一步也是选了字典序尽可能小的方案。


连通块

对于无向图来说,我们定义图 G 上的极大连通子图 S 为:

  • S 是 G 的子图,且 S 是连通的
  • 如果 S’ 也是 G 的连通子图,且 S 是 S’ 的子图,一定 S’=S

我们通常把极大连通子图简称为 连通块

形象一点说,一个连通块就是一些能互相到达的点形成的图上的一块。

一个连通块中所有点一定互相可达。

比如这个图就有两个连通块,分别是 3,4 和 1,2,5,6 。

 

其实之前在迷宫问题的时候,我们谈过连通块,这两个概念其实是一样的,我们可以把迷宫画成一般的图的样子,而且画出来就是无向图。

在迷宫上的连通块也就是在这个图上的连通块。

在无向图上,我们可以利用 DFS 可以求连通块的数量。算法流程如下:

  1. 选择一个尚未被访问的点 v,从点 v 出发进行深度优先搜索,对访问过的点进行标记
  2. 继续寻找下一个尚未被访问的点,直到没有尚未被访问的点,算法结束

进行深度优先搜索的次数就是图中的连通块个数,如果我们在每次进行深度优先搜索时,对访问过的点进行不同的标记,就可以记录每个连通块内的顶点有哪些了,也可以跟迷宫问题时一样求解连通块大小,最大连通块等问题。

求解连通块的参考程序:

#include <stdio.h>
#include <string.h>
struct edge {
    int v, next;
} e[1000];
int p[100], eid, vis[100];
void insert(int x, int y) {
    e[eid].v = y;
    e[eid].next = p[x];
    p[x] = eid++;
}
void insert2(int x, int y) {
    insert(x, y);
    insert(y, x);
}
void dfs(int u) {
    vis[u] = 1;
    for (int i = p[u]; ~i; i = e[i].next) {
        int v = e[i].v;
        if (!vis[v]) {
            dfs(v);
        }
    }
}
int main() {
    memset(p, -1, sizeof(p));
    int n, m;
    scanf("%d%d", &n, &m);
    for (int i = 0; i < m; i++) {
        int u, v;
        scanf("%d%d", &u, &v);
        insert2(u, v);
    }
    int cnt = 0;
    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            cnt++;
            dfs(i);
        }
    }
    printf("%d\n", cnt);
    return 0;
}

树上搜索

我们之前研究了图上的搜索,我们之前还学了树这种特殊的图,接下来我们就来研究一下在树上的搜索。

如果想要遍历整棵树,可以和图一样,从一个点开始(一般从根开始),每次找一个没有访问过的点访问就可以了。

但是树总有它的特殊性,那就是从根开始搜索,到达每个点的时候一定只有它的父节点是之前访问过的,所以我们并不必使用vis数组记录每个点有没有被访问过,只需要记录每个点的父节点是哪个节点,这样看这个点连着的节点时,就只需要判断它不是这个点的父节点就可以往下搜索了。

比如这样一个图,如果 1 是根,那访问到 2 的时候一定可以走 4 和 6 ,只是不能走 1 ,那就走 4 和 6 就可以了。

 

记录父节点可以使用一个数组,不过如果之后我们不会再关心每个点的父节点是谁,只是在这次搜索才用,那就可以直接把父节点当作参数传进来。

这样搜索代码写下来就是这样

void dfs(int u, int fa) { // fa 就是父节点
    printf("%d\n", u);
    for (int i = p[u]; ~i; i = e[i].next) {
        int v = e[i].v;
        if (v != fa) {
            dfs(v, u); // 那往下搜索,到 v 的时候父节点就是我们目前在的节点 u
        }
    }
}

调用dfs从根搜索的时候传给fa的参数常常是这个根本身或者一个不存在的点 0 。

平常输入树的方式有很多,比如明确告诉了每个点的父节点,或者明确说明了一个点是另一个点的父节点,此时,我们只需要父节点向子节点连有向边就足够了,对于根节点,有可能会明确知道,如果不知道,那可以统计每个点是不是有父节点或者统计每个点的度数来找到根节点,此时fa这个参数也可以就不用了。

如果没有明确说明父子关系,需要建无向边,如果已知一个点为根,那就以那个点为根,如果不知道根是哪个点但以哪个点为根都不影响结果,就可以任意指定,一般习惯用 1 。

计算树上节点深度大小的参考程序:

#include <stdio.h>
#include <string.h>
struct edge {
    int v, next;
} e[1000];
int p[100], eid, d[100];
void insert(int x, int y) {
    e[eid].v = y;
    e[eid].next = p[x];
    p[x] = eid++;
}
void insert2(int x, int y) {
    insert(x, y);
    insert(y, x);
}
void dfs(int u, int fa) {
    if (u != 1) {
        d[u] = d[fa] + 1;
    }
    for (int i = p[u]; ~i; i = e[i].next) {
        int v = e[i].v;
        if (v != fa) {
            dfs(v, u);
        }
    }
}

int main() {
    memset(p, -1, sizeof(p));
    int n;
    scanf("%d", &n);
    for (int i = 0; i < n - 1; i++) {
        int u, v;
        scanf("%d%d", &u, &v);
        insert2(u, v);
    }
    dfs(1, 0);
    for (int i = 1; i <= n; i++) {
        printf("%d %d\n", i, d[i]);
    }
    return 0;
}
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值