限于笔者技术水平,文章可能存在错漏,请各位不吝赐教,笔者会尽快改正
文章目录
前言
持续更新中,笔者学艺不精,可能存在错漏,请各位不吝赐教,笔者会尽快改正。
本章节为《Python数据结构与算法分析(第2版)》第七章“图及其算法”的读书笔记,和原文的出入较大,需要学习图及其算法的小伙伴可以先去阅读原文。
一、图的术语与实现
顶点:顶点就是节点。
个人理解:这个节点按理来说,最好抽象为类,但也可以用数字、字符串、字典、列表等来表示,具体要看节点中包含什么东西。
边:两个节点之间的连接关系,边可以是单向的,也可以是双向的。如果所有边都是单向的,则称为有向图。
个人理解:这个连接关系,并不一定要抽象为(起点, 终点, 权重)
的形式,比如,如果没有权重,直接用 {起点1: [终点1, 终点2, 终点3, ...], ...}
的格式,也是足够用了。有权重时,是否可以使用{起点1: [(终点1, 权重1), (终点2, 权重2), (终点3, 权重3) ...]}
的形式,有待商榷,更普遍的表达方式为:{起点1: {终点1: 权重1, 终点2: 权重2, 终点3: 权重3 ...}, ...}
权重:边可以带权重,用来表示从一个顶点到另一个顶点的成本。
个人理解:这个权重不一定是成本,也可以是收益,具体问题具体分析。
路径:路径是由边连接的顶点组成的序列。无权重路径的长度是路径上的边数,有权重路径的长度是路径上的边的权重之和。
环:环是有向图中的一条起点和终点为同一个顶点的路径。没有环的图被称为无环图,没有环的有向图被称为有向无环图,简称为 DAG。
个人理解:有没有环,会对程序的流程产生影响,但简单的环,一般可以通过一些方式消除这种影响。
有两种非常著名的图实现,它们分别是邻接矩阵和邻接表。
邻接矩阵:要实现图,最简单的方式就是使用二维矩阵。在矩阵实现中,每一行和每一列都表示图中的一个顶点。第 v 行和第 w 列交叉的格子中的值表示从顶点 v 到顶点 w 的边的权重。如果两个顶点被一条边连接起来,就称它们是相邻的。
个人理解:比如4个顶点构成的有向环图 1->2->3->4->1,权重是(1,1,2,4),它的邻接矩阵可以是
[[None, 1, None, None],
[None, None, 1, None],
[None, None, None, 2],
[4, None, None, None]]
这种形式。
邻接表:为了实现稀疏连接的图,更高效的方式是使用邻接表。在邻接表实现中,我们为图对象的所有顶点保存一个主列表,同时为每一个顶点对象都维护一个列表,其中记录了与它相连的顶点。
个人理解:上述环图可以写成{1: {2: 1}, 2: {3: 1}, 3: {4: 2}, 4: {1: 4}}
这种形式。
二、宽度优先(BFS)与深度优先(DFS)
1.一个简单的图
面试的时候,可能会遇到“写个BFS算法”或者“写个DFS”算法这样的问题。笔者第一次遇到这种问题时,完全是懵的,啥都没有,要我写啥?结果面试自然是一塌糊涂。
事实上,这种问题应该是很简单的,自己定义个简单的图,设定好起点和终点,然后用BFS算法解析下就可以了。
所以,这里笔者自己定义一个简单的无权重无向有环图。
graph这个图,由 0,1,2,3 这四个顶点组成了一个环,3-4-5形成一条链,画出来就是一个‘Q’的形状。
graph = { # 这是一个简单的图(邻接表)
0: [1, 2, 3],
1: [0, 2, 3],
2: [0, 1, 3],
3: [0, 1, 2, 4],
4: [5, 3],
5: [4],
}
2.BFS代码
BFS即宽度优先(也称为广度优先)算法,它的宗旨是,一层层的遍历,把每个节点相邻的点先遍历一遍,再以相邻的点为基础,向外遍历,就像水波一样,一层一层的往外荡。
正常来说,graph需要作为参数传入,这里作为示例,就不再传入了,BFS代码及注释如下(如果你不想看注释,可以直接看后面的精简版代码):
def bfs(start_k, end_k):
"""
:param start_k: 开始的位置 int型
:param end_k: 结束的位置 int型
:return: 列表,从start_k到end_k经过的节点
"""
# 定义一个字典,key是graph的key,value为["颜色", "上一个节点(父节点)"]
color_pred = {i: ["w", None] for i in graph}
color_pred[start_k][0] = "g" # 准备遍历,将第一个顶点设为灰色
queuing = [start_k] # 等待队列,这里面的节点都是灰色的
while queuing: # 等待队列尚未清空,说明尚未构建完成
grey_k = queuing.pop(0) # 依次提出灰色节点
for t_k in graph[grey_k]: # 遍历当前灰色节点的相邻节点
if color_pred[t_k][0] == "w": # 如果还没有被遍历到
color_pred[t_k] = ["g", grey_k] # 置灰,设置父节点
queuing.append(t_k) # 加入即将遍历的列表
# todo 注意,以下 if 的这段只适用于当前这种图寻找路径
if t_k == end_k: # 找到结束节点了,不用再遍历了
queuing = [] # 提前结束遍历
break
color_pred[grey_k][0] = "b" # 遍历结束,当前灰色节点变黑色
# 回溯:从最后一个节点开始,依次找到其父节点,组成一个列表
pare_k = end_k
ret_list = [pare_k]
while color_pred[pare_k][1]:
pare_k = color_pred[pare_k][1]
ret_list.append(pare_k)
if pare_k != start_k:
raise ValueError(f"节点‘{start_k}’无法到达节点‘{end_k}’")
return ret_list[::-1]
看起来有些复杂,去除注释和提前结束等组件后,基本程序是很简短的,而真正的核心,用于实现bfs过程的,其实只有6行代码:
def bfs(start_k, end_k):
# 这部分是准备工作
color_pred = {i: ["w", None] for i in graph}
color_pred[start_k][0] = "g"
queuing = [start_k]
# 这段是核心
while queuing:
grey_k = queuing.pop(0)
for t_k in graph[grey_k]:
if color_pred[t_k][0] == "w":
color_pred[t_k] = ["g", grey_k]
queuing.append(t_k)
# color_pred[grey_k][0] = "b" # 变成黑色这步,其实没必要
# 这后面是回溯的过程
ret_list = [end_k]
while color_pred[end_k][1]:
end_k = color_pred[end_k][1]
ret_list.append(end_k)
return ret_list[::-1]
3.DFS代码
BFS即深度优先算法,它的宗旨是,一条道走到黑,不撞南墙不回头,撞了南墙,先回个头,然后换个方向再撞。
笔者认为,能不用递归就不用递归,毕竟python递归的默认最大次数为1000(也有说是999的,但也差不了多少),虽然可以通过sys模块修改递归的最大次数,但并不是一个好的解决方案。最好还是能用循环的,就用循环解决,实在没法子,再用递归。
因此,笔者这里,只详细注释循环版的DFS代码。
注意,该代码只适用于上述类型的图,属于最简单的一种应用,其它类型的深度优先问题,需要进行一系列的修改,才能使用,比如这段代码是无法处理“骑士周游问题”的。
一次深度优先搜索甚至能够创建多棵深度优先搜索树,我们称之为深度优先森林。这段代码没有实现深度优先搜索树,所以它获得的路径,不一定是最优路径。注意,即便有深度优先搜索树的存在,也不一定是最优路径。(面试可能会问到深度优先搜索树,拓扑排序也会用到,所以笔者之后会加上带深度优先搜索树的DFS)
DFS代码,如果没有获得到达的路径,这种方法会报错:
def dfs(start_k, end_k):
# 准备工作和bfs是一样的
color_pred = {i: ["w", None] for i in graph}
color_pred[start_k][0] = "g"
queuing = [start_k]
# 核心方法并不相同
while queuing:
grey_key = queuing[-1] # 最后一个节点,即本次搜索的起点
# 查询当前节点相邻的白色节点,如果周边没有白色节点,则当前节点置黑
k_list = [k for k in graph[grey_key] if color_pred[k][0] == "w"]
if k_list: # 如果还有白色节点,则继续深入
new_k = k_list[0] # new_k可以是k_list中任意元素
queuing.append(new_k) # 设置下一步的起点为new_k
if new_k == end_k: # 找到end_k了,提前结束搜索
break
color_pred[new_k][0] = "g" # 将新的k置为灰色
else:
# color_pred[start_k][0] = "b" # 置为黑色,这句没必要
queuing.pop() # 回头
else:
raise ValueError(f"节点‘{start_k}’无法到达节点‘{end_k}’")
return queuing
精简版代码(循环版),这种方法如果没有获得结果,则返回空列表:
def dfs_1(start_k, end_k):
# 准备工作
node_color = {i: "w" for i in graph}
node_color[start_k] = "g"
queuing = [start_k]
# 核心方法
while queuing:
k_list = [k for k in graph[queuing[-1]] if node_color[k] == "w"]
if k_list:
new_k = random.choice(k_list) # 这样也是可以的
queuing.append(new_k)
if new_k == end_k:
break
node_color[new_k] = "g"
else:
queuing.pop() # 回头
return queuing
精简版代码(递归版),这种方法如果没有获得结果,则返回None:
node_color = {i: "w" for i in graph}
queuing = []
def dfs(start_k, end_k):
queuing.append(start_k)
if start_k == end_k:
return queuing
node_color[start_k] = "g"
for k in graph[start_k]:
if node_color[k] == "w" and dfs(k, end_k):
return queuing
else: # 说明已经无路可走了
queuing.pop() # 只能回头
4.骑士周游
骑士周游问题是一个非常经典的DFS求解问题:取一块国际象棋棋盘和一颗骑士棋子(马),目标是找到一系列走法,使得骑士对棋盘上的每一格刚好都只访问一次。
骑士周游问题的简略版代码,这个代码还是比较复杂的,但它的核心功能 kt_core 并不长,与上面的DFS算法的主要区别是结束条件不同,以及 ‘当前节点恢复为白色’ 这样一个操作,上面的DFS是置为黑色,表示当前节点不可再次访问,但在骑士周游问题上,如果从当前节点回退,之后还是有可能再次访问到当前节点的。
简略版代码如下:
class KnightTour(object):
def __init__(self, bd_size):
"""
:param bd_size: 棋盘为 bd_size * bd_size 大小
"""
self.bd_size = bd_size
self.node_num = bd_size * bd_size # 总节点数
# 每一个节点,最多有8个运动方向
self.move_8 = [(1, 2), (2, 1), (1, -2), (2, -1), (-1, 2), (-2, 1), (-1, -2), (-2, -1)]
self.kt_grape = self.get_kt_grape() # 获得图的邻接表
def get_kt_grape(self): # 笔者习惯把行写作i,列写作j,应写为row和col
return {(i, j): self.get_connections(i, j) for i in range(self.bd_size) for j in range(self.bd_size)}
def get_connections(self, i, j): # 计算(i, j)的可用连接点
ret_list = list()
for i_p, j_p in self.move_8:
new_i, new_j = i + i_p, j + j_p
if 0 <= new_i < self.bd_size and 0 <= new_j < self.bd_size: # 判断是否还在棋盘上
ret_list.append((new_i, new_j))
return ret_list
def knight_tour(self, start_pos):
"""
:param start_pos: (列号,行号)
:return: list 格式:[(列号,行号), (列号,行号), ...]
"""
pos_list = list()
node_color = {pos: "w" for pos in self.kt_grape}
return self.kt_core(start_pos, pos_list, node_color)
def kt_core(self, start_pos, pos_list, node_color): # 核心代码
pos_list.append(start_pos)
node_color[start_pos] = "g"
if len(pos_list) < self.node_num:
nbr_list = self.kt_grape[start_pos][:]
# 引导代码,优先遍历那些有效连接点更少的节点,没有这句排序,可能会很慢
nbr_list.sort(key=lambda x: len([1 for pos in self.kt_grape[x] if node_color[pos] == "w"]))
for nbr in nbr_list: # 遍历白色的邻居节点
if node_color[nbr] == "w" and self.kt_core(nbr, pos_list, node_color):
return pos_list
# 遍历完成,没有获得满意的结果,只能回头
node_color[start_pos] = "w" # 当前节点恢复为白色
pos_list.pop() # 回头
return False # 当前走法下,当前节点不满足要求
else:
return pos_list
def kt_core_s(self, start_pos, pos_list, node_color): # 核心代码,去掉引导和注释
pos_list.append(start_pos)
node_color[start_pos] = "g"
if len(pos_list) < self.node_num:
for nbr in self.kt_grape[start_pos]:
if node_color[nbr] == "w" and self.kt_core_s(nbr, pos_list, node_color):
return pos_list
node_color[start_pos] = "w"
pos_list.pop()
return False
return pos_list
if __name__ == '__main__':
board_size = 8
kt_obj = KnightTour(board_size)
print(kt_obj.knight_tour((2, 0)))
5.通用搜索树
一次深度优先搜索甚至能够创建多棵深度优先搜索树,我们称之为深度优先森林。和宽度优先搜索类似,深度优先搜索也利用前驱连接来构建树。
个人理解:这个说明看的笔者直挠头,没明白这个深度优先搜索树到底是什么。笔者猜测,应该和宽度优先搜索产生的树是一个类型的东西,知道起点,可以创建一个树,知道终点后,从终点回溯这棵树,就能找到父节点,最终回溯到起点。
深度优先搜索树可能不止一种构建方式,遍历的先后顺序,可能影响树的形状、父子节点关系等等。
依然是简易版代码,构建一个简单的深度优先搜索树,图还是最上面的graph
,下面代码中的color_pred
存储了节点的信息,可以把这个东西看成一棵树(虽然它确实抽象了一些)。
color_pred = {i: ["w", None, None, None] for i in graph} # [颜色, 父节点, 开始时间, 结束时间]
def dfs():
time = 0
for k in color_pred:
if color_pred[k][0] == 'w':
time = dfs_visit(k, time)
def dfs_visit(start_k, time):
color_pred[start_k][0] = "g"
time += 1
color_pred[start_k][2] = time
for k in graph[start_k]:
if color_pred[k][0] == "w":
color_pred[k][1] = start_k
time = dfs_2(k, time)
time += 1
color_pred[start_k][3] = time
return time
同样的理念,可以整一个BFS搜索树出来(这个是无向图的代码,有向图需要改动)。
color_pred = {i: ["w", None] for i in graph} # [颜色, 父节点]
def bfs(start_k):
color_pred[start_k][0] = "g"
queuing = [start_k]
while queuing:
grey_k = queuing.pop(0)
for k in graph[grey_k]:
if color_pred[k][0] != "w":
continue
color_pred[k][0] = "g"
queuing.append(k)
color_pred[k][1] = grey_k
个人理解:这两种搜索树,对无向环图也是适用的,但是,如果用在环图身上,这个搜索树不一定有现实意义,好像除了找两个顶点间的路径,并不能带来其它帮助。特别是无向环图的情况下,同一个起点构建出的不同树,父子节点在不同的树上,关系可能是相反的。
三、拓扑排序
1.DFS解法
拓扑排序根据有向无环图生成一个包含所有顶点的线性序列,使得如果图 G 中有一条边为(v, w),那么顶点 v 排在顶点 w 之前。
个人理解:这个东西,主要在一个有向无环图,其它的和dfs解析树的构建没啥不同,因为有向无环,所以应当添加一个判断,如果dfs从灰色节点沿着一条道路出发,又走到了灰色节点上,那么,它一定是有环的,此时,拓扑排序会失去意义。
这个图得换成有向无环图了,代码只需在dfs解析树上加上对灰色节点的判断:
graph = {0: [3], 1: [3], 2: [3], 3: [5, 6], 4: [5], 5: [7], 6: [8], 7: [8], 8: []}
color_pred = {i: ["w", None, None, None] for i in graph} # [颜色, 父节点, 开始时间, 结束时间]
REVERSE = True # 这个为True可以尽量保证小序号在前,False则是大序号尽量在前
def dfs():
time = 0
for k in sorted(color_pred, reverse=REVERSE):
if color_pred[k][0] == 'w':
time = dfs_visit(k, time)
return [i[0] for i in sorted(list(color_pred.items()), key=lambda x: x[1][-1], reverse=True)]
def dfs_visit(start_k, time):
color_pred[start_k][0] = "g"
time += 1
color_pred[start_k][2] = time
for k in sorted(graph[start_k], reverse=REVERSE):
if color_pred[k][0] == "w":
color_pred[k][1] = start_k
time = dfs_visit(k, time)
elif color_pred[k][0] == "g":
raise ValueError("有环!")
color_pred[start_k][0] = "b" # 这句就不能省了
time += 1
color_pred[start_k][3] = time
return time
if __name__ == '__main__':
print(dfs())
按结束时间,从大到小排序,就可以构建出一个有序列表,这就是拓扑排序的结果。
显然,拓扑排序可能不止一种结果,可能不止一个有序列表是正确答案,有些问题会在拓扑排序基础上,要求序列号尽量从小到大等,可以通过开启REVERSE
来实现(注意:这个排序的方法是笔者自己想出来的,是否正确需要验证)
,这里暂时不做讨论。
2.BFS解法
笔者目测,这位大佬的文章,貌似就是典型的BFS解法(笔者不确定,有懂行的兄弟姐妹评论区说一说,非常感谢):
拓扑排序入门(真的很简单)
个人理解: BFS需要入度为0的点作为起始点,一步步剥离出所有的点,所以它也不需要啥开始结束时间,但需要统计入度。另外,DFS貌似没有起始点的限制。
代码,注意,这里的REVERSE
和上面的DFS解法(这个排序的方法是笔者自己想出来的,是否正确需要验证)
,作用恰好相反:
graph = {0: [3], 1: [3], 2: [3], 3: [5, 6], 4: [5], 5: [7], 6: [8], 7: [8], 8: []}
color_pred = {i: ["w", 0] for i in graph} # [颜色, 入度]
REVERSE = False # 这个为True可以尽量保证大序号在前,False则是小序号尽量在前
def bfs():
for k, v in graph.items(): # 先计算入度
for i in v:
color_pred[i][-1] += 1
queuing = [i for i, v in color_pred.items() if v[1] == 0] # 取出所有入度为0的
for k in queuing:
color_pred[k] = "gray"
ret_list = list() # 拓扑排序结果
while queuing:
queuing.sort(reverse=REVERSE)
start_k = queuing.pop(0)
ret_list.append(start_k)
for nbr in graph[start_k]:
color_pred[nbr][1] -= 1 # 入度-1
if color_pred[nbr] == ["w", 0]:
queuing.append(nbr)
color_pred[nbr][0] = "g"
if len(ret_list) != len(graph):
raise ValueError("有环!")
return ret_list
四、强连通单元
通过一种叫作强连通单元的图算法,可以找出图中高度连通的顶点簇。
个人理解,这玩意儿就是用来算环的,具体有啥用,不知道。
把上面的dfs报错改成break,就可以获得带“环”的“拓扑排序”(不是真的拓扑排序),按这个顺序,遍历转置后的图,就可以获得一个深度优先森林,可能具备多棵树,每个树上的顶点,可以分为一组。
def scc(graph):
graph_reverse = {k: [] for k in graph}
for k, v_list in graph.items():
for v in v_list:
graph_reverse[v].append(k)
color_pred = {i: ["w", None, None, None] for i in graph} # [颜色, 父节点, 开始时间, 结束时间]
sort_list = sorted(color_pred)
tp_sort = dfs(color_pred, sort_list, graph)
color_reverse = {i: ["w", None, None, None] for i in graph}
group_sort = dfs(color_reverse, tp_sort, graph_reverse)
return grouping(color_reverse, group_sort)
def grouping(color_pred, group_sort):
group_list = []
group_x = [group_sort[0]]
end_p = group_sort[0]
for k in group_sort[1:]:
if color_pred[k][2] > color_pred[end_p][2] and color_pred[k][3] < color_pred[end_p][3]:
group_x.append(k)
else:
group_list.append(group_x)
group_x = [k]
end_p = k
else:
group_list.append(group_x)
return group_list
def dfs(color_pred, sort_list, graph):
time = 0
for k in sort_list:
if color_pred[k][0] == 'w':
time = dfs_visit(color_pred, k, time, sort_list, graph)
return [i[0] for i in sorted(list(color_pred.items()), key=lambda x: x[1][-1], reverse=True)]
def dfs_visit(color_pred, start_k, time, sort_list, graph):
color_pred[start_k][0] = "g"
time += 1
color_pred[start_k][2] = time
for k in sort_list:
if k not in graph[start_k]:
continue
if color_pred[k][0] == "w":
color_pred[k][1] = start_k
time = dfs_visit(color_pred, k, time, sort_list, graph)
elif color_pred[k][0] == "g":
# raise ValueError("有环!")
break
color_pred[start_k][0] = "b" # 这句就不能省了
time += 1
color_pred[start_k][3] = time
return time
if __name__ == '__main__':
graph_1 = {0: [3], 1: [3], 2: [3], 3: [5, 6], 4: [5], 5: [7], 6: [8], 7: [8], 8: [9], 9: [7]}
print(scc(graph_1))
五、最短路径问题
我们要解决的问题是为给定信息找到权重最小的路径。这个问题并不陌生,因为它和我们之前用宽度优先搜索解决过的问题十分相似,最短路径问题,只不过现在考虑的是路径的总权重,而不是路径的长度。需要注意的是,如果所有的权重都相等,那么两个问题就没有区别。
个人理解:最开始的理解是两节点之间的最短路径,但后来认为,不仅是单纯的两个节点之间,有可能要看整体权重,但总的来说,就是令某种条件下的权重最小。这个东西如果没有权重,或者权重都一样,那么算法的意义会有一定的折扣。
1.Dijkstra算法
个人理解:这个算法的目标,是创建一个树,使得每个节点都和start节点之间的路径权重最小,这个算法和广度优先算法非常相似。
实现代码如下(简易版代码):
def pop_min(queuing):
queuing_reverse = {v: k for k, v in queuing.items()}
min_weight = min(list(queuing_reverse))
ret_k = queuing_reverse.pop(min_weight)
queuing.pop(ret_k)
return ret_k
def dijkstra(graph, start):
# 为了更高的效率,可以考虑把queuing换成最小堆,这里为了简化代码就不实现了
color_pred = {i: [None, 100000] for i in graph} # [父节点,距离初始节点的总权重]
color_pred[start] = [None, 0]
queuing = {start: 0} # 这里为了便于取出最小权重,也备份了一个权重
while queuing:
start_k = pop_min(queuing) # 每次取出与start节点最近的节点
for nbr, weight in graph[start_k].items():
new_dis = color_pred[start_k][1] + weight
if new_dis < color_pred[nbr][1]: # 通过新节点可以更快的到达start
color_pred[nbr] = [start_k, new_dis] # 刷新父节点与总权重
queuing[nbr] = new_dis
return color_pred
if __name__ == '__main__':
my_graph = { # 一个有权重的无向图
"u": {"v": 2, "w": 5, "x": 1},
"v": {"u": 2, "w": 3, "x": 2},
"w": {"u": 5, "v": 3, "x": 3, "y": 1, "z": 5},
"x": {"u": 1, "v": 2, "w": 3, "y": 1},
"y": {"w": 1, "x": 1, "z": 1},
"z": {"w": 5, "y": 1}
}
print(dijkstra(my_graph, "u")) # 开始节点设置为u
2.Prim 算法
由于每一步都选择代价最小的下一步,因此 Prim 算法属于一种“贪婪算法”。在这个问题中,代价最小的下一步是选择权重最小的边。
个人理解:这个算法的目标,是创建一个权重总值最小的树,而不考虑和start之间的距离,但是,原文中的代码却和Dijkstra算法相同,这令笔者十分费解。
于是笔者翻阅了其他作者的文章,比如这位大佬的文章:
最小生成树——Prim算法(详细图解)
笔者认为,应当是《Python数据结构与算法分析(第2版)》第七章“图及其算法” 代码清单 7-12 Prim 算法的 Python 实现 出现了错误,所以,笔者将按自己的理解,写一个简易版的代码。
这段代码和Dijkstra算法使用同一套输入:
def prim(graph, start):
pred = {i: None for i in graph} # 父节点
queuing = {i: 100000 for i in graph} # 权重
queuing[start] = 0
while queuing:
start_k = pop_min(queuing)
for nbr, weight in graph[start_k].items():
if nbr in queuing and weight < queuing[nbr]:
pred[nbr] = start_k
queuing[nbr] = weight
return pred
总结
原文的小结:对于解决下列问题,图非常有用。
利用宽度优先搜索找到无权重的最短路径。
利用 Dijkstra 算法求解带权重的最短路径。
利用深度优先搜索来探索图。
利用强连通单元来简化图。
利用拓扑排序为任务排序。
利用最小生成树广播消息。
个人理解:
笔者在实际工作中,几乎没遇到过图相关的问题,但是,它面试总是被问到,从简单的如何设计和存储一个图,到非常坑爹的应用题,那真是,包罗万象。
不论是否会在实际工程中使用,图都是必须面对的问题,还是好好学一下吧。