图
图是由顶点的有穷非空集合和顶点之间边的集合组成, 通常表示为: G(V,E), 其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
图中的元素称为顶点,顶点与顶点之间的连接关系称为边。跟顶点相连接的边的条数称为顶点的度。
边有方向的图叫作有向图,边没有方向的图就叫作无向图 。
在有向图中,我们把度分为入度和出度,顶点的入度,表示有多少条边指向这个顶点;顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点。对应到微博的例子,入度就表示有多少粉丝,出度就表示关注了多少人。
如果每个边都有一个权重,那就称为带权图。比如QQ好友间的亲密度。
图的存储方式
邻接矩阵
图的邻接矩阵存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
对于无向图来说,如果顶点 i 与顶点 j 之间有边,我们就将 A[i][j] 和 A[j][i] 标记为1;对于有向图来说,如果顶点 i 到顶点 j 之间,有一条箭头从顶点 i 指向顶点 j 的边,那我们就将 A[i][j] 标记为1。同理,如果有一条箭头从顶点 j 指向顶点 i 的边,我们就将 A[j][i] 标记为1。对于带权图,数组中就存储相应的权重。
缺点
用邻接矩阵来表示一个图,虽然简单、直观,但是比较浪费存储空间。为什么这么说呢?
对于无向图来说,如果 A[i][j] 等于1,那 A[j][i] 也肯定等于1。实际上,我们只需要存储一个就可以了。也就是说,无向图的二维数组中,如果我们将其用对角线划分为上下两部分,那我们只需要利用上面或者下面这样一半的空间就足够了,另外一半白白浪费掉了。
还有,如果我们存储的是稀疏图(Sparse Matrix),也就是说,顶点很多,但每个顶点的边并不多,那邻接矩阵的存储方法就更加浪费空间了。比如微信有好几亿的用户,对应到图上就是好几亿的顶点。但是每个用户的好友并不会很多,一般也就三五百个而已。如果我们用邻接矩阵来存储,那绝大部分的存储空间都被浪费了。
优点
首先,邻接矩阵的存储方式简单、直接,因为基于数组,所以在获取两个顶点的关系时,就非常高效。
其次,用邻接矩阵存储图的另外一个好处是方便计算。这是因为,用邻接矩阵的方式存储图,可以将很多图的运算转换成矩阵之间的运算。
邻接表
数组与链表相结合的存储方法称为邻接表。图中顶点用一个一维数组存储,同时每个数据元素还需要存储指向第一个邻接点的指针,以便于查找该顶点的边信息。每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。乍一看,邻接表是不是有点像散列表?
无向图邻接表
有向图邻接表
带权有向图邻接表
优缺点
邻接表存储起来比较节省空间,但是使用起来就比较耗时间。
就像图中的例子,如果我们要确定,是否存在一条从顶点2到顶点4的边,那我们就要遍历顶点2对应的那条链表,看链表中是否存在顶点4。而且,我们前面也讲过,链表的存储方式对缓存不友好。所以,比起邻接矩阵的存储方式,在邻接表中查询两个顶点之间的关系就没那么高效了。
图的遍历
广度优先搜索,通俗的理解就是,地毯式层层推进,从起始顶点开始,依次往外遍历。广度优先搜索需要借助队列来实现,遍历得到的路径就是,起始顶点到终止顶点的最短路径。
深度优先搜索用的是回溯思想,非常适合用递归实现。换种说法,深度优先搜索是借助栈来实现的。
在执行效率方面,深度优先和广度优先搜索的时间复杂度都是O(E),空间复杂度是O(V)。
广度优先搜索
def bfs(self, s: int, t: int) -> IO[str]:
"""Print out the path from Vertex s to Vertex t
using bfs.
"""
if s == t: return
visited = [False] * self._num_vertices
visited[s] = True
q = deque()
q.append(s)
prev = [None] * self._num_vertices
while q:
v = q.popleft()
for neighbour in self._adjacency[v]:
if not visited[neighbour]:
prev[neighbour] = v
if neighbour == t:
print("->".join(self._generate_path(s, t, prev)))
return
visited[neighbour] = True
q.append(neighbour)
def _generate_path(self, s: int, t: int, prev: List[Optional[int]]) -> Generator[str, None, None]:
if prev[t] or s != t:
yield from self._generate_path(s, prev[t], prev)
yield str(t)
visited是用来记录已经被访问的顶点,用来避免顶点被重复访问。如果顶点 q 被访问,那相应的 visited[q] 会被设置为 true。
queue是一个队列,用来存储已经被访问、但相连的顶点还没有被访问的顶点。因为广度优先搜索是逐层访问的,也就是说,我们只有把第k层的顶点都访问完成之后,才能访问第k+1层的顶点。当我们访问到第k层的顶点的时候,我们需要把第k层的顶点记录下来,稍后才能通过第k层的顶点来找第k+1层的顶点。所以,我们用这个队列来实现记录的功能。
prev用来记录搜索路径。当我们从顶点s开始,广度优先搜索到顶点 t 后,prev 数组中存储的就是搜索的路径。不过,这个路径是反向存储的。prev[w] 存储的是,顶点w是从哪个前驱顶点遍历过来的。比如,我们通过顶点2的邻接表访问到顶点3,那 prev[3] 就等于2。为了正向打印出路径,我们需要递归地来打印,你可以看下 generate_path() 函数的实现方式。
深度优先搜索
def dfs(self, s: int, t: int) -> IO[str]:
"""Print out a path from Vertex s to Vertex t
using dfs.
"""
found = False
visited = [False] * self._num_vertices
prev = [None] * self._num_vertices
def _dfs(from_vertex: int) -> None:
nonlocal found
if found: return
visited[from_vertex] = True
if from_vertex == t:
found = True
return
for neighbour in self._adjacency[from_vertex]:
if not visited[neighbour]:
prev[neighbour] = from_vertex
_dfs(neighbour)
_dfs(s)
print("->".join(self._generate_path(s, t, prev)))