图是描述于一组对象的结构,其中某些对象对在某种意义上是相关的。这些对象的数学抽象称为顶点的(也称结点或点),并且每个相关的顶点对都称为边(也称为链接或线)
图的定义
G G G 由顶点集 V V V 和边集 E E E 组成,记为 G = ( V , E ) G=(V,E) G=(V,E)
树是图的子集,是一种特殊的图,定义是 n , ( n ≥ 0 ) n,(n≥0) n,(n≥0) 个结点的有限集合, n = 0 n=0 n=0 时称为空树,而任意一棵非空树中应满足
- 有且仅有一个特定的称为根的结点
- 当 n > 1 n>1 n>1 时,其余结点可分为 m ( m > 0 ) m(m>0) m(m>0) 个互不相交的有限集合 T 1 , T 2 , ⋯ , T m T_1,T_2,\cdots,T_m T1,T2,⋯,Tm,其中每一个集合本身又是一棵树,称为根结点的子树
图的分类
无向图
E E E 无向边的有限集合,边是顶点的无序对,记为 ( v , w ) (v,w) (v,w) 且 ( v , w ) = ( w , v ) (v,w)=(w,v) (v,w)=(w,v),称 w , v w,v w,v 互为邻接点
有向图
E E E 是有向边(弧)的有限集合,弧是顶点的有序对,记为 < v , w > <v,w> <v,w>, v v v 是弧尾, w w w 是弧头,称 v v v 邻接到 w w w 或 w w w 邻接自 v v v
简单图
不存在顶点到自身的边,同一条边不重复出现
多重图
与简单图相对,某两个结点之间的边数多于一条,且允许顶点通过通过同一个边和自己关联
完全图
子图
设有两个图 G = ( V , E ) G=(V,E) G=(V,E) 和 G ′ = ( V ′ , E ′ ) G^\prime=(V^\prime,E^\prime) G′=(V′,E′) 若 V ′ V^\prime V′ 是 V V V 的子集,且 E ′ E^\prime E′ 是 E E E 的子集,则称 G ′ G^\prime G′ 是 G G G 的子图。若有满足 V ( G ′ ) = V ( G ) V(G^\prime)=V(G) V(G′)=V(G) 的子图 G ′ G^\prime G′,则称其为 G G G 的生成子图
并非 V V V 和 E E E 的任何子集都能构成 G G G 的子图,因为这样的子集可能不是图,即 E E E 的子集中的某些边关联的顶点可能不在这个 V V V 的子集中
连通图
在无向图中,若从顶点 v v v 到顶点 w w w 有路径存在,则称 v v v 和 w w w 是连通的。若图 G G G 中任意两个顶点都是连通的,则称图 G G G 为连通图,如果一个图有 n n n 个顶点,并且有小于 n − 1 n-1 n−1 条边称为非连通图
连通分量
无向图中的极大连通子图,即选取一个顶点,以这个顶点作为一个子图,然后逐个添加与这个子图相连的顶点和边直到所有相连的顶点都加入该子图
强连通图
在有向图中,若顶点 v v v 到顶点 w w w 和顶点 w w w 到顶点 v v v 都有路径则这两个顶点称为强连通。若 G G G 图中任一对顶点都是强连通的,则称图 G G G 为强连通图
连通图的生成树
包含图中全部 n n n 个顶点,但是只有 n − 1 n-1 n−1 条边的极小连通子图,对于该生成树而言,去掉一条边则变成非连通图,加上一条边就会形成回路
顶点的度
以顶点为一个端点的边数目
边的权和网
图中每条边都可以赋予一定意义的数值,这个数值叫做这条边的权,有权值得图称为带权图,也叫做网
路径、路径长度和回路
顶点 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},\cdots,v_{i_m},v_q vp,vi1,vi2,⋯,vim,vq,路径上边的数目就是路径长度,第一个和最后一个顶点相同的路径称为回路或者环
若一个图有 n n n 个顶点,并且有大于 n − 1 n-1 n−1 条边则此图必有环
简单路径
顶点不重复出现的路径
简单回路
除了第一个和最后一个顶点其余顶点不重复出现的回路
距离
从顶点 u u u 到 v v v 的最短路径长度为其距离。不存在路径,则记距离为无穷 ∞ \infin ∞
顺序存储
邻接矩阵,是指用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息即各顶点之间的邻接关系,存储顶点之间邻接关系的二维数组称为邻接矩阵
结点数为 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,\cdots,v_n v1,v2,⋯,vn,若 ( v i , v j ) ∈ E (v_i,v_j)\in E (vi,vj)∈E,则 A [ i ] [ j ] = 1 A[i][j]=1 A[i][j]=1,否则 A [ i ] [ j ] = 0 A[i][j]=0 A[i][j]=0。若为带权图, v i v_i vi 与 v j v_j vj 有边连接则邻接矩阵中存放边的权值 A [ i ] [ j ] = w i j A[i][j]=w_{ij} A[i][j]=wij 否则 A [ i ] [ j ] = ∞ A[i][j]=\infin A[i][j]=∞
顺序存储与链式存储
邻接表,是指对图 G G G 中的每个顶点以 v i v_i vi 建立一个单链表,第 i i i 个单链表中的结点表示依附于顶点 v i v_i vi 的边(对于有向图则是以顶点 v i v_i vi 为尾的弧),这个单链表就称为顶点 v i v_i vi 的边表(对于有向图则称为出边表)。边表的头指针与顶点的数据信息采用顺序存储,称为顶点表,所以在邻接表中存在两种结点,顶点表结点与边表结点
链式存储
十字链表用于存储有向图,对应于有向图中的每条弧有一个弧结点,其中有 5 5 5 个域:尾域(tailvex)和头域(headvex)分别指示弧尾和弧头这两个顶点在图中的位置:两个链域 (hlink 与 tlink)分别指向弧头相同的下一条弧与弧尾相同的下一条弧;信息域(info)指向该弧的相关信息。弧头相同的弧就在同一个链表上,弧尾相同的弧也在同个链表上。对应于每个顶点也有一个结点,顶点结点中有 3 3 3 个域,数据域(data)存放顶点相关的数据信息,如顶点名称;两个链域(firstin 与 firstout)分别指向以该顶点为弧头与弧尾的第一个弧结点
邻接多重表用于存储无向图,与十字链表类似,每条边用一个结点表示,边结点包含六个域,其中标志域(mark)表示边是否被搜索过,两个位置域(ivex 与 jvex)分别表示该边依附的两个顶点在图中的位置,两个链域(ilink 与 jlink)分别指向下一条依附于相应顶点的边地址,信息域(info)指向和边相关的各种信息。对应于每个顶点也有一个结点,顶点结点中有 2 2 2 个域,数据域(data)存放顶点相关的数据信息;链域(firstedge)指向第一个依附该顶点的边
基本操作
例如,判断、插入、删除和数边等操作都独立于图的存储结构,对于不同的存储方式,操作算法的具体实现的性能不同。在设计具体算法的实现时,应考虑采用何种存储方式的算法效率会更高
深度优先遍历
即深度优先搜索(DFS,Depth-First-Search)类似于树的先序遍历算法,故也适用于树,首先访问图中某一起始顶点 v v v,然后由 v v v 出发,访问与 v v v 邻接且末被访问的任一顶点 w 1 w_1 w1,再访问与 w 1 w_1 w1 邻接且未被访问的任一顶点 w 2 , ⋯ w_2,\cdots w2,⋯ 重复上述过程,一条路径一直走到底。当不能再继续向下访问时,进行回溯,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直至图中所有顶点均被访问过为止
举例
算法逻辑
- 首先,创建一个空堆栈 s t a c k stack stack(用来存放结点)和一个空列表 v i s i t visit visit(用来存放已访问的结点)
- 其次,从 v A v_A vA 开始, v A v_A vA 压入堆栈,此时 v i s i t = { ∅ } , q u e u e = { v A } visit=\{\emptyset\},queue=\{v_A\} visit={∅},queue={vA}
- 当 v A v_A vA 推出堆栈时 v i s i t = { v A } visit=\{v_A\} visit={vA}, v A v_A vA 的邻接结点 v B , v C , v D v_B,v_C,v_D vB,vC,vD 压入堆栈,此时 s t a c k = { v B , v C , v D } stack=\{v_B,v_C,v_D\} stack={vB,vC,vD}
- 接着堆栈尾 v D v_D vD 推出堆栈,此时 v i s i t = { v A , v D } visit=\{v_A,v_D\} visit={vA,vD}, v D v_D vD 的邻接结点中 v A , v C , v G v_A,v_C,v_G vA,vC,vG 中未压入堆栈的 v G v_G vG 压入堆栈,此时 s t a c k = { v B , v C , v G } stack=\{v_B,v_C,v_G\} stack={vB,vC,vG}
- 然后堆栈尾 v G v_G vG 推出堆栈,此时 v i s i t = { v A , v D , v G } visit=\{v_A,v_D,v_G\} visit={vA,vD,vG}, v G v_G vG 的邻接结点中 v C , v D v_C,v_D vC,vD 已经全部压入过堆栈的故不再压栈 ,此时 s t a c k = { v B , v C } stack=\{v_B,v_C\} stack={vB,vC}
- 再然后堆栈尾 v C v_C vC 推出堆栈,此时 v i s i t = { v A , v D , v G , v C } visit=\{v_A,v_D,v_G,v_C\} visit={vA,vD,vG,vC}, v C v_C vC 的邻接结点中 v A , v D , v F , v G v_A,v_D,v_F,v_G vA,vD,vF,vG 中未压入堆栈的 v F v_F vF 压入堆栈,此时 s t a c k = { v B , v F } stack=\{v_B,v_F\} stack={vB,vF}
- 再然后堆栈尾 v F v_F vF 推出堆栈,此时 v i s i t = { v A , v D , v G , v C , v F } visit=\{v_A,v_D,v_G,v_C,v_F\} visit={vA,vD,vG,vC,vF}, v F v_F vF 的邻接结点中 v B , v C v_B,v_C vB,vC 已经全部压入过堆栈的故不再压栈,此时 s t a c k = { v B } stack=\{v_B\} stack={vB}
- 再然后堆栈尾 v B v_B vB 推出堆栈,此时 v i s i t = { v A , v D , v G , v C , v F , v B } visit=\{v_A,v_D,v_G,v_C,v_F,v_B\} visit={vA,vD,vG,vC,vF,vB}, v B v_B vB 的邻接结点中 v A , v E , v F v_A,v_E,v_F vA,vE,vF 中未压入堆栈的 v E v_E vE 压入堆栈,此时 s t a c k = { v E } stack=\{v_E\} stack={vE}
- 最后 v E v_E vE 结点压出堆栈直到 s t a c k = { ∅ } , v i s i t = { v A , v D , v G , v C , v F , v B , v E } stack=\{\emptyset\},visit=\{v_A,v_D,v_G,v_C,v_F,v_B,v_E\} stack={∅},visit={vA,vD,vG,vC,vF,vB,vE}
算法实现
# 用字典结构表示图的邻接表 graph_nt = { 'A' : ['B','C','D'], 'B' : ['A','E','F'], 'C' : ['A','D','F','G'], 'D' : ['A','C','G'], 'E' : ['B'], 'F' : ['B','C'], 'G' : ['C','D'] } def DFS(start, graph): stack = [] visit = [] stack.append(start) while stack: node = stack.pop() visit.append(node) nodes = graph[node] for i in nodes: if i not in visit: stack.append(i) visit.append(i) print(node,end='\t') DFS(start='A', graph=graph_nt)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
空间复杂度
由于 DFS 是一个递归算法,需要一个工作栈来辅助工作,最多需要图中所有顶点进栈,所以空间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)
时间复杂度
邻接矩阵,查找每个顶点的邻接点时间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),对每个顶点都进行查找,所以总的时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
邻接表,遍历过程的主要操作是对顶点遍历它的邻接点,由于通过访问边表来查找邻接点,所以时间复杂度为 O ( ∣ E ∣ ) O(|E|) O(∣E∣),访问顶点时间为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),所以总的时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)
广度优先遍历
即广度优先搜索(BFS,Breadth-First-Search)似于树的层序遍历算法,故也适用于树,其过程是以 v v v 为起始点,由近至远依次访问和 v v v 有路径相通且路径长度为 1 , 2 , ⋯ 1,2,\cdots 1,2,⋯ 的顶点,广度优先搜索是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有往回退的情况,它不是一个递归的算法。为了实现逐层的访问,算法必须借助一个辅助队列,以记忆正在访问的顶点的下一层顶点
举例
算法逻辑
- 首先,创建一个空队列 q u e u e queue queue(用来存放结点)和一个空列表 v i s i t visit visit(用来存放已访问的结点)
- 其次,从 v A v_A vA 开始, v A v_A vA 进入队列,此时 v i s i t = { ∅ } , q u e u e = { v A } visit=\{\emptyset\},queue=\{v_A\} visit={∅},queue={vA}
- 当 v A v_A vA 出队列时 v i s i t = { v A } visit=\{v_A\} visit={vA}, v A v_A vA 的邻接结点 v B , v C , v D v_B,v_C,v_D vB,vC,vD 进入队列,此时 q u e u e = { v B , v C , v D } queue=\{v_B,v_C,v_D\} queue={vB,vC,vD}
- 接着 v B v_B vB 出队列,此时 v i s i t = { v A , v B } visit=\{v_A,v_B\} visit={vA,vB}, v B v_B vB 的邻接结点中 v A , v E , v F v_A,v_E,v_F vA,vE,vF 中未进入队列的 v E , v F v_E,v_F vE,vF 进入队列,此时 q u e u e = { v C , v D , v E , v F } queue=\{v_C,v_D,v_E,v_F\} queue={vC,vD,vE,vF}
- 然后 v C v_C vC 出队列,此时 v i s i t = { v A , v B , v C } visit=\{v_A,v_B,v_C\} visit={vA,vB,vC}, v C v_C vC 的邻接结点中 v A , v D , v F , v G v_A,v_D,v_F,v_G vA,vD,vF,vG 中未进入队列的 v G v_G vG 进入队列,此时 q u e u e = { v D , v E , v F , v G } queue=\{v_D,v_E,v_F,v_G\} queue={vD,vE,vF,vG}
- 再然后 v D v_D vD 出队列,此时 v i s i t = { v A , v B , v C , v D } visit=\{v_A,v_B,v_C,v_D\} visit={vA,vB,vC,vD}, v D v_D vD 的邻接结点中 v A , v C , v G v_A,v_C,v_G vA,vC,vG 已经全部进入过队列的故不再入队,此时 q u e u e = { v E , v F , v G } queue=\{v_E,v_F,v_G\} queue={vE,vF,vG}
- 最后 v E , v F , v G v_E,v_F,v_G vE,vF,vG 结点与 v D v_D vD 情况类似,一直出队列直到 q u e u e = { ∅ } , v i s i t = { v A , v B , v C , v D , v E , v F , v G } queue=\{\emptyset\},visit=\{v_A,v_B,v_C,v_D,v_E,v_F,v_G\} queue={∅},visit={vA,vB,vC,vD,vE,vF,vG}
算法实现
graph_nt = { 'A' : ['B','C','D'], 'B' : ['A','E','F'], 'C' : ['A','D','F','G'], 'D' : ['A','C','G'], 'E' : ['B'], 'F' : ['B','C'], 'G' : ['C','D'] } def BFS(start, graph): queue = [] visit = [] queue.append(start) while queue: node = queue.pop(0) visit.append(node) nodes = graph[node] for i in nodes: if i not in visit: queue.append(i) visit.append(i) print(node, end='\t') BFS(start='A', graph=graph_nt)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
空间复杂度
由于 BFS 需要借助一个队列, n n n 个顶点均需要入队一次,所以最坏情况下 n n n 个顶点在队列,那么则需要 O ( ∣ V ∣ ) O(|V|) O(∣V∣) 的空间复杂度
时间复杂度
邻接矩阵,每个顶点入队一次,时间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),对于每个顶点,搜索它的邻接点,需要遍历一遍矩阵的一行,所以时间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),所以总的时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
邻接表,每个顶点入队一次,时间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),对于每个顶点,搜索它的邻接点,就需要访问这个顶点的所有边,所以时间复杂度为 O ( ∣ E ∣ ) O(|E|) O(∣E∣),所以总的时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)
最小生成树(MST)一个连通图的生成树包含图的所有顶点,并且只包含尽可能少的边,如果带权图则选择所有边之和权值最小的生成树
普利姆(Prlm)
- 从图中找第一个起始顶点 v 0 v_0 v0,作为生成树的第一个顶点,然后从这个顶点到其他顶点的所有边中选一条距离最近(权值最小)的边。然后把这条边的另一个顶点 v 1 v_1 v1 和这条边加入到生成树中
- 反复执行直到所有所有顶点都加入到生成树中
图 a a a 的最小生成树普利姆算法构建过程即 b → f b\rightarrow f b→f,包含双重循环,外层循环次数为 ∣ V ∣ − 1 \mid V\mid-1 ∣V∣−1,内层并列的循环次数都是 ∣ V ∣ \mid V\mid ∣V∣,故普利姆算法时间复杂度为 O ( ∣ V ∣ 2 ) O(\mid V\mid^2) O(∣V∣2),且时间复杂度只和结点数 ∣ V ∣ \mid V\mid ∣V∣ 有关,适合稠密图
克鲁斯卡尔(Kruskal)
将图中边按照权值从小到大排列存放在堆中,然后从最小的边开始扫描,每次选择最小边的时间复杂度是 O ( l o g ∣ E ∣ ) O(log\mid E\mid) O(log∣E∣),设置一个边的集合来记录,如果该边并入不构成回路的话,则将该边并入当前生成树,直到所有的边都检测完为止
图 a a a 的最小生成树克鲁斯卡尔算法构建过程即 b → f b\rightarrow f b→f,操作分为对边的权值排序部分和一个单重循环,它们是并列关系,由于排序耗费时间大于单重循环,所以克鲁斯卡尔算法的主要时间耗费在排序上,时间复杂度是 O ( ∣ E ∣ l o g ∣ E ∣ ) O(\mid E\mid log\mid E\mid) O(∣E∣log∣E∣)。排序和图中边的数量有关系,所以适合稀疏图