图基础与拓扑排序

图的基本概念

众所不一定周知,图是由两部分组成:点和边

一个最简单的有向图:

一些基础概念:

  • 有向图

    边存在方向,即 \(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 的信息(poival),最后将点 x 的最后一条边更新为边 cnt,即 fir[x] = cnt

空间复杂度 \(O(m)\)

图的遍历

对于邻接表和链式前向星而言,需要考虑图的遍历的问题。

(本章节只考虑如何遍历,不考虑遍历的过程中需要维护什么)

图的遍历实现的基础是搜索,故有两种写法:dfsbfs

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)满足两个性质:

  1. 有向图;
  2. 无环.即对于任意存在的 路径 \(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;
}

练习

posted @ 2026-01-16 13:57  Zvelig1205  阅读(6)  评论(1)    收藏  举报