如果要用图来解决问题,首先我们必须采用某种数据结构来存储和表示“图”。相对于数组、链表等来说,图的存储结构就复杂的多了。
- 首先,图上的任何一个顶点都可以被看作是第一个顶点,任意顶点的邻接顶点之间也不存在次序关系。还记得在《图论(一)基本概念》中的“同构图”吧,图的形状可以千变万化的。因此也就无法以数据元素在内存中的物理位置来表示元素之间的关系,也就是说,图不可能用数组这样简单的顺序存储结构来表示。
- 其次,如果使用链表一样的链式存储结构,不同顶点的邻接顶点数量是不一样的,相差可能很大,如何在操作和效率之间寻求平衡是个大难题。
不过不用担心,计算机科学界不缺乏牛人,前辈们早就为我们设计好了,而且方法不止一种,发明了大量的图表示法,甚至还有专门从事图表示法的研究员(Jeremy P.Spinrad),还写过一本书《Efficient Graph Representations》。
尽管有大量的图表示法可用,但我们需要掌握的,也是最常用的、最著名的,可用性和普及率都最高的,只有两类:邻接表法和邻接矩阵法。都带有“邻接”两字,这是数学语言,大白话的意思就是“邻居”。
(1)邻接表
邻接表的核心思想就是针对每个顶点设置一个邻居表。
以上面的图为例,这是一个有向图,分别有顶点a, b, c, d, e, f, g, h共8个顶点。使用邻接表就是针对这8个顶点分别构建邻居表,从而构成一个8个邻居表组成的结构,这个结构就是我们这个图的表示结构或者叫存储结构。
a, b, c, d, e, f, g, h = range(8)
N = [{b, c, d, e, f}, # a 的邻居表
{c, e}, # b 的邻居表
{d}, # c 的邻居表
{e}, # d 的邻居表
{f}, # e 的邻居表
{c, g, h}, # f 的邻居表
{f, h}, # g 的邻居表
{f, g}] # h 的邻居表
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
这样,N构成了一个邻居节点集。可以通过N对图进行操作了。
# 顶点f的邻居顶点
print(N[f])
# 顶点g是否是a的邻居顶点
print(g in N[a])
# 顶点a的邻居顶点个数
print(len(N[a]))
- 1
- 2
- 3
- 4
- 5
- 6
输出结果:
{2, 6, 7}
False
5
- 1
- 2
- 3
注意:每个顶点的邻居表都是一个集合(set),为什么用set,因为不能重复存储邻居顶点,这是一个非常自然的选择。那么,可不可以用list,当然可以。用字典呢,当然也可以,甚至在表示带权重值的图时,使用字典表示更合理。
N = [{b: 1, c: 2, d: 1, e: 2, f: 3}, # a 的邻居表
{c: 1, e: 2}, # b 的邻居表
{d: 3}, # c 的邻居表
{e: 1}, # d 的邻居表
{f: 2}, # e 的邻居表
{c: 1, g: 1, h: 1}, # f 的邻居表
{f: 1, h: 2}, # g 的邻居表
{f: 1, g: 2}] # h 的邻居表
# 边(a,f)的权重
if f in N[a]:
print(N[a][f])
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
输出结果:
3
- 1
需要注意的是,不管邻居表是用set,list,还是dict,都是邻接表的各种变形,最终使用哪个取决于这个图本身是什么,我们要用这个图干什么。实际应用中我们可以针对图本身特点和我们要解决问题特点针对性的构建图的表示结构。
(2)邻接矩阵
邻接矩阵的核心思想是针对每个顶点设置一个表,这个表包含所有顶点,通过True/False来表示是否是邻居顶点。
还是针对上面的图,分别有顶点a, b, c, d, e, f, g, h共8个顶点。使用邻接矩阵就是针对这8个顶点构建一个8×8的矩阵组成的结构,这个结构就是我们这个图的表示结构或存储结构。
a, b, c, d, e, f, g, h = range(8)
N = [[0, 1, 1, 1, 1, 1, 0, 0], # a的邻接情况
[0, 0, 1, 0, 1, 0, 0, 0], # b 的邻居表
[0, 0, 0, 1, 0, 0, 0, 0], # c 的邻居表
[0, 0, 0, 0, 1, 0, 0, 0], # d 的邻居表
[0, 0, 0, 0, 0, 1, 0, 0], # e 的邻居表
[0, 0, 1, 0, 0, 0, 1, 1], # f 的邻居表
[0, 0, 0, 0, 0, 1, 0, 1], # g 的邻居表
[0, 0, 0, 0, 0, 1, 1, 0]] # h 的邻居表
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
同样,可以对N进行图操作了,操作方式与邻接表方式有所不同。
# 顶点g是否是a的邻居顶点
print(N[a][g])
# 顶点a的邻居顶点个数
print(sum(N[a]))
# 顶点a的邻居顶点
neighbour = []
for i in range(len(N[f])):
if N[f][i]:
neighbour.append(i)
print(neighbour)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
输出结果:
0
5
[2, 6, 7]
- 1
- 2
- 3
在邻接矩阵表示法中,有一些非常实用的特性。
- 首先,可以看出,该矩阵是一个方阵,方阵的维度就是图中顶点的数量,同时还是一个对称矩阵,这样进行处理时非常方便。
- 其次,该矩阵对角线表示的是顶点与顶点自身的关系,一般图不允许出现自关联状态,即自己指向自己的边,那么对角线的元素全部为0;
- 最后,该表示方式可以不用改动即可表示带权值的图,直接将原来存储1的地方修改成相应的权值即可。当然, 0也是权值的一种,而邻接矩阵中0表示不存在这条边。出于实践中的考虑,可以对不存在的边的权值进行修改,将其设置为无穷大或非法的权值,如None,-99999/99999等。
最后总结下,邻接表和邻接矩阵两种表示方法各有特点,具体使用哪个应该针对具体问题具体分析。但事实上,如果不是特别巨大无比的图,用不着费劲思考,用哪种都可以的。