数据结构入门——图的结构与拓扑排序
文章目录
前言
本系列文章将简要介绍数据结构课程入门知识,文章将结合我们学校(吉大)数据结构课程内容进行讲述。文中算法大部分来自朱允刚老师上课的讲解,朱老师是我遇到最认真负责的老师,很有幸能成为朱老师的学生。
一、图的结构
注:以下内容只是对与图相关的定义的简单阐述,深入理解请学习离散数学图论。
图(Graph)是一种较线性表和树更为复杂的非线性结构。在图结构中,对结点(图中常称为顶点)的前驱和后继个数不加限制,即结点之间的关系是任意的。图中任意两个结点之间都可能相关。
图状结构可以描述各种复杂的数据对象。
图的应用极为广泛,特别是近年来的迅速发展,已经渗透到诸如语言学、逻辑学、物理、化学、电讯工程、计算机科学以及数学的其它分支中。
## 定义 ### 图的定义 - 图G由两个集合V和E组成,记为G = (V , E);其中 V 是顶点的有穷非空集合,E 是连接 V 中两个不同顶点的边的有穷集合。通常,也将图G的顶点集和边集分别记为V(G)和E(G)。 - 若图中的边限定为从一个顶点指向另一个顶点,则称此图为有向图。 - 若图中的边无方向性,则称之为无向图。 - 若G = (V, E)是有向图,则它的一条有向边是由V中两个顶点构成的有序对,亦称为弧,记为
度的定义
设G是无向图,v∈V(G),E(G)中以v为端点的边的个数,称为顶点v的度。
若G是有向图,则v的出度是以 v 为始点的边的个数,v 的入度是以 v 为终点的边的个数 ,顶点的度=入度+出度。
顶点的度数之和是边数的两倍
路径
设G是图,若存在一个顶点序列vp ,v1 ,v2 ,…… ,vq-1 ,vq 使得 <vp,v1 >,< v1 ,v2> , ……,< vq-1,vq>或 (vp ,v1 ),(v1 ,v2 ),……,(vq-1 ,vq )属于E(G),则称vp到vq存在一条路径,其中vp 称为起点,vq称为终点。
路径的长度是该路径上边的个数。
如果一条路径上除了起点和终点可以相同外,再不能有相同的顶点,则称此路径为简单路径。
如果一条简单路径的起点和终点相同,且路径长度大于等于2,则称之为简单回路。
子图
设G,H是图,如果 V ( H ) ⊆ V ( G ) V(H)\subseteq V(G) V(H)⊆V(G), E ( H ) ⊆ E ( G ) E(H)\subseteq E(G) E(H)⊆E(G),则称H是G的子图,G是H的母图。如果H是G的子图,并且V(H) = V(G),则称H为G的支撑子图。
连通图与连通分量
设G是图,若存在一条从顶点vi到顶点vj的路径,则称vi与vj可及(连通)。
若G为无向图,且V(G)中任意两顶点都可及,则称G为连通图。
若G为有向图,且对于V(G)中任意两个不同的顶点vi和vj, vi与vj可及, vj与vi也可及,则称G为强连通图
设图G = (V,E)是无向图,若G的子图GK是一个连通图,则称GK 为G的连通子图。
设图G = (V,E)是有向图,若G的子图GK是一个强连通图,则称GK 为G的强连通子图。
对于无向图G的一个连通子图GK,如果不存在G的另一个连通子图 G’,使得V(GK)⊂V(G’),则称GK为G的连通分量。(GK再加一个顶点就不连通了)
对于有向图G的一个强连通子图GK,如果不存在G的另一个强连通子图G’,使得V(GK)⊂V(G’),则称GK为G的强连通分量。
权图
设G = (V, E)是图,若对图中的任意一条边l,都有实数w(l)与其对应,则称G为权图,记为G = (V, E, w)。记w(u, v)表示w((u, v))或w(<u, v>),规定:
∀u∈V, 有w((u, u))=0或w(<u, u>)=0
∀u, v∈V, 若(u, v)∉E(G)或<u, v>∉E(G),则w((u, v))=+∞或w(<u, v>)= +∞
若σ=(v0,v1,v2,……,vk) 是权图G中的一条路径,则
∣
σ
∣
=
∑
i
=
1
k
w
(
v
i
−
1
,
v
i
)
|\sigma|=\sum_{i=1}^kw(v_{i-1},v_i)
∣σ∣=∑i=1kw(vi−1,vi) 称为加权路径 σ 的长度或权重。
权通常用来表示从一个顶点到另一个顶点的距离或费用。
图的存储结构
邻接矩阵
用顺序方式或链接方式存储图的顶点表v0,v1,…vn−1,图的边用 n*n 阶矩阵 A=(aij) 表示,A 的定义如下:
⑴若非权图,则:
- aii = 0;
- aij = 1,当 i ≠ j 且 <vi , vj > 或 (vi , vj) 存在时;
- aij = 0,当 i ≠ j 且 <vi, vj > 或 (vi, vj) 不存在时。
⑵若权图,则:
- aij 为对应边<vi,vj >或(vi ,vj)的权值。
称矩阵 A 为图的邻接矩阵
特点:
无向图的邻接矩阵对称,可压缩存储;有n个顶点的无向图所需存储空间为n(n+1)/2
有向图邻接矩阵不一定对称;有n个顶点的有向图所需存储空间为n²
邻接表
顺序存储顶点表。
对图的每个顶点建立一个单链表(n个顶点建立n个单链表),第 i 个单链表中的结点包含顶点vi的所有邻接顶点(边链表)。
由顺序存储的顶点表和链接存储的边链表构成的图存储结构被称为邻接表。
边链表
-
与顶点 v 邻接的所有顶点以某种次序组成的单链表称为顶点 v 的边链表。
-
边链表的每一个结点叫做边结点,对于非权图和权图边结点结构分别为:
VerAdj | link |
---|
VerAdj | cost | link |
---|
struct Edge
{
int VerAdj ; // 邻接顶点序号
int cost ; // 边的权值
Edge *link ; // 指向下一个边结点的指针
};
struct Vertex
{
int VerName ; // 顶点的名称
Edge *adjacent ; // 边链表的头指针
};
其中, VerAdj存放 v 的某个邻接顶点在顶点表中的下标;
link存放指向 v 的边链表中结点 VerAdj 的下一个结点的指针。
cost 存放边(v, VerAdj)或<v, VerAdj>的权值
比较
- 采用邻接矩阵还是用邻接表来存储图,要视对给定图实施的具体操作。
- 对边很多的图(也称稠密图),适于用邻接矩阵存储,因占用的空间少。另一好处是可以将图中很多运算转换成矩阵运算,方便计算。
- 对顶点多边少的图(也称稀疏图,如微信有几亿用户,每个用户好友一般三五百个),若用邻接矩阵存储,对应的邻接矩阵将是一个稀疏矩阵,存储利用率很低。因此,顶点多而边少的图适于用邻接表存储。
二、图的遍历
关于图的遍历可视化可以看看这个网站https://visualgo.net/zh/dfsbfs
- 从图的某顶点出发,访问图中所有顶点,且使每个顶点恰被访问一次的过程被称为图遍历。
- 图中可能存在回路,且图的任一顶点都可能与其它顶点相通,在访问完某个顶点之后可能会沿着某些边又回到了曾经访问过的顶点。
- 为了避免重复访问,可设置一个标志顶点是否被访问过的辅助数组 vis[ ],它的初始状态为 0,在图的遍历过程中,一旦某一个顶点 i 被访问,就立即让vis[i] 为 1,防止它被多次访问。
图的深度优先遍历
深度优先遍历又被称为深度优先搜索 DFS ( Depth First Search )
基本思想:
- DFS 在访问图中某一起始顶点 v0 后,由 v0 出发,访问它的任一邻接顶点 v1;再从 v1出发,访问v1的一个未曾访问过的邻接顶点 v2;然后再从 v2 出发,进行类似的访问,…如此进行下去,直至到达一个顶点,它不再有未访问的邻接顶点。
- 接着,退回一步,退到前一次刚访问过的顶点,看是否还有 其它没有被访问的邻接顶点。如果有,则访问此顶点,之后再从此顶点出发,进行与前述类似的访问;如果没有,就再退回一步进行搜索。
- 重复上述过程,直到图中所有顶点都被访问过为止。
递归算法
//v为开始结点,Head为顶点表,vis为vist数组初始为0,用于标记结点访问情况
void DFS(Vertex Head[],int v,int vis[]){
print("%d",v);//输出v
vis[v]=1;
Edge* p=Head[v]->adjacent;
while(p!=NULL){
if(vis[p->VerAdj]==0)
DFS(Head,p->Veradj,vis);
p=p->link;
}
}
迭代算法
-
将所有顶点的vis[ ]值置为0, 初始顶点v0入栈;
-
检测栈是否为空,若栈为空,则算法结束;
-
从栈顶弹出一个顶点v,如果v未被访问过则:
访问v,并将vis[v]值更新为1;
将v的未被访问的邻接顶点入栈 -
执行步骤 2。
void DFS(Vertex Head[],int v,int vis[]){
stack<int> S;//创建堆栈S
for(int i=0;i<n;++i)
vis[i]=0;
S.push(v);//v入栈
while(!S.empty()){//当S不空时
v=S.pop();//弹出堆栈顶元素
if(vis[v]==0){
printf("%d",v);
vis[v]=1;
Edge* p=Head[v]->adjacent;
while(p!=NULL){//找v的未被访问的邻接顶点压栈
if(vis[p->VerAdj]==0)
S.push(p->VerAdj);
p=p->link;
}
}
}
}
图深度优先遍历的次序不唯一
图的广度优先遍历
基本思想:
- 首先访问初始点顶点v0;
- 之后依次访问与v0邻接的全部顶点w1,w2,…,wk;
- 然后,再顺次访问与w1,w2,…,wk邻接的尚未访问的全部顶点;
- 再从这些被访问过的顶点出发,逐个访问与它们邻接的尚未访问过的全部顶点……
- 依此类推,直到连通图中的所有顶点全部访问完为止。
图的广度优先遍历类似于树的层次遍历,是一种分层的搜索过程,每前进一层可能访问一批顶点,不像深度优先搜索那样有时需要回溯。因此,广度优先搜索不是一个递归过程,也不需要递归算法。
为了实现逐层访问,算法中使用了一个队列,记忆还未被处理的节点,用以确定正确的访问次序。
与深度优先搜索过程一样,为避免重复访问,需要一个辅助数组 vis[ ],给被访问过的顶点加标记。
- 将所有顶点的vis[ ]值置为0, 访问初始顶点v,置vis[v]=1,v入队;
- 检测队列是否为空,若队列为空,则算法结束;
- 出队一个顶点v,考察其每个邻接顶点w:
如果w未被访问过,则
访问w;
将vis[w]值更新为1;
将w入队; - 执行步骤 2 。
void BFS(Vertex Head[],int v,int vis[]){
queue<int> Q;//创建队列 Q
for(int i=0;i<n;++i)
vis[i]=0;
printf("%d",v);
vis[v]=1;
Q.push(v);
while(!Q.empty()){//当队列不空时
v=Q.front();
Q.pop();//出队
Edge* p=Head[v]->adjacent;
while(p!=NULL){
if(vis[p->VerAdj==0]){
printf("%d",p->VerAdj);
vis[p->VerAdj]=1;
Q.push(p->VerAdj);
}
p=p->link;
}
}
}
图遍历的应用
求无向图的连通分量数
思想:每遍历一个连通分量,计数器加1
for(int i=0;i<n;++i)
if(vis[i]=0){
DFS(Head,i,vis);
++count;
}
判断无向图是否是连通图
算法与上面一样,只需在结尾时判断count是否为1
判断图中顶点u到v是否存在路径
以u为起点遍历,看DFS遍历过程中是否经过v
bool DFS(Vertex Head[],,int u,int v,int vis[]){
vis[u]=1;
if(u==v) return true;
Edge* p=Head[v]->adjacent;
while(p!=NULL){
int k=p->VerAdj;
if(vis[k]==0)
if(DFS(Head,p->Veradj,vis)==true)
return true;
p=p->link;
}
return false;
}
判断图中是否有环(有向图)
有向图:在深度优先遍历过程中,一个顶点会经历3种不同的状态:
- vis[i]=0,顶点i尚未被遍历到。
- vis[i]=1,顶点i已经被遍历到,但对于它的遍历尚未结束。该顶点还有若干邻接顶点尚未遍历,当前算法正在递归地深入探索该顶点的某一邻接顶点。
- vis[i]=2,顶点i的所有邻接顶点已完成遍历,其自身的遍历也已结束。
void HasCircle(Vertex Head[],int v,int vis[],int circle){//有向图判环,初始circle=0
vis[v]=1;
Edge* p=Head[v]->adjacent;
while(p!=NULL){
k=p->VerAdj;
if(vis[k]==1){circle=1;return;}
if(vis[k]==0)
HasCircle(Head,k,vis);
if(circle=1)return;
p=p->link;
}
vis[v]=2;
}
三、拓扑排序与关键路径
拓扑排序
- 一个任务(例如一个工程)通常可以被分解成若干个子任务,要完成整个任务就可以转化为完成所有的子任务。
- 在某些情况下,各子任务之间有序,要求一些子任务必须先于另外一些子任务被完成。
- 各任务之间的先后关系可以用有向图来表示。
AOV网:在有向图中,顶点表示活动(或任务),有向边表示活动(或任务)间的先后关系,称这样的有向图为AOV网(Activity On Vertex Network)。
- 在AOV网络中,如果活动Vi 必须在活动Vj 之前进行,则存在有向边<Vi, Vj>.
- AOV网络中不能出现有向回路,即有向环。在AOV网络中如果出现了有向环,则意味着某项活动应以自己作为先决条件。
拓扑序列: 就是把AOV网中的所有顶点排成一个线性序列,若AOV网中存在有向边 Vi → Vj ,则在该序列中,Vi必位于Vj之前。
拓扑排序: 构造AOV网的拓扑序列的过程被称为拓扑排序。
注:如果通过拓扑排序能将AOV网的所有顶点都排入一个拓扑序列中,则该AOV网络中必定不会出现有向环;相反,如果不能把所有顶点都排入一个拓扑序列,则说明AOV网络中存在有向环,此AOV网络所代表的任务是不可行的。
拓扑排序算法基本步骤
- 从图中选择一个入度为0的顶点且输出之。
- 从图中删除该顶点及该顶点引出的所有边。
- 执行①②,直至所有顶点已输出,或图中剩余顶点入度均不为0(说明存在回路,无法继续拓扑排序)。
注意:对于任何无回路的AOV网,其顶点均可排成拓扑序列,其拓扑序列未必唯一。
1.建立一个数组count[ ],count[i]的元素值取对应顶点i的入度;
2.建立一个堆栈,栈中存放入度为0的顶点,每当一个顶点的入度为0,就将其压入栈
void Toposort(Vertex Head[],int n) {// 有向图的拓扑排序算法
int* count = new int[n];
for (int i = 0; i < n; ++i)
count[i] = 0;// 计算count数组,存各顶点入度
for (int i = 1; i < n; ++i) {
Edge* p = Head[i]->adjacent;
while (p != NULL) {
int k = p->VerAdj;
count[k] += 1;
p = p->link;
}
}
stack S;
for(int i=0;i<n;++i)
if(count[i]==0)
S.push(i);//入度为0的顶点进栈
for (int i = 1; i < n; ++i) {//拓扑排序
if (S.empty()) {
printf("有环");
return;
}//尚未输出n个顶点就没有入度为0的顶点了,说明有回路
int j=s.pop();
printf("%d",j);//选出1个入度为0的顶点并输出
Edge* p=Head[j]->adjacent;// 删除 j 和 j 引出的边
while(p!=NULL){
k=p->VerAdj;
count[k]--;//顶点 k 的入度减 1
if(count[k]==0)
S.push(k);//入度为 0的顶点入栈
p=p->link;
}
}
}
关键路径
AOV网与AOE网
AOV网(Activity On Vertex):顶点表示活动或任务(Activity),有向边表示活动(或任务)间的先后关系。
AOE网(Activity On Edges):有向边表示活动或任务(Activity) ,用边上的权值表示活动的持续时间,顶点称为事件(Event):表示其入边的任务已完成,出边的任务可开始的状态。
- 在AOE网络中, 有些活动可以并行进行,但有些活动必须顺序进行。
- 从源点到各个顶点,以至从源点到汇点的路径可能不止一条。这些路径的长度也可能不同。
- 只有各条路径上所有活动都完成了,整个工程才算完成。
- 因此,完成整个工程所需的最短时间取决于从源点到汇点的最长路径长度,即在这条路径上所有活动的持续时间之和。这条路径长度最长的路径就叫做关键路径(Critical Path)。
与关键活动有关的量
-
事件 vj的最早发生时间 ve(j)
v e ( j ) = { 0 j=0 m a x i { v e ( i ) + w ( < i , j > ∣ < i , j > ∈ E ( G ) ) } j=2, … ,n ve(j)=\begin{cases} 0& \text{j=0}\\{max_i\{ve(i)+w(<i,j>|<i,j>∈E(G))\}}& \text{j=2,…,n} \end{cases} ve(j)={0maxi{ve(i)+w(<i,j>∣<i,j>∈E(G))}j=0j=2,…,n -
事件 vj的最迟发生时间vl(j)
v l ( j ) = { v e ( n ) j=n m i n k { v l ( k ) − w ( < j , k > ∣ < j , k > ∈ E ( G ) ) } j=n-1, … ,1 vl(j)=\begin{cases} {ve(n)}& \text{j=n}\\{min_k\{vl(k)-w(<j,k>|<j,k>∈E(G))\}}& \text{j=n-1,…,1} \end{cases} vl(j)={ve(n)mink{vl(k)−w(<j,k>∣<j,k>∈E(G))}j=nj=n-1,…,1 -
活动 ai 的最早开始时间 e(i)
设活动 ai在有向边 < vj, vk >上,e(i)=ve(j) -
活动 ai的最迟开始时间l(i)
不会引起时间延误的前提下,活动ai允许的最迟开始时间。设活动ai在有向边<vj, vk>上, 则
l(i) = vl(k)-weight(<j, k>)
关键路径与关键活动
关键路径:从源点到汇点的最长路径。
关键活动:关键路径上的活动,活动的最早开始时间等于活动的最迟开始时间,即l(i)=e(i) .
求关键活动的步骤
- 对AOE网进行拓扑排序,按顶点拓扑序求各顶点 vj 的最早发生时间 ve(j);
- 按顶点的逆拓扑序求各顶点 vj 的最迟发生时间vl(j);
- 根据各顶点ve和vl值,求出各活动 ai 的最早开始时间 e(i)和最迟开始时间l(i),若 e(i)=l(i),则ai是关键活动。
关键活动算法
- 对AOE网进行拓扑排序,若网中有回路终止算法,按拓扑序求出各顶点的最早发生时间ve;
void VertexEarliestTime(Vertex Head,int &ve[]){
for(int i=0;i<n;++i)
ve[i]=0;
for(int i=0;i<n-1;++i){
Edge* p=Head[j]->adjacent;
while(p!=NULL){
int k=p->VerAdj;
if(ve[i]+p->cost>ve[k])
ve[k]=ve[i]+p->cost;
p=p->link;
}
}
}
注:如果图中顶点未按拓扑序编号
方案1:先执行拓扑排序,将拓扑序存入数组Topo,即Topo[i]为拓扑序中第i个顶点的编号。
方案2:执行拓扑排序过程中,弹栈选出入度为0的顶
点,更新该顶点的邻接顶点的入度时(入度减1),同时更新其ve值。从而无需调用VertexEarliestTime函数.
- 按逆拓扑序求各顶点的最迟发生时间vl;
void VertexLastestTime(Vertex Head,int &ve[],int &vl[]){
for(int i=0;i<n;++i)
vl[i]=ve[n-1];
for(int i=n-2;i>=0;--i){
Edge* p=Head[j]->adjacent;
while(p!=NULL){
int k=p->VerAdj;
if(vl[k]-p->cost<vl[i])
vl[i]=vl[k]-p->cost;
p=p->link;
}
}
}
注:如果图中顶点未按拓扑序编号,解决方法同上
- 根据ve和vl的值,求各活动的最早开始时间e与最迟开始时间l,若e=l,则 对应活动是关键活动。
void ActivityStartTime (Vertex Head, int ve[], int vl[]){
for(int i=0;i<n;++i){
Edge* p=Head[i]->adjacent;
while(p!=NULL){
int k=p->VerAdj;
int e=ve[i];
l=vl[k]-cost(p);
if(l==e)
printf("<%d,%d>是关键路径",i,k);
p=p->link;
}
}
}
void CriticalPath (Vertex Head,int n){
int ve[n],vl[n];
VertexEarliestTime (Head,ve). //顶点最早发生时间
VertexLatestTime (Head,ve,vl). //顶点最迟发生时间
ActivityStartTime (Head,ve,vl). //活动最早最晚开始时间
}