大部分内容基于中国大学MOOC的2021考研数据结构课程所做的笔记,该课属于付费课程(不过盗版网盘资源也不难找。。。)。后续又根据23年考研的大纲对内容做了一些调整,将二叉排序树和平衡二叉树的内容挪到了查找一章,并增加了并查集、平衡二叉树的删除、红黑树的内容。
排序一章的各种算法动态过程比较难以展现,所以阅读体验可能不是特别好。
西电的校内考试分机试和笔试。笔试占50分,机试2小时4道题占30分,做出2道满分,多做一道总分加5分。机试尽量把老师平时发的OJ题目都过一遍。笔试内容偏基础,但考的量比较大。
其他各章节的链接如下:
图
图的基本概念
注:这一节基本是对离散数学图论部分的复习,部分概念可能略有不同
图的定义
图 G G G由顶点集 V V V和边集 E E E组成,记为 G = ( V , E ) G=(V,E) G=(V,E),其中 V ( G ) V(G) V(G)表示图 G G G中顶点的有限非空集; E ( G ) E(G) E(G)表示图 G G G中顶点之间的关系(边)集合。若 V = { v 1 , v 2 , . . . , v n } V=\{v_1,v_2,...,v_n\} V={v1,v2,...,vn},则用 ∣ V ∣ |V| ∣V∣表示图 G G G中顶点的个数,也称图 G G G的阶, E = { ( u , v ) ∣ u ∈ V , v ∈ V } E=\{(u,v)|u\in V,v\in V\} E={(u,v)∣u∈V,v∈V},用 ∣ E ∣ |E| ∣E∣表示图 G G G中边的条数
线性表可以是空表,树可以是空树,但图不可以是空,即 V V V一定是非空集
无向图,有向图
若 E E E是无向边(简称边)的有限集合时,则图 G G G为无向图。边是顶点的无序对,记为 ( v , w ) (v,w) (v,w)或 ( w , v ) (w,v) (w,v),因为 ( v , w ) = ( w , v ) (v,w)=(w,v) (v,w)=(w,v),其中 v , w v,w v,w是顶点。可以说顶点 w w w和顶点 v v v互为邻接点。边 ( v , w ) (v,w) (v,w)依附于顶点 w w w, v v v,或者说边 ( v , w ) (v,w) (v,w)和顶点 v , w v,w v,w相关联。
若 E E E是有向边(也称弧)的有限集合时,则图 G G G为有向图。弧是顶点的有序对,记为 < v , w > <v,w> <v,w>,其中 v , w v,w v,w是顶点, v v v称为弧尾, w w w称为弧头, < v , w > <v,w> <v,w>称为从顶点 v v v到顶点 w w w的弧,也称 v v v邻接到 w w w,或 w w w邻接自 v v v。 < v , w > ≠ < w , v > <v,w>\neq<w,v> <v,w>=<w,v>
例:
上图中左边图 G 1 G_1 G1为有向图,右边图 G 2 G_2 G2为无向图,可表示为
G 1 = ( V 1 , E 1 ) V 1 = { a , b , c , d , e , f } E 1 = { < a , b > , < b , a > , < c , c > , < e , e > , < b , c > , < a , d > } G_1=(V_1,E_1)\quad V_1=\{a,b,c,d,e,f\} \quad E_1=\{<a,b>,<b,a>,<c,c>,<e,e>,<b,c>,<a,d>\} G1=(V1,E1)V1={a,b,c,d,e,f}E1={<a,b>,<b,a>,<c,c>,<e,e>,<b,c>,<a,d>}
G 2 = ( V 2 , E 2 ) V 1 = { a , b , c , d } E 1 = { ( b , b ) , ( a , b ) , ( a , d ) , ( a , c ) , ( b , c ) , ( c , d ) , ( b , d ) } G_2=(V_2,E_2)\quad V_1=\{a,b,c,d\} \quad E_1=\{(b,b),(a,b),(a,d),(a,c),(b,c),(c,d),(b,d)\} G2=(V2,E2)V1={a,b,c,d}E1={(b,b),(a,b),(a,d),(a,c),(b,c),(c,d),(b,d)}
简单图,多重图
简单图——1.不存在重复边 2.不存在顶点到自身的边
多重图——图 G G G中某两个结点之间的边数多于一条,有允许顶点通过同一条边和自己关联,则 G G G为多重图
数据结构课程只探讨“简单图”
顶点的度,出度,入度
对于无向图:
顶点 v v v的度是指依附于该顶点的边的条数,记为 T D ( v ) TD(v) TD(v)。
在具有 n n n个顶点, e e e条边的无向图中, ∑ i = 1 n T D ( v i ) = 2 e \sum_{i=1}^{n}TD(v_i)=2e ∑i=1nTD(vi)=2e
即无向图的全部顶点的度的和等于边数的2倍
对于有向图:
入度是以顶点 v v v为终点的有向边的数目,记为 I D ( v ) ID(v) ID(v)
入度是以顶点 v v v为起点的有向边的数目,记为 O D ( v ) OD(v) OD(v)
顶点 v v v的度等于其入度和出度之和,即 T D ( v ) = I D ( v ) + O D ( v ) TD(v)=ID(v)+OD(v) TD(v)=ID(v)+OD(v)
在具有 n n n个顶点, e e e条边的有向图中, ∑ i = 1 n I D ( v i ) = ∑ i = 1 n O D ( v i ) = e \sum_{i=1}^{n}ID(v_i)=\sum_{i=1}^{n}OD(v_i)=e ∑i=1nID(vi)=∑i=1nOD(vi)=e
顶点——顶点的关系描述
- 路径——顶点 v p v_p vp到顶点 v q v_q vq之间的一条路径是指顶点序列, v p , v i 1 , v i 2 , . . . , v i m , v q v_p,v_{i_1},v_{i_2},...,v_{i_m},v_q vp,vi1,vi2,...,vim,vq
顶点之间有可能不存在路径,有向图的路径也是有向的
- 回路/环——第一个顶点和最后一个顶点相同的路径
- 简单路径——在路径序列中,顶点不重复出现的路径
- 简单回路——除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路
这个定义与离散数学中的定义不同
- 路径长度——路径上边的数目
- 点到点的距离——从顶点 u u u出发到顶点 v v v的最短路径若存在,则此路径的长度称为从 u u u到 v v v的距离。若 u u u到 v v v根本不存在路径,则记该距离为无穷 ( ∞ ) (\infty) (∞)
- 无向图中,若从顶点 v v v到顶点 w w w有路径存在,则称 v v v和 w w w是连通的
- 有向图中,若从顶点 v v v到顶点 w w w和从顶点 w w w到顶点 v v v之间都有路径,则称这两个顶点是强连通的
连通图,强连通图
若图 G G G中任意两个顶点都是连通的,则称图 G G G为连通图,否则称为非连通图
常见考点:
对于 n n n个顶点的无向图 G G G
若 G G G是连通图,则最少有 n − 1 n-1 n−1条边。若 G G G是非连通图,则最多可能有 C n − 1 2 C_{n-1}^{2} Cn−12条边
若图中任何一对顶点都是强连通的,则称此图为强连通图
常见考点:
对于 n n n个顶点的有向图 G G G,若 G G G是强连通图,则最少有 n n n条边(形成回路)
研究图的局部——子图
设有两个图 G = ( V , E ) G=(V,E) G=(V,E)和 G ′ = ( V ′ , E ′ ) G'=(V',E') G′=(V′,E′),若 V ′ V' V′是 V V V的子集,且 E ′ E' E′是 E E E的子集,则称 G ′ G' G′是 G G G的子图
若有满足 V ( G ′ ) = V ( G ) V(G')=V(G) V(G′)=V(G)的子图 G ′ G' G′,则称其为 G G G的生成子图
对于有向图和无向图这两个定义并无不同
并非任意挑几条边,几个点都能构成子图
连通分量
无向图中的极大连通子图称为连通分量
子图必须连通,且包含尽可能多的顶点和边
强连通分量
有向图中的极大强连通子图称为有向图的强连通分量
子图必须强连通,同时保留尽可能多的边
生成树
连通图的生成树是包含图中全部顶点的一个极小连通子图
边尽可能的少,但要保持连通
若图中顶点数为 n n n,则它的生成树含有 n − 1 n-1 n−1条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路
生成森林
在非连通图中,连通分量的生成树构成了非连通图的生成森林
边的权,带权图/网
- 边的权——在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值
- 带权图/网——边上带有权值的图称为带权图,也称网
- 带权路径长度——当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度
几种特殊形态的图
- 无向完全图——无向图中任意两个顶点之间都存在边
若无向图的顶点数 ∣ V ∣ = n |V|=n ∣V∣=n,则 ∣ E ∣ ∈ [ 0 , C n 2 ] = [ 0 , n ( n − 1 ) / 2 ] |E|\in[0,C_n^2]=[0,n(n-1)/2] ∣E∣∈[0,Cn2]=[0,n(n−1)/2]
- 有向完全图——有向图中任意两个顶点之间都存在方向相反的两条弧
若有向图的顶点数 ∣ V ∣ = n |V|=n ∣V∣=n,则 ∣ E ∣ ∈ [ 0 , 2 C n 2 ] = [ 0 , n ( n − 1 ) ] |E|\in[0,2C_n^2]=[0,n(n-1)] ∣E∣∈[0,2Cn2]=[0,n(n−1)]
- 稀疏图——边数很少的图
- 稠密图——边数很多的图
没有绝对的界限,一般来说 ∣ E ∣ < ∣ V ∣ l o g ∣ V ∣ |E|<|V|log|V| ∣E∣<∣V∣log∣V∣时,可以将 G G G视为稀疏图
- 树——不存在回路,且连通的无向图
n n n个顶点的树,必有 n − 1 n-1 n−1条边
常见考点: n n n个顶点的图,若 ∣ E ∣ > n − 1 |E|>n-1 ∣E∣>n−1则一定有回路
- 有向树——一个顶点的入度为0,其余顶点的入度均为1的有向图,称为有向树
邻接矩阵法
不管是无向图还是有向图,都可以用邻接矩阵表示并存储,下面是两个具体的例子
无向图
有向图
结点数为 n n n的图 G = ( V , E ) G=(V,E) G=(V,E)的邻接矩阵 A A A是 n × n n\times n n×n的。将 G G G的顶点编号为 v 1 , v 2 , . . . , v n v_1,v_2,...,v_n v1,v2,...,vn,则
A [ i ] [ j ] { 1 ( v i , v j ) o r < v i , v j > ∈ E ( G ) 0 ( v i , v j ) o r < v i , v j > ∉ E ( G ) A[i][j]\begin{cases}1\quad\quad\quad(v_i,v_j)or<v_i,v_j>\in E(G)\\ 0\quad\quad\quad(v_i,v_j)or<v_i,v_j>\notin E(G)\end{cases} A[i][j]{1(vi,vj)or<vi,vj>∈E(G)0(vi,vj)or<vi,vj>∈/E(G)
用代码实现就是
#define MaxVertexNum 100 //顶点数目的最大值
typedef struct{
char Vex[MaxVertexNum]; //顶点表
int Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
int vexnum,arcnum; //图的当前顶点数和边数/弧数
}MGraph;
顶点可以保存更复杂的信息
可以用 b o o l bool bool型或枚举型变量表示边
思考:如何求顶点的度,入度,出度?
-
无向图:
第 i i i个结点的度=第 i i i行(或第 i i i列)的非零元素个数
-
有向图:
第 i i i个结点的出度=第 i i i行的非零元素个数
第 i i i个结点的入度=第 i i i列的非零元素个数
第 i i i个结点的度=第 i i i行,第 i i i列的非零元素个数之和
邻接矩阵法求顶点的度/出度/入度的时间复杂度都为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)
如果要存储的是带权图(网),则
#define MaxVertexNum 100 // 顶点数目的最大值
#define INFINITY 最大的int值 //宏定义常量“无穷”
typedef char VertexType; //顶点的数据类型
typedef int EdgeType; //带权图中边上权值的数据类型
typedef struct{
VertexType Vex[MaxVertexNum]; //顶点
EdgeType Edge[MaxVertexNum][MaxVertexNum]; //边的权
int vexnum,arcnum; //图的当前顶点数和弧数
}MGraph;
可用 i n t int int的上限值表示“无穷”,在带权值当中如果一个值为0或无穷表示与之对应的两个顶点之间不存在边
邻接矩阵的性能分析
空间复杂度: O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)——只与顶点数有关,和实际的边数无关(注: O ( ∣ V ∣ ) + O ( ∣ V ∣ 2 ) O(|V|)+O(|V|^2) O(∣V∣)+O(∣V∣2))
适合存储稠密图。
无向图的邻接矩阵为对称矩阵,可以压缩存储(只存储上三角区/下三角区)
邻接矩阵法的性质
设图 G G G的邻接矩阵为 A A A(矩阵元素为 0 / 1 0/1 0/1),则 A n A^n An的元素 A n [ i ] [ j ] A^n[i][j] An[i][j]等于由顶点 i i i到顶点 j j j的长度为 n n n的路径的数目
邻接表法
邻接表 | 邻接矩阵 | |
---|---|---|
空间复杂度 | 无向图 O ( ∣ V ∣ + 2 ∣ E ∣ ) O(|V|+2|E|) O(∣V∣+2∣E∣),有向图 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣) | O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2) |
适合用于 | 存储稀疏图 | 存储稠密图 |
表示方式 | 不唯一 | 唯一 |
计算度/出度/入度 | 计算有向图的度,入度不方便,其余很方便 | 必须遍历对应行或列 |
找相邻的边 | 找有向图的入边不方便,其余很方便 | 必须遍历对应行或列 |
邻接矩阵是数组实现的顺序存储,空间复杂度高,不适合存储稀疏图。邻接表法用顺序+链式存储,类似树的孩子表示法,即各个结点顺序存储,再用一个链表来指明和这个结点相邻的各个边
用一个一维数组来存储各个顶点的信息,其中包括顶点的数据域,指向这个顶点的第一条边/弧的指针。当我们声明一个图的时候其实就是声明了一个顶点结点的数组,另外还要记录当前图中结点和边的总数。对于各条边/弧也会有一个与之对应的结点,内部会指明这条边/弧指向哪个结点(A与B相邻,B的编号是1,所以有一条A指向1,也就是B的边。另外A和C,D也就是第2,3个结点也是相邻的,所以上面有2和3),指向下一条弧的指针。如果我们要存储的是带权图,可以再加入权值信息
代码实现如下
//用邻接表存储的图
typedef struct{
AdjList vertices;
int vexnum,arcnum;
}ALGraph;
//“顶点”
typedef struct VNode{
VertexType data; //顶点信息
ArcNode *first; //第一条边/弧
}VNode,Adjust[MaxVertexNum]
//“边/弧”
typedef struct ArcNode{
int adjvex; //边/弧指向哪个结点
struct ArcNode *next; //指向下一条弧的指针
//InfoType info; //边权值
}ArcNode;
从上图能看出同一条边实际上被存储了两次,边结点的数量是 2 ∣ E ∣ 2|E| 2∣E∣,整体空间复杂度为 O ( ∣ V ∣ + 2 ∣ E ∣ ) O(|V|+2|E|) O(∣V∣+2∣E∣)
如果要确定一个顶点的度,只需要遍历和该顶点相关的边链表即可。有多少个边结点它的度就有多少。同时遍历边链表也意味着可以找到与当前顶点相连的所有边
上面给的例子是无向图,也可以用邻接表来存储有向图,原理是类似的。每个结点后面跟的信息是从这个结点往外发射的弧。
从上面可以看到边结点的数量是 ∣ E ∣ |E| ∣E∣,整体空间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。
如果要找到一个结点的出度,只需要遍历和这个结点相关的边链表,该链表反映从当前结点出去的弧。所以找出度和从当前结点往外射的弧很简单,要找入度和指向当前结点的弧就比较麻烦。如果要统计某结点的入度和指向该结点的弧就只能遍历所有结点的边链表。
邻接表适合存储稀疏图,但是邻接表要计算有向图的度,入度,入边不方便
图的邻接表表示方式不唯一。而只要确定了顶点编号,图的邻接矩阵表示方式唯一
十字链表 邻接多重表
邻接矩阵 | 邻接表 | 十字链表 | 邻接多重表 | |
---|---|---|---|---|
空间复杂度 | O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2) | 无向图 O ( ∣ V ∣ + 2 ∣ E ∣ ) O(|V|+2|E|) O(∣V∣+2∣E∣),有向图 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣) | O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣) | O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣) |
找相邻边 | 遍历对应行或列时间复杂度为$O( | V | )$ | 找有向图的入边必须遍历整个邻接表 |
删除边或顶点 | 删除边很方便,删除顶点需要大量移动数据 | 无向图中删除边或顶点都不方便 | 很方便 | 很方便 |
适用于 | 稠密图 | 稀疏图和其他 | 只能存有向图 | 只能存无向图 |
表示方式 | 唯一 | 不唯一 | 不唯一 | 不唯一 |
十字链表
定义两种结构体,一种表示顶点,另一种表示弧。从指定结点出发顺着绿色指针线路往后找就可以找到指定结点的所有出边。顺着橙色指针线路往后找就可以找到指定结点的所有入边。空间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣) ,且找各个顶点的出边和入边都很方便
十字链表只能用于存储有向图
邻接多重图
用邻接矩阵存储无向图空间复杂度太高。用邻接表存储无向图每条边对应两份冗余信息,删除顶点,删除边等操作时间复杂度高。
故可以用邻接多重图来存储无向图
空间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣) ,每条边只对应一份数据。删除边,删除节点,找到和指定结点相连的边等操作都很方便。
邻接多重表只适用于存储无向图
图的基本操作
操作 | 描述 |
---|---|
A d j a c e n t ( G , x , y ) Adjacent(G,x,y) Adjacent(G,x,y) | 判断图G是否存在边<x,y>或(y,x) |
N e i g h b o r s ( G , x ) Neighbors(G,x) Neighbors(G,x) | 列出图G中与结点x邻接的边 |
I n s e r t V e r t e x ( G , x ) InsertVertex(G,x) InsertVertex(G,x) | 在图G中插入顶点x |
D e l e t e V e r t e x ( G , x ) DeleteVertex(G,x) DeleteVertex(G,x) | 从图G中删除顶点x |
A d d E d g e ( G , x , y ) AddEdge(G,x,y) AddEdge(G,x,y) | 若无向边(x,y)或有向边<x,y>不存在,则向图G中添加该边 |
R e m o v e E d g e ( G , x , y ) RemoveEdge(G,x,y) RemoveEdge(G,x,y) | 若无向边(x,y)或有向边<x,y>存在,则从图G中删除该边 |
F i r s t N e i g h b o r ( G , x ) FirstNeighbor(G,x) FirstNeighbor(G,x) | 求图G中顶点x的第一个邻接点,若有则返回顶点号。若x没有邻接点或图中不存在x则返回-1 |
N e x t N e i g h b o r ( G , x , y ) NextNeighbor(G,x,y) NextNeighbor(G,x,y) | 假设图G中顶点y是顶点x的一个邻接点,返回除y之外顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1 |
G e t _ e d g e _ v a l u e ( G , x , y ) Get\_edge\_value(G,x,y) Get_edge_value(G,x,y) | 获取图G中边(x,y)或<x,y>对应的权值 |
S e t _ e d g e _ v a l u e ( G , x , y , v ) Set\_edge\_value(G,x,y,v) Set_edge_value(G,x,y,v) | 设置图G中边(x,y)或<x,y>对应的权值为v |
此外,还有图的遍历算法,包括深度优先遍历和广度优先遍历
F i r s t N e i g h b o r ( G , x ) FirstNeighbor(G,x) FirstNeighbor(G,x)与 N e x t N e i g h b o r ( G , x , y ) NextNeighbor(G,x,y) NextNeighbor(G,x,y)两操作在图的遍历算法中很常用
Adjacent(G,x,y)
无论是无向图还是有向图,邻接矩阵的时间复杂度都为 O ( 1 ) O(1) O(1),邻接表的时间复杂度则为 O ( 1 ) ∼ O ( ∣ V ∣ ) O(1)\sim O(|V|) O(1)∼O(∣V∣)。一般邻接矩阵更好
Neighbors(G,x)
若为无向图,邻接矩阵的时间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),邻接表的时间复杂度则为 O ( 1 ) ∼ O ( ∣ V ∣ ) O(1)\sim O(|V|) O(1)∼O(∣V∣)。邻接表更好
若为有向图,邻接矩阵找出边和入边的时间复杂度均为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),邻接表的时间复杂度则为出边: O ( 1 ) ∼ O ( ∣ V ∣ ) O(1)\sim O(|V|) O(1)∼O(∣V∣),入边: O ( ∣ E ∣ ) O(|E|) O(∣E∣)。邻接矩阵更好
InsertVertex(G,x)
刚开始插入顶点的时候,这个顶点和其他任何顶点都是不相邻的
若为无向图,邻接矩阵的时间复杂度为 O ( 1 ) O(1) O(1)(矩阵元素化0在邻接矩阵初始化就已完成)。邻接表的时间复杂度则也为 O ( 1 ) O(1) O(1)。对于有向图也类似,都能在 O ( 1 ) O(1) O(1)复杂度内完成
DeleteVertex(G,x)
若为无向图,在邻接矩阵中删除结点就把和这个点对应的行和列元素全部置为0,然后可以在顶点的结构体中增加一个bool型变量用于判断这个顶点是否为空顶点。其实做一个这样简单的标记就可以,用这种方式实现删除显然要比移动大量的元素好很多。时间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)。邻接表的时间复杂度则为 O ( 1 ) ∼ O ( ∣ E ∣ ) O(1)\sim O(|E|) O(1)∼O(∣E∣)
最好的情况是要删除的结点没有连任何边,最坏的情况是要删除的结点和其他顶点都连有边,且这些边都恰好存储在边链表的最后
若为有向图,邻接矩阵的时间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),邻接表的时间复杂度则为出边: O ( 1 ) ∼ O ( ∣ V ∣ ) O(1)\sim O(|V|) O(1)∼O(∣V∣),入边: O ( ∣ E ∣ ) O(|E|) O(∣E∣)。
AddEdge(G,x,y)
若为无向图,邻接矩阵的时间复杂度为 O ( 1 ) O(1) O(1)。邻接表的时间复杂度也为 O ( 1 ) O(1) O(1)(采用链表的头插法更省时间,也可以用尾插法)。对于有向图也类似
FirstNeighbor(G,x)
若为无向图,邻接矩阵的时间复杂度为 O ( 1 ) ∼ O ( ∣ V ∣ ) O(1)\sim O(|V|) O(1)∼O(∣V∣)。邻接表的时间复杂度则为 O ( 1 ) O(1) O(1)
若为有向图,邻接矩阵的时间复杂度为 O ( 1 ) ∼ O ( ∣ V ∣ ) O(1)\sim O(|V|) O(1)∼O(∣V∣)。邻接表的时间复杂度则为找出边邻接点: O ( 1 ) O(1) O(1),找入边邻接点: O ( 1 ) ∼ O ( ∣ E ∣ ) O(1)\sim O(|E|) O(1)∼O(∣E∣)
NextNeighbor(G,x,y)
若为无向图,邻接矩阵的时间复杂度为 O ( 1 ) ∼ O ( ∣ V ∣ ) O(1)\sim O(|V|) O(1)∼O(∣V∣)。邻接表的时间复杂度则为 O ( 1 ) O(1) O(1)
Get_edge_value(G,x,y) /Set_edge_value(G,x,y,v)
这两个操作的核心在于找到边,因此时间开销和 A d j a c e n t ( G , x , y ) Adjacent(G,x,y) Adjacent(G,x,y)一样
图的广度优先遍历(BFS)
之前讲过树的广度优先遍历(树本身就是一种特殊的图),图的广度优先遍历和树的广度优先遍历相当类似。区别在于树的广度优先遍历不存在“回路”,搜索相邻的结点时不可能搜到已经访问过的结点。图搜索相邻的顶点时,有可能搜到已经访问过的顶点。
代码实现
广度优先遍历(BFS)要点:
- 找到与一个顶点相邻的所有顶点
- 标记哪些顶点被访问过
- 需要一个辅助队列
实现第一步需要借助图的基本操作中的 F i r s t N e i g h b o r ( G , x ) FirstNeighbor(G,x) FirstNeighbor(G,x)和 N e x t N e i g h b o r ( G , x , y ) NextNeighbor(G,x,y) NextNeighbor(G,x,y)
实现第二步需要设置访问标记数组 v i s i t e d visited visited,初始时元素都置为 f a l s e false false表示结点未访问
若为连通图,算法实现如下
bool visited[MAX_VERTEX_NUM]; //标记访问数组
//广度优先遍历
void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
visit(v); //访问初始顶点v
visited[v]=TRUE; //对v做已访问标记
Enqueue(Q,v);
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入队列
}//if
}//while
}
}
同一个图的邻接矩阵表示方式唯一,因此广度优先遍历序列唯一。
同一个图邻接表表示方法不唯一,因此广度优先遍历序列不唯一
上述代码如果为非连通图,则无法遍历完所有结点。所以可以稍作改进
bool visited[MAX_VERTEX_NUM]; //标记访问数组
void BFSTraverse(Graph G){ //对图G进行广度优先遍历
for(i=0;i<G.vexnum;++i){
visited[i]=FALSE; //访问标记数组初始化
}
InitQueue(Q); //初始化辅助队列
for(i=0;i<G.vexnum;++i){ //从0号顶点开始遍历
if(!visited[i]){ //对每个连通分量调用一次BFS
BFS(G,i); //vi未访问过,从vi开始BFS
}
}
}
void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
visit(v); //访问初始顶点v
visited[v]=TRUE; //对v做已访问标记
Enqueue(Q,v);
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入队列
}//if
}//while
}
}
对于无向图,调用BFS函数的次数=连通分支数
上面以无向图为例进行讲解。若改为有向图,即使只有一个连通分支,从不同的顶点出发也不一定都能一次性遍历完所有顶点,不同顶点调用BFS函数的次数不一定相等
复杂度分析
空间复杂度
BFS算法的空间复杂度主要来自辅助队列
故空间复杂度最坏情况如下图,辅助队列大小为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)
时间复杂度
BFS算法的时间复杂度主要来自访问各个顶点和探索各条边
邻接矩阵存储的图:
访问 ∣ V ∣ |V| ∣V∣个顶点需要 O ( ∣ V ∣ ) O(|V|) O(∣V∣)的时间。查找每个顶点的邻接点都需要 O ( ∣ V ∣ ) O(|V|) O(∣V∣)的时间,总共有 ∣ V ∣ |V| ∣V∣个顶点
故时间复杂度= O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
邻接表存储的图:
访问 ∣ V ∣ |V| ∣V∣个顶点需要 O ( ∣ V ∣ ) O(|V|) O(∣V∣)的时间。查找每个顶点的邻接点共需要 O ( ∣ E ∣ ) O(|E|) O(∣E∣)的时间
故时间复杂度= O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)
广度优先生成树
以下图为例,从2号结点开始进行广度优先遍历,根据广度优先遍历过程中访问结点的先后顺序依次将未访问(未入队)的结点插入构造广度优先生成树。
如果对邻接表做一些小小的改变,则得到的广度优先生成树会有所不同
可见广度优先生成树由广度优先遍历的过程确定。由于邻接表表示方法不唯一,因此基于邻接表的广度优先生成树也不唯一
广度优先生成森林
对非连通图的广度优先遍历,可得广度优先生成森林
图的深度优先遍历(DFS)
图的深度优先遍历类似树的先根遍历,都可以用递归来实现。只不过树的先根遍历新找到的结点一定是没有访问过的
//树的先根遍历
void PreOrder(TreeNode *R){
if(R!=NULL){
visit(R); //访问根结点
while(R还有下一个子树T){
PreOrder(T); //先根遍历下一棵子树
}
}
}
代码实现
和广度优先遍历一样,访问标记数组元素初始都置为 f a l s e false false
如果为连通图,则算法实现如下
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)){
if(!visited[w]){ //w为u的尚未访问的邻接顶点
DFS(G,w);
}
}
}
和广度优先遍历类似,上述代码如果为非连通图,则无法遍历完所有结点。所以可以稍作改进
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void DFSTraverse(Graph G){ //对图G进行深度优先遍历
for(v=0;v<G.vexnum;v++){
visited[v]=FALSE; //初始化已访问标记数据
}
for(v=0;v<G.vexnum;++v){ //本代码是从v=0开始遍历
if(!visited[v]){
DFS(G,v)
}//if
}
}
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)){
if(!visited[w]){ //w为u的尚未访问的邻接顶点
DFS(G,w);
}
}
}
算法复杂度分析
空间复杂度
深度优先搜索的空间复杂度主要来自于函数的递归调用
最坏情况下图为单支树,递归深度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)。
最好情况则为 O ( 1 ) O(1) O(1)
时间复杂度
和广度优先遍历一样,时间复杂度=访问各结点所需时间+探索各条边所需时间
邻接矩阵存储的图:
访问 ∣ V ∣ |V| ∣V∣个顶点需要 O ( ∣ V ∣ ) O(|V|) O(∣V∣)的时间
查找每个顶点的邻接点都需要 O ( ∣ V ∣ ) O(|V|) O(∣V∣)的时间,而总共有 ∣ V ∣ |V| ∣V∣个顶点,
时间复杂度= O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
邻接表存储的图:
访问 ∣ V ∣ |V| ∣V∣个顶点需要 O ( ∣ V ∣ ) O(|V|) O(∣V∣)的时间
查找每个顶点的邻接点共需要 O ( ∣ E ∣ ) O(|E|) O(∣E∣)的时间,
时间复杂度= O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)
深度优先生成树
类比广度优先生成树
同一个图的邻接矩阵表示方式唯一,因此深度优先遍历序列唯一,对应深度优先生成树也唯一。
同一个图邻接表表示方法不唯一,因此深度优先遍历序列不唯一,对应深度优先生成树也不唯一
深度优先生成森林
类比广度优先生成森林
图的遍历与图的连通性
对无向图进行BFS/DFS遍历,调用BFS/DFS函数的次数=连通分量数。对于连通图,只需调用1次BFS/DFS。
对有向图进行BFS/DFS遍历,调用BFS/DFS函数的次数需要具体问题具体分析。若起始顶点到其他各顶点都有路径,则只需调用1次BFS/DFS函数
对于强连通图,从任一结点出发都只需调用1次BFS/DFS
最小生成树
由于最小生成树的概念, P r i m Prim Prim算法, K r u s c a l Kruscal Kruscal算法在离散数学中已经学习过,所以下面会介绍的简略些
对于一个带权连通无向图 G = ( V , E ) G=(V,E) G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设 R R R为 G G G的所有生成树的集合,若 T T T为 R R R中边的权值之和最小的生成树,则 T T T称为 G G G的最小生成树/最小代价树( M i n i m u m − s p a n n i n g − T r e e , M S T Minimum-spanning-Tree,MST Minimum−spanning−Tree,MST)
最小生成树可能有多个,但边的权值总是唯一且最小的
最小生成树的边数=顶点数-1。砍掉一条则不连通,增加一条边则会出现回路
如果一个连通图本身就是一棵树,则其最小生成树就是它本身
只有连通图才有生成树,非连通图只有生成森林
计算最小生成树通常用 P r i m Prim Prim(普里姆)算法与 K r u s k a l Kruskal Kruskal(克鲁斯卡尔)算法
这两种算法的原理离散数学里都讲过,下面会记得简略些
Prim(普里姆)算法
从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止
时间复杂度为 O ( ∣ V 2 ∣ ) O(|V^2|) O(∣V2∣),适合用于边稠密图
Kruskal(克鲁斯卡尔)算法
每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选);直到所有结点都连通
时间复杂度为 O ( ∣ E ∣ l o g 2 ∣ E ∣ ) O(|E|log_2|E|) O(∣E∣log2∣E∣),适合用于边稀疏图
Prim(普里姆)算法的实现思想
以下图为例进行演示,从 v 0 v_0 v0开始构建最小生成树。两个数组 i s J o i n [ 6 ] isJoin[6] isJoin[6]标记各结点是否已经加入树, l o w C o s t [ 6 ] lowCost[6] lowCost[6]为各结点加入树的最低代价。
接下来进行第一轮处理。其中更新还没加入的顶点的 l o w C o s t lowCost lowCost值只需要看这些顶点与刚刚新加入树的顶点之间相连的边的权值是否比现有的 l o w C o s t lowCost lowCost值更小,若更小就要更新
接下来进行第二轮处理,和第一轮原理类似
接下来的第三,四,五轮处理原理都是类似的。直到所有结点都加入树
故从 v 0 v_0 v0开始,总共需要 n − 1 n-1 n−1轮处理。
每一轮处理:循环遍历所有个结点,找到 l o w C a s t lowCast lowCast最低的,且还没加入树的顶点。
再次循环遍历,更新还没加入的各个顶点的 l o w C a s t lowCast lowCast值
每一轮处理有两次遍历循环,故时间复杂度为 O ( 2 n ) O(2n) O(2n)。总时间复杂度 O ( n 2 ) O(n^2) O(n2),即 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
Kruskal(克鲁斯卡尔)算法的实现思想
需要用到并查集,刚开始把所有的点看成属于不同的集合
以下图为例进行演示,初始将各条边按权值排序,接下来要把各条边检查一遍
接下来进行第一轮处理,检查第1条边。
下面说的“连起来”意味把这两个点变成属于同一个集合
接下来的处理是类似的,每一轮用并查集的知识检查该边两个顶点是否连通(是否属于同一集合),直到所有边都被检查一遍。如果像下面一样发现该边两个顶点已经连通就跳过
该算法共执行 e e e轮,每轮判断两个顶点是否属于同一集合,需要 O ( l o g 2 e ) O(log_2e) O(log2e)。故总时间复杂度为 O ( e l o g 2 e ) O(elog_2e) O(elog2e)
最短路径问题(BFS算法)
接下来几节会介绍几种与最短路径问题有关的算法(BFS算法, D i j k s t r a Dijkstra Dijkstra算法, F l o y d Floyd Floyd算法)
其中BFS算法和 D i j k s t r a Dijkstra Dijkstra算法用于解决单源最短路径问题, F l o y d Floyd Floyd算法用于求各顶点间的最短路径
BFS算法求单源最短路径只适用于无权图,或所有边的权值都相同的图。 D i j k s t r a Dijkstra Dijkstra算法和 F l o y d Floyd Floyd算法则对带权图和无权图都适用
无权图可以视为一种特殊的带权图,只是每条边的权值都为1
代码实现
在广度优先算法上做一点修改即可,在 v i s i t visit visit一个顶点时,修改其最短路径长度 d [ ] d[] d[]并在 p a t h [ ] path[] path[]记录前驱结点。 d [ ] d[] d[]数组反映起点到目标结点的最短长度, p a t h [ ] path[] 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); //队头元素u出队
for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w)){
if(!visited[w]){ //w为u的尚未访问的邻接顶点
d[w]=d[u]+1; //路径长度加1
path[w]=u; //最短路径应从u到w
visited[w]=TRUE; //设已访问标记
EnQueue(Q,w); //顶点w进队
}//if
}
}//while
}
之前提到过广度优先生成树,在生成树中某结点在哪一层直接反映了从起点(根结点)到该结点的最短路径长度。既然是最短路径,就意味着如果以起点为根结点来构造一棵生成树的话,用广度优先构造出的生成树深度一定是最小的
最短路径问题(Dijkstra算法)
由于 D i j k s t r a Dijkstra Dijkstra算法在离散数学中已经学习过,所以下面会介绍的简略些
下面以有向图为例进行演示,无向图的一条无向边可以看成双向的两条有向边,解决了有向图,对无向图的处理是类似的。
D i j k s t r a Dijkstra Dijkstra算法不适用于有带负权值的带权图
算法演示
以下图演示用 D i j k s t r a Dijkstra Dijkstra算法求从 V 0 V_0 V0开始到其他各个顶点的最短路径。其中 d i s t dist dist数组反映目前能找到的最优路径长度, p a t h path path数组记录各个结点在最短路径上的直接前驱
接下来进行第一轮处理
接下来进行第二轮处理
后面的第三轮,第四轮处理都是类似的。在第四轮中由于找不到 f i n a l final final值为 f a l s e false false且邻接自 V i V_i Vi的顶点,故算法终止
从 d i s t dist dist数组可知起点到各个结点的最短路径长度,而检查 p a t h path path数组就能得到该最短路径的具体信息
代码实现思路与时间复杂度
初始:若从 V 0 V_0 V0开始,令
final[0]=true;
dist[0]=0;
path[0]=-1;
其余顶点
final[k]=false;
dist[k]=arcs[0][k];
path[k]=(arcs[0][k]==无穷)?-1:0
注: a r c s [ i ] [ j ] arcs[i][j] arcs[i][j]表示 V i V_i Vi到 V j V_j Vj的弧的权值
接下来的 n − 1 n-1 n−1轮处理:循环遍历所有顶点,找到还没确定最短路径,且 d i s t dist dist最小的顶点,令
final[i]=true;
并检查所有邻接自 v i v_i vi的顶点,对于邻接自 v i v_i vi的顶点 v j v_j vj若
final[j]==false && dist[i]+arcs[i][j]<dist[j];
则令
dist[j]=dist[i]+arcs[i][j];
path[j]=i;
时间复杂度:
O ( n 2 ) O(n^2) O(n2)即 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。经过 n − 1 n-1 n−1轮处理,每次处理时间复杂度为 O ( n ) + O ( n ) O(n)+O(n) O(n)+O(n)
最短路径问题(Floyd算法)
F l o y d Floyd Floyd算法可以用于负权值带权图,但不能解决带有“负权回路”的图(有负权值的边组成回路),这种图可能没有最短路径
F l o y d Floyd Floyd算法:求出每一对顶点之间的最短路径
使用动态规划思想,将问题的求解分为多个阶段
对于 n n n个顶点的图 G G G,求任意一对顶点 V i → V j V_i\to V_j Vi→Vj之间的最短路径可分为如下几个阶段:
#初始:不允许在其他顶点中转,最短路径是?
#0:若允许在 V 0 V_0 V0中转,最短路径是?
#1:若允许在 V 0 , V 1 V_0,V_1 V0,V1中转,最短路径是?
#2:若允许在 V 0 , V 1 , V 2 V_0,V_1,V_2 V0,V1,V2中转,最短路径是?
…
#n-1:若允许在 V 0 , V 1 , V 2 . . . . . . V n − 1 V_0,V_1,V_2......V_{n-1} V0,V1,V2......Vn−1中转,最短路径是?
算法演示
下面演示该算法如何进行
初始状态会设置两个矩阵,其中由于初始时不允许在其他顶点中转,故 p a t h ( − 1 ) path^{(-1)} path(−1)矩阵的元素均置为-1,表示两个顶点之间不存在中转点
下面进行第一轮处理,允许在 V 0 V_0 V0进行中转
遍历上一阶段得到的矩阵 A ( − 1 ) A^{(-1)} A(−1),若
A ( k − 1 ) [ i ] [ j ] > A ( k − 1 ) [ i ] [ k ] + A ( k − 1 ) [ k ] [ j ] A^{(k-1)}[i][j]>A^{(k-1)}[i][k]+A^{(k-1)}[k][j] A(k−1)[i][j]>A(k−1)[i][k]+A(k−1)[k][j]
则令
A ( k ) [ i ] [ j ] = A ( k − 1 ) [ i ] [ k ] + A ( k − 1 ) [ k ] [ j ] A^{(k)}[i][j]=A^{(k-1)}[i][k]+A^{(k-1)}[k][j] A(k)[i][j]=A(k−1)[i][k]+A(k−1)[k][j]
p a t h ( k ) [ i ] [ j ] = k path^{(k)}[i][j]=k path(k)[i][j]=k
否则 A ( k ) A^{(k)} A(k)和 p a t h ( k ) path^{(k)} path(k)保持原值
k k k表示刚刚新允许中转的结点
如遍历上例中的 A − 1 A^{-1} A−1矩阵就能发现
A ( − 1 ) [ 2 ] [ 1 ] > A ( − 1 ) [ 2 ] [ 0 ] + A ( − 1 ) [ 0 ] [ 1 ] = 11 A^{(-1)}[2][1]>A^{(-1)}[2][0]+A^{(-1)}[0][1]=11 A(−1)[2][1]>A(−1)[2][0]+A(−1)[0][1]=11
故令
A ( 0 ) [ 2 ] [ 1 ] = 11 A^{(0)}[2][1]=11 A(0)[2][1]=11
p a t h ( 0 ) [ 2 ] [ 1 ] = 0 path^{(0)}[2][1]=0 path(0)[2][1]=0
遍历完后进行第二轮处理,处理规则和之前一样
再进行第三次处理
处理完后发现从 A ( − 1 ) A^{(-1)} A(−1)和 p a t h ( − 1 ) path^{(-1)} path(−1)开始经过 n n n轮递推,得到了 A ( n − 1 ) A^{(n-1)} A(n−1)和 p a t h ( n − 1 ) path^{(n-1)} path(n−1),故算法结束。基于得到的两个矩阵就能获得各顶点间的最短路径长度和最短路径信息
上面举的书上的例子比较简单,因为显然中转点最多也只能有1个。上课时又举了一个更复杂的例子,但是处理方式并没有什么不同。
初始状态如下
进行第一轮处理时会发现没有任何一条边以 V 0 V_0 V0为弧头,所以其实允许在 V 0 V_0 V0中转不会影响 A A A和 p a t h path path矩阵
进行第二轮处理
进行第三轮处理
过程中会发现 A ( 1 ) [ 0 ] [ 3 ] > A ( 1 ) [ 0 ] [ 2 ] + A ( 1 ) [ 2 ] [ 3 ] = 3 A^{(1)}[0][3]>A^{(1)}[0][2]+A^{(1)}[2][3]=3 A(1)[0][3]>A(1)[0][2]+A(1)[2][3]=3,所以要令 A ( 2 ) [ 0 ] [ 3 ] = 3 ; p a t h ( 2 ) [ 0 ] [ 3 ] = 2 ; A^{(2)}[0][3]=3;path^{(2)}[0][3]=2; A(2)[0][3]=3;path(2)[0][3]=2;
注意到 V 2 V_2 V2和 V 3 V_3 V3之间并不存在直接相连的边,但是 V 2 V_2 V2到 V 3 V_3 V3的最短路径之前已经更新过了,两结点中间有还要经过中转结点 V 1 V_1 V1。上一轮处理已经考虑到增加 V 1 V_1 V1为中转点,现在是基于之前的最优结果再增加一个 V 2 V_2 V2作为中转点。所以这里找到的 V 0 V_0 V0到 V 3 V_3 V3的最短路径完整信息应该是 V 0 → V 2 → V 1 → V 3 V_0\to V_2\to V_1\to V_3 V0→V2→V1→V3
进行第四轮处理
进行第五轮处理
最终结果如下
接下来看如何利用 A A A和 p a t h path path矩阵找最短路径。找最短路径的长度很方便,但是找最短路径的完整信息会麻烦一些,需要通过 p a t h path path矩阵递归地找到完整路径。以 V 0 V_0 V0到 V 4 V_4 V4为例,由 p a t h [ 0 ] [ 4 ] = 3 path[0][4]=3 path[0][4]=3可知 V 0 V_0 V0到 V 4 V_4 V4中间有中转点 V 3 V_3 V3。而 V 0 V_0 V0到 V 3 V_3 V3并不存在直接路径,二者之间还存在一些中转顶点,由 p a t h [ 0 ] [ 3 ] = 2 path[0][3]=2 path[0][3]=2可知 V 0 V_0 V0到 V 3 V_3 V3还需要经过 V 2 V_2 V2。由 p a t h [ 3 ] [ 4 ] = − 1 path[3][4]=-1 path[3][4]=−1与 p a t h [ 0 ] [ 2 ] = − 1 path[0][2]=-1 path[0][2]=−1可知 V 0 V_0 V0到 V 2 V_2 V2与 V 3 V_3 V3到 V 4 V_4 V4不需要经过中转顶点就能直接到达,由 p a t h [ 2 ] [ 3 ] = 1 path[2][3]=1 path[2][3]=1可知从 V 2 V_2 V2到 V 3 V_3 V3还要经过中转点 V 1 V_1 V1。所以完整的路径为 V 0 → V 2 → V 1 → V 3 → V 4 V_0 \to V_2 \to V_1 \to V_3 \to V_4 V0→V2→V1→V3→V4
Floyd算法核心代码
//......准备工作,根据图的信息初始化矩阵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; //中转点
}
}
}
}
时间复杂度: O ( ∣ V ∣ 3 ) O(|V|^3) O(∣V∣3)
空间复杂度: O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
总结
B F S BFS BFS算法 | D i j k s t r a Dijkstra Dijkstra算法 | F l o y d Floyd Floyd算法 | |
---|---|---|---|
无权图 | √ | √ | √ |
带权图 | × | √ | √ |
带负权值的图 | × | × | √ |
带负权回路的图 | × | × | × |
时间复杂度 | O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)或 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣) | O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2) | O ( ∣ V ∣ 3 ) O(|V|^3) O(∣V∣3) |
通常用于 | 求无权图的单源最短路径 | 求带权图的单源最短路径 | 求带权图中各顶点间的最短路径 |
注:也可用 D i j k s t r a Dijkstra Dijkstra算法求所有顶点间的最短路径,重复 ∣ V ∣ |V| ∣V∣即可,总的时间复杂度也是 O ( ∣ V ∣ 3 ) O(|V|^3) O(∣V∣3)
有向无环图描述表达式
有向无环图(DAG)
有向无环图:若一个有向图中不存在环,则称为有向无环图,简称DAG图(Directed Acyclic Graph)
DAG描述表达式
之前学过如何像下面的例子一样用树来表示一个算术表达式,观察下面的例子可以发现给定算术表达式有些部分是重复出现的,比如 ( c + d ) ∗ e (c+d)*e (c+d)∗e,这些部分表现为下面树中被标成红色和绿色的两棵子树。
这两棵子树由于计算结果是一样的,所以可以去掉其中一棵子树来进行化简。处理后得到的图就是一个有向无环图,而且该图还能对算术表达式中重复出现的部分继续进行化简,只保留一份 ( c + d ) (c+d) (c+d)和 b b b
以下再看一道题来加深印象
29.[2019 统考真题]用有向无环图描述表达式 ( x + y ) ( ( x + y ) / x ) (x+y)((x+y)/x) (x+y)((x+y)/x),需要的顶点个数至少是()。
A.5 B.6 C.8 D.9
答案:A
化简步骤如下:
注意上面第二步结束后不要忘了两个 x x x还可以进一步化简
要把对应实际算术表达式的树充分化简转化为有向无环图,可以先仿造上面两例的步骤来对树进行初步化简,然后利用顶点中不可能出现重复的操作数的特点来检查化简是否正确。
课上讲的方法感觉记忆太麻烦,还不如直接硬解来的方便,加上本节不是考察重点。故那种方法略过不记。
拓扑排序
AOV网
A O V AOV AOV网( A c t i v i t y O n V e r t e x N e t W o r k Activity\quad On\quad Vertex \quad NetWork ActivityOnVertexNetWork,用顶点表示活动的网):
用 D A G DAG DAG图(有向无环图)表示一个工程。顶点表示活动,有向边 < V i , V j > <V_i,V_j> <Vi,Vj>表示活动 V i V_i Vi必须先于活动 V j V_j Vj进行
拓扑排序
所谓的拓扑排序实际上就是找到做事的先后顺序,如上图的“番茄炒蛋工程”的拓扑序列即为:
准备厨具 → \to →买菜 → \to →洗番茄 → \to →切番茄 → \to →打鸡蛋 → \to →下锅炒 → \to →吃
拓扑排序的实现:
1.从AOV网中选择一个没有前驱的顶点并输出
2.从网中删除该顶点和所有以它为起点的有向边
3.重复1和2直到当前的AOV网为空或当前网中不存在无前驱的顶点为止
后者说明当前所有顶点入度>0,原图存在有回路
课本上对于拓扑排序的定义如下:
拓扑排序:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
1.每个顶点出现且只出现一次
2.若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径
或定义为:拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面。每个AOV网都有一个或多个拓扑排序
代码实现
先用邻接表来存储图
#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode{ //边表结点
int adjvex; //该弧所指向的顶点的位置
struct ArcNode *nextarc; //指向下一条弧的指针
//InfoType info; //网的边权值
}ArcNode;
typedef struct VNode{ //顶点表结点
VertexType data; //顶点信息
ArcNode *firstarc; //指向第一条依附该结点的弧的指针
}VNode,AdjList[MaxVertexNum];
typedef struct{
AdjList vertices; //邻接表
int vexnum,arcnum; //图的顶点数和弧数
}Graph; //Graph是以邻接表存储的图类型
拓扑排序算法实现如下,这段代码省略了对两个数组的声明, i n d e g r e e indegree indegree用于记录每个结点当前的入度, p r i n t print print用于记录拓扑序列,此外还要定义一个栈 S S S用来保存当前度为0的顶点(也可用队列)
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
for(p=G.vertices[i].firstarc;p;p=p->nextarc){
//将所有i指向的顶点的入度减1,并且将入度减为0的顶点压入栈S
v=p->adjvex;
if(!(--indegree[v])){
Push(S,v); //入度为0,则入栈
}
}
}//while
if(count<G.vexnum){
return false; //排序失败,有向图中有回路
}else{
return true; //拓扑排序成功
}
}
代码执行的逻辑动态展示起来比较麻烦,略过不计
这种算法每个顶点都需要处理一次( P o p ( S , i ) ; Pop(S,i); Pop(S,i);),每条边都需要处理一次( v = p − > a d j v e x v=p->adjvex v=p−>adjvex)。故时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣),若采用邻接矩阵,则需 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
逆拓扑排序
对一个 A O V AOV AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序:
1.从 A O V AOV AOV网中选择一个没有后继(出度为0)的顶点并输出
2.从网中删除该顶点和所有以它为终点的有向边
3.重复1和2直到当前当前的 A O V AOV AOV网为空
上图中的“番茄炒蛋工程”的逆拓扑序列即为
吃 → \to →下锅炒 → \to →切番茄 → \to →洗番茄 → \to →打鸡蛋 → \to →准备厨具 → \to →买菜
实现逆拓扑排序可以模仿拓扑排序,只不过拓扑排序中我们看的是一个结点的入度,而逆拓扑排序中我们看的是一个结点的出度
这里上课没有讲具体如何实现
使用不同的存储结构来对时间复杂度的影响是很大的,因为在逆拓扑排序中删除一个顶点时也要删除指向该顶点的边,显然用邻接表实现逆拓扑排序比较低效(找到指向一个顶点的边需要遍历整个邻接表),用邻接矩阵会更方便些
当然也可以用逆邻接表,邻接表中每个顶点指向的边表保存的是从这个顶点往外发射的边的信息,而逆邻接表中每个顶点对应的边的信息指的是指向这个顶点的边。
逆拓扑排序的实现(DFS实现)
如何用深度优先算法实现拓扑排序见课本综合题9。下面只讲用深度优先算法实现逆拓扑排序,只需要在原有的深度优先搜索算法代码基础上做一点点小的改变
当我们访问完一个顶点及所有与该顶点邻接的所有顶点后把这个顶点输出,即在顶点退栈前输出。用这种方式输出得到的序列恰好就是逆拓扑排序序列
void DFSTraverse(Graph G){ //对图G进行深度优先遍历
for(v=0;v<G.vexnum;v++){
visited[v]=FALSE; //初始化已访问标记数据
}
for(v=0;v<G.vexnum;++v){ //本代码是从v=0开始遍历
if(!visited[v]){
DFS(G,v)
}
}
}
void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
visited[v]=TRUE; //设已访问标记
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)){
if(!visited[w]){ //w为u的尚未访问的邻接顶点
DFS(G,w);
} //if
}
print(v); //输出顶点
}
思考:如果存在回路,则不存在逆拓扑排序序列,如何判断回路?
关键路径
AOE网
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)
AOE网具有以下两个性质:
1.只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
2.只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
另外,有些活动是可以并行进行的
在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;
也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。
关键路径
从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动
完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长
事件 v k v_k vk的最早发生时间 v e ( k ) v_e(k) ve(k) —— 决定了所有从 v k v_k vk开始的活动能够开工的最早时间
活动 a i a_i ai的最早开始时间 e ( i ) e(i) e(i) —— 指该活动弧的起点所表示的事件的最早发生时间
事件 v k v_k vk的最迟发生时间 v l ( k ) vl(k) vl(k) —— 它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间
活动 a i a_i ai的最迟开始时间 l ( i ) l(i) l(i) —— 它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差
活动 a i a_i ai的时间余量 d ( i ) = l ( i ) − e ( i ) d(i)=l(i)-e(i) d(i)=l(i)−e(i),表示在不增加完成整个工程所需总时间的情况下,活动 a i a_i ai可以拖延的时间
若一个活动的时间余量为零,则说明该活动必须要如期完成, d ( i ) = 0 d(i)=0 d(i)=0即 l ( i ) = e ( i ) l(i)=e(i) l(i)=e(i)的活动 a i a_i ai是关键活动
由关键活动组成的路径就是关键路径
求关键路径
关键活动、关键路径的特性
若关键活动耗时增加,则整个工程的工期将增长
缩短关键活动的时间,可以缩短整个工程的工期
当缩短到一定程度时,关键活动可能会变成非关键活动
可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的