系列文章目录:
图
图(Graph)是一种由顶点和边组成的抽象数据结构,用于表示对象之间的连接关系。图中的顶点表示对象,边表示顶点之间的连接关系。
一般地,图这个集合用 $G(V,E)$ 表示,其中V是顶点(vertex)的集合,E是边(edge)的集合。
注意: 图的点是有穷非空集合,即顶点数是有限的,且至少有一个顶点,而边可以没有一条边。
图的分类
有向图和无向图
图按照边有无方向分为有向图和无向图,顾名思义有向图中的边是有方向的,而无向图中的边是没有方向的。
注意: 有向图的边集合 中要使用尖括号表示,强调关系指向。
简单图和多重图
图按照是否允许重边/自环可分为简单图(simple graph)、严格多重图(most common)和伪图(pseudograph),以下我们以无向图举例:
本章学习中一般探讨的内容都是简单图,即不允许有重边和自环。
稠密图与稀疏图
按照图的边数与顶点数的比值,图可以分为稠密图和稀疏图:
- 无向图
- 完全图:边数 $|E|=\frac{|V|(|V|-1)}{2}\approx \frac{|V|^2}{2}$
- 稠密图:边数 $|E| \sim \frac{|V|^2}{2}$
- 稀疏图:边数 $|E| \ll \frac{|V|^2}{2}$
- 有向图
- 完全图:边数 $|E|=|V|(|V|-1)\approx |V|^2$
- 稠密图:边数 $|E| \sim |V|^2$
- 稀疏图:边数 $|E| \ll |V|^2$
对于有向图和无向图最大边数的推导:
- 无向图:
我们令 $|V|=n$,则边数 $|E|=\binom{n}{2} =\frac{n(n-1)}{2}$,当 $n$ 趋于无穷大时,$|E|$ 趋于 $\frac{n^2}{2}$。
- 有向图:
我们同样令 $|V|=n$,那么每个顶点可以与剩下的 $n-1$ 个顶点连边,因此 $|E|=n(n-1)$,当 $n$ 趋于无穷大时,$|E|$ 趋于 $n^2$。
一些定义和关系
基本概念
- 路径(Path): 从一个顶点到另一个顶点所经过的结点序列
- 路径长度(Path Length): 路径上经过的边的个数
- 回路(环)(Cycle / Circuit): 路径的第一个顶点和最后一个顶点相同
- 简单路径(Simple Path): 不重复经过顶点的路径
- 简单回路: 除了第一个顶点和最后一个顶点外,不重复经过其他顶点的回路
- 欧拉图 (Euler Graph): 一个无向图,如果从图中任意两个顶点 $v_i$ 和 $v_j$ 都存在一条路径,则称该图为欧拉图
- 欧拉回路 (Euler Circuit): 在一个图中,若存在一条回路包含图中所有的边,则称这条回路为欧拉回路
- 度( Degree ,deg(v) ): 在无向图中,与一个顶点相邻的边的条数称为该顶点的度
- 入度( In-Degree ,deg⁻(v) ): 有向图中,以某顶点为终点的边的条数称为该顶点的入度
- 出度( Out-Degree ,deg⁺(v) ): 有向图中,以某顶点为起点的边的条数称为该顶点的出度
- 子图(Subgraph): 若两个图 $G=(V,E)$ 和 $G’=(V’,E’)$ 满足 $V’\subseteq V$ 且 $E’\subseteq E$,则称 $G’$ 是 $G$ 的子图
- 连通图(Connected Graph): 在无向图中,若从顶点 $v_i$ 到顶点 $v_j$ 有路径,则称 $v_i$ 和 $v_j$ 是连通的。若图中任意两个顶点都是连通的,则称该图为连通图
- 连通分量(Connected Component): 无向图中的极大连通子图称为该图的连通分量
- 连通分量为子图;
- 子图为连通图;
- 连通子图含有极大顶点数;
- 具有极大顶点数的连通子图包含依附于这些顶点的所有边
- 强连通图(Strongly Connected Graph): 有向图中,若从顶点 $v_i$ 到顶点 $v_j$ 有路径,且从顶点 $v_j$ 到顶点 $v_i$ 也有路径,则称 $v_i$ 和 $v_j$ 是强连通的。若图中任意两个顶点都是强连通的,则称该图为强连通图
- 强连通分量(Strongly Connected Component ,SCC): 有向图中的极大强连通子图称为该图的强连通分量
- 生成树(Spanning Tree): 一个连通图的生成树是一个极小连通子图,它含有图中全部顶点,但只有足以构成一棵树的 $|V|-1$ 条边
- 生成森林(Spanning Forest): 非连通图的生成森林是由各个连通分量的生成树构成的集合
- 权(Weight): 在图的边或顶点与某个数值之间建立对应关系,称为权
- 带权图(Weighted Graph / Network): 在图中边上带权值的图称为带权图,也称为网(络)
- 最短路径(Shortest Path): 从一个顶点到另一个顶点的权值之和最小的路径称为最短路径
- 最小生成树(Minimum Spanning Tree ,MST): 一个连通图的生成树是一个极小连通子图,它含有图中全部顶点,但只有足以构成一棵树的 $|V|-1$ 条边,且各边的权值之和最小
关系
顶点度公式
证明:
由于无向图每条边都对应两个顶点,每条边被它的两个端点各计算一次度,因此每条边被计算两次。即 $\sum_{v\in V}deg(v)=2|E|$
最大边数公式
树的性质
前面说过,树是一种连通且无环的无向图,满足树的边数 $|E|$ 等于顶点数 $|V|$ 减一,即 $|E|=|V|-1$ (用归纳法证)
欧拉图条件
- 无向图欧拉回路条件: 所有顶点的度数都为偶数
- 无向图欧拉路径条件: 连通且恰有 0 或 2 个奇度顶点(实际上这个是充分必要条件)
图的存储结构
邻接矩阵 (Adjacency Matrix)
邻接矩阵是这样一种矩阵,对于个某个元素 ,如果顶点 到 有边,则 ,否则 。
显然,对于有 $n$ 个顶点的图,邻接矩阵的大小为 $n^2$ 。
当然地,对于有向图,邻接矩阵是对称的,因为如果有 到 的边,那么一定有 到 的边,则 。
邻接表 (Adjacency List)
邻接表是这样一种数据结构,对于每一个顶点,存储一个链表,链表中的元素为与该顶点相连(出度)的顶点(位置)。
对于竞赛中,邻接表一般使用 vector<vector<int> > a(n); a[u].push_back(v)
表示u与v相连。
当然,如果是无向图,那么需要将两个顶点都加入对方的链表中,即:
1 2
| G[u].push_back(v); G[v].push_back(u);
|
如果是用指针实现,请参考下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| struct EdgeNode { int edge; EdgeNode* next; };
struct VertexNode { int vertex; EdgeNode *fiedge; };
struct Graph { int vNum; int eNum; VertexNode *AdjList; };
void addEdge(Graph &G, int u, int v) { EdgeNode *p = new EdgeNode; p->edge = v; p->next = G.AdjList[u].fiedge; G.AdjList[u].fiedge = p; }
void initGraph(Graph &G, int vNum, int eNum) { G.vNum = vNum; G.eNum = eNum; G.AdjList = new VertexNode[G.vNum]; for(int i=0; i<G.vNum; i++) { G.AdjList[i].vertex = i; G.AdjList[i].fiedge = nullptr; } }
|
一定要注意,如果对空间要求比较严格的场景中,使用邻接表来存储无向图会多一倍的空间开销,那么此时使用邻接表不是一个很好的选择。后路面讲到的邻接多重表就可以解决这个问题。
逆向邻接表
和前面的邻接表类似,只不过存储的是入度的顶点(位置)。
这里就不贴代码了,和邻接表类似。
十字链表(Orthogonal List)
十字链表将邻接表和逆邻接表结合起来,使得可以同时表示有向图的出度和入度。
在十字链表中:
- 顶点结点(VNode):存储顶点信息,以及指向第一条入边和第一条出边。
- 边结点(ENode):存储一条有向边
u->v
,主要包含:
tailvex
:边的起点编号
headvex
:边的终点编号
hlink
:下一条“以 v 为终点”的边(入边链)
tlink
:下一条“以 u 为起点”的边(出边链)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| struct ArcNode { int tailvex, headvex; ArcNode *hlink; ArcNode *tlink; };
struct VNode { int data; ArcNode *firstin; ArcNode *firstout; };
struct OLGraph { VNode *xlist; int vexnum, arcnum; };
void addArc(OLGraph &g, int u, int v) { ArcNode *arc = new ArcNode; arc->tailvex = u; arc->headvex = v; arc->hlink = g.xlist[v].firstin; g.xlist[v].firstin = arc; arc->tlink = g.xlist[u].firstout; g.xlist[u].firstout = arc; }
|
邻接多重表 (Adjacency Multi-list)
该数据结构主要用于优化无向图的存储,原理和十字链表类似。
- 顶点结点(VNode):存储顶点信息,以及连接该顶点一条边的指针
- 边结点(ENode):存储一条无向边
u-v
,主要包含:
ivex
:边连接其中一个顶点编号
jvex
:边连接另一个顶点编号
ilink
:挂在顶点 ivex
的下一条边
jlink
:挂在顶点 jvex
的下一条边
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| struct ENode { int ivex, jidx; ENode *ilink, *jlink; };
struct VNode { int idx; ENode *firstedge; };
struct AMLGraph { int vNum, eNum; VNode *adjList; };
void addEdge(AMLGraph &G, int u, int v) { ENode *p = new ENode; p->ivex = u; p->jidx = v; p->ilink = G.adjList[u].firstedge; G.adjList[u].firstedge = p; p->jlink = G.adjList[v].firstedge; G.adjList[v].firstedge = p; }
|
一个例子
例如下图,我们有集合 :
图的遍历
图的遍历方式有很多,最基础的是深度优先搜索(DFS)和广度优先搜索(BFS),之后我们还会学习拓扑排序(Topological Sort)、强连通分量遍历(SCC)、最短路径遍历、最小生成树遍历、Eulerian / Hamiltonian 遍历等方法。
深度优先搜索(DFS)
顾名思义,该方法就是沿着一个方向一直往下搜索,直到无法继续为止,然后回溯到上一个节点,继续搜索其他路径。
这里采取洛谷书上的伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| void dfs(...) // 函数内的参数主要是递归层数、当前节点位置等,记得要传vis { if(达到目的/遍历完毕) // 比如说遍历到终点 { 判断是否满足条件 记录结果 return; } for(所有邻接点) { if(该点未访问) { vis标记为已访问(保存现场) dfs(递归层数+1/下一邻接点); vis标记为未访问(恢复现场) } } }
|
递归的程序一定要留出口,否则会爆栈。
回溯时记得恢复现场,否则会影响后续的搜索。
广度优先搜索(BFS)
广度优先搜索就是从起点开始,一层一层地向外扩展,直到找到目标节点。
这里采取洛谷书上的伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| void bfs(...) //由于bfs采用队列,这里传入的参数主要是队列、起点位置等 { 初始化队列q; q.push(起点); while(!q.empty()) { auto now=q.front(); //获取队首元素 q.pop(); //弹出队首元素 for(所有邻接点) { if(该点合法的) q.push(下一邻接点); } } }
|
拓扑排序(Topological Sort)
拓扑排序是针对有向无环图(DAG)的遍历方式,它按照拓扑结构,将节点排序,使得每条有向边都从排在前面的节点指向排在后面的节点。
这里补充拓扑顺序的定义:
对于一个有向无环图(DAG) $G=(V,E)$ , 如果存在一个顶点序列:
使得满足每条有向边u->v都有u在v前面 , 那么这个序列就被称为拓扑顺序。
例如上面这个图,它的拓扑顺序有:
显然地,我们可以得到拓扑排序的性质:
- 拓扑排序的起点是入度为0的节点
- 拓扑排序的结果不唯一
该排序方法常用于任务调度、编译顺序(先编译依赖的库)、课程安排(先修课关系)、版本依赖管理等
拓扑排序的算法实现有两种方式,一种是Kahn(BFS)算法,另一种是DFS算法。
Kahn算法
Kahn算法是基于BFS实现的,它的思想是:每次从图中取出一个入度为0的节点,将其加入拓扑序列,然后将其所有邻接点的入度减1,重复此过程直到图为空。
这里我们使用栈来实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| int* topoSort(Graph &G) { int topo[G.vNum]; int in[G.vNum]={0}; for(int i=0; i<G.vNum; i++) { EdgeNode *p=G.AdjList[i].fiedge; while(p) { in[p->edge]++; p=p->next; } } Stack s; for(int i=0; i<G.vNum; i++) if(in[i]==0)s.push(i); int idx=0; while(!s.isEmpty()) { int u=s.top(); s.pop(); topo[idx++]=u; EdgeNode *p=G.AdjList[u].fiedge; while(p) { in[p->edge]--; if(in[p->edge]==0)s.push(p->edge); p=p->next; } } if(idx!=G.vNum) return nullptr; int *res=new int[G.vNum]; for(int i=0;i<G.vNum;i++) res[i]=topo[i]; }
|
竞赛中的写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| vector<int> topoSort(vector<vector<int> > &G) { vector<int> topo; vector<int> in(G.size(), 0); stack<int> s; for(int u=0; u<G.size(); u++)for(auto &v: G[u])in[v]++; for(int u=0; u<G.size(); u++)if(in[u]==0)s.push(u); while(!s.empty()) { int u=s.top(); s.pop(); topo.push_back(u); for(auto &v: G[u]) { in[v]--; if(in[v]==0)s.push(v); } } return topo; }
|
当然,对于可交换的顶点,如果要使其按字典序排列,可以使用priority_queue
DFS算法
使用DFS算法实现拓扑排序的思路是:从任意一个节点开始,进行深度优先搜索,当搜索到一个节点时,如果该节点的所有邻接点都已经访问过,则将该节点加入拓扑序列。但这样找到的排序是拓扑序列的逆序,最后要将其反转即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| struct topoSortDFS { Graph &G; int *vis, *topo, idx; bool hasCycle; topoSortDFS(Graph &Graph): G(Graph) { vis = new int[G.vNum]{0}; topo = new int[G.vNum]{0}; idx= G.vNum-1; hasCycle = false; } void dfs(int u) { vis[u]=1; for(EdgeNode *p=G.AdjList[u].fiedge; p; p->edge) { int v=p->edge; if(!vis[v]) { dfs(v); if(hasCycle) return; } else if(vis[v]==1) {hasCycle=true; return;} } topo[idx--]=u; vis[u]=2; } void run() { for(int i=0; i<G.vNum; i++) { if(!vis[i]) dfs(i); if(hasCycle) return; } } };
|
注意,这里的vis
数组有三种状态:
目的是为了区别正在访问的结点和已经访问的结点,如果dfs过程中vis为1,说明有环。
最小生成树
最小生成树(Minimum Spanning Tree,MST)是在带权无向强连通图中将该图转化为树,并使得该树边权和最小的一颗树。
树的边权和:
即 $|V|-1$ 条边的权值之和。请区分Huffman编码中的带权路径长度。
最小生成树的性质有:
- 该树的边数一定是 $|V|-1$
- 权值和最小
- 生成的树无根(因为图是无向的)
- 不唯一(如果有若干边权值相等)
- 局部最优->全局最优(贪心)
Kruskal 算法
Kruskal 算法是一种常见并且好写的最小生成树算法,由 Kruskal 发明。该算法的基本思想是从小到大加入边,是个贪心算法。算法步骤如下:
- 将所有边按权值从小到大排序
- 从权值最小的边开始,依次加入生成树,如果该边加入后不形成环,则加入生成树,否则舍弃该边 (并查集DSU)
- 重复步骤2,直到生成树中有 $|V|-1$ 条边为止
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
| struct Edge { int u,v,w; bool operator<(const Edge &other) const{return w < other.w;} };
struct DSU { vector<int> fa, rank; DSU(int n): fa(n), rank(n, 0) { iota(fa.begin(), fa.end(), 0); }
int find(int x) { if (fa[x] != x) fa[x] = find(fa[x]); return fa[x]; }
bool unite(int x, int y) { x = find(x), y = find(y); if (x == y) return false;
if (rank[x] < rank[y]) fa[x] = y; else if (rank[x] > rank[y]) fa[y] = x; else { fa[y] = x; rank[x]++; } return true; } };
struct LCRSNode { int vertex; LCRSNode* firstChild; LCRSNode* nextSibling; LCRSNode(int v): vertex(v), firstChild(nullptr), nextSibling(nullptr) {} };
vector<Edge> Kruskal(Graph& G) { vector<Edge> edges; for (int u=0; u<G.vNum; u++) { for (EdgeNode* p=G.AdjList[u].fiedge; p; p=p->next) { int v = p->edge, w = p->weight; if (u < v) edges.push_back({u, v, w}); } }
sort(edges.begin(), edges.end());
DSU dsu(G.vNum); vector<Edge> mst; for (auto& e : edges) { if (dsu.unite(e.u, e.v)) { mst.push_back(e); if (mst.size() == G.vNum - 1) break; } } return mst; }
LCRSNode* MSTtoLCRS(int root, vector<Edge>& mst, int n) { vector<vector<int>> tree(n); for (auto& e : mst) { tree[e.u].push_back(e.v); tree[e.v].push_back(e.u); }
function<LCRSNode*(int,int)> dfs = [&](int u, int parent) -> LCRSNode* { LCRSNode* node = new LCRSNode(u); LCRSNode* prevChild = nullptr; for (int v : tree[u]) { if (v == parent) continue; LCRSNode* child = dfs(v, u); if (!node->firstChild) node->firstChild = child; else prevChild->nextSibling = child; prevChild = child; } return node; };
return dfs(root, -1); }
|
竞赛中的写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| struct Edge { int u, v, w; Edge(int u, int v, int w): u(u), v(v), w(w){} bool operator<(const Edge &e) const { return w<e.w; } };
int find(vector<int> &fa, int x) { if(fa[x] != x) fa[x] = find(fa, fa[x]); return fa[x]; }
vector<Edge> kruskal(vector<Edge> &edges, int n) { vector<int> fa(n+1); for(int i=1; i<=n; i++) fa[i]=i; sort(edges.begin(), edges.end()); vector<Edge> mst; for(auto &e: edges) { int fu=find(fa, e.u), fv=find(fa, e.v); if(fu!=fv) { fa[fu]=fv; mst.push_back(e); } } return mst; }
|
Prim 算法
Prim算法与Kruskal算法不同,每次要选择权重距离最小的一个结点,以及用新的边更新其他结点的距离。而Kruskal算法每次选择权值最小的边。
算法具体步骤:
- 从任意一个点出发,把它加入生成树。
- 用一个 小根堆 (优先队列) 维护当前生成树到外部点的最小边。
- 每次取堆顶最小边,如果该点未加入生成树,就把它加入,并更新堆。
- 直到所有点都加入,累加得到最小生成树的权值。
这里用邻接表和小根堆实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| struct node { int u,v,w; };
vector<node> Prim(Graph &G) { vector<node> MST; vector<bool> vis(G.vNum, false); using tup = tuple<int,int,int>; priority_queue<tup, vector<tup>, greater<tup>> pq; vis[0] = true; for (EdgeNode *p = G.AdjList[0].fiedge; p; p = p->next) pq.push({p->weight, 0, p->edge}); while (!pq.empty() && MST.size() < G.vNum - 1) { auto [w,u,v] = pq.top(); pq.pop(); if(vis[v]) continue; vis[v] = true; MST.push_back({u,v,w}); for (EdgeNode *p = G.AdjList[v].fiedge; p; p = p->next) if (!vis[p->edge]) pq.push({p->weight, v, p->edge}); } }
|
竞赛中的写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| #include <bits/stdc++.h> using namespace std; #define tup tuple<int,int,int>
struct node { int u,v,w; };
vector<node> Prim(vector<vector<int>>& g, vector<vector<int>> &weight, int st) { vector<node> MST; vector<bool> vis(g.size(), false); priority_queue<tup, vector<tup>, greater<tup>> pq; vis[st] = true; for (auto &v: g[st])pq.push({weight[st][v], st, v}); while (!pq.empty() && (int)MST.size() < (int)g.size() - 1) { auto [w, u, v] = pq.top(); pq.pop(); if (vis[v]) continue; vis[v] = true; MST.push_back({u, v, w}); for (auto nxt: g[v]) { if (!vis[nxt]) pq.push({weight[v][nxt], v, nxt}); } } return MST; }
|