一、图的基本概念
1.1图的定义
图是由树变化而来,我们所学习的树在遍历的过程中是没有回路的,而图可以有。可以说树是一种特殊的图。基于这种理解下图就是由顶点集以及顶点与顶点之间形成的边集组成,那么这句话的隐藏含义就是,边集中的任意一条边的两端都存在顶点。且图中边的数量一定不大于顶点的数量。
这里需要注意两个符号的含义:1.V,v,都可以代表顶点。2.|V|代表图的顶点数,也称为图的阶。2.E,e,都可以代表边。4.|E|代表图的边数。
注意,线性表可以是空表,树可以是空树,但是图不能为空,但是图的边可以为空,也就是图可以允许只有顶点没有边,每一个顶点都是图的最大连通分量。
1.2图的逻辑结构
图分为有向图和无向图。有向图是指将两个顶点相连的边是有方向的,我们称这种有方向的边为“弧”,用箭头来表示,而这条弧中有箭头的一端称为弧头,没有箭头的一端称为弧尾。无向图就是有向图的“弧”换成“边”即可。而弧是只能从弧尾到弧头,但是边可以两端互相走。弧用<>表示,而边用()表示。
而两种图的应用面也非常广。比如无向图中,在微信中两个人,A加了B的好友,那么此时B不需要在回加A的好友,两个人就互相是彼此的好友。有向图中,比如抖音,A关注了B但是B没有关注A,那么此时就不能说A和B是互关的关系。
1.3顶点的度
无向图中顶点的度就是指无向图中的某个结点上连了几条边,那么边的数量就是这个结点的度。而无向图中所有结点的度数和是无向图结点和的二倍。
只有有向图才有入度和出度的概念,在有向图中的一个结点中,入度就是指指向该结点的弧共有几条。而出度就是以该结点为起点出发的弧有几条。入度之和+出度之和=弧的总数。
1.4图的基本术语
路径:略。回路:起点和终点相同的路径。简单路径:一条路径中不重复出现某一点。简单回路:在一条回路中出起点终点外,不重复出现某一点。这里要注意,路径是顶点序列。
顶点之间的距离:就是在一个图中,能找到这两个顶点之间的最短路径,如果找不到这两个顶点之间的路径,那么就需要将路径长度记为 ∞ 。
连通和强连通:连同是存在于无向图中,如果两个顶点之间存在边,那么就可以说明这两个顶点为连通的。强连通存在于有向图中,如果两个顶点之间存在互相指向对方的两条弧,那么就可以说明这两个顶点为强连通的。
1.5连通图和强连通图
在一个无向图中,如果任意两个顶点都是连通的,那么就可以说明这个图为连通图。在一个有向图中,如果任意两个顶点都是强连通的,那么就可以说明这个图为强连通图。这里要注意:连通的强连通的两个顶点之间允许存在第三者。
连通图和强连通图还存在一些基本考点:1.在一个有n个顶点的无向图中,如果想要成为连通图,那么就需要至少n-1条边,这个比较好理解。难的是下一条,2.还是这个无向图,如果想要保证图是连通图,那么至少需要C2(2n-1)+1条边。
那么这个结论是怎么来的呢:首先我么要理解这个“保证”和“至少”加在一起是什么意思,就是需要保证无论我如何浪费边的数量,最后都能再加1条边使整个图连通。这样一来问题就迎刃而解了。我们只需要尽量的浪费边的数量即可。首先我们在n-1个顶点之间尽量多的连接。那么就需要在n-1个定点中随机选取两个点连接也就是C2(2n-1),还剩最后一个顶点没有和这n-1个顶点连接,而我们还剩一条边,这样连起来就使整个图连通至少需要C2(2n-1)+1条边。
1.6子图
子图是指在一个图中拿出一些顶点,以及图中原有的边能构成图的图称为子图,也就是说我们如果只拿出A和B顶点,如果想要构成子图的话就不能拿出C和D之间的边。
有了子图的概念,我们就要讲一个重要的考点:连通分量和强连通分量
连通分量是指,在一个图中能拿出的极大连通子图。极大连通子图是指,拿出能连通的子图中的全部顶点以及图中原有的连接这些顶点的所有边。强连通分量性质类似,不做赘述。
生成树:生成树就是极小连通分量,有了极大连通分量的概念,极小连通分量就是指尽量砍掉极大连通分量中的尽可能多的边,还要保证连通。
1.7带权边
带有权值的边就是带权边,而图中的边都是带权边,那么这个图称为带权图,也叫网。
1.8几种特殊的图
1.无向完全图,还记得我们前面说过的尽可能的浪费边的数量吗?无向完全图即此图。有向完全图顶点之间要互相指向其余类似。2.稀疏图和稠密图,顶点的数量远远大于边的数量就是稀疏图,稠密图反之。3.树:树就是一种特殊的图,没有回路的图称为树。
二、图的存储及基本操作
1.邻接矩阵法
邻接矩阵法就是用矩阵来存储一个图,而矩阵中的数据其实就是边的权值。
首先是无向图:当一个图没有权值时默认图中所有的边权值为1。那么我们用邻接矩阵法就可以表示,图中如果某两个顶点之间有边直接相连,那么就可以在矩阵中的对应位置写成1,如果是带权图,那么就要写上权值。而如果某两个点之间没有边直接相连,那么就需要在矩阵中的对应位置写成0,如果是带权图,那么就要写成 ∞ 。
有向图中的弧由于是单向的,那么假如A->B存在弧而B->A不存在弧,那么就只能在A->B的对应位置写上对应的权值。这样看来无向图所对应的矩阵是对称矩阵。而对称矩阵还可以压缩存储。
此处对称矩阵还有一个性质,那就是关于矩阵的幂所对应的图的几何意义。
邻接矩阵由于是n行n列的矩阵。空间复杂度较高,而且由于矩阵数据的意义是存放边的权值,那么如果边很少的话,用邻接矩阵来存放就显得有些浪费空间。
2.邻接表法
邻接表法可以做到给出任意一个顶点,可以顺着这个顶点找到所有从该顶点出发的边或弧。那么是如何做到的呢?
首先我们需要准备一个数组,数组中存放着图中所有的顶点,然后顶点的结构体中还存放着该顶点的第一条边,然后由第一条边来指向第二条边以此类推。这样数组中的每个顶点就都连接着一个链表,也就是邻接表法。
根据邻接表的特点不难看出,想要找到一个顶点的度,以及入度是很简单的。但是想要找到有向图中的出度是很难的。需要从表头开始遍历整个邻接表。因为顶点所连接的链表内容是从这个顶点出发的弧,也就是存放了出度,但是没有存放入度,所以想找到入度就要从表头开始遍历,看看哪个顶点的链表中的指针指向了该顶点。
那么相对于邻接矩阵法而言,两种方法各有优劣。比如邻接表不好寻找入度的特点,邻接矩阵就可以顺利实现。但是邻接矩阵由于是二维数组,空间复杂度很高。邻接矩阵有着存储情况唯一的特点,而邻接表中的链表部分存储情况却不唯一。
3.图的基本操作
图的基本操作主要用的有两个,1.FirstNeighbor,2.NextNeighbor。第一个基本操作是寻找某个顶点的第一个邻居,第二个基本操作是用来寻找某个顶点的第二个邻居。在邻接矩阵存储中,根据图是无向图还是有向图来遍历矩阵的行或列即可,第一个非0数字既是第一个邻居,第二个邻居同理。在邻接表存储中,只需要根据基本操作来看该顶点所连接链表中的数据即可。
三、图的遍历
1.广度优先遍历(BFS)
广度优先遍历所要用到的基本操作就是 FirstNeighbor 和 NextNeighbor 。因为广度优先遍历说白了就是往广的方向遍历图,给定一个顶点,我们根据基本操作来找到这个顶点的所有邻居,然后根据我们找到的邻居递归的寻找邻居们的所有邻居。这种遍历方式就是广度优先遍历(BFS)。
我们了解了广度优先遍历的逻辑后就要了解这种遍历方式出现的问题以及如何解决。
1:在我们访问第一个结点的所有邻居后,递归的访问邻居的所有邻居的过程中会出现一个问题,我们访问的顶点可能已经在第一轮就已经被访问了。那么此时我们若再次访问就会导致最后得到的广度优先遍历序列重复,所以我们需要一个数组来帮助我们来判断各个结点是否已经访问。
2:我们这样递归的访问下去后,如果是连通图还好说,随便给定一个顶点即可找到从该顶点到达其余任意顶点的路径,这样可以保证我们访问图中的全部顶点。但是如果此图是一个非连通图的话,就不能访问到全部顶点,这样会导致最后得到的广度优先遍历序列缺失。
解决办法也很简单,我们前面定义过一个数组来帮助我们判断各个结点是否已经访问,那么在我们第一次的函数递归结束后,只需要遍历一下这个数组,如果还有结点没有被访问,就说明此图非连通,就需要继续将没有被访问的顶点传参再次调用函数。
这里要注意:有向图中的弧是单向的,判断是否连通的时候与无向图不同。
2.深度优先遍历(DFS)
深度优先遍历是给定一个顶点,然后从这个顶点出发找到这个顶点的第一个邻居,然后再找到这个邻居的第一个邻居,以此类推继续向后寻找,当找不到邻居之后就可以返回,返回的过程中还需要判断这个顶点还有没有第二个邻居,如果有还需要继续深入访问。这种递归方式不同于广度优先遍历,深度优先是找到1个邻居就可以继续从邻居开始找邻居的邻居,而广度优先遍历是必须要找到全部的邻居后才能从邻居开始,找邻居的全部邻居。需要仔细思考这个过程。
这个过程与广度优先遍历一样,也需要一个数组来记录各个结点有没有被访问。
3.广度优先遍历序列与深度优先遍历序列
广度优先遍历序列需要用到辅助队列的帮助才能实现,深度优先遍历序列需要辅助栈的帮助才能实现。这里的队列以及栈都是用来辅助顶点元素形成序列的。而函数的调用以及递归调用都是在函数调用栈中进行,这一点需要区分好。
广度优先遍历序列:首先给定一个顶点,那么就需要让这个顶点入队,根据广度优先遍历我们要找到这个顶点的所有邻居,让他们依次入队,(这里要注意我的措辞,依次入队,那么这也就代表着邻接表法中,顶点所连接链表中元素顺序不同,那么入队顺序也就不同)入队完成后,队头元素即可出队,然后继续让没入队的新队头顶点的邻居入队,然后队头顶点出队,如果对头顶点没有邻居,直接出队即可。
深度优先遍历序列:首先给定一个顶点,令该顶点入栈,然后找到这个顶点的第一个邻居,让邻居入队(注意此时不能像广度优先遍历一样让队头元素出队,因为此时我们还不知道队头顶点是否还有其他邻居),然后我们需要继续寻找邻居的第一个邻居,令其入队。如果找不到那么即可令此元素出栈,然后判断栈中下一个元素是否有邻居,如果有邻居,令其入队,否则令此元素出栈,以此类推即可得到深度优先遍历序列。
注意:这里我多次提到 “第一个邻居” 的概念,就是要时刻记得,邻接表中存储的顶点的第一个邻居是可以变化的,那么序列也会发生变化。
4.广度优先生成树与深度优先生成树
广度优先生成树是由广度优先遍历得来,根据广度优先遍历的逻辑,给定一个顶点,那么就可以将此顶点视为根结点,然后寻找到的邻居们就可以连接到这个根结点上,递归进行下去。
邻接矩阵法:广度优先生成树是由广度优先遍历的来,而广度优先遍历在邻接矩阵中的遍历方式唯一,那么我们得到的广度优先生成树也唯一。
邻接表法:在邻接表法中,广度优先遍历的方式会随着邻接表的变动而改变,这样我们得到的广度优先生成树也不唯一,比如C顶点的链表的第一个元素为D,第二个元素为F,那么我们在C下连接的孩子就依次是D、F,然后我们接下来先要访问的是D的链表元素。但是如果链表顺序改变,那么孩子的顺序就是F、D。那么接下来先要访问的就是F的链表元素。
至于深度优先生成树与广度优先生成树类似,邻接矩阵法生成树唯一,而邻接表法生成树不唯一。根据深度优先遍历的方式,以及深度优先遍历序列,即可自行判断生成树。
四、图的应用
4.1最小生成树
前面我们已经知道了生成树的概念,以及广度优先生成树和深度优先生成树,但是我们得到的这些生成树所对应的图都是无权图,或者说图中各个边的权值都是1。而最小生成树的含义就是,我们需要一棵边的权值加起来最小的生成树。
4.1.1Prim算法
这种算法的核心就是给出一个顶点,然后找到距离该顶点权值最小的顶点,将他们连接起来,然后继续寻找距离这两个顶点权值最小的顶点,将他们连接起来,最后生成一棵树。
那么,我们如何才能找到那个距离该顶点权值最小的顶点呢?这时我们就需要一个数组来记录每个顶点到我们选中顶点的直接相连的边的权值,如果不直接相连,那么在数组中记录为 ∞ 。当我们找到第一个顶点相连后,数组中的数据也要随之改变,原本的权值可能会因为新加入的顶点而改变,比如3原本与1相连,权值为10,但是由于2的加入,而且3还和2有直接相连的边权值为5,那么此时数组中下标为3的值就由10变为了5。这样依次寻找,即可找到最小生成树。
4.1.2Kruskal算法
这种算法的核心就是找到图中权值最小的边,然后再继续寻找权值最小的边,直到所有的顶点都相连为止。
4.2最短路径问题
4.2.1BFS算法
BFS算法来计算最短路径的适用范围为无权图,或者各个边权值都相等的图。那么我们如何用BFS算法来计算最短路径呢?
其实只需要在BFS算法的基础上加上两个数组即可。第一个数组 d[ ] 用来记录各个顶点到达指定顶点的距离,第二个数组 Pre[ ] 用来记录到达各个顶点的最短路径上的前驱结点。
例如给定一个顶点A,让我们用BFS算法来求各个顶点到达该顶点的距离。首先我们要从A出发按照广度优先遍历的方法来找到与A直接相连的所有邻居,并在辅助数组中记录A的所有邻居已经被访问。然后在数组 d[ ] 中记录A到这些邻居的距离为1,然后在数组 Pre[ ] 中记录A到达这些邻居最短路径上最后一个顶点的前驱,这里的前驱为A。然后我们再根据据广度优先遍历来寻找这些邻居的邻居,以同样的方法在三个数组中记录相应的数据即可。最后我们就可以找到A到达其余任意顶点的最短路径。此外我们根据 Pre[ ] 数组还可以找到A是通过哪些顶点到达的。
4.2.2Dijkstra算法(这里用有向图举例)
Dijkstra算法适用于带正权图,想要完成Dijkstra算法我们首先就要创建三个数组,第一个数组 isfind[ ] 用来记录是否已经找到到达该顶点的最短路径,第二个数组 d[ ] 用来记录到达各个顶点的最短路径长度,第三个数组 Pre[ ] 用来记录到达各个顶点的最短路径上的前驱结点。
明白这三个数组所代表的含义之后,我们就可以进行Dijkstra算法。
第一步:我们给定一个顶点 1,那么此时的 isfind[ ] 中 1 处为true,其余结点为fauls。此时数组 d[ ] 中 1 处的值为0,其余顶点如果有直接与顶点 1 相连的顶点(注意这句话说的是顶点 1 的出度,也就是弧尾在 1 处才能算,而 1 的入度不能算)那么就可以在对应顶点的数组 d[ ] 中记录相应的权值。此时数组 Pre[ ] 中1所对应位置的值就可以记作-1,因为1没有相应的前驱,而与顶点 1 直接相连的顶点处的数组 Pre[ ] 中对应的值就可以记作 1 ,因为目前来说他们从1出发的最短路径上的前驱结点就是 1 。
第二步:我们要找到此时数组 d[ ] 中,除1之外的其余最短路径,我们假设从顶点 1 到顶点 2 的最短路径为 5 ,而数组 d[ ] 中从定地点 1 到达顶点 3 的最短路径为15。其余均为 ∞ 。那么此时数组 d[ ] 中的除顶点 1 之外的最小值为 5 。那么我们此时就应该从顶点 2 开始,按照顶点 1 的办法故技重施,我们假设此时顶点 2 可以到达顶点3权值为6、顶点 4权值为7、顶点 5权值为8。那么我们就会发现,原本数组 d[ ] 中顶点 3 记录的最短路径为 15 前驱为1,由于顶点 2 的加入就需要改变,顶点1到顶点3的最短路径就可以进行更新:从顶点1到顶点2再从顶点2到顶点3,路径长度为5+6=11<15。那么数组 d[ ] 中顶点 3 所在位置的数据就要更新为11,而其他结点由于顶点 2 的加入也需要改变数据。数组 Pre[ ] 中顶点 3 所在位置原本数据为 1 ,现在就要改为 2 。然后我们就按照这种方法以此类推下去。直到找到所有从 1 出发到达各个顶点的最短路径为止。
注意:Dijkstra算法可能会无法算出带负权图的最短路径。
4.2.3Floyd算法
本算法语言不好描述,复习到这里需要看王道视频的此算法,这里说明一点:要弄清楚Floyd算法矩阵的幂次方代表着什么。
4.3有向无环图(DAG图)
有向无环图是一种没有环路的有向图,考点一般会给出一个表达式,让你判断这个表达式的有向无环图至少需要多少个结点?
首先我们要知道有向无环图类似于中缀表达式转化为树的形式,也可以说有向无环图就是中缀表达式的树的变换。
一般的做题思路就是要画出这个表达式的有向无环图。在画图的过程中我们秉持着5个步骤,这里我们拿(x+y)*((x+y)/x)这个表达式举例。
1.我们将表达式中最先计算的所有项都写在第一层,那么这里第一层就要写x,y。这个表达式中有很多个x,y但是我们每个字母只写一次即可,但是中缀表达式树就要全部写出。(注意:这里的第一层类似于树的叶子结点,而不是根结点)。
2.其次我们用最先计算的符号以树的形式,让运算符成为这些字母的父节点。例如这里的 + 就成为了x和y的父节点。
3.然后我们还要按照表达式中的内容来判断谁是谁的父节点,例如这里的 / 就是 + 与 x 的父节点。而 * 就是 + 与 / 的父节点。
4.最后我们按照运算符的先后次序为各个运算符分层。例如,这个表达式中的 + 就是在有向无环图的第二层,而 / 则在有向无环图的第三层,* 则在第四层。
5.最后我们根据表达式来看我们画出的图还有没有可以合并的结点即可。
4.4拓扑排序
我们知道了有向无环图的概念之后,拓扑排序就是在有向无环图的基础上进行的。如果我们将有向无环图的各个结点都变成一个活动,那么整个DAG图就变成了一个包含着各个活动的工程,那么此时的DAG图就变成了AOV网。而拓扑排序也是从AOV网中得来。
拓扑排序是从AOV网中每次选择一个入度为0的结点(活动)开始排序,每次选完一个活动之后要将该活动连带着该活动的弧从AOV网中删除,直到到达最后一个结点(活动)停止。
这样的排序方式也很好理解,我们可以将入度为0的活动想象成可以直接做的活动,而入度大于0的活动想象成在入度为0的活动都做完之前无法进行的活动。
比如入度为0的活动为买菜,而入度为1的活动为洗菜,那么如果我们不先买菜,也就谈不上洗菜了。
还有一点,为什么拓扑排序需要在有向无环图中进行呢?举个例子,假设我们的一个工程中包含了鸡蛋孵小鸡,小鸡下鸡蛋。并且鸡蛋孵小鸡指向了小鸡下鸡蛋而小鸡下鸡蛋又指向了鸡蛋孵小鸡形成一个环。那么问题就很大了,到底是先有的鸡还是先有的蛋呢?这样一来蛋越来越多永远不会拿去煮来吃。所以拓扑排序一定要在AOV网有向无环图中进行。
逆拓扑排序:逆拓扑排序实现起来与拓扑排序基本大同小异。拓扑排序是每次删除入度为0的顶点,而逆拓扑排序只需要每次删除出度为0的点即可,其他操作方式与拓扑排序相同。
这里还有一个考点就是:深度优先遍历(DFS)来实现逆拓扑排序,原理很简单,由于深度优先遍历是尽量向深处访问,访问结束后退栈。那么这种性质就完全适用于逆拓扑排序,因为向深处访问的过程就是逐渐接近出度为0的活动,这样一来就可以保证出度为0的活动最后入栈,那么出度为0的活动就可以最先出栈。
那么我们只需要令入度为0的活动最先进栈即可。也就是说我们只需要保证从入度为0的活动开始进行深度优先遍历即可。退栈过程得到的序列就是逆拓扑排序序列。
这里要注意,我们输出命令也要写在递归函数中,这样才能保证函数递归的归的过程也就是出栈的过程执行之前就将栈顶元素打印了出来。原因也很简单,活动如果先出栈了,我们都找不到原来的栈顶元素了,那还打印个锤子。
4.5关键路径
在前面的拓扑排序中,我们知道了什么是AOV网,AOV网是由DAG图得来,AOV网可以代表各个活动在工程中的完成顺序。而关键路径是在AOE网中得来,AOE网不仅能反应各个活动在整个工程中开始的顺序,还能反应完成各个活动所需要的时间。
比如买菜需要5分钟,而洗菜需要两分钟。那么显然买菜这个活动在洗菜的前面,而且必须要等待买菜结束后,才能开始洗菜,也就是洗菜最快也只能在5分钟后进行。
关键路径:我们在AOE网中找到入度为0的点称为源点(开始的点)通过不同的路径达到出度为0的点称为汇点(结束的点)。在这些不同的路径中,那条耗时最长的路径就是AOE网的其中一条关键路径,而路径上的活动称为关键活动。而完成关键路径的最短时间就是整个工程完成所需要的最短时间。这里完成关键路径的最短时间的意义是每个关键活动都接踵而至,也就是买完菜立刻洗菜,洗完菜立刻切菜。这样就可以保证每个活动都不拖延,就能保证时间最短。