1. 图的定义
图 由顶点集 和边集 组成,记为 ,其中 表示图 中顶点的有限非空集; 表示图 中顶点之间的关系(边)集合。若 ,则用 表示图 中顶点的个数,也称图 的阶,,用 表示图 中边的条数。
线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集。
图的逻辑结构应用示例: 铁路网,公路网,微信好友关系(无向),微博(有向)
1.1 无向图和有向图
无向图:当E是无向边(简称边)的有限集合时,则图G为无向图。边记为 或 。
有向图:当E是有向边(也称弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为 ,其中v称为弧尾,w称为弧头。
1.2 简单图和多重图
简单图:
-
- 不存在重复边
-
- 不存在顶点到自身的边 多重图:
-
- 图G中某两个顶点之间的边数多于1条
-
- 允许顶点通过同一条边和自己关联
1.3 顶点的度、入度、出度
对于无向图:顶点v的度是指依附于该顶点的边的条数,记为TD(v)。
对于有向图:入度ID(v),以顶点v为终点的有向边的数目;出度OD(v),以顶点v为起点的有向边的数目。顶点v的度等于二者之和。
1.4 顶点-顶点的关系描述
路径——顶点 到顶点 之间的一条路径是指顶点序列, , , ,... , 。有向图的路径也是有向的
回路——第一个顶点和最后一个顶点相同的路径称为回路或环。
简单路径——在路径序列中,顶点不重复出现的路径称为简单路径。
简单回路——除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。
路径长度——路径上边的数目。
点到点的距离——从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离。若从u到v根本不存在路径,则记该距离为无穷()。
无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的。
有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称这两个顶点是强连通的。
1.5 图的局部——子图
子图:取出原图中顶点和边中的某一些顶点和边形成的图,就是原图的子图。并非任意挑几个点、几条边都能构成子图。
生成子图:包含了原图中的所有顶点,但去掉了一些边。
有向图的子图和生成子图也是这个概念。
1.6 连通分量:描述无向图
无向图中的极大连通子图称为连通分量。(子图必须连通,且包含尽可能多的顶点和边)
1.7 强联通分量:描述有向图
有向图中的极大强连通子图称为有向图的强连通分量。(子图必须连通,同时保留尽可能多的边)
1.8 生成树
连通图(无向)的生成树是包含图中全部顶点的一个极小连通子图。(边尽可能的少,但要保持连通)
1.9 生成森林
在非连通图中(无向),连通分量的生成树构成了非连通图的生成森林。
1.10 边的权、带权图/网
边的权——在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
带权图/网——边上带有权值的图称为带权图,也称网。
带权路径长度——当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度。
1.11 几种特殊形态的图
无向完全图:无向图中任意两个顶点之间都存在边。
有向完全图:有向图中任意两个顶点之间都存在方向相反的两条弧。
稀疏图:边数很少的图。
稠密图:与稀疏图相反。稀疏或稠密没有绝对的界限。
树:不存在回路,且连通的无向图。
n个顶点的树,必有n-1条边。
n个顶点的图,若|E|>n-1,则一定有回路。
有向树:一个顶点的入度为0,其余顶点的入度均为1的有向图,称为有向树。有向树并不一定是一个强连通图
2. 图的存储结构
2.1 图的存储——邻接矩阵法
用一个二维数组,即邻接矩阵来表示图的边。顶点表中元素的下标跟邻接矩阵中是一一对应的。顶点中可以存更复杂的信息,可以用bool型或枚举型变量表示边。
#define MaxVertexNum 100; //顶点数目的最大值
typedef struct
{
char Vex[MaxVertexNum]; //顶点表,顶点中可以存更复杂的信息
int Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表;可以用bool型或枚举型变量表示边
int vexnum,arcnum; //图当前的定点数和边数/弧数
} MGraph;
2.1.1 邻接矩阵中求顶点的度、入度和出度
无向图中
第i个结点的度 = 第i行(或第i列)的非零元素个数。
有向图中
第i个结点的出度 = 第i行的非零元素个数。
第i个结点的入度 = 第i列的非零元素个数。
第i个结点的度 = 第i行、第i列的非零元素个数之和。
邻接矩阵法求顶点的度/出度/入度的时间复杂度为 , 即
2.1.2 邻接矩阵法存储带权图(网)
可用int的上限表示无穷;有的表示中用0表示顶点指向自身的边,因此对于邻接矩阵法存储带权图时如果两个顶点间的值为“无穷”或“0”,代表没有边。
2.1.3 邻接矩阵法的性能分析
空间复杂度为: ——只和顶点数有关,和实际的边数无关。
因此邻接矩阵法适合用于存储稠密图。因为顶点多,边数少的话存储空间会被浪费掉。
无向图的邻接矩阵是对称矩阵,可以压缩存储(只存储上三角区/下三角区)
2.1.4 邻接矩阵法的性质
2.2 邻接表
由于数组实现的顺序存储,空间复杂度高, 不适合存储稀疏图。
邻接表法:顺序+链式存储。
- 用一个一维数组存储顶点的信息。
//顶点
typedef struct VNode
{
VertextType data; //顶点的数据域
ArcNode *first; //指向顶点第一条边/弧的指针
}VNode, AdjList[MaxVertexNum];
- 声明一个图,即声明一个由顶点结点组成的数组,并且记录顶点数和边数
//用邻接表存储的图
typedef struct
{
AdjList vertices; //声明一个存储顶点信息的数组
int vexnum,arcnum; //记录顶点数和边数
} ALGraph;
3.对边/弧采用链表存储
//边/弧
typedef structARCNode
{
int adjvex; //边/弧指向哪个结点(即当前指向的结点)
strcut ArcNode * next; //指向下一条弧的指针
//InfoType info; //如果带权值的这里可以增加边的权值信息
} ArcNode;
**当用邻接表存储无向图时,边结点的数量是有冗余的,为2|E|,整体空间复杂度为
有向图边结点的数量是|E|,整体空间复杂度为
2.2.1 邻接表求顶点的度、入度和出度
邻接表存储的无向图可以通过遍历该顶点后的边链表中边结点个数来获取顶点的度。
用邻接表方式存储有向图,其出度可以遍历该顶点后的边链表,但求该顶点的入度需要把整个邻接表遍历一遍,时间复杂度很高。这是用邻接表存储有向图的缺点。
2.2.2 图的邻接表和邻接矩阵表示对比
邻接表的表示方式不唯一,邻接矩阵的表示方式唯一
2.3 十字链表存储有向图
原因: 用邻接矩阵存储有向图空间复杂度高,用邻接表存储有向图不容易找到顶点的入度。
十字链表法想找到有向图的出度和入度都很方便。空间复杂度
如何找到指定顶点的所有出边?顺着绿色线路找
如何找到指定顶点的所有入边?顺着橙色线路找
注意:十字链表只用于存储有向图
2.4 用邻接多重表的方式存储无向图
原因: 用邻接矩阵存储无向图空间复杂度高。用邻接表存储无向图,每条边对应两份冗余信息,删除顶点、删除边等操作时间复杂度高。
想要找到一条边比较方便,并且不会带来额外的空间复杂度。
2.4.1 用邻接多重表删除一条边:以AB为例
删除边后只需要将前面的指针指向删除后面结点的对应指针就可以
2.4.2 用邻接多重表删除一个顶点:以E为例
删除一个顶点,将顶点后链接的边结点删除,并将指向相应删除边结点的前面的结点置为空
2.4.3 邻接多重表优点
-
空间复杂度低:每条边只对应一份数据,。
-
删除边、删除结点等操作很方便
注意:邻接多重表只适用于存储无向图
3. 图的基本操作:基于邻接矩阵和邻接表
3.1 Adjacent(G,x,y):判断图G是否存在边<x,y>或(x,y)
1)对无向图而言
邻接矩阵时间复杂度为 ,邻接表时间复杂度为
2) 对有向图而言
邻接矩阵时间复杂度为 ,邻接表时间复杂度为
3.2 Neighbors(G,x):列出图G中与结点x邻接的边
1) 对无向图而言
邻接矩阵时间复杂度为 ,邻接表时间复杂度为
2) 对有向图而言
邻接矩阵时间复杂度为 ,邻接表 找出边时间复杂度为 找 入边 时间复杂度为
3.3 InsertVertex(G,x):在图G中插入顶点x
邻接矩阵时间复杂度为 ,邻接表时间复杂度为
3.4 DeleteVertex(G,x):从图G中删除顶点x
1) 对无向图而言
邻接矩阵中删除顶点,可以将该顶点对应的该行和该列置为0,并且在顶点结构体中增加bool型变量来表示该处顶点是否为空即可。时间复杂度为
邻接表中删除顶点不光需要删除该顶点及其相关的链表,并把其他顶点链表中链接的跟该顶点相关的边表结点删除。时间复杂度为
2) 对有向图而言
邻接矩阵中删除顶点的时间复杂度为 ,邻接表中删除出边的时间复杂度为 ,删除入边的实践复杂度为
3.5 AddEdge(G,x,y):若无向边(x,y)或有向边<x,y>不存在,则向图G中添加该边
邻接矩阵中添加边的时间复杂度为 , 对邻接表时间复杂度为 ,对邻接表使用头插法更节省时间 只需要 的时间复杂度。
3.6 FirstNeighbor(G,x):求图G中顶点x的第一个邻接点,若有则返回顶点号,若x没有邻接点或图中不存在x则返回-1.
1) 对无向图而言
邻接矩阵的时间复杂度为 , 邻接表时间复杂度为
2) 对有向图而言
邻接矩阵的时间复杂度为 ,邻接表找出边邻接点时间复杂度为 ,找入边邻接点时间复杂度为
3.7 NextNeighbor(G,x,y):假设图G中顶点y是顶点x的一个邻接点,返回除y之外的顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1。
邻接矩阵的时间复杂度为 , 邻接表时间复杂度为 。
3.8 权值操作
Get_edge_value(G,x,y):获取图G中边(x,y)或<x,y>对应的权值
Set_edge_value(G,x,y): 设置图G中边(x,y)或<x,y>对应的权值为v,
类似于判断图G中是否存在边<x,y>或(x,y)。Adjacent(G,x,y):核心在于找到边。时间开销相同。
4. 图的遍历
4.1 图的广度优先遍历 BFS
4.1.1 图的广度优先遍历类比树的广度优先遍历
树的广度优先遍历就是层序遍历,图的广度优先遍历搜索相邻的结点。
树的广度优先遍历,不存在“回路”,搜索相邻的结点时,不可能搜到已经访问过的结点。
图的广度优先遍历,搜索相邻的顶点时,有可能搜到已经访问过的顶点,因此要给顶点加一个tag,来标记它是否被访问过。
要点
-
- 找到与一个顶点相邻的所有顶点。
- 可以利用FirstNeighbor(G,x)和NextNeighbor(G,x)两个操作
-
-
标记哪些顶点被访问过
定义一个bool型数组来标记每一个顶点是否被访问过。
bool visited[MAX_VERTEX_NUM]; //访问标记数组
-
-
- 需要一个辅助队列
4.1.2 广度优先遍历代码实现
1)算法核心实现
bool visited [MAX_VERTEX_NUM]; //访问标记数组,初始值都设为false
//广度优先遍历
void BFS(Graph G,int v)//从顶点出发,广度优先遍历 图 G
{
visit(v); //访问初始顶点v
visited[v] = TRUE; //对v做已访问标记
Enqueue(Q,v); //顶点v入队列Q
while( !isEmpty(Q))
{
DeQueue(Q,v); //顶点v出队列
for (w = FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
{
//检测v所有邻接点
if(!visited[w]) //w为v的尚未访问的邻接结点
{
visit(w); //访问顶点w
visited[w] = TRUE;//对w做已访问标记
EnQueue(Q,w); //顶点w入队列
} //endif
}//endfor
}//endwhile
}
邻接表存储形式不同,广度优先遍历的遍历序列不唯一;邻接矩阵的表示方式唯一,广度优先遍历的遍历序列唯一。
2)算法优化
上述算法当图是非连通图时,无法完成遍历,为此需要对算法进一步优化。
bool visited [MAX_VERTEX_NUM]; //访问标记数组,初始值都设为false
void BFSTraverse(Graph G) //对图G进行广度优先遍历
{
for (i = 0; i<G.vexnum;++i) //访问标记数组初始化
{
visited[i] = FALSE;
}
InitQueue(Q); //初始化辅助队列Q
for(i = 0;i<G.vexnum;++i) //从0号顶点开始遍历
{
if(!visited[i]); //如果vi未访问过
BFS(G,i); //从vi开始BFS
}
}
//广度优先遍历
void BFS(Graph G,int v)//从顶点出发,广度优先遍历 图 G
{
visit(v); //访问初始顶点v
visited[v] = TRUE; //对v做已访问标记
Enqueue(Q,v); //顶点v入队列Q
while( !isEmpty(Q))
{
DeQueue(Q,v); //顶点v出队列
for (w = FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
{
//检测v所有邻接点
if(!visited[w]) //w为v的尚未访问的邻接结点
{
visit(w); //访问顶点w
visited[w] = TRUE;//对w做已访问标记
EnQueue(Q,w); //顶点w入队列
} //endif
}//endfor
}//endwhile
}
3)复杂度分析:
空间复杂度:
最坏情况,辅助队列大小为
时间复杂度:
对于无向图,调用BFS的次数 = 连通分量数
-
邻接矩阵存储的图:
- 访问|V|个顶点需要的时间
- 查找每个顶点的邻接点都需要的时间,而总共有个顶点
- 时间复杂度 =
-
邻接表存储的图:
- 访问|V|个顶点需要的时间
- 查找各个顶点的邻接点共需要的时间
- 时间复杂度 =
广度优先和深度优先算法的时间开销不要钻到代码里看最深层循环,要将其简化就看做是访问各个顶点和个条边的过程
4.1.3 广度优先生成树
根据广度优先遍历的过程得出的,将图的回路消除,保留第一次访问的边,得到广度优先生成树。
广度优先生成树由广度优先遍历过程确定,由于邻接表的表示方式不唯一,因此基于邻接表的广度优先生成树也不唯一。
4.1.3 广度优先生成森林
对非连通图的广度优先遍历,可以得到广度优先生成森林。
4.1.4 思考:有向图的BFS过程
4.2 图的深度优先遍历 DFS
4.2.1 图的深度优先遍历与树的深度优先遍历类比
图的深度优先遍历类似于树的先根遍历,树的先根遍历访问到的下一个结点一定是没有访问过的。
4.2.2 图的深度优先遍历思想及代码实现
主要思想是相比树增加一个标记数组来记录结点是否访问过。
1) 核心思想:通过递归递归,借助函数调用栈实现深度优先遍历
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void DFS(Graph G ,int v) //从顶点v出发,深度优先遍历图G
{
visit(v); //访问结点v
visited[v] = TRUE; //设已访问标记
for(w = FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) //w为v的尚未访问的邻接结点
{
if(!visited[w])
{
DFS(G,W);
}
}
}
2)代码优化,解决如果是非连通图,无法遍历完所有结点的问题
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void DFSTraverse(Graph G) //对图G进行深度优先遍历
{
for(v = 0; v <G.vexnum; ++v)
visited[v] = FALSE; //初始化已访问标记数据数组为false
for(v = 0; v <G.vexnum; ++v) //本代码中从v = 0开始遍历
if(!visited[v]) //如果结点没有被访问过
DFS(G,v); //执行DFS
}
void DFS(Graph G ,int v) //从顶点v出发,深度优先遍历图G
{
visit(v); //访问结点v
visited[v] = TRUE; //设已访问标记
for(w = FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) //w为v的尚未访问的邻接结点
{
if(!visited[w])
{
DFS(G,W);
}
}
}
4) 复杂度分析
空间复杂度
主要来自函数调用栈,最坏情况,递归深度为 ,最好情况
时间复杂度 可以把BFS和DFS都进行简化:
时间复杂度 = 访问各结点所需时间 + 探索各条边所需时间。
-
邻接矩阵存储的图:
- 访问|V|个顶点需要的时间
- 查找每个顶点的邻接点都需要的时间,而总共有个顶点
- 时间复杂度 =
-
邻接表存储的图:
- 访问|V|个顶点需要的时间
- 查找各个顶点的邻接点共需要的时间
- 时间复杂度 =
4.2.3 快速求出深度优先遍历序列
邻接表的表示方式不唯一,因此得到的深度优先遍历序列也不唯一。
4.2.4 深度优先生成树
深度优先遍历过程中,第一次访问到一个结点的边保留,重复访问的边删除,得到深度优先生成树。
同样的,由于邻接表的表示方式不唯一,深度优先生成树也不唯一。
4.2.5 深度优先生成森林
非连通图生成的多棵深度优先生成树得到深度优先生成森林。
4.3 图的遍历和图的连通性
对无向图进行BFS/DFS遍历,调用BFS/DFS的次数 = 连通分量数,对于连通图,只需调用一次BFS/DFS。
对有向图进行行BFS/DFS遍历,调用BFS/DFS函数的次数要具体问题具体分析,若起始顶点到其他各顶点都有路径,则只需调用1次BFS/DFS函数。对于强连通图。从任一结点出发都只需调用1次BFS/DFS。
5. 图的经典应用
5.1 最小生成树(最小代价树)
生成树的概念:
连通图的生成树是包含图中全部顶点的一个极小连通子图。若图中顶点数为n,则它的生成树含有n-1条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。
对于一个带权连通无向图 ,生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的生成树,则称T为G的最小生成树。(Minimum-Spanning-Tree MST)
- 最小生成树可能有多个,但边的权值之和总是唯一且最小的
- 最小生成树的边数 = 顶点数 - 1.砍掉一条则不连通,增加一条边则会出现回路。
- 如果一个连通图本身就是一棵树,则其最小生成树就是它本身
- 只有连通图才有生成树,非连通图只有生成森林
因此最小生成树的研究对象是 带权的连通的无向图
- 求最小生成树
- Prim 算法
- Kruskal算法
5.1.1 Prim算法(普里姆):
从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树;直到将所有顶点都纳入为止
1) Prim算法的实现思想
2) Prim算法的时间复杂度
总时间复杂度 即
5.1.2 Kruskal算法(卡鲁斯卡尔)
每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选)直到所有顶点都连通
1) Kruskal算法的实现思想
检查是否连通需要用到并查集的方法,判断两个点是否同属于一个集合
2) Kruska算法的时间复杂度
共执行e轮,每轮判断两个顶点是否属于同一集合,需要,总时间复杂度
5.2 最短路径问题
单源最短路径 从一个顶点到其他顶点可以走的最短路径,
- BFs算法(无权图)
- Dijkstra算法(迪杰斯特拉算法)(带权图、无权图) 各顶点间最短路径 每对顶点之间的最短路径
- Floyd算法(带权图、无权图)
5.2.1 BFS求无权图的单元最短路径
无权图可以视为一种特殊的带权图,只是每条边的权值都为1
1) 代码实现思想:从广度优先遍历算法进行改造
原有的广度优先遍历算法
bool visited[MAX_VERTEX_NUM];//访问标记数组
//原有的广度优先遍历算法
void BFS(Graph G,int v)
{
visit(v);
visited[v] = TRUE;
Enqueue(Q,v);
while(!isEmpty(Q))
{
DeQueue(Q,v);
for(w = FirstNeighbor(G,v)); w>=0;w = NextNeighbor(G,v,w))
{
if(!visited[w])
{
visit(w); //主要改造visit函数
visited[w] = TRUE;
EnQueue(Q,w);
}
}
}
}
对广度优先遍历算法进行改造 主要
- 改造visit函数
- 初始化d[] 和 path[]两个数组
//求顶点u到其他顶点的最短路径
void BFS_MIN_Distance(Graph G,int u)
{
//d[i]表示从u到i节点的最短路径
for(i = 0; i < G.vexnum; ++i)
{
d[i] = ∞; //初始化路径长度
path[i] = -1; //最短路径从哪个顶点过来
}
d[u] = 0;
visited[u] = TRUE;
EnQueue(Q,u);
while(isEmpty(Q)) //BFS算法主过程
{
DeQueue(Q,u);
for(w = FirstNeighbor(G,u);w >= 0; w = NexiNeighbor(G, u, w))
{
if(!visited[w])
{
d[w] = d[u] + 1; //路径长度+1
path[w] = u; //最短路径应该从u到w
visited[w] = TRUE;
EnQueue(Q ,w);
}
}
}
}
5.2.2 最短路径:Dijkstra算法(迪杰斯特拉算法)
BFS算法求单源最短路径只适用于无权图,或所有边的权值都相同的图。
带权路径长度——当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度
1) 算法实现思想
-
初始:从开始,初始化三个数组信息如下
-
第1轮:循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点,令final[i] = true。
- 后面几轮跟第一轮一样,当找不到final值为false的其他顶点算法结束。
2) Dijkstra算法的时间复杂度
初始:
若从 开始,令
final[0] = true;dist[0] = 0; path[0] = -1;
其余顶点
final[k] = falsel;
dist[k] = arcs[0][k](两点的弧长); 与V0不直接相邻的点 该值为 ∞
path[k] =(arcs[0][k] == ∞) ? -1 :0; 即与V0不相邻的为-1,相邻的点为0;
n-1轮处理
循环遍历所有顶点,找到还没确定最短路径,且dist最小的顶点,令final[i] = true;
并检查所有邻接自的顶点,
若final[j] == false;且dist[i] + arcs[i][j] <dist[j],
则令dist[j] = dist[i] + arcs[i][j]; path[j] = i。
arcs[i][j]表示 到 的弧的权值
时间复杂度 即
3) 注意:Dijkstra算法不适用于有负权值的带权图
5.2.3 Floyd算法(Floyd-Warshall算法)
使用动态规划的思想,将问题的求解分为多个阶段。
1) 算法思想
-
-
初始状态:若不允许在其他顶点种转,最短路径是?——设置两个矩阵 和
开始,设置两个矩阵,第一个矩阵A即图的邻接矩阵, 第二个矩阵path表示我们目前能够找到的最短路径当中两个顶点之间的中转点。
-
-
-
#0:若允许在 中转,最短路径是?——求 和 。
基于上一阶段两个矩阵的信息,求得这一阶段两个最优的矩阵A和path
依次扫描A-1矩阵中的所有元素,检查是否满足下列条件,若满足,则更新两个矩阵。
-
-
- #1:若允许在 、中转,最短路径是?——求 和 。
-
- #2:若允许在 、、中转,最短路径是?——求 和
- n轮递推后
2) 核心代码
//............准备工作,根据图的信息初始化矩阵A 和 path
for(int k = 0;k < n;k++) //考虑以Vk为中转点
{
for(int i = 0; i < n; i++) //遍历整个矩阵,i为行号,j为列好
{
for (int j = 0; j < n; j++)
{
if(A[i][j]>A[i][k]+A[k][j]) //以Vk为中转点的路径更短
{
A[i][j] = A[i][K] +A[k][j]; //更新最短路径长度
path[i][j] = k; //中转点
}
}
}
}
3) 时间复杂度,,空间复杂度
4) 算法示例和应用
可以通过迭代算法,求path
5) 注意:Floyd算法可以用于负权值的图,不能解决带有“负权回路”的图
6. 有向无环图及应用
有向无环图:若一个有向图中不存在环,则称为有向无环图,简称DAG图(Directed Acyclic Graph)
6.1 有向无环图应用:描述表达式
-
- 把各个操作数不重复地排成一排
-
- 标出各个运算符的生肖顺序(先后顺序有点出入无所谓)
-
- 按顺序加入运算符,注意“分层”(某个运算符要依据下一层运算符的结果来计算)
-
- 从底向上逐层检查同层的运算符,是否可以合体。
6.2 有向无环图应用:拓扑排序
6.2.1 AOV网(Activity On Vertex NetWork)
AOV网:用顶点表示活动的网。用DAG图(有向无环图)表示一个工程。
顶点表示活动,有向边 表示活动 必须先于活动 进行。
AOV网一定是一个有向无环图
6.2.2 拓扑排序
拓扑排序:找到做事的先后顺序
6.2.3 拓扑排序的实现
-
- 从AOV网中选择一个没有前驱(入度为0)的顶点并输出
-
- 从网中删除该顶点和所有以它为起点的有向边
-
- 重复1和2直到当前的AOV网为空或当前网中不存在无前驱的顶点为止
每个AOV网可能存在多个拓扑排序序列,如果原图存在回路,则肯定不存在拓扑排序。
6.2.4 拓扑排序的代码实现
- 定义indegree[]和print[]两个数组,初始化一个栈S。
typedef struct VertexType
{
int a;
} VertexType;
typedef struct ArcNode //边表结点
{
int adjvex; //该弧所指向的顶点的位置
struct ArcNode* nextarc; //指向下一条弧的指针
//InfoType info; //网的边权值
} ArcNode;
typedef struct VNode //顶点表结点
{
VertexType data; //顶点信息
ArcNode* firstarc; //指向第一条依附该顶点的弧的指针
} VNode,AdjList[MAX_VERTEX_NUM];
typedef struct
{
AdjList vertices; //邻接表
int vexnum,arcnum; //图的顶点数和弧数
} Graph; //Graph是以邻接表存储的图类型
bool TopologicalSort(Graph G)
{
InitStack(S); //初始化栈,存储入度为0的顶点
for (int i = 0; i < G.vexnum; i++)
{
if (indegree[i] == 0)
{
Push(S, i); //将所有入度为0的顶点进栈
}
}
int count = 0; //计数,记录当前已经输出的顶点数
while (!IsEmpty(S)) //栈不空,则存在入度为0的顶点
{
Pop(S, i); //栈顶元素出栈
print[count++] = i; //输出顶点i,print[count] = i;然后将print数组的游标count++
for (p = G.vertices[i].firstarc; p; p = p->nextarc)
{
//将所有i指向的顶点的入度减1,并且将入度减为0 的顶点压入栈S
v=p->adjvexl
if (!(--intdegree[v]))
{
Push(S, v); //入度为0则入栈
}
}
}
if (count < G.vexnum)
return false; //拓扑排序失败,有向图中有回路
else
return true; //拓扑排序成功
}
6.2.5 拓扑排序算法的时间复杂度
6.2.6 逆拓扑排序
可以参考拓扑排序的实现,不过需要考虑的是出度。
使用邻接表的存储结构来实现逆拓扑排序算法,由于其查找出度需要遍历全表,时间复杂度高,实现起来十分低效。
可以考虑使用逆邻接表,逆邻接表中存储的是指向该点的边。
6.2.7 使用DFS算法可以实现拓扑排序和逆拓扑排序
DFS实现逆拓扑排序即在顶点退栈前输出。对下列代码还应增加对回路的判断,如果存在回路,则不存在逆拓扑排序序列。
7. 关键路径
7.1 AOE网
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)。
7.1.1 AOE网的性质
-
- 只有在某顶点所代表的的事件发生后,从该顶点出发的各有向边所代表的活动才能开始,
-
- 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的的事件才能发生,另外有些活动是可以并行进行的。
在AOE网中仅有一个入度为0 的顶点,称为开始顶点(源点),它表示整个工程的开始;也仅有一个出度为0 的顶点,称为结束顶点(汇点),它表示整个工程的结束。
从源点到汇点的邮箱路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。
完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长。
事件 的最早发生时间 :
决定了所有从 开始的活动能够开工的最早时间
活动的最早开始时间 :
该活动弧的起点所表示的事件的最早发生时间
事件 的最迟发生时间 :
它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。
活动的最迟开始时间 :
它是指该活动弧的终点所表示的事件的最迟发生时间与该活动所需时间之差
活动的时间余量:
表示在不增加完成整个工程所需总时间的情况下,活动可以拖延的时间。若一个活动的时间余量为0,则说明该活动必须要如期完成,这种活动是关键活动,由关键活动组成的路径就是关键路径。
7.2 求关键路径的步骤
-
- 求所有事件的最早发生时间ve()
- 按拓扑排序的序列,依次求各个顶点的ve(k):
- ve(源点)=0
- ve(k)=Max{ve(j)+Weight(vk,vj)},vj为vk的任意后继
-
- 求所有事件的最迟发生时间vl()
- 按逆拓扑排序的序列,依次求各个顶点的vl(k):
- vl(汇点)= ve(汇点)
- vl(k)=Min{vl(j)-Weight(vk,vj)},vj为vk的任意后继
-
- 求所有活动的最早发生时间e()
- 若边<vk,vj>表示活动ai,则有e(i) = ve(k)
-
- 求所有活动的最迟发生时间l()
-
- 若边<vk,vj>表示活动ai,则有l(i) = vl(j)- Weight(vk.vj)
-
- 求所有活动的时间余量d()——d(i)为0的活动就是关键活动
- d(i) = l(i)-e(i)
7.2.1 关键活动、关键路径的特性
- 若关键活动耗时增加,则整个工程的工期将延长
- 缩短关键活动的时间,可以缩短整个工程的工期
- 当缩短到一定程度时,关键活动可能会变成非关键活动
可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。