图论学习总结

· · 个人记录

本文是笔者 2023 CSP-S 复习写的博客,主要是总结知识点用,如有遗漏或不准确,还请海涵。

QWQ \

图论

OI 中关于图论的考查内容很多,很杂,很广泛。这里从浅入深地,循序渐进地讲解一些图论知识。

\

Part1 初识图论

什么是图?

图,是由若干个结点,结点之间有若干条的数学模型。

不妨举个例子。

这是一个 7 个结点的图,图中共有 7 条边。

\

借助此图,我们来介绍一些关于图的概念。

如上例,这个图就是一个无向图。

\

现在我们来给这些边加上方向:

如图,每条边都有一个方向,因此这个图是一个有向图。

\

当然,在有向图中,我们一般会把边分为两种:

相应地,我们也把度数分成两种:

如上图,点 5 的入度为 1,出度为 4;点 2 的入读为 2,出度为 1

\

此外,若有向图中存在一个点,可以从一条路径出发,走回到自己,那么我们称这个是一个有环图

比如点 2 可以走 2 -> 4 -> 5,那这就构成了一个环,这个图就是一个有环图。

对于一个有向无环图,我们简称为DAG

\

Part2 图的存储

如何存储一个图?

不难发现,在最普通的图中,我们所需要存储的信息即为边的连接情况

有向图为例。

还用刚刚这个图:

\

邻接矩阵

我们可以记录两个点之间是否有边:开一个二维数组,用 a[u][v] 来表示是否存在一条从 u 走到 v 的边。

显然,a[5][2] = truea[1][3] = false

代码示例

for(int i = 1; i <= m; i++) {
    int u, v;
    scanf("%d %d", &u, &v);
    a[u][v] = true;
}

这样的方法称之为邻接矩阵,不过这种方法耗空间耗时间,费时还费力,几乎不会使用。

所以我们可以考虑第二个方案。

\

vector 存图

每个点都开一个 vector,来存储这个点相邻的可以到达的点

比如遇到了一条边 uv,那么我们就让 edge[u].push_back(v),其中 edge[u] 表示点 uvector

这样,空间的浪费会大大减少,时间效率也可以有很大的提高。

代码示例

for(int i = 1; i <= m; i++) {
    int u, v;
    scanf("%d %d", &u, &v);
    edge[u].push_back(v);
}
\

在遍历每个点的出边时

//比如要遍历 u 的所有出边:
for(int i = 0; i < edge[u].size(); i++) {
    int v = edge[u][i];
    //v 代表一条出边的末端点
}

但这依然不是我们最常见的存图方式。

\

链式前向星

这个可以说是最最常用的存图方式了,请务必将板子一字不差地背下来

链式前向星的基本思路是开 n链表,第 i 个链表代表第 i 个点的出边集合。

每个点的出边维护一个向下的指针,每个点再维护一个头指针。

没看懂没有关系,可以直接背板子,板子很短。

代码示例

struct edge {
    int to, nxt;
    //to 表示这条边的终点,nxt 表示下一条边的指针
} e[maxm];

int tot, head[maxn];
//tot 表示当前边的总数,head[i] 表示第 $i$ 个点出边的头指针
void add(int u, int v) {
    e[++tot].to = v;
    e[tot].nxt = head[u];
    head[u] = tot;
}
\

在遍历每个点的出边时

//比如要遍历 u 的所有出边:
for(int i = head[u]; i; i = e[i].nxt) {
    int v = e[i].to;
    //v 代表一条出边的末端点
}
\

使用这种方法的优点是,空间小时间小灵活操作方便。相较于上两种方法,这种方法是最快的

\

有时,一个图上的点可能有点权(就是这个点上带一个数字),一个图上的边也可能有边权(就是这条边上带一个数字)。

遇到边权时,链式前向星的板子可以写成这样:

struct edge {
    int to, w, nxt;
} e[maxm];

int tot, head[maxn];
void add(int u, int w, int v) {
    e[++tot].to = v;
    e[tot].w = w;
    e[tot].nxt = head[u];
    head[u] = tot;
}
//w 代表边权
\

如果是点权的话,直接开一个数组记录每个点点权,即可。

\

Part3 图的遍历

如何将整个图都扫一遍呢?

常见的方法是 dfs(深度优先搜索)bfs(广度优先搜索)

\ \

应用

我们来找几个例题吧。

1. 给定一个 n 个点,m 条边的有向无环连通图,保证 1 号点入度为 0,求图中以每个点为起点的路径数(自己到自己也算一条路径)。

不妨开一个数组 cnt,用 cnt_u 来表示以点 u 为起点的路径数。

如果存在一条边 uv,那么以点 v 为起点的路径,点 u 也能走,所以 cnt[u] += cnt[v] 就可以了 。

dfs 即可。

主要程序

void dfs(int u) {
    cnt[u] = 1;
    for(int i = head[u]; i; i = e[i].nxt) {
        int v = e[i].to;
        dfs(v);
        cnt[u] += cnt[v];
    }
}
\

2. 给定一个 n 个点,m 条边的有向连通图,保证 1 号点入度为 0,请判断这个图是不是一个 DAG。

上一题直接给定了一个 DAG,而这道题让我们判断是不是 DAG,实际上就是判断这个图有没有环

我们还是拿最开始的那张图。

\

让我们动手从 1 开始模拟一下 dfs 的过程。

停!

我们从 2 走到了 5,现在又走到了 2,而 2 此时还在栈中。

这说明什么?

我们已经找到了一个环。

也就是说,这个图不是一个 DAG。

总结方法就是,如果我们 dfs 走到了一个还正在递归的结点,这说明找到了环,那么我们此时就可以返回 false 了。

可以开一个数组 vis,当 vis_u = 0 时,表示 u 还没有遍历;当 vis_u = 1 时,表示 u 已经全部遍历完;当 vis_u = -1 时,表示 u 正在递归。

主要参考代码

bool dfs(int u) {
    if(vis[u] == -1) return false;
    //u 正在递归,出现了环
    if(vis[u] == 1) return true;
    //u 已经走过,不用再走了
    vis[u] = -1;
    //将 u 设置为正在递归

    for(int i = head[u]; i; i = e[i].nxt) {
        int v = e[i].to;
        if(!dfs(v)) return false;
    }
    vis[u] = 1;
    //u 已经递归完毕
    return true;
}
\

Part4 图的更细的分化

图只分为有向图和无向图吗?

显然不仅仅是这样。

\

也许你见过下面这样的图:

这种类似于一个倒悬的树的图,我们称之为

一棵树有什么呢?

它有树根,有子树,还有叶子,有......

有很多。我们一一来介绍。

\ \

以上概念都是非常常用的,建议感性理解一下。

\

细心观察的你可能发现了,图中这棵树上的边都是没有方向的。也就是说,树实际上是一个无向图

\

唉?那怎么区分一个无向图是不是树呢?

\

再观察一下这张图:

发现了什么没?

没有?可以翻一下上面对于树的一些概念的介绍:

除根结点外,每个结点有且仅有一个父亲。

\

想到了吗?

没错,除根结点外,每个点和父亲只有一条连边。

也就是说,如果一个连通的无向图n 个点,那么只要这个图有恰好 n-1 条边,那么这就是一棵树

\

很好理解对吧。

\

那么就让我们做一些习题吧。

\

3. 给定一个 n 个点的树,令 1 为根结点,请求出每个结点的子树大小(即子树中共有多少个点)。

输入:第一行一个正整数 n,接下来 n-1 行,每行两个整数 uv 表示 uv 之间有一条边。

\

首先输入这个树。

唉?无向图怎么存边呢?

无向图,实际上就是双向都有,加边的时候正反各加一遍就好。

for(int i = 1; i < n; i++) {
    int u, v;
    scanf("%d %d", &u, &v);
    add(u, v); add(v, u);
}
\

当然,你要注意,无向图中,链式前向星存边的数组要开 2 倍(因为每个边都正反加了两次)。

struct edge {
    int to, nxt;
} e[maxn<<1];
\

接下来祭出 dfs 大法。

由于指定了根结点是 1,所以直接 dfs(1) 就好了。

考虑 dfs 怎么写。

假如 uv 的父亲,而 v 的子树大小我们已经求出来了,那么怎么求 u 的子树大小呢?

vu 的儿子,那么 v 的子树,自然也是 u 的子树的一部分。(子树的子树)

所以用 sze 来表示每个结点子树大小的话,sze[u] += sze[v] 即可。

别忘了初始时设所有 sze 都为 1。(自己也算自己子树内的结点)

\

还有,因为这是无向图,而我们之前存边时,每个边正反存了两次。所以在 dfs 时,需要判断走的这条边有没有走到了父结点。

父结点刚走到你,你又走回来了。

void dfs(int u, int fa) {
    //fa 表示 u 的父结点
    sze[u] = 1;
    for(int i = head[u]; i; i = e[i].nxt) {
        int v = e[i].to;
        if(v == fa) continue;
        //不能走回父亲结点
        dfs(v, u);
        sze[u] += sze[v];
    }
}
\

最后输出即可。

\

关于树的其它知识点,我会在后面的文章写出来。

虽说还没有开写啦

\

还是回到我们的 DAG

\

Part5 拓扑排序

实际上就是让图的遍历顺序合理一些

\

先给出一个例题。

P1137 旅行计划

链接

形式化题意:给定一个 n 个点,m 条边的 DAG,求以每个点为终点的最长路径的长度。

\

一个朴素的想法是,用 ans_u 来表示点 u 的答案。如果存在一条边 uv,那么 ans_v 就是 ans_u+1.....吗?

想一个例子:假如存在两条边,1323,且 ans_1 = 1ans_2 = 2

此时 ans_3 应该是多少呢?

ans_1 + 1 = 1 + 1 = 2 吗?显然不是

$$ \ $$ 也就是说,如果有若干个点可以走到同一个结点,那么这个结点的答案应该取**最大值**。 $$ ans_v = {\rm max} \{ ans_u \} + 1 $$ 取最大值,也就是说,只有把点 $v$ 的**所有入边**都算完之后,才能真正得到 $v$ 的答案,$ans_v$。 所以我们在跑这个图的时候,**需要遵循一定的顺序**,这个顺序需要保证:**如果存在一条边 $u$ 到 $v$,则 $u$ 必须在 $v$ 之前**。 $$ \ $$ 这就是**拓扑排序**的用处,用来求上述的一个顺序,这个顺序我们也称之为**拓扑序**。 如图。 ![](https://cdn.luogu.com.cn/upload/image_hosting/eumjplad.png?x-oss-process=image) 首先,我们一眼就能发现,$1$ 一定是这个图的拓扑序的第一个点。 为什么? 因为 $1$ 没有任何一条入边,也就是说 $1$ 的**入度为 $0$**。 所以有: - **在拓扑序中,入度为 $0$ 的点一定在最前面。** 考虑第二个点。 在和 $1$ 相邻的点中,$2$ 和 $3$ 哪个是拓扑序的第二个点呢? $$ \ $$ 考虑,如果是 $3$ 的话,不行,因为有一条 $2$ 到 $3$ 的边。 如果是 $2$ 的话,那么就可以满足要求。 实际上可以这样考虑:如果把原图中的点 $1$ 删掉,那么此时 $2$ 的入度就减少 $1$,变成 $0$ 了。 此时 $2$ 满足要求,所以把它放在第二位。 $$ \ $$ 接下来也不难。将点 $2$ 删除,此时 $3$ 的入度为 $0$,放在第三位。 将点 $3$ 删除,此时 $4$ 的入度为 $0$,放在第四位。、 将点 $4$ 删除,此时 $5$ 的入度为 $0$,放在第五位。 将点 $5$ 删除,此时图中没有结点了,也就是说**所有结点入度都变为 $0$**。 $$ \ $$ 所以可以总结出拓扑排序的中心思路: - **按顺序将拓扑序中的点扫一遍,每次遍历所有出边,令这些出边所指向的点入度减 $1$。若某一个点入度变为 $0$,则将其加入拓扑序的末尾。** $$ \ $$ 于是这就是拓扑排序。 **代码参考**: ```cpp void toposort() { queue<int> q; for(int i = 1; i <= n; i++) if(!in[i]) q.push(i), topo[++cnt] = i; //in[i] 表示点 i 的入度,topo 数组表示拓扑序 while(!q.empty()) { int u = q.front(); q.pop(); for(int i = head[u]; i; i = e[i].nxt) { int v = e[i].to; in[v]--; if(!in[v]) q.push(v), topo[++cnt] = v; } } } ``` 时间复杂度 $\mathcal{O}(n+m)$,也就是线性的。 $$ \ $$ 但我们不得不说,**一个图的拓扑序可能不止一个**。 ![](https://cdn.luogu.com.cn/upload/image_hosting/0fzsa4li.png?x-oss-process=image) 我们把上图的边 $1$ 到 $2$ 删除后,得到了这样的一个图。 这下,$1$ 和 $2$ 的入度都为 $0$ 了,那么拓扑序中 $1$ 在前还是 $2$ 在前都不重要了。 因为 $1$ 和 $2$ 之间没有边,二者没有关系! 所以这个图就有两个拓扑序:$12345$ 和 $21345$,这两种都对。 $$ \ $$ 我们还需要强调的是,拓扑排序只能用于 DAG,有向无环图。 为什么? $$ \ $$ **考虑有向有环图,就拿最开始的图为例。** ![](https://cdn.luogu.com.cn/upload/image_hosting/ozistkni.png?x-oss-process=image) 我们说过,在拓扑序中,如果存在一条 $u$ 到 $v$ 的边,那么 $u$ 一定在 $v$ 之前。 看到了吗?$2$ 在 $4$ 之前,$4$ 在 $5$ 之前,$5$ 在 $2$ 之前。 ~~真的成环了~~ 所以有环图是不能拓扑排序的,它不存在拓扑序。 $$ \ $$ **考虑无向图。** 这不是废话嘛,一条边可以走两个方向,这不就是一个环() 比如 $1$ 和 $2$ 之间有边,那么 $1$ 可以到 $2$,$2$ 可以到 $1$。 就这样。 $$ \ $$ **关于习题** 实际上,本文中 Part1 到 Part4 都是基础知识,应该没有对应的题目。 Part5 的拓扑排序还是有一些题目的,一般考察的都是**拓扑排序 + dp**。 其实拓扑排序就是 DAG 上 dp 的经典套路。 建议多加练习,熟悉板子,可以在洛谷的题单中找题做做。 $$ \ $$ 这里可能会列举一些题目,当然这都是后话了,有时间我会补充进来的。 $$ \ $$ 本文就到这里啦 : ),欢迎阅读我的其它文章(~~虽说都还没写~~)。 $$ QWQ $$