图的表示
对于图 G = ( V , E ) G=(V,E) G=(V,E),可以用两种标准的表示方法表示,一种表示法将图作为邻接链表的组合,另外一种表示法则将图作为邻接矩阵来看待。两种表示方法既可以表示无向图,也可以表示有向图。表示稀疏图(边的条数 ∣ E ∣ |E| ∣E∣远远小于 ∣ V ∣ 2 |V|^2 ∣V∣2的图)邻接链表更有优势。但如果稠密图( ∣ E ∣ |E| ∣E∣接近 ∣ V ∣ 2 |V|^2 ∣V∣2的图)的情况下,可能更倾向于使用邻接矩阵表示,如果需要快速判断任意两个节点之间是否有边相连,可能也需要使用邻接矩阵表示图。
邻接链表
对于图 G = ( V , E ) G=(V,E) G=(V,E)来说,其邻接链表表示由一个包含 ∣ V ∣ |V| ∣V∣条链表的数据 V d j Vdj Vdj所构成,每个节点有一条链表。对于每个节点 u ∈ V u \in V u∈V,邻接链表 A d j [ u ] Adj[u] Adj[u]包含所有与节点 u u u之间有边相连的节点 v v v,即 A d j [ u ] Adj[u] Adj[u]包含图 G G G中所有与 u u u邻接的结点。
如果 G G G是一个有向图,则对于边 ( u , v ) (u,v) (u,v)来说,结点 v v v将出现在链表 A d j [ u ] Adj[u] Adj[u]里,因此,所有邻接链表的长度之和等于 ∣ E ∣ |E| ∣E∣,如果 G G G是一个无向图,则对于边 ( u , v ) (u,v) (u,v)来说,结点 v v v将出现在链表 A d j [ u ] Adj[u] Adj[u]里,结点 u u u将出现在链表 A d j [ v ] Adj[v] Adj[v]里,因此,所有邻接链表的长度之和等于 2 ∣ E ∣ 2|E| 2∣E∣,但不管是有向图还是无向图,邻接链表表示法的存储空间需求均为 Θ ( V + E ) \Theta(V+E) Θ(V+E)
邻接链表表示法的鲁棒性很高,可以对其进行简单修改来支持许多其他的图变种,比如带权重的图。邻接链表的一个潜在缺陷是无法快速判断一条边 ( u , v ) (u,v) (u,v)是否是图中一条边,唯一的办法是在邻接链表 A d j [ u ] Adj[u] Adj[u]里面搜索结点 v v v,邻接矩阵表示则克服了这个缺陷,但付出的代价是更大的存储空间消耗。
邻接矩阵
对于邻接矩阵表示来说,我们通常会将图 G G G中的节点编为1,2,…, ∣ V ∣ |V| ∣V∣,这种编号可以任意的。在进行此种编号之后,图 G G G的邻接矩阵表示由一个 ∣ V ∣ ∗ ∣ V ∣ |V|*|V| ∣V∣∗∣V∣的矩阵 A = ( a i j ) A=(a_{ij}) A=(aij)表示,该矩阵满足下述条件:
a i j = f ( x ) = { 1 若 ( i , j ) ∈ E 0 其 他 a_{ij}=f(x)=\begin{cases} 1\quad {若}(i,j) \in E \\ 0 \quad 其他\end{cases} aij=f(x)={
1若(i,j)∈E0其他
不管一个图有多少条边,邻接矩阵的空间需求是 Θ ( V 2 ) \Theta(V^2) Θ(V2),无向图的邻接矩阵是一个对称矩阵,在其应用中,可能只需要存储半个矩阵。
邻接矩阵也可以表示权重图,例如,如果 G = ( V , E ) G=(V,E) G=(V,E)为一个权重图,其权重函数为 w w w,则我们直接将边 ( u , v ) ∈ E (u,v) \in E (u,v)∈E的权重 w ( u , v ) w(u,v) w(u,v)存放在邻接矩阵中的第 u u u行第 v v v列记录上。对于不存在的边,用0或者 ∞ \infty ∞来表示一条不存在的边。
如下图所示,(a)表示的无向图,(b)表示的是用邻接链表表示的图 ©表示的是用邻接矩阵表示的图
下图是有向图,邻接链表和邻接矩阵两种方式表示如下:
表示图的属性
对图进行操作的多数算法需要维持图中节点或边的某些属性。这些属性可以使用通常的表述法来进行表示,如 v . d v.d v.d表示节点 v v v的属性 d d d,当使用一对节点来表示一条边的时候,如果边有一种属性 f f f,则边 ( u , v ) (u,v) (u,v)的这种属性可以表示为 ( u , v ) . f (u,v).f (u,v).f。但在一些给定的实际场景中,如果使用邻接链表来表示图,一种可能的方法是使用额外的数组来表示节点属性,如一个与 A d j Adj Adj数组相对应的数组 d [ 1.. ∣ V ∣ ] d[1..|V|] d[1..∣V∣],如果与 u u u邻接的节点都在 A d j [ u ] Adj[u] Adj[u]中,则属性 u . d u.d u.d将存放在数组项 d [ u ] d[u] d[u]里。还有许多其他方法可以用来实现属性的表示,例如,在面向对象的程序设计语言里,节点属性可以表示为 V e r t e x t Vertext Vertext类下面的一个子类中的实例变量。
图的遍历
广度优先搜索和深度优先搜索是图的两种主要遍历算法。
广度优先搜索(BFS)
广度优先搜索是最简单的图搜索算法之一,也是许多重要的图算法的原型。给定图 G = ( V , E ) G=(V,E) G=(V,E)和一个可以识别的源节点 s s s,广度优先搜索对图 G G G中的边进行系统性的探索来发现可以从源节点 s s s到达的所有节点。
用链表来存储图,python成图如下:
'''
节点定义: 值,next指针
'''
class AdjNode:
def __init__(self, key):
self.vertex = key
self.next = None
'''
图定义
'''
class Graph:
def __init__(self, num):
self.V = num
self.graph = [None] * self.V
# add edges
def add_edge(self, s, d):
node = AdjNode(d)
#链表的插入
node.next = self.graph[s]
self.graph[s] = node
#无向图,另一边链表也需更新
node = AdjNode(s)
node.next = self.graph[d]
self.graph[d] = node
广度优先搜索之所以如此得名是因为该算法始终将已发现节点和未发现节点之间的边界,沿其广度方向向外扩展。也就是说,算法需要在发现所有距离源节点 s s s为 k k k的所有节点之后,才会发现距离源节点 s s s为 k + 1 k+1 k+1的其它节点。
如下图所示,描述的是BFS在一个样本图的推进过程:
首先从源点 s s s出发, s s s的邻接节点是 w w w和 r r r,放入队列,再访问 w w w节点,访问完后,将 w w w的邻接节点 t t t和 x x x分别放入队列,依次进行下去,直到访问完所有节点。每个节点 u u u里面记录的是 u . d u.d u.d的值,从源节点到该节点的路径。
广度优先搜索的结果可能依赖于对每个节点的邻接节点的访问顺序,但算法所计算出来的距离 d d d都是一样的。
python实现图的BFS遍历:
# BFS: Breadth first search
def bfs(self, root):
#记录访问的结点visited,先进先出队列
visited, queue = list(), collections.deque([root])
visited.append(root)
while queue:
# Dequeue a vertex from queue
vertex = queue.popleft()
#print(vertex)
# 该节点的neighbours
node = self.graph[vertex]
while node:
if node.vertex not in visited:
visited.append(node.vertex)
queue.append(node.vertex)
node = node.next
return visited
让我们来看下广度优先搜索的运行时间,首先对队列进行操作,每个节点的入队次数最多一次,因而出队最多一次,入队和出队的时间均为 O ( 1 ) O(1) O(1),因此,对队列进行操作的总时间为 O ( V ) O(V) O(V),因为算法只在一个节点出队的时候才对该节点的邻接链表进行扫描,所以每个邻接链表最多只扫描一次,由于所有邻接链表的长度之和是 Θ ( E ) \Theta(E) Θ(E),用于扫描邻接链表的总时间为 O ( E ) O(E) O(E),因此广度优先搜索的总运行时间为 O ( V + E ) O(V+E) O(V+E)
深度优先搜索
深度优先搜索所使用的策略就像其名字所隐含的:只要可能,就在图中尽量“深入"。深度优先搜索总是对最近才发现的节点 v v v的出发边进行探索,直到该节点的所有出发边都被发现为止。一旦节点 v v v的所有边都被发现,搜索则"回溯"到 v v v的前驱节点( v v v是经过该节点才被发现的),来搜索该前驱节点的出发边。该过程一直持续到从源节点可以到达的所有节点都被发现为止。
深度优先搜索的另一个重要性质是,节点的发现时间和完成时间具有所谓的括号化结构。如果以左括号"(u"来表示节点u的发现,以右括号"u)"来表示节点u的完成,则发现和完成的历史记载形成一个规整的表达式,这里”规整“的意思是所有括号都适当地嵌套在一起。如下图所示,对图a进行深度优先搜索对应的括号化结构如图(b)所示:
python实现图的DFS遍历:
# DFS: Depth first search
def dfs(self, root, visited=None):
if visited is None:
visited = list()
visited.append(root)
#print(root)
node = self.graph[root]
while node:
if node.vertex not in visited:
self.dfs(node.vertex, visited)
node = node.next
return visited
从上述代码分析可以看出,需要遍历每个节点,所以需要的时间为 Θ ( V ) \Theta(V) Θ(V),对每个节点 v ∈ V v \in V v∈V,需要访问该节点的邻接节点 A d j [ v ] Adj[v] Adj[v],而 ∑ v ∈ V ∣ A d j [ v ] ∣ = Θ ( E ) \sum_{v \in V}|Adj[v]|=\Theta(E) ∑v∈V∣Adj[v]∣=Θ(E),所以,整体深度优先搜索算法的运行时间为 Θ ( V + E ) \Theta(V+E) Θ(V+E)。
拓扑排序
深度优先搜索有着很重要的应用,利用深度优先搜索可以对有向无环图进行拓扑排序。对于一个有向无环图 G = ( V , E ) G=(V,E) G=(V,E)来说,其拓扑排序是 G G G中所有节点的一种线性次序,该次序满足如下条件:如果图 G G G包含边 ( u , v ) (u,v) (u,v),则节点 u u u在拓扑排序中处于节点 v v v的前面,可以将图的拓扑排序看做是将图的所有节点在一条水平线上排开,图的所有有向边都从左指向右。
如下图所示,(a)所示的有向无环图中,有向边 ( u , v ) (u,v) (u,v)表明服装 u u u必须在服装 v v v之前穿上,图(b)将拓扑排序后的有向无环图在一条水平线上展示出来,在该水平线上,所有的有向边都从左指向右。
图(b)描述的是经过拓扑排序后的节点次序,这个次序与节点的完成时间恰好相反。我们可以在 Θ ( V + E ) \Theta(V+E) Θ(V+E)的时间内完成拓扑排序。
强连通分量
深度优先搜索的一个经典应用:将有向图分解为强连通分量。有向图 G = ( V , E ) G=(V,E) G=(V,E)的强连通分量是一个最大节点集合 C ⊆ V C\subseteq V C⊆V,对于该集合中的任意一对节点 u u u和 v v v来说,路径 u − > v u->v u−>v和 v − > u v->u v−>u同时存在,也就是说,节点 u u u和节点 v v v可以互相到达,如下图所示:
(a)有向图 G G G,每个加了阴影的区域是 G G G的一个强连通分量,每个节点上注明了其深度优先搜索中的发现时间和完成时间,所有的树都加了额外的阴影。(b)图 G G G的转置图 G T G^T GT,加了深阴影的节点 b 、 c 、 g 、 h 全 部 是 深 度 优 先 树 的 根 节 点 。 ( c ) 中 , 对 图 b、c、g、h全部是深度优先树的根节点。(c)中,对图 b、c、g、h全部是深度优先树的根节点。(c)中,对图G$的强连通分量进行收缩而成,这种收缩将每个强连通分量收缩为一个节点,即由一个节点来替换整个连通分量。