上一篇关于树的博客:二叉树的总结-CSDN博客
本篇并不会对每一个点写的很细,只是对概念进行理解和梳理。
文章目录
如何理解图:
将一些散落的点连接在一起,那便构成了图。回想我们之前接触的树,那就是一种有约束的图,相对于树而言,图的约束更少了一点。同时,为了更好的编程,或是去约束图,于是就强调了边的概念。(树也是有边的概念的,比如哈夫曼树的权,只是我们一般都忽视了)
注意:一个孤立的点也能称之为图
图的一些名词及理解
- 子图:取图的一部分,本身还是图,对照树和子树的概念
- 度:对于无向图而言,度就是点的边
- 出度,入度:对于有向图,出与入的概念就是边的箭头的指向。指向这个点就是入度,反之出度
- 连通分量:对于无向图,如果有:有边ba,bc,那么a,c之间是连通。abc这三个点构成的子图就是就是连通分量。
- 强连通分量:对于有向图,它的任意两个点都可以连通的,那它就是强连通分量。
注意:一个孤立的点也是强连通分量。
图的存储
邻接矩阵:
就是一个二维数组去存储。先把点编号:1,2,3…,然后看数组下标a[1][2]
就代表1和2这两个点的边,如果这个边存在,那就a[1][2]=1
否则局赋值0。
它的优势就是好查找,缺点就是存储空间的利用很有问题。这其实就是数组存储本来就有的问题。
邻接表:
创建数组存储点,创建链表来表示临边。比如编号1的点a[1]
有两个边(2 3),那就创建两个节点,data存2和3(主要用于记录指向的点)。然后有 :1的next指向data=2的节点,这个data=2的节点的next指向data=3的节点,这个data=3的节点的next指向空(先3再2也可以),这个链表就是1的邻接表。然后创建2的邻接表时,又根据它的边数去创建节点。
它的优势就是优化了存储好,查找麻烦,也是链表本来的问题
补充:
- 有向图就只需要考虑出度或者入度,考虑入度的叫:邻接表,考虑出度的叫:逆邻接表。他们的优点和缺点很明显,就是单向查找。
- 同时,相对于无向图,有向图的每一个节点都可以理解为对应的一条独立的边。而无向图的的节点就是边的两倍,毕竟有重复的嘛。
- 以及,使用邻接表的时候就通过
a[node->data]
来得到对应的点。
十字链表:
针对有向图邻接表的不方便,于是诞生了十字链表。这其实和之前的双向链表的来源很像,不也是为了处理一个问题从而诞生一种方式嘛,只不过数据结构的构成不一样而已,本质区别不大的。
依旧是用数组存储点,点的指针有两个(很像双向链表)一个是考虑入度的next一个考虑出度的next。
这里不太一样的是:用节点一一代表边,然后边的的结构就分为了两个data和两个next。两个data作用自然就是存储箭头的两边的点的编号,next就是对应的考虑入度(逆邻接表)和出度(邻接表)的指针。
这样,就把有向图的邻接表和逆邻接表结合起来了。
小拓展
不知道大家注意到了没有,用邻接表拿点的时候是:a[node->data]
,这个a的值在哈希表
里面就是value,而node->data
就是key转化的编号。
哈希表(Hash Table又名散列表),就如其名,和我们伟大的哈希有关的一种处理零散数据的表。(有兴趣可以去看看哈希筛
)
它的原理就是把数据分为两个部分,一个是key,一个是value,并且这两个部分可以是任何类型的数据。然后把key经过一定的算法转化为Int类型,然后令:a[key] = value
建立两者的关系。
至于那个算法是啥,就不多拓展了哈,大家可以自行搜索一下(或者后面我可能写一篇相关的博客)。
图的两个优先搜索
先给理论:
- 广度搜索:从编号为1的点开始,先找他所有的临边的点,找完后再依次以临边的点为基础,找临边的临边的点。
- 深度搜索:从编号为1的点开始,选中一个临边的点,去找这个点的临边的点,直到找不到为止,再以这个编号1开始找另一个临边的点。
由于上一篇二叉树的总结写过了,所以具体实现代码就不贴了哈,毕竟是总结嘛,篇幅有限,篇幅有限,绝对不是因为我懒。
注意: 找过点不需要重复再找,实现的时候可能要多开一个数组用b[next->data]
=0或者=1来判断是否遍历过了。
图的应用:
然后呢,来到应用层:找最短路径什么的,毕竟来到图之后,才算是真实世界有了具体的联系了,而不像之前,总是要归于具体的问题我们才能处理。
最短路径
这个很好理解,我们有很多条路,但是我们只关心两个点:起点和终点。然后在众多路径之中找到最短的路径。它的应用场景是:地图的导航。我们取自己的定位为起点,一些建筑物为终点,得到起点到这些建筑物的最短路径。
迪杰斯特拉算法
迪杰斯特拉(Dijkstra)
,简单来说,我们开一个数组a[n]
其中a[1]
就代表起点到标记点1的距离一开始记为很大很大。然后开始依次放入点,并更新距离a[n]
:首先把起点放进去,更新出起点可以直接到达的点的距离。遍历数组a[n]
找到没有放入,且距离最小的点放入其中。这样就可以保证起点可以沿着这个点去到其他的点,并得到一个新的到达其他的点的距离,更新到对应点的最小的距离。如此循环,直到把所有的点都放进去更新一遍。
补充: 这种方式保证了局部最优,其实并不会出现:a->b更新一次后,又有a->c->d->b来更新距离,因为如果有的话,那么就不会先引入a->b了而是先引入的a->c->d。
弗洛伊德算法
由于有时候我们不仅仅是需要只要一个起点,我们想直到任意一点到任意一点的距离。我们如果用n次迪杰斯特拉的话,显得很呆,于是就有了:弗洛伊德算法。但是他们两者在时间复杂度上是一致的,甚至可以说:弗洛伊德简化了 n次迪杰斯特拉 的使用。
首先:我们拿到这两个点vj vi
得到距离(数值或无穷)然后在他们中间加入V0
比较:(vj vi)
和(vj,V0,vi)
的距离,取最小的。然后加入V1
用上面的方式得到(vj....V1)
和(V1....vi)
把他们合起来(vj...V1...vi)
与上一个距离比较,依次类推。
最小生成树
这个最小生成树和最短路径可不一样,它是需要关联起所以的点的。它的应用范围呢大概就是去处理:为一个地区规划路线,如何做到即连通了所有的城市,又可以使得修路的代价最小。
之前便提到过,图就是边随意连接的一种树,树就是限制了边的一种图,我们现在的目的就是现在它的边。这个边不能瞎限制,每一条路都要代价,也就是权值,我们要找到最小的哪一种修路的方法。于是。就有两位伟大的人基于贪心算法
的思想基础,以不同的关注基点提出了对应的算法。普利姆(prim)算法(加点算法)
,克鲁斯卡尔(kruskal)算法(加边算法)
。自然,当稠密图(边相对多)时,用加点的算法会好一些,反正用加边的算法会好一些。这就是侧重不同使用范围也不同。
小声叨叨:难道局部最优就是全局最优吗?(有大神解释不?)
普里姆算法:
普里姆(prim)算法
,又称为加点法
。正如其名他就是以点为基础而成立的。首先选一个幸运观众A作为一个基础点计入树tree
中,然后开一个数组closedge[n]
去记录这个点的每一条边的权值,找到最小边连接的那个点B,然后把这个最小的B加入树tree
中,并且更新数组closedge[n]
的权值,就是把这个(A,B)的权去掉,然后加入B这个点的和非树tree
中的点的所有边的权值。重复如上行为,直到把所有的点都加入tree
中。
强调: 非树tree中的点,就保证了不会连成图。
参考实现代码:(辅助理解)
//假设有n个点
struct{
int from; //表示tree中的点的编号,为0表示存入tree中了
int value; //表示对应权值,赋值0表示不存在
}closedge[n]//下标n表示与from连接的点的编号
struct{
int Inx;
int value;
}Min; //记录closedge最小权值的下标
void Prim(Gn:gn,int a,Tree* root){ //gn:连通网,a:初始点 Tree:树
//初始化:
closedge[a].value = 0; closedge[a].form = 0;
Min.Inx = 0; Min.value = Max; //Max就当一个很大的数吧,摆烂了
for(int i = 1;i<=n;i++){
if(!a){
closedge[i].from = a;
closedge[i].value = gn.weights[a][i]; //表示a,i边的权值
if(closedge[i].value<Min.value && closedge[i].value !=0 ){
Min.value = closedge[i].value;
Min.Inx = i;
}
}
}
//生成树:
for(int e = 1;e<n-1;e++){
if(Min.Inx ==0){
printf("出错了");
break;
}
int v = Min.Inx;
int u = closedge[v].from;
root.connect(v,u)//树的连接两点方法,不是重点
closedge[v].from = 0; //表示这个点被收编了
//更新closede:和Min
Min.Inx = 0 ;Min.value = Max;
for(int i = 0; i<n;i++ ){
//更新到某个点的最小权值:
if(closedge[i].from != 0 && gn.weights[v][i]!=0
&& gn.weights[v][i]<closedge[i].value){
closedge[i].value = gn.weights[v][i];
closedge[i].from = v ;
}
//更新最小数据:
if(closedge[i].from != 0 && closedge[i].value !=0
&& closedge[i].value<Min.value ){
Min.value = closedge[i].value;
Min.Inx = i;
}
}
}
}
克鲁斯卡尔算法
克鲁斯卡尔(kruskal)算法
又称为加边法
,还是那句话,正如其名,以边为基础。先把所有的边存入数组:edge[n]
,然后依次连接权值最小的边,注意不要成一个环。如过选的这个边导致它成环了,那就跳过这个边,选下一个。
那么,我如何知道是否构成了一个环呢?我的想法是:记录这个边连接的两个点A,B。然后从A开始对这个新构成的图做深度查找,如果找不到B,那就是不成环。
有向无环图:
它用来表示一些有层次递进的约束关系。比如:工程上器件的递进组装,工程施工图等约束关系
拓扑排序
简单来说就是把被要求的放在最前面,我们不妨就找没要求的放在第一层,也就是入度为0的点。然后,这些点出度所连接的点,把他们的入度减一,就相当于我们满足了它的一个要求了。这个时候再去审视这些没有放入数组的点,谁的入度为0,谁就放在第二层,依旧是没要求的放进去。
关键路径
由于之前就已经为我的器件们分好了层,现在我需要知道完成这一个项目的时间到底是多少,于是就在这些边上面,加了完成时间,也就是权值。
关键路径 简单来说:就是耗时最长的路径。因为很多的工作都是可以分路径完成部分零部件,再组装到一起的,如果是非最耗时路径耽误几天也无所谓(只要在那个开始组装时间内完成就可以了),所以耗时最长的路径自然就是最关键的路径了。
由于一个大的产品是由许多小的器件构成,小的器件是由他们的零件构成,于是就有点套娃的感觉在里面了,我们把它分为三层,这里以中间的器件为主要视角来讲解一些名词的理解:
- 事件最早开始时间:就是零件的关键路径导致的开始组装器件的时间。这就可以顺着拓扑结构去推时间
- 事件最晚开始时间:就是组装产品时的非关键路径器件时,可以晚几天开工,但是不能大于关键路径的器件路径完工时间,这就耽误工期了不是。这个自然就是逆着拓扑结构去推时间了。
- 活动的开始时间:可以理解为开始组装的时间,那就自然有最早,最晚,冗余三个时间点了。
注意: 具体在找的时候,我们可以先去找时间最早开始时间,并对每一个活动点进行记录。然后依据这个最早开始时间,才能反向最大限度的去给予最晚开始时间。而关键路径,一般就是活动冗余时间为0的那一群点组合起来的(因为如果有冗余时间,那就意味着,这一层有别的点的时间比你长,那你就不是关键路径)。