目录
图的定义
图是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
图的定义,需要注意几点:
1. 图中数据元素,我们称之为顶点。
2. 与线性表中可以没有数据,树中可无结点不同,图结构中,不允许没有顶点。在定义中,若V是顶点的集合,则强调了顶点集合V有穷非空。
3.图中,任意两个顶点之间都可能有关系,顶点之间的逻辑关系用边来表示。
各种图定义
无向边:若顶点Vi到Vj之间的边没有方向,则称这条边为无向边,用无序偶对(vi,vj)来表示。若图中任意两个顶点之间的边都是无向边,则称该图为无向图。如下所示:
有向边:若从顶点vi到vj的边有方向,则称这条边为有向边,也称为弧,用有序偶<vi,vj>来表示,vi称为弧尾,vj称为弧头。若任意两个顶点之间的边都是有向边,则称该图为有向图。如下所示:
请注意,无向边用“()”表示,而有向边用“<>”表示。
在图中,若不存在顶点到其自身的边,且同一条边不重复出现,则称这样的图为简单图。
以上两者就不属于简单图。
而在无向图中,若任意两个顶点之间都存在边,则称该图为无向完全图。如下:
而在有向图中,若任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。如下:
有些图的边或弧具有与它相关的数字,这种与图的边或弧相关的的数叫做权。这些权可以表示从一个顶点到另一个顶点的距离或耗费。这种带权的图通常称为网,如下
图的顶点与边间关系
以如下无向图为例:
顶点A的度为3,此图的边数为5,此图的各个顶点度的和为3+2+2+3=10。经推敲后发现,边数其实是各顶点度数和的一半。
以如下有向图为例:
顶点A的入度为2(从B到A的弧,从C到A的弧),出度是1(从A到D的弧),所以顶点A的度是2+1=3;此有向图的弧为4,而各顶点的入度和为2+1+0+1=4;各顶点的出度和为2+1+1+0=4;
无向图中,顶点B到顶点D四种不同路径如下:
有向图中,路径也是有向的,如下,顶点B到D有两种路径,而顶点A到B,就不存在路径。
树中根结点到任意结点的路径是唯一的,但是图中顶点与顶点之间的路径却是不唯一的。
路径的长度是路径上的边或弧的数目。
就拿上图来举例,左侧的路径长度为2,右侧路径长度为3。
第一个顶点到最后一个顶点相同的路径称为回路或环。序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环。
如上所示,因第一个顶点和最后一个顶点都是B,且C、D、A没重复出现。因此是一个简单环;而右侧的环,由于顶点C的重复,它就不是简单环了。
连通图相关术语
无向图篇
在无向图G中,如果顶点A到顶点D有路径,则称顶点A和D是连通的。如果对于图中任意两个顶点都是连通的,则称G是连通图。如图下:
左侧图中,其他顶点与E,F无路径,因此不能算作连通图;而且右侧图中,顶点A、B、C、D都是连通的,所以它本身是连通图。
无向图中的极大连通子图称为连通分量。注意连通分量的概念,其强调:
1. 要是子图
2. 子图要是连通的
3. 连通子图含有极大顶点数
4. 具有极大顶点数的连通子图包含依附于这些顶点的所有边。
上左侧图虽然是一个无向非连通图,但它有两个连通分量,即下图所示:
而下图为什么不是其连通分量呢?
因为他不满足连通子图的极大顶点数,因此它不是图1的无向图的连通分量。
有向图篇
在有向图中,每一对顶点都存在路径,则称其是强连通图。有向图中的极大强连通子图称作有向图的强连通分量。
左图并非强连通图,因为顶点A到顶点D存在路径,而D到A就不存在。右图就是强连通图,而且显然图右是图左的极大强连通子图,即是它的强连通分量。
连通图的生成树定义
一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但只有足以构成一棵树的n-1条边。
误点:此处对环的理解有误,以为必须是封闭的形状才是环,环指的是至少有一个路径,使得第一个顶点和最后一个顶点相同。随便添加一条边后,都可以至少有一条路径满足环的要求。
从上面可知,图1是一个普通图,但显然它不是生成树,当去掉两条构成环的边后,如图2或图3,此时它们都是一棵生成树;由此得出,若一个图有n个顶点和小于n-1条边,则是非连通图,若是它多余n-1边条,则必定构成一个环;比如图2和图3,随便加哪两顶点的边都将构成环;
有向图如何转变为有向树
如果一个有向图恰有一个顶点的入度为0,其余顶点的入度均为1,则是一颗有向树。一个有向图的生成森林由若干棵不相交的有向树的弧。
如上,有向图去掉一些弧后,它可以分解为两棵有向树。
图的存储结构
邻接矩阵
考虑到图是由顶点和边两部分存储,合在一起比较困难,所以就很自然地考虑到分两个结构来分别存储。顶点不分大小、主次,所以用一个一维数组来存储很不错;而边或弧由于是顶点与顶点之间的关系,就考虑用二维数组来存储。
图的邻接矩阵存储方式是用两个数组来表示图。一个一维数组来存储图中顶点信息,一个二维数组来存储图中的边或弧的信息。
无向图的邻接矩阵
无向图的邻接矩阵特点是对角线对称。判断vi到vj是否存在弧,只需要查找矩阵中是否为1即可,求vi的所有邻接点就是将矩阵第i行全部扫一遍。
有向图的邻接矩阵
有向图讲究入度与出度,顶点v1的入度为2,出度为1。
有权图邻接矩阵
为什么用无穷大来表示顶点之间不存在关系呢?权值有可能是0或负数,所以必须要用一个不可能的值来代表不存在。
邻接矩阵的代码表现
构建邻接矩阵的存储结构
构建无向网图
以上代码的是对无向图的邻接矩阵的表现,只不过此时的顶点之间的边有权值。
邻接表
将数组与链表相结合的存储方法称为邻接表;图中顶点用一个一维数组存储,顶点数组中,每个数据元素还需要存储第一个邻接点的指针,以便于查找该顶点的边信息。
如下图就是一个无向图的邻接表:
data是数据域,存储顶点信息,firstedge是指针域,指向边表第一个结点;边表结点由adjvex和next两个域组成,adjvex是邻接点域,存储某顶点的邻接点在顶点表中的下标,next则存储指向边表中下一个结点的指针。比如v1与v0、v2互为邻接点,则在v1的边表中,adjvex分别为v0的0和v2的2。
若是有向图的话,因为有向图有方向,我们是以顶点为弧尾来存储边表,这样可以得到每个顶点的出度。但也有时为了便于确定顶点的入度或以顶点为弧头的弧,我们可以建立一个有向图的逆邻接表。
有向图的邻接表与逆邻接表如下:
而对于带权值的网图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可。如下图所示:
邻接表的结点定义
邻接表的构建
十字链表
十字链表是邻接表和逆邻接表结合而成的,这样可以容易找到顶点的出度和入度。而且它除了结构复杂一点外,时间复杂度与邻接表相同。
以下方十字链表为例:
我们首先需要得知,顶点表结点结构如下:
firstin是表示该顶点的出度边表的指针,firstout是该顶点的入度边表的指针。
而边表结点结构如下:
第一个是弧起点的下标,第二个是其出度的下标,第三个是入度边表的指针域,第四个是出度边表的指针域
顶点出度链表图解
顶点入度链表图解
邻接多重表
如果我们在无向图的应用中,关注的是顶点,那么邻接表是不错的选择,但如果我们更关注边的操作——比如删除一条边等操作,那就需要找到这条边的两个边表结点进行操作。
仿造十字链表,对结构进行些许改造,以下图为例
作出邻接多重表如下:
对单个结点链接进行分析
第二格是用来存储连接结点指针域,如果第一格相同,则进行结点连接。
而后部分则是第三格相同,则链接在一起。
边集数组
边集数组由两个一维数组组成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标、终点下标和权组成。
在边集数组中,查找一个顶点的度需要扫描整个边数组,所以它更适合对边依次进行的操作,而不适合对顶点的操作。
以下图为例
图的遍历
从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程叫图的遍历。
深度优先遍历
首先从顶点A开始,做上表示走过的记号后,走向B、F,我们给自己定一个原则,在未碰到重复顶点时,始终走向右边,注意不要被图中内容误导,不是根据图的右边来定义,每一个分支顶点都可以放右边,只是注意后续分支影响,该分支顶点也可以自己定义后续分支顶点谁在右边。
假设B为右边顶点,一直遍历到F,到F时再往右走到了A顶点,此时发现A顶点已被标记,回到F顶点,此时A已经被标记过了,于是检查另一分支顶点G,G中B、D已走过,然后又到新的分支顶点H,至此H的分支顶点时D、E,都被标记过,因此一直从H返回到D,在返回途中也会查找每个顶点的分支顶点是否有新的——至此在D中查找到了I。
直到回到顶点A,你这才完成遍历。
邻接矩阵方式的代码
以上图为例,我们逐一推敲邻接矩阵的深度优先遍历。我们需要一个辅助数组visited,它的作用是记录访问过的顶点。
图中流程大致如下:遍历数组表,当数组表Graph[0][1]为1时,visit[1]变为true,这代表着它已被访问;随之数组表由Graph[1][0]开始,直到再次找到表中为1的数值,重复上方操作。
邻接矩阵的深度优先递归算法
如果邻接矩阵为非连通图
邻接表方式的代码
用下图来推敲邻接表的深度优先遍历
当从顶点表开始进行遍历,访问其边表顶点。若是未被访问过,则进入该顶点的顶点表再次进行访问。确定是否该顶点被访问过,也是使用visit辅助数组
邻接表深度遍历代码如下:
非连通图的遍历代码
对比两种存储结构的优劣
对于同样n个顶点e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,时间复杂度为O();而邻接表则是O(n+e);邻接表在点多变少的稀疏图来说,邻接表结构大大占优。
对有向图来说,它只是对通道存在可行不可行,算法上完全没有变化,是可以通用的。
广度优先遍历
如果说图的深度优先遍历类似树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历了,如下图:
我们将图左稍稍变形,变形原则是顶点A放置在最上第一层,让与它有边的顶点B、F为第二层,再让与B、F有边的顶点C、I、G、E为第三层,再让这四个顶点有边的D、H放在第四层。
邻接矩阵的广度优先遍历
以下图开始为例进行推敲,假设以2为顶点开始遍历。
因为顶点是根据顶点的分支顶点来遍历,不仅需要一个数组记录哪些顶点被访问过,还需要一个阵列记录当前顶点哪些分支顶点等待访问。
邻接矩阵的广度优先遍历代码如下:
执行第一个顶点的遍历
执行第一个顶点的分支顶点的遍历
邻接表的广度优先遍历
广度优先遍历代码如下
深度优先遍历与广度优先遍历的优劣
两种遍历方式在时间复杂度上一样的,不同之处仅仅在于对顶点访问的顺序不同;遍历的目的是为了寻找合适的顶点,深度优先更适合目标比较明确的情况,而广度优先更适合在不断扩大遍历范围时找到相对最优解的情况。
最小生成树
以上是带权值的图,即网结构,我们把构造连通网的最小代价生成树称为最小生成树
普利姆算法
从顶点出发选择权值最小的两条边,然后在两个顶点集合中选择权值最小的边。两个顶点结合分别是已选顶点的集合,另一个是未选顶点结合。
这两个集合中的顶点连接选择一条权值最小的边进行连接,同时将这条边的顶点移动到已选顶点集合中。所以我们需要一个数组域adjvex用于存储连接顶点的下标,一个lowcost数组用来存储它的权值。
普利姆算法的运行逻辑
以下图为例开始推敲:
从v0顶点开始,而顶点集与adjvex数组和lowcost数组关系如下:
而如果顶点是从v3开始的话,顶点集 与adjvex数组和lowcost数组关系如下:
可以发现规律,起始顶点的adjvex都是它自己的下标,而lowcost则是它邻接矩阵的当行数据。而当lowcost=0时,则说明该顶点已进入已选顶点集合中。
继续以起始顶点v0开始分析,它的最小权值边为顶点v5的连接边,选中顶点v5,表格更新如下:
此时已选顶点集合中为{v0,v5},现在是从这两个顶点中选择与未选顶点的最小权值边。
比较v0与v5与未选顶点的每条边的权值
得出表格更新如下
此时顶点v2与顶点v3同样身为最小权值,因为顺序,优先访问顶点v2,v2的adjvex变为5,v2的lowcost变为0,表格更新如下:
再次比较v2顶点与未选顶点集合的最小权值边,以及其他已选顶点与未选顶点集合的最小权值边。
又得出了v3,重复上方v2的修改方式,将v3的adjvex变为2,lowcost变为0;
普里姆算法的代码实现
克鲁斯卡尔算法
克鲁斯卡尔算法以边为目标进行构建,只不过构建时需要考虑是否会形成环路而已。
当选取了n-1条边时,此时最小生成树已经形成。
克鲁斯卡尔算法的三个关键点
在该算法中,主要运行步骤如下:
1.如何判别边的两个顶点是位于两个连通分量
2.此外,图采用哪种存储形式也是很重要的——因为该算法的主体是边,而邻接表和邻接矩阵存储顶点来存储边的信息,需要搜索边时需要历经每个顶点才能找到最短边。
3.采用哪种排序方式?采用插入排序,时间复杂度O(n2);采用堆排序或快速排序,时间复杂度O(nlog2n)
三个关键点解决如下:
1.图采用哪种存储形式?
数据存储结构设计如下:
用边集数组的形式进行存储,可以有效存储边的信息
2.如何确定两个顶点为连通分量?
此处使用了一则并查集的方式,大致原理如下:
一个集合构造一棵树,任选一个元素为根结点,建立数组parent记录每个结点的父结点,式子parent【当前结点】=根结点。
而合并就是将另一子树的根结点更改,如下:
而确认两个顶点为连通分量,也是将两个顶点合并,即合成为同一棵树。
克鲁斯卡尔算法的运行逻辑
开始初步运行:
根据如上步骤得出图如下:
当非连通顶点得以连接后,parent数组如下:
当连通顶点与其他子树连通时如下:
当连通顶点即将构成环时
当边数为n-1时,最小生成树生成
克鲁斯卡尔代码实现
边集数组结构定义
算法实现
两套算法的优劣对比
克鲁斯卡尔算法的Find函数由边数e决定,时间复杂度为O(loge),而外面有一个for循环e次。所以克鲁斯卡尔算法的时间复杂度为O(eloge)。
而循环嵌套可得知普里姆算法的时间复杂度为O()。
对比两个算法,克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。
最短路径
在非网图中,他没有边上的权值,所谓最短路径,其实就是指两顶点之间边数最少的路径;对于网图而言,最短路径是指两顶点之间经过的边上权值之和最少的路径,并且我们称路径上第一个顶点是源点,最后一个顶点是终点。
迪杰斯特拉算法
需要一个已选顶点集合和未选顶点集合来控制顶点是否使用,另外还需要一个数组来控制达到目前顶点的最短路径,还需要一个数组控制达到当前顶点时,上一个顶点是什么。
算法运行流程
求出下图0-6的最短路径
各项数组参数从顶点0开始如下:
开始顶点0到其他顶点的最短路径:
而此时在顶点1时,完成如下流程:
此时顶点1结束后,各参数如下:
由dist数组可知,目前最短路径为顶点2,于是我们访问顶点2
顶点2各参数如下:
后续以同理往下推,当结束顶点3的访问时,注意此刻最短路径为顶点5了。
最终到达了顶点6时
最后,求得最短路径及长度如下:
算法代码实现
数组初始化
这里的startV是可以从任意顶点起始,将dist[i]中数组全都填入该顶点的邻接矩阵数据,path[i]数组能与该顶点相连通的录入该顶点下标,不能连通的输入-1,S数组用来确定该顶点是否选中。
主算法运行
while大循环控制
再一次在新的dist表中找到最小顶点,记录其下标,接着看加入新顶点后是否需要修改dist表和path表。
弗洛伊德算法
算法概念了解
以上图为例,dist矩阵代表顶点之间的权值大小,path矩阵代表顶点路径。
迭代第一次,加入顶点A,此时路径能以顶点A为中转,得出表格如下:
迭代第二次,加入顶点B,此时路径能以A,B为中转,得出表格如下:
迭代第三次,加入顶点C,此时路径能以A,B,C为中转,得出表格如下:
综上,得出公式结论如下:
算法代码实现
初始化dist数组和path数组
主程序运行
这一段path矩阵需要着重修改,因为有acb存在,可能需要三维数组进行存储。
两种算法的优劣
两者算法复杂度都是O(),对于单个顶点到单个顶点的最短路径可以用迪杰斯特拉算法,(单个顶点到单个顶点的该算法复杂度为O()),多个顶点到多个顶点用弗洛依德算法更合适。
拓扑排序
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,我们称为AOV网。AOV网中的弧存在某种制约关系,比如演职人员确定好了才可以进场拍摄;另外,AOV网中不能存在回路。
设G是一个有n个顶点的有向图,V中顶点序列为v1,……,vn,若满足一个顶点vi到vj有一条路径,则在顶点序列中顶点vi必在vj前。则我们称这样的顶点为一个拓扑序列。
如上图的拓扑序列不止一条,v0v1v2v5……v16是一条拓扑序列,v0v1v4……v16是一条拓扑序列。
而所谓拓扑排序,其实就是对一个有向图拓扑构造拓扑序列的过程。构造时有两个结果:若是此网的全部顶点都被输出,说明它不存在环的AOV网,若是输出顶点少了,即使是一个,也说明这个网存在环,不是AOV网。
拓扑排序算法
对AOV网基本思路是:
(1)从AOV网中选择一个没有前驱的顶点并且输出;
(2)从AOV网中删去该顶点,并且删去所有以该顶点为尾的弧;
(3)重复上述两步,直到全部顶点都被输出,或AOV网中不存在没有前驱的顶点。
该拓扑排序为1、2、3、4、5、6、7
该图的数据结构:
因为需要删除顶点,所以我们选择用邻接表来建立AOV网更加方便,而考虑到算法过程中始终要查找入度为0的顶点,我们在原来顶点表结点结构中,增加一个入度域in,表示入度的数量。
另外,我们还需要一个栈S来存储所有无前驱的顶点,也可以用队列。
拓扑排序流程
以下图为例子开始实现算法流程:
扫描顶点表,将堆栈初始化。将入度为0的顶点B,E压入堆栈。
将栈顶元素E出栈,根据顶点E的firstedge遍历所有的边,其指向各个顶点的入度值-1,在处理时,如果发现某个顶点入度值为0,则压入栈堆。
此时发现C入度为0,入栈。后面输出也是同理类推。
拓扑排序代码实现
数据初始化
算法运行
关键路径
在一个表示工程的带权有向图中,用顶点表示事件,有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网,我们称之为AOE网。
尽管AOE网和AOV网都是用来对工程建模,但它们还是有很大不同,主要体现在AOV网是顶点表示活动的网,它只描述活动之间的制约关系;而AOE网是用边表示活动的网,边上的权值表示活动持续的时间。
路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大的长度路径叫关键路径,在关键路径上的活动叫关键活动。
四个关键参数
为此,我们需要定义如下几个参数:
1. 事件的最早发生时间ve[k]:ve[k]是指从始点开始到顶点vk的最大路径长度。这个长度决定了所有从顶点vk发出的活动能够开工的。
以上公式的含义是,一个顶点有几种不同的路径时,取权值和最大的那个值,就比如此时ve[4]有5和7两条路径取了7。
2. 事件的最晚发生时间ltv:再不推迟整个工期的情况下,事件允许的最晚发生时间
从终点倒推,回溯到某顶点时有多个路径时,选权值最小的那条路径。
3. 活动的最早开工时间ee[i]:若活动a是由弧<vk,vj>表示,则活动a的最早开始时间应等于事件的最早发生时间。
当活动顶点为起始点时,起始点的所有到其他顶点的弧,ee[i]=0,而a3这个活动开始时间,就是v1这个事件开始时间,a3的ee【i】=6;同理,a9这个活动最早开始时间,就是v6这个事件最早开始时间,a9的ee【i】=ve【6】=16
4. 活动的最晚开工时间lte:即弧ak的最晚发生时间
算法代码实现
求拓扑序列
误点1:
误点2:
以为就这下面一种输出方式,实际上这只是其中一种.
输出内容与边表的链接有关,以下两个边表位置可以置换,但置换后输出先后改变.
还是用取点摘边法来判断有哪些输出方式吧.
关键路径的算法代码
初始化
计算ltv
计算ete和lte
所以最终,就是判断lte与ete是否相等,相等意味着没有任何空闲,是关键活动。
分析整个求关键路径的算法,第6行是拓扑排序,时间复杂度为O(n+e),第8~9行时间复杂度为O(n),第10~19行时间复杂度为O(n+e),第20~~31行时间复杂也为O(n+e),根据我们对时间复杂度的定义,所有的常数系数可以忽略,所以最终求关键路径算法的时间复杂度依然是O(n+e)。