图基础与拓扑排序
图的基本概念
众所不一定周知,图是由两部分组成:点和边
一个最简单的有向图:

一些基础概念:
-
有向图
边存在方向,即 \(u\to v\) 不代表 \(v\to u\)。
-
无向图
边不存在方向,\(u\to v\) 同时代表 \(v\to u\)。
在代码中一般用双向图代表无向图。 -
自环
存在边 \(u\to u\) 即为自环。
-
重边
存在两条或多条 \(u\to v\) 的边。
-
简单图
不存在 重边 和 自环 的图。
-
度数
点的度数包括 入度 和 出度。
- 入度即指向该点的边的数量
- 出度即该点发出的边的数量
点的度数为入度和出度的和。
-
权值
包括边权和点权,边权可以理解为边的长度。
图的建立与存储
以有向图为例
1. 直接存边
常用于 kruskal 求最小生成树中。
直接用一个 struct 将边的信息存下来,包括边连接的两个点和边权值。
struct edge
{
int from, to, val;
} e[N];
main() 中
for (int i = 1; i <= m; i++)
cin >> e[i].from >> e[i].to >> e[i].val;
sort(e + 1, e + m + 1, cmp);
2. 邻接矩阵
常用于 Floyd 求最短路中。
用一个二维数组 dis 来存储边的信息。
对于无权图(没有边权)来说,dis[u][v] 常用 \(0\) 代表 \(u\) 和 \(v\) 之间没有边连接,用 \(1\) 代表 \(u\) 和 \(v\) 之间有边连接;
而有权图中,常用极大值代表没有边相连,用其他较小的数代表相连的边的权值。
for (int i = 1; i <= m; i++)
{
int u, v, w;
cin >> u >> v >> w;
dis[u][v] = w;
}
这样存图的空间复杂度是 \(O(n^2)\) 的,当 \(n\) 的范围达到 \(10^5\) 时需要考虑其他方法。
3. 邻接表(vector)
可用于大多数存图。
当数据规模在 \(n=10^5,m=10^5\) 时,邻接矩阵中会有大量的 dis[u][v] 为空,需要考虑优化掉这些未使用的空间。
考虑到 vector 可以动态存储元素,则可以对每一个点 \(u\) 开一个 vector,以存储由点 \(u\) 发出的每条边的信息(如边指向的点,边权值)。
vector<vector<int>> e(n + 7);
for (int i = 1; i <= m; i++)
{
int u, v;
cin >> u >> v;
e[u].push_back(v);
}
struct node
{
int to, val;
node() {}
node(int to, int val) : to(to), val(val) {}
};
vector<vector<node>> e(n + 7);
for (int i = 1; i <= m; i++)
{
int u, v, w;
cin >> u >> v >> w;
e[u].push_back(node(v, w));
}
空间复杂度 \(O(m)\)。
4. 链式前向星
可用于大多数存图。
使用时的复杂度和 邻接表 相同,只掌握其中一种即可。
链式前向星的思想类似链表。
对于每个点,存储其发出的最后一条边的编号(fir 数组)。
对于每条边,存储其之前的那条边(nex 数组)、指向的点(poi 数组)和权值(val 数组)等信息。
代码如下:
int fir[N], nex[M], poi[M], val[M], cnt;
void ins(int x, int y, int z)
{
++cnt;
nex[cnt] = fir[x];
poi[cnt] = y;
val[cnt] = z;
fir[x] = cnt;
}
main() 中:
for (int i = 1; i <= m; i++)
{
cin >> u >> v >> w;
ins(u, v, w);
}
当我们新 ins 一条边的时候,首先边的编号 ++cnt,然后, cnt 这条边的前一条边就是更新前的点 x 的最后一条边,即 nex[cnt] = fir[x],然后统计边 cnt 的信息(poi 和 val),最后将点 x 的最后一条边更新为边 cnt,即 fir[x] = cnt。
空间复杂度 \(O(m)\)。
图的遍历
对于邻接表和链式前向星而言,需要考虑图的遍历的问题。
(本章节只考虑如何遍历,不考虑遍历的过程中需要维护什么)
图的遍历实现的基础是搜索,故有两种写法:dfs 和 bfs。
以 dfs 为例,其搜索模板是:
void dfs(当前状态)
{
if (终止条件)
{
统计 / 输出;
return;
}
for (方向 / 边)
{
if (访问过)
continue;
标记为访问过;
dfs(下一个状态);
}
}
两种存图方式的主要区别在 for 遍历中。
对于邻接表,遍历为:
for (auto p : e[now])
{
if (vis[p])
continue;
vis[p] = 1;
dfs(p);
}
直接在 vector 中遍历所指向的所有点即可。
对于链式前向星,遍历为:
for (int i = fir[now]; i; i = nex[i])
{
int p = poi[i];
if (vis[p])
continue;
vis[p] = 1;
dfs(p);
}
即先找到当前点发出的最后一条边,然后通过边去找点之前发出的边,即可实现遍历。
两种遍历图的方式复杂度都是 \(O(n+m)\)。
邻接表的优点有:
- 代码短,便于理解和记忆;
- 可以对点进行排序,方便按照某种方式进行遍历。
链式前向星的优点有:
- 常数小,运行速度比邻接表稍快;
- 在网络流中的某些操作中找反边方便。
拓扑排序
了解拓扑排序之前,首先要了解一种特殊的图:有向无环图。
顾名思义,有向无环图(DAG)满足两个性质:
- 有向图;
- 无环.即对于任意存在的 路径 \(u\to v\),不存在路径可以满足 \(v \to u\)。
只有有向无环图可以进行拓扑排序,能进行拓扑排序的一定是有向无环图。
首先明确,拓扑排序是给点按照先后关系进行排序的,而且需要使用 bfs。
对于某些点,若没有其他的点指向该点,则说明该点是整个图的“第一层”,直接放到队列中即可。
而判断有没有其他的点指向某个点,则需要用到 入度 这个概念。
当点的入度为 \(0\) 时就说明这个点时“第一层”的点,可以直接加入队列。
随后遍历队列中存的点。
每当拿到一个点时,相当于这个点已经从图中删掉(被拿去排序了),所以便可将这个点和这个点发出的所有的边一起删掉。
而代码实现上,也不需要直接删除,只需要将边指向的点的入度 \(-1\) 即可。
持续遍历下去,知道队列为空即可。
代码:
#include <bits/stdc++.h>
using namespace std;
int main()
{
int n;
cin >> n;
vector<vector<int>> e(n + 7);
vector<int> deg(n + 7);
for (int i = 1; i <= n; i++)
{
int x;
cin >> x;
while (x)
{
e[i].push_back(x);
deg[x]++;
cin >> x;
}
}
queue<int> h;
for (int i = 1; i <= n; i++)
if (deg[i] == 0)
h.push(i), cout << i << ' ';
while (h.size())
{
int now = h.front();
h.pop();
for (auto p : e[now])
{
deg[p]--;
if (deg[p] == 0)
{
h.push(p);
cout << p << ' ';
}
}
}
return 0;
}

浙公网安备 33010602011771号