『学习笔记』图的基本应用

\(\textsf{update on 2022/6/18 修补了不足之处,修改码风,增加了推荐习题。}\)


什么是图

图 (\(Graph\)),是顶点 (\(Vertex\)) 和边 (\(Edge\)) 的集合。

图分为有向图和无向图,带权图和无权图。

  • 有向图:边是有方向的,即只能单向通行。
  • 无向图:边是双向的,即可以双向通行。
  • 带权图:边是带权的,即每条边都有一个特定的值 \(w\)
  • 无权图:每条边的权值都为 \(1\)

图的相关概念:

  • 路径:从顶点 \(u\) 到顶点 \(v\) 的一种方案,即通过任意条边从 \(u\) 到达 \(v\)
  • 连通图:任意两个顶点之间都有至少一条路径。
  • 子图:故名思意,当一个图 \(H\) 是图 \(G\) 的子集时,那么称 \(H\)\(G\) 的子图。
  • 二分图:图被分为两个子集,每个子集的顶点互不相交,即没有边连接同一个子集中的顶点。
  • 稀疏图:即顶点较多,而边很少的图,顶点和边的比较小。
  • 稠密图:与稀疏图相反,顶点和边的比较大。

一般情况下,图的顶点数设为 \(n\),边数设为 \(m\),输入边时,输入 \(u\ v\ w\) 表示有一条边从 \(u\) 连向 \(v\),权值为 \(w\)

图的存储

邻接矩阵

定义一个 \(n \times n\) 的矩阵 \(g\)\(g_{u,v}\) 表示顶点 \(u\) 和顶点 \(v\) 之间边的权值,如果顶点之间没有边,则为 \(0\)。每个顶点与自己的距离为 \(0\),即 \(g_{u,u}=0\)

若是无权图,那么如果 \(g_{u,v}\) 有连边,那么 \(g_{u,v}=1\)

以这样一张无向无权图为例:

那么对应的邻接矩阵就是:

\[\begin{matrix}& \begin{matrix} 0&1&2&3&4&5 \end{matrix}\\ \begin{matrix} 0\\1\\2\\3\\4\\5 \end{matrix}& \begin{bmatrix} 0&0&1&0&1&1\\ 0&0&0&0&1&1\\ 1&0&0&1&1&0\\ 0&0&1&0&0&0\\ 1&1&1&0&0&1\\ 1&1&0&0&1&0\\ \end{bmatrix} \end{matrix} \]

可以发现,无向图的邻接矩阵中,沿正对角线对称。

邻接矩阵代码(有向带权图):

#include <iostream>
using namespace std;
const int N=1e3+5; // 顶点数最大为1000
int n,m,u,v,w;
int g[N][N]; // 邻接矩阵存图

int main(){
    // n个点,m条边
    cin >> n >> m;
    while(m--){
        // 依次输入m条边
        // 从顶点u到顶点v有一条权值为w的边
        cin >> u >> v >> w;
        g[u][v]=w;
    }
    // do something
    return 0;
}

邻接矩阵的空间复杂度为 \(\mathcal{O}(n^2)\),所以只能存储较小的图。它的空间复杂度太大了,所以很多情况下都不会使用。

邻接表

这是一种更优的存图方式,优化了邻接矩阵,将为每个顶点建立的与其它顶点的关系数组改为链表,只存储和当前顶点之间有连边的顶点。对于稀疏图来说,有很大的优势。

还是那张图,来看一看它的邻接表存储:

img

存储为:

\[\begin{matrix} 0:&2&\rightarrow&4&\rightarrow&5 \\ 1:&4&\rightarrow&5 \\ 2:&0&\rightarrow&3&\rightarrow&4 \\ 3:&2\\ 4:&0&\rightarrow&1&\rightarrow&2&\rightarrow&5\\ 5:&0&\rightarrow&1&\rightarrow&4\\ \end{matrix} \]

注意,每个链表中的顶点是独立的,只跟这个顶点有关系,跟别的顶点没半点关系。不要将箭头误解为边。每个顶点都有一个对应的链表,这个链表存储了与顶点相连的所有顶点编号。可以将上面的 \(\rightarrow\) 看作逗号。

那么如何存储带权图呢?

考虑这张图:

那么链表的每个结点就要记录两个值了:连接的顶点,连接的某个顶点的边的权值。

邻接表如下:

\[\begin{matrix} 1:&3(3)&\rightarrow&4(1)&\rightarrow&5(5)\\ 2:&3(4)&\rightarrow&4(2)\\ 3:&1(3)&\rightarrow&2(4)&\rightarrow&4(7)\\ 4:&1(1)&\rightarrow&2(2)&\rightarrow&3(7)&\rightarrow&5(8)\\ 5:&1(5)&\rightarrow&4(8)\\ \end{matrix} \]

就是这么简单,对于链表,我们使用 STL 中的 vector 存储,每个节点需要两个值,使用 pair<int,int>,下面是一个存储有向带权图的程序:

#include <iostream>
#include <vector>
using namespace std;
const int N=1e3+5; // 最大顶点数:1000
int n,m,u,v,w;
vector<pair<int,int>> g[N]; // 邻接表存图

int main(){
    cin >> n >> m;
    while(m--){
        cin >> u >> v >> w;
        g[u].push_back(make_pair(v,w)); // 存边
        // pair<到达的顶点编号,边权>
    }
    // do something
    return 0;
}

这种存图方式相比邻接矩阵占用空间少多了,但还存在一个问题:如果一个顶点没有与任何顶点相连,那么这个顶点的 vector 就多占了一些空间,还有一种更优的存图方式:链式前向星。

链式前向星

就是直接用链表代替 vector,省去了不必要的空间,常数更小。

用一个数组 \(e\) 存储所有边,类型为边的类型 edge

edge 中包含:

  • to:这条边指向的顶点。
  • nxt:这条边之前输入的一条从同一顶点出发的边在数组 \(e\) 中的下标。
  • val:视情况而定,如果是带权图,那么就有这一项。表示边权。

然后再定义一个数组 \(head\),存储每个顶点的最后一条边在 \(e\) 中的下标。

先来看看如何加边吧。

void add(int u,int v,int w){ // 传入一条边,参数分别为起始顶点,指向顶点,边权
    top++; // 边的总数+1
    e[top].to=v; // 这条边通向的是 v
    e[top].val=w; // 这条边的边权
    e[top].nxt=head[u];
    // head[u] 即为上一个输入的,起始顶点与当前边相同的边在 e 中的下标
    // nxt 连向 head[u] 的作用便是让我们遍历某个顶点的所有出边时,可以找到上一条出边,还可以通过上一条出边找到上上条,直到遍历了所有出边
    head[u]=top; // 既然又输入了一条,那么由顶点 u 出发的所有边中最后输入的一条就是当前这条边了
}

举个例子吧,依次输入了(每个括号中依次为 \(u,v,w\),即为起始顶点,到达顶点,边权):

\((1,3,4),(1,2,4),(1,5,1),(1,3,6)\)

本例中,这些边的起始顶点都为 \(1\),只是为了方便模拟过程。输入中其中若有其它起始顶点,也是一样的道理。

head 数组初值全部为 \(0\),显然某个起始顶点第一条出边的 nxt 就为 \(0\)

  • 输入 \((1,3,4)\):这时输入了第 \(1\) 条边。\(e_1\) 通向的是顶点 \(3\),边权是 \(4\),由于是第一条边,nxt\(0\),即表示没有上一条边。
  • 输入 \((1,2,4)\):第 \(2\) 条边,\(e_2\) 通向的顶点为 \(2\),边权也是 \(4\),但起始顶点是同一个的,上一个输入的边在 \(e\) 中的下标为 \(1\)\(head_1\) 存着),nxt 自然指向 \(1\)
  • 输入 \((1,5,1)\):第 \(3\) 条起始顶点为 \(1\) 的边,存储在 \(e_3\)。通向顶点 \(5\),权值 \(1\),上一条同一起始顶点相同的边就在隔壁 \(e_2\),所以 nxt\(2\)
  • 输入 \((1,3,6)\):假设在输入这条边之前输入了 \(3\) 条起始顶点不是 \(1\) 的边。那么这条边就应该存在 \(e_{3+3}\) 也就是 \(e_6\)。通向 \(3\),权值 \(6\),而它的上一条同一起始顶点的边在 \(e_3\),所以 nxt\(3\)

最后,起始顶点为 \(1\) 的遍历过程就是:

  • 先取 \(head_1\),发现最后一条边在 \(e_6\),先对 \(e_6\) 进行操作。
  • 通过 \(e_6\)nxt 得知,另一条相同起始顶点的边在 \(e_3\),对 \(e_3\) 操作。
  • 通过 \(e_3\)nxt,对 \(e_2\) 进行操作。
  • 通过 \(e_2\)nxt,对 \(e_1\) 进行操作。

也不难理解吧?

下面是遍历某个顶点 \(u\) 的代码:

// 设 u 是要遍历的顶点
// 从第一条边开始遍历,每次找下一条边
// 要是遍历到最后一条边了,那么这条边的 nxt 一定为 0,退出循环
for(int i=head[u]; i; i=e[i].nxt){
    int v=e[i].to,w=e[i].val;
    // v 是 u 连向的一个顶点,w 是边权。
    // do something
}

请注意,循环的第二项,也就是循环成立条件,为 \(i\) 是否等于 \(0\),因为 nxt 初值为 \(0\),如果还有相邻顶点,这个 nxt 一定指向某个节点在链表 \(e\) 中的下标。

\(i=0\) 时就说明没有相邻顶点了,退出。

如果最小顶点 \(\leq 0\) 怎么办呢?这就要先将 \(e\) 中所有 \(nxt\) 初始化为一个比最小顶点编号还要小的值,判断条件改为是否等于这个值,就可以顺利遍历了。

就是这么简单,链式前向星是目前最省空间的存图方式,有人说这个就是邻接表,虽说没什么大问题,但链式前向星还是与邻接表有区别的。

链式前向星这名字真帅...

图的遍历

图的遍历分为深度优先搜索和广度优先搜索,简单来说,就是搜索。

搜索对于图论来说,就是基础算法,很多图论算法都是基于搜索的。

深度优先搜索 (dfs)

一搜直接搜到底,然后回溯,再搜其它边。

以这张图为例,让我们来看看它的深度优先搜索是如何进行的。

设搜索初始顶点为 \(1\)

\[\texttt{顶点1(1)} \begin{cases} \texttt{顶点2(2)} \begin{cases} \texttt{顶点3(3)} \begin{cases} \texttt{顶点6(4)}\\ \texttt{回溯} \end{cases}\\ \texttt{顶点5(5)} \begin{cases} \texttt{顶点7(6)}\\ \texttt{回溯} \end{cases}\\ \texttt{回溯} \end{cases}\\ \texttt{顶点4(7)} \begin{cases} \texttt{顶点8(8)} \begin{cases} \texttt{回溯} \end{cases}\\ \texttt{回溯} \end{cases}\\ \texttt{顶点6:已经去过了,就不用再去了}\\ \texttt{回溯} \end{cases} \]

括号中代表搜索的顺序。一直搜到底才回溯,就是深度优先搜索。

这样就能成功遍历这张图了。那么这张图的深度优先搜索序(其中一种)就是:\(1,2,3,6,5,7,4,8\),深度优先搜索序可以有多种,取决于。代码也很好写,我们使用链式前向星存图,下面是 dfs 代码(输出搜索序):

// id表示搜索到的当前顶点
// vis数组表示是否访问过,初始值为0
// write是快读函数,输出后接一个空格
void dfs(int id){
    vis[id]=1; // 标记
    printf("%d\n",id) // 先输出
    // 遍历顶点
    for(int i=head[id]; i; i=e[i].nxt)
        // 只要一个相邻顶点没访问过,就进去搜。
        if(!vis[e[i].to])
            dfs(e[i].to);
}

普遍的深度优先搜索序就是这样的,和树的前序遍历一样,如果需要求图的后序遍历,只需将输出语句放在函数末尾即可。

广度优先搜索 (bfs)

和树的广度优先搜索一样,可以将它想成同学传话,同学 \(1\) 号同时传给周围的几个同学,周围的几个同学又传给自己周围的同学,然后再传 \(\dots \dots\) 直到没有同学不知道要传的话了为止。

同样:

\[\texttt{顶点1(1)} \begin{cases} \texttt{顶点2(2)} \begin{cases} \texttt{顶点3(3)} \begin{cases} \texttt{顶点6(4)}\\ \texttt{回溯} \end{cases}\\ \texttt{顶点5(3)} \begin{cases} \texttt{顶点7(4)}\\ \texttt{回溯} \end{cases}\\ \texttt{回溯} \end{cases}\\ \texttt{顶点4(2)} \begin{cases} \texttt{顶点8(3)} \begin{cases} \texttt{回溯} \end{cases}\\ \texttt{回溯} \end{cases}\\ \texttt{顶点6:已经去过了,就不用再去了}\\ \texttt{回溯} \end{cases} \]

这次则是直接从左依次推到右,但你好像要学会分身术,是这样的(层次遍历关系):

  • 顶点 \(1\)
  • 顶点 \(1\) 中:顶点 \(2\) 和 顶点 \(4\)
  • 顶点 \(2\) 中:顶点 \(3\),顶点 \(5\);顶点 \(4\) 中:顶点 \(8\)
  • 顶点 \(3\) 中:顶点 \(6\);顶点 \(5\) 中:顶点 \(7\)

这样就可以生成广度优先搜索序了:\(\color{red}1\color{black},\color{orange}2\color{black},\color{orange}4\color{black},\color{gold}3\color{black},\color{gold}5\color{black},\color{gold}8\color{black},\color{green}6\color{black},\color{green}7\) 其中标了颜色,不同的颜色代表不同的层次。

至于代码,使用一个队列,每次将遍历的顶点的所有相邻顶点 push 进去,每次遍历使用队列头顶点,pop 掉,代码也很好写:

// vis数组表示是否访问过,初始值为0
// write是快读函数,输出后接一个空格
// q是队列
void bfs(){
    q.push(1); // 初始顶点先入队
    vis[1]=1; // 标记
    while(!q.empty()){
        int u=q.front();
        q.pop();
        write(u); // 先输出
        for(int i=head[u]; i; i=e[i].nxt){
            int v=e[i].to;
            if(!vis[v]){ // 没去过才push
                q.push(v); // 将每个相邻顶点扔进队列
                vis[v]=1;
            }
        }
    }
}

链式前向星存图是反着遍历的,它越早输入的边就越靠后遍历(数组的前面),head 指向的是最后一条连接某个顶点的边,依次找到第一条连接某个顶点的边,所以不是输入的顺序。

上面代码输入:

8 8
1 2
1 4
1 6
2 3
2 5
3 6
4 8
5 7

会输出一种广度优先搜索序:\(\color{red}1\color{black},\color{orange}6\color{black},\color{orange}4\color{black},\color{orange}2\color{black},\color{gold}8\color{black},\color{gold}5\color{black},\color{gold}3\color{black},\color{green}7\),这种顺序也是可以的。

P5318 【深基18.例3】查找文献

题目大意:

输入一张有向无权图,输出最小的 dfs 序和 bfs 序。

思路

既然让我们输出最小的序,那么邻接矩阵是无需做任何操作即可实现,但数据范围太大:\(n \leq 10^5\),邻接矩阵存不下,那就要考虑邻接表了,我们可以输入后先排序,再搜索。链式前向星也不合适,虽然更快,但做到排序还是很难的。

dfs 和 bfs 参考上面的链式前向星搜索,只需改变遍历方式即可。

代码

#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
template<typename T=int>
inline T read(){
    T X=0; bool flag=1; char ch=getchar();
    while(ch<'0' || ch>'9'){if(ch=='-') flag=0; ch=getchar();}
    while(ch>='0' && ch<='9') X=(X<<1)+(X<<3)+ch-'0',ch=getchar();
    if(flag) return X;
    return ~(X-1);
}

template<typename T=int>
inline void write(T X){
    if(X<0) putchar('-'),X=~(X-1);
    T s[20],top=0;
    while(X) s[++top]=X%10,X/=10;
    if(!top) s[++top]=0;
    while(top) putchar(s[top--]+'0');
    putchar(' ');
}

const int N=1e5+5;
int n,m,u,v;
bool vis[N];
queue<int> q; // 用来广搜的队列
vector<int> p[N]; // 邻接表

void dfs(int x){ // 和上面一样,深搜板子
    vis[x]=1;
    write(x);
    // 就是遍历改了一下,邻接表
    for(int i=0,len=p[x].size(); i<len; i++)
        if(!vis[p[x][i]])
            dfs(p[x][i]);
}

void bfs(){ // 也是一样的,就改遍历
    q.push(1);
    vis[1]=1;
    while(!q.empty()){
        u=q.front(); // u 可以直接用全局里定义的
        q.pop();
        write(u);
        for(int i=0,len=p[u].size(); i<len; i++){
            v=p[u][i]; // 同上的 u
            if(!vis[v]){
                q.push(v);
                vis[v]=1;
            }
        }
    }
}

int main(){
    n=read(),m=read();
    for(int i=1; i<=m; i++){
        u=read(),v=read();
        p[u].push_back(v); // 邻接表存图
    }
    for(int i=1; i<=n; i++)
        sort(p[i].begin(),p[i].end()); // 排序所有顶点
    dfs(1);
    puts("");
    memset(vis,0,sizeof(vis));
    bfs();
    puts("");
    return 0;
}

推荐习题

拓扑排序

拓扑排序是对有向无环图上的顶点进行排序,使每一条 \(u \rightarrow v\) 边,\(u\) 总在 \(v\) 之前出现。就是将一个有向无环图拉成一条链,使顶点先后顺序不变。

我们称有向无环图为 DAG(Directed Acyclic Graph)

想象一个场景:学习算法。

例如要学习拓扑排序,那要先学会什么是图,图的存储,图的遍历等知识。我们将每个算法抽象为一个顶点,入边是需要先学习的算法指来的,出边则是该算法学习后才可以学的算法。

可以证明,它是有向无环的。设想一下,如果有 \(3\) 个算法,分别为 \(1,2,3\),如果想学习 \(1\) 则需要先学习 \(3\),而学习 \(2\) 需要学习 \(1\),学习 \(3\) 需要学习 \(2\),那么就形成了一个环,无论怎么学都学不会任何一个算法。

拓扑排序一般就指使用 Kahn 算法,下面是具体流程:

  1. 将所有入度为 \(0\) 的顶点 push 进队列。
  2. 遍历队头 \(u\) 顶点的所有相邻顶点。
  3. 对于每一个点 \(v\),进行操作,并使其入度减一,删边。
  4. \(v\) 入度为 \(0\) 时,push 进队列
  5. 重复第 \(2\) 条,直到队列为空为止。

其中的入度表示指向某个顶点的边的个数,入度为 \(0\) 即表示某个算法不需要先学习任何算法就能学会。

出度表示通过某个顶点指向其它顶点的边的个数,即为前置知识为这个算法的算法的个数。

P4017 最大食物链计数

题目大意

给出一张 DAG,求从入度为 \(0\) 的顶点到出度为 \(0\) 的顶点的路径总数 \(\bmod 80112002\)

思路

拓扑排序板子题,用一个数组 \(f\) 表示从一个不会捕食其他生物的生产者到各个顶点的路径数,详细见代码。

代码

#include <iostream>
#include <vector>
#include <queue>
using namespace std;
template<typename T=int>
inline T read(){
    T X=0; bool flag=1; char ch=getchar();
    while(ch<'0' || ch>'9'){if(ch=='-') flag=0; ch=getchar();}
    while(ch>='0' && ch<='9') X=(X<<1)+(X<<3)+ch-'0',ch=getchar();
    if(flag) return X;
    return ~(X-1);
}

template<typename T=int>
inline void write(T X){
    if(X<0) putchar('-'),X=~(X-1);
    T s[20],top=0;
    while(X) s[++top]=X%10,X/=10;
    if(!top) s[++top]=0;
    while(top) putchar(s[top--]+'0');
    putchar('\n');
}

const int N=5e3+5,M=5e5+5,mod=80112002;
struct edge{
    int to,nxt;
}e[M]; // 链式前向星
int n,m,u,v,ans;
int head[N],top;
int ind[N],outd[N],f[N]; // 入度,出度,从一个不会捕食其他生物的生产者到每个顶点的路径数
queue<int> q; // 拓扑排序用队列

void add(int u,int v){
    top++;
    e[top].to=v;
    e[top].nxt=head[u];
    head[u]=top;
    outd[u]++; // 记录出度与入度
    ind[v]++;
}

void topo(){
    for(int i=1; i<=n; i++)
        if(!ind[i]){
            q.push(i); // push 入度为 0 的顶点
            f[i]=1; // 有 1 条路径
        }
    while(!q.empty()){
        int u=q.front();
        q.pop();
        for(int i=head[u]; i; i=e[i].nxt){
            int v=e[i].to;
            // 既然 u 能到 v,那么 v 的路径数就要加上 u 的路径数
            f[v]=(f[u]+f[v])%mod; // 记得取模
            if(!--ind[v]) // 删边并 push
                q.push(v);
        }
    }
}

int main(){
    n=read(),m=read();
    for(int i=1; i<=m; i++){
        u=read(),v=read();
        add(u,v);
    }
    topo(); // 拓扑排序
    for(int i=1; i<=n; i++)
        if(!outd[i]) // 不会被任何生物捕食的消费者,也就是出度为 0,说明要计入答案
            ans=(ans+f[i])%mod;
            // 拓扑排序时已经记录了 f[i],f[i] 表示从一个不会捕食其它生物的生产者到当前生物的路径数
            // 注意取模
    write(ans);
    return 0;
}

推荐习题

posted @ 2022-06-18 09:59  仙山有茗  阅读(162)  评论(2编辑  收藏  举报