作为一名 OIer ,从变量,到数组,再到 STL,最后是树,我们已经接触了许多不同形态的数据结构,现整理如下4种:
集合 | 链表、队列、栈等 |
---|---|
树 | 图 |
我们今天要谈的就是第4种数据结构—— 图 。
注:带星号的内容是补充内容
定义: 图 (Graph) 是由若干给定的顶点及连接两顶点的边所构成的图形。
功能: 通常用来描述某些事物之间的某种 特定关系 (顶点用于代表事物,边用于表示两个事物间所具有某种关系 )。
组成:( 二元组: G = ( V ( G ) , E ( G ) ) G=(V(G),E(G)) G=(V(G),E(G)) )
种类 & 特定术语:
无向图 (边没有指定方向的图):
有向图(边具有指定方向的图):
*混合图 ( 无向图 + 有向图 ):
带(赋)权图( 边上带有权值的图 ):
其它术语:
对于两顶点 u u u 和 v v v ,若存在边 ( u , v ) (u,v) (u,v),则称 u u u 和 v v v 是 相邻的 (adjacent)。
路径 (path): 相邻顶点的序列。
圈 (cycle): 起点和终点重合的路径。
连通图 (connected graph) : 任意两点之间都有路径连接的图。
自环 (loop): 对 E E E 中的边 e = ( u , v ) e=(u,v) e=(u,v),若 u = v u=v u=v,则 e e e 被称作一个自环。
度 (degree) : 与一个顶点 v v v 关联的边的条数,记作 d ( v ) d(v) d(v) 。( 度 = 入度 + 出度,记作 d ( v ) = d − ( v ) + d + ( v ) d(v)=d^-(v)+d^+(v) d(v)=d−(v)+d+(v) )
在有向图中:
*握手定理(图论基本定理):对于任何无向图 G = ( V , E ) G=(V,E) G=(V,E),有 ∑ v ∈ V d ( v ) = 2 ∣ E ∣ \sum_{v \in V}d(v)=2 \left|E\right| ∑v∈Vd(v)=2∣E∣,进一步有 ∑ v ∈ V d − ( v ) = ∑ v ∈ V d + ( v ) = ∣ E ∣ \sum_{v \in V}d^-(v)=\sum_{v \in V}d^+(v)=\left|E\right| ∑v∈Vd−(v)=∑v∈Vd+(v)=∣E∣。
推论: 在任意图中,度数为奇数的点必然有偶数个。
*阶 (order): 图 G G G 的点数 $ \left| V(G) \right| $ 。
树 (tree): 没有圈的连通图。
森林 (forest): 没有圈的非连通图。
**有向环 (暂无): **一条至少含有一条边且起点和终点相同的有向路径。
有向无环图 (DAG): 没有环的有向图。
注:带星号的内容是补充内容。以下内容中,n代表节点总数,m代表边总数。距离均为无向图。
//定义 & 初始化
struct Edge { int u, v; };
vector<Edge> E;
E.resize((m << 1) + 5);
//存边
for (int i = 1, u, v; i <= m; i++) {
scanf("%d%d", &u, &v);
E[i].u = E[i + m].v = u;
E[i].v = E[i + m].u = v;
}
//遍历与u相邻的所有点,并输出 & 输出总数
int cnt = 0;
for (int i = 1; i <= m << 1; i++)
if (E[i].u == u)
printf("%d ", v), cnt++;
printf("\n%d", cnt);
//判断u,v之间是否存在某条边
for (int i = 1; i <= m; i++)
if (E[i].u == u && E[i].v == v)
return true;
return false;
实现
使用一个结构体数组来存边,数组中的每个元素都包含一条边的起点与终点(带边权的图还包含边权)。
特点 & 应用
优点:
在 Kruskal 算法(最小生成树) 中,由于需要将边按边权排序,需要直接存边。
在有的题目中,需要多次建图(如建一遍原图,建一遍反图),此时既可以使用多个其它数据结构来同时存储多张图,也可以将边直接存下来,需要重新建图时利用直接存下的边来建图。
缺点:由于直接存边的遍历效率低下,一般不用于遍历图。
时间复杂度
查询是否存在某条边: O ( m ) O(m) O(m)。
遍历与一个点相关的所有边: O ( m ) O(m) O(m)。
遍历整张图: O ( n m ) O(nm) O(nm)。
空间复杂度: O ( m ) O(m) O(m)。
//定义 & 初始化
int G[MAXN][MAXN];
//存边
for (int i = 1; i <= m; i++) {
scanf("%d%d", &u, &v);
G[u][v] = G[v][u] = 1;
}
//遍历与u相邻的所有点,并输出 & 输出总数
int cnt = 0;
for (int v = 1; v <= n; v++)
if (G[u][v])
printf("%d ", v), cnt++;
printf("\n%d", cnt);
//判断u,v之间是否存在某条边
return G[u][v];
实现
使用一个二维数组 G
来存边,其中 G u , v G_{u,v} Gu,v 为 1 表示存在 u u u 到 v v v 的边,为 0 表示不存在。
特别地,如该图为无向图,则有 G u , v = G v , u G_{u,v}=G_{v,u} Gu,v=Gv,u。
如果是带边权的图,则可以在 G u , v G_{u,v} Gu,v 中存储 u u u 到 v v v 的边的边权。
特点 & 应用
在无向图中,任一顶点 i i i 的度为第 i i i 列(或第 i i i 行)所有非零元素的个数。
在有向图中,顶点 i i i 的出度为:第 i i i 行所有非零元素的个数,入度为:第 i i i 列所有非零元素的个数
优点: 可以 O ( 1 ) O(1) O(1) 查询一条边是否存在。
缺点:
邻接矩阵只适用于没有重边(或重边可以忽略,比如求最短路的时候取最小边权)的情况。
由于邻接矩阵在稀疏图上效率很低(尤其是在点数较多的图上,空间无法承受),所以一般只会在稠密图上使用邻接矩阵。
时间复杂度
查询是否存在某条边: O ( 1 ) O(1) O(1)。
遍历与一个点相关的所有边: O ( n ) O(n) O(n)。
遍历整张图: O ( n 2 ) O(n^2) O(n2)。
空间复杂度: O ( n 2 ) O(n^2) O(n2)。
//定义 & 初始化
vector<int> G[MAXN];
//存边
for (int i = 1, u, v; i <= m; i++) {
scanf("%d%d", &u, &v);
G[u].push_back(v);
G[v].push_back(u);
}
//遍历与u相邻的所有点,并输出 & 输出总数
for (auto v : G[u])
printf("%d ", v);
printf("\n%d", G[u].size());
//判断u,v之间是否存在某条边
for (auto i : G[u])
if (i == v)
return false;
return true;
实现
使用一个动态数组 vector G[MAXN]
来存边,其中 G u G_u Gu 存储的是与点 u u u 相关的所有边的信息(终点、边权等)。
特点 & 应用
尤其适用于需要对一个点的所有出边进行排序的场合。
时间复杂度
查询是否存在某条边: O ( d ( n ) ) O(d(n)) O(d(n)), 如果事先进行了排序就可以使用 二分 做到 O ( log ( d ( u ) ) ) O(\log(d(u))) O(log(d(u))))。
遍历与一个点相关的所有边: O ( d ( u ) ) O(d(u)) O(d(u))。
遍历整张图: O ( n + m ) O(n+m) O(n+m)。
空间复杂度: O ( m ) O(m) O(m)。
//定义 & 初始化
struct Edge {
int next, to;
} edge[MAXN];
int head[MAXN];
//存边
int cnt = 0;
void AddEdge (int u, int v) {
size[u]++, size[v]++;
edge[++cnt].to = v;
edge[cnt].next = head[u];
head[u] = cnt;
}
for (int i = 1, u, v; i <= m; i++) {
scanf("%d%d", &u, &v);
AddEdge(u, v);
AddEdge(v, u);
}
//遍历与u相邻的所有点,并输出 & 输出总数
int tot = 0;
for (int i = head[u]; i; i = edge[i].next)
printf("%d ", edge[i].to), tot++;
printf("\n%d", tot);
//判断u,v之间是否存在某条边
for (int i = head[u]; i; i = edge[i].next)
if (edge[i].to == v)
return true;
return false;
实现
本质上是用链表实现的邻接表,不同在于,链式前向星是给每条边编上号,然后规定遍历顺序。
特点 & 应用
优点:
i ^ 1
即是 i
的反边(常用于 网络流)。缺点: 不能快速查询一条边是否存在,也不能方便地对一个点的出边进行排序。
时间复杂度
查询是否存在某条边: O ( d ( n ) ) O(d(n)) O(d(n))。
遍历与一个点相关的所有边: O ( d ( u ) ) O(d(u)) O(d(u))。
遍历整张图: O ( n + m ) O(n+m) O(n+m)。
空间复杂度: O ( m ) O(m) O(m)。
//定义 & 读入
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (i != j) F[i][j] = INF;
for (int i = 1, u, v, w; i <= m; i++) {
scanf("%d%d%lf", &u, &v, &w);
F[u][v] = min(F[u][v], w); //防止重边
}
//具体算法
void Floyd () {
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
F[i][j] = min(F[i][j], F[i][k] + F[k][j]);
}
实现
我们定义一个数组 F k , i , j F_{k,i,j} Fk,i,j,表示只允许经过结点 1 1 1 到 k k k,即在子图 V ′ = 1 , 2 , . . . , k V^{\prime}=1,2,...,k V′=1,2,...,k 中的路径,结点 i i i 到结点 j j j 的最短路长度。很显然, F n , i , j F_{n,i,j} Fn,i,j 就是结点 到结点 的最短路长度。
接下来考虑如何求出 F F F 数组的值。
F 0 , i , j = { 0 i = j + ∞ ( i , j ) ∉ E w ( i , j ) ( i , j ) ∈ E \begin{equation} F_{0,i,j} = \left\{ \begin{aligned} 0 & \quad & i=j\\ +\infty & \quad & (i,j) \notin E\\ w(i,j) & \quad & (i,j) \in E\\ \end{aligned} \right. \end{equation} F0,i,j=⎩ ⎨ ⎧0+∞w(i,j)i=j(i,j)∈/E(i,j)∈E
i = j i=j i=j ,即从自己到自己,则 F 0 , i , j = 0 F_{0,i,j}=0 F0,i,j=0。
i , j i,j i,j 无边相连,则 F 0 , i , j = + ∞ F_{0,i,j}=+\infty F0,i,j=+∞。
i , j i,j i,j 有边相连,则 F 0 , i , j = w ( i , j ) F_{0,i,j}=w(i,j) F0,i,j=w(i,j)。
F k , i , j F_{k,i,j} Fk,i,j: F k , i , j = min ( F k − 1 , i , j , F k − 1 , i , k + F k − 1 , k , j ) F_{k,i,j}=\min(F_{k-1,i,j},F_{k-1,i,k}+F_{k-1,k,j}) Fk,i,j=min(Fk−1,i,j,Fk−1,i,k+Fk−1,k,j) 。其中,
F k − 1 , i , j F_{k-1,i,j} Fk−1,i,j,为不经过 k k k 点,经过 1 1 1 到 k − 1 k-1 k−1 的从 i i i 到 j j j 的最短路径。
F k − 1 , i , k F_{k-1,i,k} Fk−1,i,k,为经过 1 1 1 到 k − 1 k-1 k−1 从 i i i 到 k k k 的最短路。
F k − 1 , k , j F_{k-1,k,j} Fk−1,k,j,为经过 1 1 1 到 k − 1 k-1 k−1 从 k k k 到 j j j 的最短路。
故, F k − 1 , i , k + F k − 1 , k , j F_{k-1,i,k}+F_{k-1,k,j} Fk−1,i,k+Fk−1,k,j 为先经过 1 1 1 到 k − 1 k-1 k−1 从 i i i 到 k k k,再经过 1 1 1 到 k − 1 k-1 k−1 从 k k k 到 j j j 的最短路。
即是,经过 k k k 点,从 i i i 到 j j j 的最短路径。
优化
时间上已经不能优化了,而空间上,我们可以省略数组的第一维,于是可以改成 F[i][j] = min(F[i][j], F[i][k] + F[k][j]);
。
But……Why……
我们注意到 F k , i , j F_{k,i,j} Fk,i,j 的涵义是第一维为 k − 1 k-1 k−1 这一行和这一列的所有元素的最小值,包含了 F k − 1 , i , j F_{k-1,i,j} Fk−1,i,j,那么我在原地进行更改也不会改变最小值的值,因为如果将该三维矩阵压缩为二维,则所求结果 F i , j F_{i,j} Fi,j一开始即为原 F k − 1 , i , j F_{k-1,i,j} Fk−1,i,j 的值,最后依然会成为该行和该列的最小值。
故可以压缩。
Question & Answer
Q1: 为什么中间点 k k k 需在最外层枚举?
A1: 由于 F k F_k Fk 是由 F k − 1 F_{k-1} Fk−1 转移而来的,所以我们可以将枚举的中间点 k k k 视为 DP 的阶段,因此 k k k 作为阶段必须在最外层枚举。
特点
是用来求任意两个结点之间的最短路的,即多源最短路。
复杂度比较高,但是常数小,容易实现。
适用于任何图,不管有向无向,边权正负,但是最短路必须存在。(不能有负环)
应用
复杂度
时间复杂度: O ( N 3 ) O(N^3) O(N3),
空间复杂度: O ( N 2 ) O(N^2) O(N2)。
//定义 & 读入
struct Edge {
int v, w;
Edge (int V, int W) { v = V, w = W; }
};
struct Node {
int u, dis;
Node (int U, int Dis) { u = U, dis = Dis; }
bool operator < (Node x) const { return dis > x.dis; }
};
vector<Edge> G[MAXN];
int dis[MAXN];
bool vis[MAXN]
for (int i = 1, u, v, w = 1; i <= m; i++) {
scanf("%d%d", &u, &v);
G[u].push_back(Edge(v, w));
G[v].push_back(Edge(u, w));
}
//具体算法
int Dijkstra (int S, int T) {
for (int i = 1; i <= n; i++)
dis[i] = INF, vis[i] = 0;
dis[S] = 0;
priority_queue<Node> Q;
Q.push(Node(S, 0));
while (!Q.empty()) {
int u = Q.top().u;
Q.pop();
if (vis[u]) continue;
vis[u] = true;
for (auto v : G[u])
if (dis[v.v] > dis[u] + v.w)
dis[v.v] = dis[u] + v.w,
Q.push(Node(v.v, dis[v.v]));
}
return dis[T];
}
流程
将结点分成两个集合:已确定最短路长度的点集(记为 S S S 集合)的和未确定最短路长度的点集(记为 T T T 集合)。一开始所有的点都属于 T T T 集合。
初始化 d i s s = 0 dis_s=0 diss=0,其他点的 d i s dis dis 均为 + ∞ +\infty +∞。
然后重复以下操作,直到 T T T 集合为空,算法结束。
优化 & 时间复杂度
特点 & 应用
是一种求解 非负权图 上单源最短路径的算法。
本质是贪心。
持续更新中。。。(话说我好慢)