数据结构梳理(6) - 图

前言

这段时间偷懒了,上次二叉树写完之后,很长时间又没更新博客了,也没学啥东西,就一直咸鱼,所以今天赶紧脱离舒适区,继续把数据结构梳理完,目前为止,已经梳理了线性表、链表、栈、队列、二叉树,这次轮到图了,不出意外,图是数据结构系列的最后一篇,因为最基本的数据结构也就是这些,当然肯定还有其它各种各样的数据结构,实际开发中也会用到各种各样的高级容器,但是目前我的水平还不足够,对其它更高级的数据结构了解不多,等以后工作之后,再把后续高级的容器梳理出来吧,OK,不废话了,赶紧开始吧。

目录

1、图的结构、常见类型、表示方法
2、图的基本操作
2、基于邻接矩阵实现图
4、基于邻接表实现图

正文

1、图的结构、常见类型、重要概念、表示方法

首先来对图做一个基本的认识,从概念开始,官方给的图的概念如下:

图是一种数据元素间为多对多关系的数据结构,加上一组基本操作构成的抽象数据类型。

呃,读了一遍之后,发现还是很抽象,无法去形象化,没关系,我们通过概念只需要留个映像即可,从某种意义上来说,概念只是一种严格的定义,我们没有很大的必要去纠结它,当我们在使用图一段时间后,如果别人有一天问你,什么是图的时候,这时你已经对图有了一定的认知,但是你不知道怎么去完整准确的表述这个数据结构,这时候你再回过头看一下图的概念定义,你就明白概念存在的意义了。

为了方便快速建立对图的认知和学习,我还是以最常用也是最简单高效的办法,举一个图的例子,从这个例子中我们再来学习图,Ok,我这里就直接放出这个例子,然后针对这个例子,再来学习相关知识。

在这里插入图片描述

1.1 图的常见类型和结构

相信这里其实已经不用我来多赘述了,我们从图中可以一目了然,可以看到图一般分为两种类型,一种是无向图,一种是有向图,顾名思义,无向图中各节点连接的线是没有明确的方向的,而有向图中的就有方向,既然有方向就意味着这两个节点的关系是单向的,无方向就是双向的,至于这里提到的两个节点之间的“关系”具体是指什么,这个就要看具体业务场景了,比如要抽象出马路所表示的图结构,而马路上的普通车道自然就是无向的,单向车道就是有向的,所以其构建的图分别就是无向图和有向图。

接下来再来看看一个图的数据结构中主要包含哪些元素,对于一个图来说,一般的表示方法是使用两个元素来表示,一个是顶点,一个是,也就是图由这两个元素来构成,这里的顶点其实就是包含数据元素值的节点,边其实就是这些节点间的关系。

了解了结构之后,我们再来看看两种类型的图的定义,来加深对图结构的理解,首先是无向图,它的定义如下:

无向图G=<V,E>,其中:
1.V是非空集合,称为顶点集。
2.E是V中元素构成的无序二元组的集合,称为边集。

要注意的就是第二点中“无序”两个字,其它的没啥了,在看完这个概念之后,再结合上面的图,怎么样,是不是对无向图有了一个清晰的认识,ok,然后再来看下有向图的定义:

有向图是一个二元组<V,E>,其中
1.V是非空集合,称为顶点集。
2.E是V×V的子集,称为弧集。

从概念中可以看到表述十分的严谨,既然是子集,那么同样的二元组<1,2>和<2,1>就是不同的,自然也就准确表述出了“有向”的效果。

1.2 图的其它重要概念

现在我们再来了解下关于图的一些其它专业名词,扩展下知识,这些概念可能在各个地方的表述都不一样,但是为了在我们深入学习图的时候,碰到这些词汇不至于一脸懵,所以我们还是有必要来学习的。

  • 孤立点:V中不与E中任一条边关联的点称为D的孤立点
  • 简单图:在无向图中,关联一对顶点的无向边如果多于1条,则称这些边为平行边,平行边的条数称为重数。在有向图中,关联一对顶点的有向边如果多于1条,并且这些边的始点与终点相同(也就是它们的的方向相同),称这些边为平行边。含平行边的图称为多重图,既不含平行边也不含环的图称为简单图
  • 完全无向图:设G是简单无向图,若G中任意节点都与其余节点邻接,则称G为完全无向图
  • 完备图:图中任两个顶点a与b之间,恰有两条有向边(a,b),及(b,a),则称该有向图为完备图
  • 基本图:把有向图D的每条边除去定向就得到一个相应的无向图G,称G为D的基本图,称D为G的定向图
  • 强连通图:给定有向图G=(VE),并且给定该图G中的任意两个结点u和v,如果结点u与结点v相互可达,即至少存在一条路径可以由结点u开始,到结点v终止,同时存在至少有一条路径可以由结点v开始,到结点u终止,那么就称该有向图G是强连通图
  • 弱连通图:若至少有一对结点不满足单向连通,但去掉边的方向后从无向图的观点看是连通图,则D称为弱连通图
  • 单向连通图:若每对结点至少有一个方向是连通的,则D称为单向连通图
  • 强连通分支:有向图G的极大强连通子图称为该有向图的强连通分支
  • 出度和入度:对有向图来说,以顶点为头的边的数目称为该顶点的入度,以顶点为尾(这里的尾指 指向顶点 的一端)的边的数目称为该顶点的出度,一个顶点的入度与出度之和称为该顶点的度。

我们没有必要一下子全部记下它们,只需要留有一个映像,当我们下次见到这些词汇的时候,能有个初步记忆,然后多回顾几次就自然而然记下来了。

1.3 图的表示方法

在了解了上面这么多枯燥的概念之后,我们再来学习一些比较有意思的知识,首先抛出一个问题,假设让你用数据的方式将一个图表示出来,你会怎么做呢,是不是感觉有点无从下手,毕竟图是一个抽象化的东西,而要使用数据量化的方式去表示还是很有难度的,当然你这时候可能有许多奇思妙想,可能你想出来的表达方式也很棒,不过我们还是来看看“走在我们前面的人”是怎么想的。

图的表示方法一般来说有两种:邻接矩阵和邻接表,下面我们来学习下这两个东西。

1.3.1 邻接矩阵表示法

首先学习邻接矩阵表示法,什么是邻接矩阵,其分为两部分:顶点集合和边集合。因此,有一个一维数组存放图中所有顶点数据,然后再用一个二维数组存放顶点间关系(边或弧)的数据,这个二维数组称为邻接矩阵。邻接矩阵又分为有向图邻接矩阵和无向图邻接矩阵。

看了上面相应的解释之后,我们还是拿上面的例子来看邻接矩阵表示法到底是如何表示一个图的,为了方便,再次贴上上面的例子图
在这里插入图片描述
刚才说了,邻接矩阵表示法逻辑上分为两部分,一个一维数组用来存顶点元素,这个非常简单,声明一个一维数组存一下顶点即可,就不多说了,我们重点关注的就是另外一个二维数组,首先看左边的无向图,对这个图来说,二维数组的值应该是怎么样的呢,在这个图中,我们一共可以看到有五个顶点,然后我们将这五个顶点按照横向和纵向排列,形成一个类似座标系的样子,如下

    1   2   3   4   5
    __________________
1 |
2 |
3 |
4 |
5 |

这样就形成了一个5*5的二维矩阵,然后矩阵中每个值相当于是对应的横纵座标值的交叉点,这个交叉点的值怎么填呢,如果对应的横纵座标值代表的顶点之间存在边,那么该值为1,如果不存在边,则为0,同时,对于对角线上的值,例如1和1的交叉点,也就是自己和自己的交叉点,因为这里没有环,所以就是0,最后,对于左边的无向图,其邻接矩阵如下

    1   2   3   4   5
    __________________
1 | 0   1   1   0   0                                     0   1   1   0   0
2 | 1   0   0   1   1       去掉无关的数,得到邻接矩阵         1   0   0   1   1
3 | 1   0   0   0   1            ====》》》                1   0   0   0   1 
4 | 0   1   0   0   1                                     0   1   0   0   1
5 | 0   1   1   1   0                                     0   1   1   1   0

我们可以发现对于无向图来说,其邻接矩阵有一个很明显的特点就是以对角线分割的“上三角”和“下三角”是对称的,那么这样可以带来什么效果呢,答案就是我们在存储的时候,可以只需要存储上三角或者下三角中的数据,而不用存储整个矩阵,这样就大大节约了空间开销。

ok,然后我们再来看看有向图,由于有向图中边(或者叫“弧”)是有方向的,所以这就会导致(1,2)座标处的值为1,但是(2,1)处的值可能为0,所以也就不具有无向图邻接矩阵的对称效果,其它就不没啥了,我们看看例子中这个有向图的邻接矩阵是多少,如下

0   1   0   0   0
0   0   0   0   1
1   0   0   0   1
0   1   0   0   0
0   0   0   1   0

有关邻接矩阵的知识主要就是这些,在这里要说明一下的是,我们这里对角线上的值一直是给的0,那是因为我们的图中不含有环,如果图中某个顶点含有环的话,那么该点在矩阵对角线中的值就是1了,所以不要认为对角线上的值就固定为0了。

1.3.2 邻接表表示法

然后再来看看另外一种表示方法,邻接表,这个方式我自己感觉没有邻接矩阵方式优雅方便,但是既然有这种表示方法,自然是有其独特的优势的,我们都要去认真学习,多一种选择即是多了一种优化方式。

邻接表这种表现方式也相对简单点,如果对数据结构中的链表有一定了解的话,理解起来就非常容易了,它以图的顶点开始,将其所有邻接的点依次以链表的形式连接起来,这样就形成了一个以该顶点为头结点的链表,对每个顶点都做一遍这个操作,假设有n个顶点,这样就形成了n个链表,这n个链表可以用对应类型的长度为n的数组存储,最终这个数组就是邻接表,这个邻接表就代表了这个图。

上面的文字描述是我自己以通俗易懂的方式表达的邻接表,可能还是不够形象,我们同样的拿上面的例子来看,最终其邻接表是什么样的,ok,再次贴上刚才的图
在这里插入图片描述
邻接表的逻辑结构也是分为两部分,一个用于存储顶点元素的一维数组,还有一个就是链表数组,也就是邻接表,我们要重点关注的也就是这个链表数组,先看无向图,按照刚才的解释,我们从顶点1开始,其邻接了顶点2和顶点3,所以,以顶点1为头结点形成的链表如下
在这里插入图片描述
怎么样,是不是很简单,然后剩下的顶点也是做同样的处理,我就不赘述了,最终你会得到五条链表,这五条链表的总体也就是这个图的邻接表,无向图的邻接表如下
在这里插入图片描述
然后就是有向图,这个和无向图的邻接表稍微有点区别,对有向图来说,在形成链表时,链接在头结点后面的顶点必须是由头结点指向它的,或者换句话说,就是必须是以顶点为起点,然后其它点为终点的节点才可以链接在这个顶点形成的链表上,对例子中的有向图来说,例如顶点2,以该点为起点的邻接点只有1和4,而顶点5虽然和它邻接,但是并不是以顶点2为起点的,所以不能链接上去。所以最终这个有向图的邻接表如下
在这里插入图片描述

1.3.3 两种表示方法的简单对比

在上面我们简单的了解了两种表示图的基本方法,现在你可能有个疑问,这两种表示方法该如何做选择呢,其实这个问题只需要稍微思考下即可,在邻接矩阵中我们已经提到了,无向图的存储空间是要远远小于有向图的,当然这只是其中一个因素,但是邻接矩阵并不总是在所有情况下都表现最优,假设有一个图,其包含的边或者弧很少,或者其含有很多的孤立点,那么在邻接矩阵中很多的值就是0,真正有用的数值比例就非常少,但是却浪费了大量的空间。

反观邻接表这种表示方法,我们不难发现其最大的一个特点就是出现了数据冗余,即便对于上面例子中的“不算复杂”的图而言,其冗余情况也比较严重,例如1指向2,下面的2又指向1,但是它正好在图的边比较少的情况下,更加能直观和节省空间的表示出图来,因为由于边少,数据冗余也不会有很多。

所以这两种表示方法其实真好适用于不同的情况,对于结构复杂、边较多的图,适用于邻接矩阵,对于结构简单、边较少的图,适用于邻接表

2、图的基本操作

关于图的基本操作,主要包括插入删除一个顶点,插入删除一条边,深度优先遍历,广度优先遍历,求最小生成树,特别的对于有向无环图的话,还有拓扑排序等操作,下面来简单了解一下这些操作。

2.1 插入删除一个顶点

首先是插入一个顶点,具体根据是用邻接矩阵还是邻接表存储分为两种情况,如果是邻接矩阵存储的,那么插入一个顶点就很简单了,首先在存顶点的一维数组中加入这个顶点,然后更新邻接矩阵的二维数组,因为只是一个点,所以它和其它点不存在边,所以对应二维数组中行和列的值也是0;如果是邻接表存储的,同样的,先在顶点数组中加入这个顶点,然后在邻接表中加入以这个顶点为头节点的链表,因为它和其它点不存在边,所以这个链表只有一个节点,就是这个顶点本身。

接下来看删除节点,同样的分为两种情况,如果是邻接矩阵,那么删除一个节点就是在顶点数组中删除对应位置的节点,然后数组后面的元素前移,同时对于邻接矩阵中和该顶点有关的行和列全部删除,相应的后面的行和列前移即可;如果是邻接表,那么操作就稍微有点复杂了,这里主要分为三步:假设待删除节点为a,它在存放顶点的一维数组中的下标为index,(1)首先删除邻接表中以a为头结点的对应链表(2)然后在存放顶点的一维数组中删除掉a,然后将在数组中位于index后面的元素前移(3)遍历邻接表,也就是所有剩下的链表,删除所有下标为index的节点,也就是删除所有遇到的a,同时将所有下标大于index的节点的下标减一

这里对于邻接表中删除节点,有点复杂,我这里只是进行了文字描述,可能不是那么的清晰,如果不能理解,可以看看这篇文章,帮助理解,地址:https://blog.csdn.net/bbewx/article/details/25005679

2.2 插入删除一条边

这个问题,相信我们在弄明白了插入删除顶点之后,就不是啥大问题了,基本上是同样的思路,其核心就是修改邻接矩阵和邻接表的结构,只要我们对邻接矩阵和邻接表的结构熟悉,那么这些问题就迎刃而解,不过我这里还是啰嗦一下吧。

为了简化描述,这里插入的边的两个顶点都是已经存在的,如果还包括不存在的顶点,那么就先按照上面说的方法插入这些不存在的顶点,然后再进行插入边的操作即可。

首先是插入一条边,对于邻接矩阵来说,非常简单,只需要更改邻接矩阵中对应位置的值,例如在顶点a和顶点b间插入一条边,如果是无向图的话,得修改两个地方的值,座标(a,b)和(b,a)处的值都要更新为1,如果是有向图,那么只需要根据插入边的方向更改其中一个值即可。对于邻接表来说,也比较简单,还是假设在顶点a和顶点b间插入一条边,如果是无向图,那么只需在顶点a对应的链表尾端链接上顶点b的节点,在顶点b对应的链表尾端链接上顶点a的节点,如果为有向图的话,那么只需要根据方向选择为起点的顶点对应链表进行修改即可。

插入边弄明白了,那么删除边其实就是一个逆操作,大致思路都是相同的,这里就不再赘述了。

2.3 深度优先遍历

看到这个操作,相信大家都会有点似曾相识,没错,这个操作在讲二叉树的时候也提到过,这里对于图的深度优先遍历的思想其实和在二叉树中的前序遍历差不多,只不过是换了个遍历的对象而已,现在是图了。

然后我们看下深度优先搜索的官方标准解释:

假设给定图G的初态是所有顶点均未曾访问过。在G中任选一顶点v为初始出发点(源点),则深度优先遍历可定义如下:首先访问出发点v,并将其标记为已访问过;然后依次从v出发搜索v的每个邻接点w。若w未曾访问过,则以w为新的出发点继续进行深度优先遍历,直至图中所有和源点v有路径相通的顶点(亦称为从源点可达的顶点)均已被访问为止。若此时图中仍有未访问的顶点,则另选一个尚未访问的顶点作为新的源点重复上述过程,直至图中所有顶点均已被访问为止。

这段定义已经解释的非常详细了,如果代码能力好的话,基本就可以根据这段定义来写出对应的代码了,最后,使用深度优先搜索来遍历图其实就是深度优先遍历了。

为了加深对这个操作的理解,以及形象的理解深度优先遍历中的“深度”二字,我们可以通过对二叉树的前序遍历来理解,如果还是觉得抽象,我们就举个例子来看一下,如下是一个无向图:
在这里插入图片描述
按照上面深度优先遍历的思想,我们现在来遍历一遍上面这个图,首先任意选取一个顶点,假设为A,接下来访问它的邻接顶点,一共有三个B、C、D,我们假设为B,那么再以B为出发点,访问它的邻接顶点,它一共有两个邻接顶点C、E,我们假设为C,再以C为出发点,它一共有两个邻接顶点A、B,但是A和B均被访问过了,所以这里回溯到以B为出发点,访问B的另一个邻接顶点E,接着再以E为出发点,访问F和G,最后回溯到访问A的邻接顶点D,至此,整个遍历过程结束,最终深度优先遍历的结果是ABCEFGD。

从这个过程中,我们可以明显的知道,深度优先遍历的结果并不是唯一的,上面说的最后的序列也只是其中的一种,只要遍历的时候,符合深度优先遍历的思想即可。

最后关于深度优先遍历在代码实现方面,只需要记住核心:使用栈

2.4 广度优先遍历

学习了深度优先遍历之后,我们再来学习广度优先遍历,同样的,对于这个操作,我们其实也可以类比到二叉树中,没错,就是二叉树的层序遍历,在这里,我们也可以发现很多事物都具有通性,类比学习不失为提高学习效率的一种好办法,OK,我们还是先来读一读官方对于广度优先搜索的定义,如下:

在图G中任意选择某个顶点V0出发,并访问此顶点;然后从V0出发,访问V0的各个未曾访问的邻接点W1,W2,…,Wk;然后,依次从W1,W2,…,Wk出发访问各自未被访问的邻接点,直到全部顶点都被访问为止。

上面这段话的理解应该比较简单,我就不过多赘述了,我自己最初在学习这种遍历方式的时候,冒出来的第一个感受就是:暴力,我们可以明显感觉到这种遍历方式就是从起点开始,呈辐射状波及开来,直至覆盖整个图。然后,我们还是拿深度优先遍历中的这个例子图来看,对这张图来说,它的广度优先遍历的结果又是什么呢

首先任意选取一个顶点,假设为A,然后访问A,接下来访问A的所有邻接点,A一共有三个邻接点B、C、D,所以接下来访问B、C、D,然后以B为出发点,访问B的邻接点C、E,但是C已经被访问过了,所以访问E,然后以E为出发点,访问F、G,至此,整个过程结束,最终广度优先遍历的序列为ABCDEFG。

同样的,关于广度优先遍历的代码实现方面,我们只需要记住核心:使用队列

2.5 求最小生成树

求最小生成树是图这个数据结构比较重要的一部分,我们先来了解下什么是最小生成树,不过在这之前,我们先看什么是生成树,从生成树的概念开始,如下:

一个有 n 个结点的连通图的生成树是原图的极小连通子图,且包含原图中的所有 n 个结点,并且有保持图连通的最少的边。

概念比较简单,也说的很清楚,为了加深映像,我们拿上面例子中的图来看,它的生成树是什么样子,如下
在这里插入图片描述
我们可以明显的发现,对于生成树来说,肯定不是只有一种,但是只要满足生成树的性质,就可以称作这个图的生成树。

ok,明白了什么是生成树之后,最小生成树就是在这基础上加了些“枝叶”而已,最小生成树的简称是最小权重生成树,现在假设我们上面例子中的无向图的边带有权值,那么相信你也猜出来了,最小生成树就是权值最小的那一颗生成树,现在我们给例子中的无向图加上权值,那么它的最小生成树就是下面这个样子了
在这里插入图片描述
ok,现在我们了解了最小生成树,那么最关键的问题就来了,我们如何求最小生成树呢,其实这个问题早就被我们智慧的前辈们解决了,主要涉及到是两个算法,kruskal(克鲁斯卡尔)算法和prim(普里姆)算法。我们现在来学习这两个算法的核心细想。

首先是kruskal算法,它的官方描述是:先构造一个只含 n 个顶点,而边集为空的子图,若将该子图中各个顶点看成是各棵树上的根结点,则它是一个含有 n 棵树的一个森林。之后,从网的边集 E 中选取一条权值最小的边,若该条边的两个顶点分属不同的树,则将其加入子图,也就是说,将这两个顶点分别所在的两棵树合成一棵树;反之,若该条边的两个顶点已落在同一棵树上,则不可取,而应该取下一条权值最小的边再试之。依次类推,直至森林中只有一棵树,也即子图中含有 n-1条边为止。

ok,我们现在按照kruskal算法的思路,来分析上图中的最小生成树是怎么得来的,首先我们找权值最小的边,发现是连接2-5的边权值最小为1,同时顶点2和顶点5此时不属于同一棵树,所以把它们合并,接下来找权值第二小的,我们发现是连接2-1的边权值为2,由于此时顶点1和顶点2不属于同一棵树,所以把顶点1合并到顶点2、5所在的树中,接下来找权值第三小的边,发现是连接2-4的边权值为3,然后合并顶点4,依次类推,最后合并顶点5,至此,所有的顶点现在形成了一棵树,算法结束,这棵树也就是最小生成树。怎么样,只要掌握了算法核心思想,求最小生成树是不是很简单。

接下来我们再看看prim算法,同样的,先看它的核心思想:首先在图中任意选取一个顶点a,然后将a加入集合V中,然后以该顶点为出发点,选取权值最小的边,得到顶点b,再将顶点b加入集合V中,然后以a、b为出发点,找寻权值最小的边,得到顶点c,再将顶点c加入到集合V中,如此不断循环,直至集合V中包含图中所有顶点。

然后我们同样的按照prim算法的思想来分析下上图中的例子,首先把任意选取一个顶点,假设为顶点2,先把顶点2加入到集合V中,然后以顶点2为出发点找权值最小的边,发现一共有三条,而连接2-5的边权值最小为1,所以将顶点5加入到集合V中,接下来以顶点2和顶点5为出发点,找权值最小的边,我们发现是连接2-1的边权值最小为2,所以我们将顶点1加入到集合V中,依次类推,先后通过连接2-4的边将顶点4加入到集合V中,通过连接1-3的边将顶点3加入到集合中,至此发现集合V中已经包含了所有顶点,算法结束,得到右边的最小生成树。

现在我们再看看一个额外的问题,最小生成树是唯一的吗?可能在上图的例子中会容易感觉最小生成树是唯一的,因为毕竟要总权值最小,因为最小所以会觉得是唯一的,其实并不是,如果权值相同的话,例如所有的边权值都为1,那么最小生成树就不是唯一的了。

2.6 拓扑排序

到现在为止,我们把图的基本操作都学习的差不多了,现在再来了解一下我觉得关于图比较有意思的一个东西,它就是拓扑排序,它是一种针对有向无环图的操作,它按照特定的规则将有向无环图转换成一个序列。现在我们来看看它的官方描述

对一个有向无环图G进行拓扑排序,是将G中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若边(u,v)∈E(G),则u在线性序列中出现在v之前。通常,这样的线性序列称为满足拓扑次序的序列,简称拓扑序列。简单的说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。

这个概念比较好理解,我就不赘述了,那么这个特定的序列有什么用呢?这个就要看实际的业务场景了,比如在大学里,我们一共要修满固定的学分,至于选什么课是由学生决定的,只要学分足够即可,但是如果想修有些课程必须先修其它课程,例如想修数据结构课程,必须先修C语言课程,那么求一个学生可以修满学分的课程路线就可以使用图的拓扑排序来解决。

说完了拓扑排序的概念和应用,那么我们该如何来实现呢,主要步骤如下:(1)在有向图中选择一个入度为0的顶点,并输出。(2)将该顶点在图中删除,并删除与之关联的所有边,同时更新邻接顶点的入度减一。(3)重复步骤1和2,直至所有的顶点都删除。

如果不好理解,可以看看下面这个例子图,整个过程可以用下图的过程来表示
在这里插入图片描述
但是这种方式时间复杂度过高,每次都要扫描图中所有的顶点,复杂度为O(n*n),有一种改进的方式是使用队列,类似二叉树的层序遍历,主要步骤如下:
(1)遍历图中所有的顶点,将入度为0的顶点 入队列。(2)从队列中出一个顶点,打印顶点,更新该顶点的邻接点的入度减1,如果邻接点的入度减1之后变成了0,则将该邻接点入队列。(3)重复执行步骤2,直至队列为空。
改进之后,效率提高了很多,相当于空间换时间,时间复杂度接近O(V+E)。

好了,关于图的基本操作就到这里了,由于篇幅的限制,接下来的代码实现里,主要实现无向图深度优先遍历和广度优先遍历,以及求生成树这三个操作,这几个操作是相对核心的方法,其它的操作我就不在代码里写了,相信只要明白了操作的原理和核心实现思路,代码实现都不是啥大问题,ok,接下来就是代码时刻!!!

3、基于邻接矩阵实现图

首先实现基于邻接矩阵实现无向不带权图,其中包括的操作主要有添加顶点,添加边,深度优先遍历,广度优先遍历,求生成树。代码中最后的测试用例使用的图如下:
在这里插入图片描述
好了,现在上代码:

public class Graph {

	// 顶点
	private class Vertex {
		char label;// 如A,B,C
		boolean wasVisited;// 标识是否访问过此顶点

		public Vertex(char vertex) {
			this.label = vertex;
			wasVisited = false;
		}
	}

	static final int MAX_VERTEX = 20;// 最多20个顶点
	Vertex[] vertex;// 顶点数组
	int[][] adjacency;// 邻接矩阵
	int numOfVertex;// 当前图中顶点的数量

	public Graph() {
		vertex = new Vertex[MAX_VERTEX];
		adjacency = new int[MAX_VERTEX][MAX_VERTEX];
		numOfVertex = 0;
		// 初始化邻接矩阵
		for (int i = 0; i < MAX_VERTEX; i++) {
			for (int j = 0; j < MAX_VERTEX; j++)
				adjacency[i][j] = 0;
		}
	}

	// 添加顶点
	public void addVertex(char v) {
		vertex[numOfVertex++] = new Vertex(v);
	}

	// 无向图 添加边
	public void addEdge(int start, int end) {
		adjacency[start][end] = 1;
		adjacency[end][start] = 1;
	}

	// 打印某个顶点
	public void showVertex(int index) {
		System.out.print(vertex[index].label);
	}

	// 打印邻接矩阵
	public void show() {
		for (int i = 0; i < numOfVertex; i++) {
			for (int j = 0; j < numOfVertex; j++) {
				System.out.print(adjacency[i][j] + "  ");
			}
			System.out.println();
		}
	}

	
	//找到与某一顶点邻接而未被访问的顶点,步骤如下
 	//在邻接矩阵中,找到指定顶点所在的行,从第一列开始向后寻找值为1的列,列号是邻接顶点的号码	
	//检查此顶点是否访问过。
	//如果该行没有值为1而又未访问过的点,则此顶点的邻接点都访问过了。
	public int getUnVisitedVertex(int index) {
		for (int i = 0; i < numOfVertex; i++)
			if (adjacency[index][i] == 1 && vertex[i].wasVisited == false)
				return i;
		return -1;
	}

	// 图的深度优先遍历
	public void dfs() {
		vertex[0].wasVisited = true;// 从头开始访问
		showVertex(0);
		Stack<Integer> stack = new Stack<>();
		stack.push(0);
		
		//1.用peek()方法获取栈顶的顶点 2.试图找到这个顶点的未访问过的邻接点 
		//3.如果没有找到这样的顶点,出栈 4.如果找到,访问之,入栈
		while (!stack.isEmpty()) {
			int index = getUnVisitedVertex((int) stack.peek());
			if (index == -1)// 没有这个顶点
				stack.pop();
			else {
				vertex[index].wasVisited = true;
				showVertex(index);
				stack.push(index);
			}
		}
		// 栈为空,遍历结束,标记位重新初始化
		for (int i = 0; i < numOfVertex; i++)
			vertex[i].wasVisited = false;
	}

	// 图的广度优先遍历
	public void bfs() {
		vertex[0].wasVisited = true;
		showVertex(0);
		Queue<Integer> queue = new ArrayDeque<>();
		queue.add(0);
		int v2;
		while (!queue.isEmpty()) {// 直到队列为空
			int v1 = (int) queue.remove();
			// 直到点v1没有未访问过的邻接点
			while ((v2 = getUnVisitedVertex(v1)) != -1) {
				// 取到未访问过的点,访问之
				vertex[v2].wasVisited = true;
				showVertex(v2);
				queue.add(v2);
			}
		}
		for (int i = 0; i < numOfVertex; i++)
			vertex[i].wasVisited = false;
	}

	// 求生成树
	public void st() {
		vertex[0].wasVisited = true;
		Stack<Integer> stack = new Stack<>();
		stack.push(0);
		while (!stack.isEmpty()) {
			int currentVertex = stack.peek();
			int v = getUnVisitedVertex(currentVertex);
			if (v == -1)
				stack.pop();
			else {
				vertex[v].wasVisited = true;
				stack.push(v);
				// 当前顶点与下一个未访问过的邻接点
				showVertex(currentVertex);
				showVertex(v);
				System.out.print("  ");
			}
		}
		for (int i = 0; i < numOfVertex; i++)
			vertex[i].wasVisited = false;
	}

	public static void main(String[] args) {
		Graph graph = new Graph();
		graph.addVertex('A');
		graph.addVertex('B');
		graph.addVertex('C');
		graph.addVertex('D');
		graph.addVertex('E');
		graph.addVertex('F');
		graph.addVertex('G');
		graph.addEdge(0, 1);
		graph.addEdge(0, 2);
		graph.addEdge(0, 3);
		graph.addEdge(1, 2);
		graph.addEdge(1, 4);
		graph.addEdge(4, 5);
		graph.addEdge(4, 6);
		System.out.println("图的邻接矩阵为:");
		graph.show();
		System.out.println("深度优先遍历为:");
		graph.dfs();
		System.out.println();
		System.out.println("广度优先遍历为:");
		graph.bfs();
		System.out.println();
		System.out.println("其中一个生成树为:");
		graph.st();
	}

}

最后的运行结果也贴一下吧,如下
在这里插入图片描述
代码中相关的注释已经写的非常清楚了,所以这里就不过多的解释代码,如果难以理解,基本上自己动手写一遍,遇到问题了再看看很快就能明白。

4、基于邻接表实现图

同样的,基于邻接表,我们也实现一遍,最后的测试用例使用的图和上面邻接矩阵实现图使用的是一样的测试图,ok,上代码。

//基于邻接表实现图
public class AdjacencyListGraph {
	// 邻接表中对应的链表的节点
	private class ENode {
		int ivex; // 该边所指向的顶点的在顶点数组mVexs中的下标
		ENode nextNode; // 指向下一条边的指针

		public ENode() {
			ivex = 0;
			nextNode = null;
		}
	}

	// 邻接表中对应的链表,其实就是一个节点+顶点信息
	private class VNode {
		char data; // 顶点信息
		boolean wasVisited; // 标记是否访问过此顶点,默认没有访问
		ENode firstEdge; // 指向第一条依附该顶点的弧

		public VNode() {
			data = '0';
			wasVisited = false;
			firstEdge = null;
		}
	};

	private VNode[] mVexs; // 顶点数组

	public AdjacencyListGraph(char[] vexs, char[][] edges) {
		// 初始化"顶点数"和"边数"
		int vlen = vexs.length;
		int elen = edges.length;

		// 初始化"顶点" 也就是邻接表的链表
		mVexs = new VNode[vlen];
		for (int i = 0; i < vlen; i++) {
			mVexs[i] = new VNode();
			mVexs[i].data = vexs[i];
			mVexs[i].firstEdge = null;
		}

		// 初始化"边"
		for (int i = 0; i < elen; i++) {
			// 获取边的起始顶点和结束顶点
			char c1 = edges[i][0];
			char c2 = edges[i][1];
			// 获取边的起始顶点和结束顶点的下标
			int p1 = getPosition(c1);
			int p2 = getPosition(c2);
			// 初始化 结束顶点
			ENode endNode = new ENode();
			endNode.ivex = p2;
			// 将endNode链接到"p1所在链表的末尾"
			if (mVexs[p1].firstEdge == null)// 如果p1所在链表为空,那么直接链接在p1后面即可
				mVexs[p1].firstEdge = endNode;
			else
				linkLast(mVexs[p1].firstEdge, endNode);
			// 初始化 开始顶点
			ENode startNode = new ENode();
			startNode.ivex = p1;
			// 将startNode链接到"p2所在链表的末尾"
			if (mVexs[p2].firstEdge == null)
				mVexs[p2].firstEdge = startNode;
			else
				linkLast(mVexs[p2].firstEdge, startNode);

		}
	}

	// 将node节点链接到链表的最后
	private void linkLast(ENode firstNode, ENode node) {
		ENode tempNode = firstNode;

		while (tempNode.nextNode != null)
			tempNode = tempNode.nextNode;
		tempNode.nextNode = node;
	}

	// 返回顶点的下标值
	private int getPosition(char ch) {
		for (int i = 0; i < mVexs.length; i++)
			if (mVexs[i].data == ch)
				return i;
		return -1;
	}

	// 打印邻接表
	public void print() {
		System.out.println("图的邻接表为:");
		for (int i = 0; i < mVexs.length; i++) {
			System.out.print(i + "(" + mVexs[i].data + ") --> ");
			ENode node = mVexs[i].firstEdge;
			while (node != null) {
				if (node.nextNode != null) {
					System.out.print(node.ivex + "(" + mVexs[node.ivex].data + ") --> ");
				} else {
					System.out.print(node.ivex + "(" + mVexs[node.ivex].data + ")");
				}

				node = node.nextNode;
			}
			System.out.printf("\n");
		}

	}

	// 深度优先遍历
	public void dfs() {
		System.out.println("深度优先遍历为:");
		// 从下标为0的位置开始遍历
		VNode node = mVexs[0];
		// 更新起始点的访问状态
		node.wasVisited = true;
		// 打印起始点的值
		System.out.print(node.data);
		// 看到深度优先遍历,直接掏出 栈 即可
		Stack<VNode> stack = new Stack<>();
		stack.push(node);

		while (!stack.isEmpty()) {
			VNode startNode = stack.peek();
			// 如果该点有邻接点
			if (startNode.firstEdge != null) {
				// 找到该链表中下一个没有被访问过的点
				ENode nextUnVisitedNode = startNode.firstEdge;
				while (mVexs[nextUnVisitedNode.ivex].wasVisited == true && nextUnVisitedNode.nextNode != null) {
					nextUnVisitedNode = nextUnVisitedNode.nextNode;
				}
				// 如果没有 未被访问的节点,换句话说就是,走到了链表的尾端依然没找到,
				// 那么说明该节点的所有邻接点都已经访问过了,然后将该节点出栈
				if (mVexs[nextUnVisitedNode.ivex].wasVisited == true) {
					stack.pop();
				} else {// 如果找到了未被访问的邻接点
					// 更新对应的节点状态为 已访问
					mVexs[nextUnVisitedNode.ivex].wasVisited = true;
					// 打印相应的值
					System.out.print(mVexs[nextUnVisitedNode.ivex].data);
					// 将这个已访问的节点入栈
					stack.push(mVexs[nextUnVisitedNode.ivex]);
				}
			} else {// 如果该点没有邻接点,直接出栈
				stack.pop();
			}
		}
		// 遍历完之后,重置访问状态
		for (int i = 0; i < mVexs.length; i++) {
			mVexs[i].wasVisited = false;
		}
		System.out.println();
	}

	// 广度优先遍历
	public void bfs() {
		System.out.println("广度优先遍历为:");
		// 从下标为0的位置开始遍历
		VNode node = mVexs[0];
		// 更改起始点的访问状态
		node.wasVisited = true;
		// 打印起始点
		System.out.print(node.data);
		// 看到广度优先遍历,直接掏出 队列 即可
		Queue<VNode> queue = new ArrayDeque<>();
		queue.add(node);
		while (!queue.isEmpty()) {
			VNode startNode = queue.remove();
			// 如果该点没有邻接点,也就是孤立点,那么直接退出循环即可
			if (startNode.firstEdge == null) {
				System.out.println("貌似找了个孤立点,换一个点试试");
				break;
			}
			// 找到该链表中所有没有被访问过的点,然后依次打印
			ENode nextUnVisitedNode = startNode.firstEdge;
			// 循环走到链表的尾端
			while (nextUnVisitedNode != null) {
				// 如果下一个节点未被访问
				if (mVexs[nextUnVisitedNode.ivex].wasVisited == false) {
					// 更新访问状态
					mVexs[nextUnVisitedNode.ivex].wasVisited = true;
					// 入队
					queue.add(mVexs[nextUnVisitedNode.ivex]);
					// 打印
					System.out.print(mVexs[nextUnVisitedNode.ivex].data);
				}
				// 如果下一个节点被访问了,那么我们无需做任何操作,直接进入下次循环,继续判断即可
				if (nextUnVisitedNode.nextNode != null) {
					nextUnVisitedNode = nextUnVisitedNode.nextNode;
				} else {
					break;
				}
			}
		}
		// 遍历完之后,重置访问状态
		for (int i = 0; i < mVexs.length; i++) {
			mVexs[i].wasVisited = false;
		}
		System.out.println();
	}

	// 求生成树
	public void mst() {
		System.out.println("其中一颗生成树为:");
		VNode node = mVexs[0];
		node.wasVisited = true;
		Stack<VNode> stack = new Stack<>();
		stack.push(node);
		while (!stack.isEmpty()) {
			VNode startNode = stack.peek();
			if (startNode.firstEdge != null) {
				ENode nextUnVisitedNode = startNode.firstEdge;
				while (mVexs[nextUnVisitedNode.ivex].wasVisited == true && nextUnVisitedNode.nextNode != null) {
					nextUnVisitedNode = nextUnVisitedNode.nextNode;
				}
				if (mVexs[nextUnVisitedNode.ivex].wasVisited == true) {
					stack.pop();
				} else {
					mVexs[nextUnVisitedNode.ivex].wasVisited = true;
					System.out.print(""+startNode.data+mVexs[nextUnVisitedNode.ivex].data+" ");
					stack.push(mVexs[nextUnVisitedNode.ivex]);
				}
			} else {
				stack.pop();
			}
		}
		for (int i = 0; i < mVexs.length; i++) {
			mVexs[i].wasVisited = false;
		}
		System.out.println();
	}

	public static void main(String[] args) {
		char[] vexs = { 'A', 'B', 'C', 'D', 'E', 'F', 'G' };
		char[][] edges = new char[][] { { 'A', 'B' }, { 'A', 'C' }, { 'A', 'D' }, { 'B', 'C' }, { 'B', 'E' },
				{ 'E', 'F' }, { 'E', 'G' } };
		AdjacencyListGraph graph = new AdjacencyListGraph(vexs, edges);

		graph.print(); // 打印图
		graph.dfs(); //深度优先遍历
		graph.bfs(); //广度优先遍历
		graph.mst(); //求生成树
	}
}

代码最后的运行结果为
在这里插入图片描述
最后要提一下的是,上面两种方式的实现中,求生成树这里,其实就是使用的深度优先遍历来求解的路径,如果要是求最小生成树的话,那么这样肯定是错的,具体的方法在上面也说过了,就是那两个算法,同时不要忘了更新节点的结构,加上一个权值,有兴趣的可以写写对应的代码锻炼一下。

结语

呼,到这里,图算是告一段落了,图应该是数据结构里比较复杂的,但是再复杂的只要静下心来,都是可以解决的,然后开头也说了,这一篇应该是数据结构系列的最后一篇,过段时间,再准备下一波内容,好了,就这样!!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章