www.hello-algo.com/chapter_gra…
1. 图
图(graph)是一种非线性数据结构,由顶点(vertex)和边(edge)组成。我们可以将图 G 抽象地表示为一组顶点 V 和一组边 E 的集合。将顶点看作节点,将边看作连接各个节点的引用(指针),图为网络关系,自由度更高,更为复杂。
1.1. 图的常见类型与术语
图通过是否有方向,是否是全连通在一起的,边是否有权重,分为三种类型
1.1.1. 无向图和有向图
1.1.2. 连通图和非连通图
1.1.3. 有权图和无权图
1.1.4. 图的术语
- 邻接(adjacency):当两顶点之间存在边相连时,称这两顶点“邻接”。顶点 1 的邻接顶点为顶点 2、3、5。
- 路径(path):从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。边序列 1-5-2-4 是顶点 1 到顶点 4 的一条路径。
- 度(degree):一个顶点拥有的边数。对于有向图,入度(in-degree)表示有多少条边指向该顶点,出度(out-degree)表示有多少条边从该顶点指出。
1.2. 图的表示
1.2.1. 邻接矩阵
图的顶点数量:n 邻接矩阵大小:n×n 邻接矩阵的行(列):图的顶点 邻接矩阵元素:边,两个顶点存在边则为 1,否则为 0
特性:
- 顶点不可与自己连接,主对角线无意义
- 对于无向图,两方向边等价;对于有向图,1->2 时对应元素为 1,2->1 的对应元素为 0.
- 邻接矩阵的元素从 1 和 0 替换为权重,则可表示有权图。
- 直接访问矩阵元素即可获得边信息,增删改查时间复杂度为 O(1),空间复杂度为 ,
1.2.2. 邻接表
邻接表(adjacency list)使用 n个链表来表示图,链表节点表示顶点。第 i 个链表对应顶点 i ,其中存储了该顶点的所有邻接顶点(与该顶点相连的顶点)。
相较于邻接列表,邻接表存储的仅实际存在的边,空间复杂度更低,时间复杂度会变高。 邻接表结构与哈希表中的“[[#2.1. 链式地址|链式地址]]”非常相似 但是可以将链表转为 [[#5. AVL 树:|AVL树]] 或红黑树提高效率,从而将时间效率从 O(n) 优化至 O(logn) ; 还可以把链表转换为哈希表,从而将时间复杂度降至 O(1) 。
1.3. 图的应用
| 顶点 | 边 | 图计算问题 | |
|---|---|---|---|
| 社交网络 | 用户 | 好友关系 | 潜在好友推荐 |
| 地铁线路 | 站点 | 站点间的连通性 | 最短路线推荐 |
| 太阳系 | 星体 | 星体间的万有引力作用 | 行星轨道计算 |
2. 图的基本操作
基本操作分为对边的操作和对顶点的操作 实现方式分为邻接矩阵实现和邻接表实现 以无向图为例,设图中共有 n 个顶点和 m 条边,
2.1. 基于邻接矩阵实现
- 初始化图:初始化为[[#1.2.1. 邻接矩阵|邻接矩阵]]。传入 n个顶点,初始化长度为
vertices的顶点列表,初始化矩阵 adjMat(大小 n×n) - 添加或删除边:添加边时,对应索引置 1,如 ;删除时对应索引置 0,如 .
- 添加顶点:矩阵尾部新增一行一列且均为 0
- 删除顶点:对应的一行一列置 0,需要注意的是,当删除首行首列时达到最差情况,需要将 个元素“向左上移动”,从而使用 时间。
class GraphAdjMat:
"""基于邻接矩阵实现的无向图类"""
def __init__(self, vertices: list[int], edges: list[list[int]]):
"""构造方法"""
# 顶点列表,元素代表“顶点值”,索引代表“顶点索引”
self.vertices: list[int] = []
# 邻接矩阵,行列索引对应“顶点索引”,列表实现矩阵
self.adj_mat: list[list[int]] = []
# 添加顶点
for val in vertices:
self.add_vertex(val)
# 添加边
# 请注意,edges 元素代表顶点索引,即对应 vertices 元素索引
for e in edges:
self.add_edge(e[0], e[1])
def size(self) -> int:
"""获取顶点数量"""
return len(self.vertices)
def add_vertex(self, val: int):
"""添加顶点"""
n = self.size()
# 向顶点列表中添加新顶点的值
self.vertices.append(val)
# 在邻接矩阵中添加一行
new_row = [0] * n
self.adj_mat.append(new_row)
# 在邻接矩阵中添加一列
for row in self.adj_mat:
row.append(0)
def remove_vertex(self, index: int):
"""删除顶点"""
if index >= self.size():
raise IndexError()
# 在顶点列表中移除索引 index 的顶点
self.vertices.pop(index)
# 在邻接矩阵中删除索引 index 的行
self.adj_mat.pop(index)
# 在邻接矩阵中删除索引 index 的列
for row in self.adj_mat:
row.pop(index)
def add_edge(self, i: int, j: int):
"""添加边"""
# 参数 i, j 对应 vertices 元素索引
# 如果对应的是值的话也可以用index进行操作得到
# 索引越界与相等处理
if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j:
raise IndexError()
# 在无向图中,邻接矩阵关于主对角线对称,即满足 (i, j) == (j, i)
self.adj_mat[i][j] = 1
self.adj_mat[j][i] = 1
def remove_edge(self, i: int, j: int):
"""删除边"""
# 参数 i, j 对应 vertices 元素索引
# 索引越界与相等处理
if i < 0 or j < 0 or i >= self.size() or j >= self.size() or i == j:
raise IndexError()
self.adj_mat[i][j] = 0
self.adj_mat[j][i] = 0
def print(self):
"""打印邻接矩阵"""
print("顶点列表 =", self.vertices)
print("邻接矩阵 =")
print_matrix(self.adj_mat)
2.2. 基于邻接表的实现
设无向图的顶点总数为 n、边总数为 m ,
- 初始化图:初始化为[[#1.2.2. 邻接表|邻接表]]。在邻接表中创建 n 个顶点和 2m 条边,这里是 2m 的原因是,图中一条边能连接两个顶点,无需双向,但是当它初始化为一个邻接表的话,就需要双向的连接,即,在第一个链表中 1——>3,在第二个链表中同样要有 3——>1,此时使用 时间。
- 添加或删除边:添加边时,对应的链表末尾添加边即可;删除时在对应链表中先查后删指定边即可,可以类似于链表的删除。注意这里面添加和删除都要同时处理两个链表
- 添加顶点:添加一个链表,该链表仅有一个顶点(链表头)
- 删除顶点:对应的链表删除到仅有一个链表头,与之相应的每一个链表都要删除与之对应的边。
class GraphAdjList:
"""基于邻接表实现的无向图类"""
"""使用列表(动态数组)来代替链表"""
"""使用哈希表来存储邻接表,`key` 为顶点实例,`value` 为该顶点的邻接顶点列表(链表)"""
"""在邻接表中使用 `Vertex` 类来表示顶点"""
"""“Vertex 类”通常指图论中的顶点类。它代表图中的一个节点,并存储与该节点相关的信息。"""
def __init__(self, edges: list[list[Vertex]]):
"""构造方法"""
# 邻接表,key:顶点,value:该顶点的所有邻接顶点
self.adj_list = dict[Vertex, list[Vertex]]()
# 添加所有顶点和边
for edge in edges:
self.add_vertex(edge[0])
self.add_vertex(edge[1])
self.add_edge(edge[0], edge[1])
def size(self) -> int:
"""获取顶点数量"""
return len(self.adj_list)
def add_edge(self, vet1: Vertex, vet2: Vertex):
"""添加边"""
if vet1 not in self.adj_list or vet2 not in self.adj_list or vet1 == vet2:
raise ValueError()
# 添加边 vet1 - vet2
self.adj_list[vet1].append(vet2)
self.adj_list[vet2].append(vet1)
def remove_edge(self, vet1: Vertex, vet2: Vertex):
"""删除边"""
if vet1 not in self.adj_list or vet2 not in self.adj_list or vet1 == vet2:
raise ValueError()
# 删除边 vet1 - vet2
self.adj_list[vet1].remove(vet2)
self.adj_list[vet2].remove(vet1)
def add_vertex(self, vet: Vertex):
"""添加顶点"""
if vet in self.adj_list:
return
# 在邻接表中添加一个新链表
self.adj_list[vet] = []
def remove_vertex(self, vet: Vertex):
"""删除顶点"""
if vet not in self.adj_list:
raise ValueError()
# 在邻接表中删除顶点 vet 对应的链表
self.adj_list.pop(vet)
# 遍历其他顶点的链表,删除所有包含 vet 的边
for vertex in self.adj_list:
if vet in self.adj_list[vertex]:
self.adj_list[vertex].remove(vet)
def print(self):
"""打印邻接表"""
print("邻接表 =")
for vertex in self.adj_list:
tmp = [v.val for v in self.adj_list[vertex]]
print(f"{vertex.val}: {tmp},")
p.s.关于 Vertex 类1: “Vertex 类”通常指图论中的顶点类。它代表图中的一个节点,并存储与该节点相关的信息。一个基本的 Vertex 类通常包含以下属性和方法:
属性:
id: 顶点的唯一标识符。 可以是数字、字符串或其他可哈希对象。data: 与顶点关联的数据。 可以是任何类型的数据,例如名称、权重、坐标等。neighbors: 顶点的邻居节点。通常使用列表、集合或字典来存储邻居节点的id或Vertex对象本身。选择哪种数据结构取决于具体的图的类型和算法需求。例如,对于无向图,可以使用集合;对于有向图,可以使用字典来存储邻居节点及其对应的边的权重。
2.3. 效率对比
| 邻接矩阵 | 邻接矩阵 | 邻接表(链表) | 邻接表(哈希表) |
|---|---|---|---|
| 判断是否邻接 | |||
| 添加边 | |||
| 删除边 | |||
| 添加顶点 | |||
| 删除顶点 | |||
| 内存空间占用 | |||
| 邻接表(哈希表)的时间效率与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。 |
3. 图的遍历
树:一对多 图:任意多对多 图和树的关系:树是一个特殊的图 树的遍历方式:[[#2.1. 层序遍历|广度优先遍历]] 和[[#2.2. 前序、中序、后序遍历|深度优先遍历]] 。 图的遍历方式:广度优先遍历和深度优先遍历
3.1. 图的广度优先遍历
在实现时,通常利用队列的先入先出属性来实现,为了防止重复遍历顶点,我们需要借助一个哈希集合 visited 来记录哪些节点已被访问。
def graph_bfs(graph: GraphAdjList, start_vet: Vertex) -> list[Vertex]:
"""广度优先遍历"""
# 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
# 顶点遍历序列
res = []
# 哈希集合,用于记录已被访问过的顶点
visited: set[Vertex] = {start_vet} #类型注解语法:使用冒号`:`在变量后声明类型,明确`visited`是一个包含`Vertex`元素的集合。
# 队列用于实现 BFS
que: deque[Vertex] = deque([start_vet])# 使用冒号 `:` 声明变量类型,明确 `que` 是一个存储 `Vertex` 类型元素的队列
# 以顶点 vet 为起点,循环直至访问完所有顶点
while len(que) > 0:
vet = que.popleft() # 队首顶点出队
res.append(vet) # 记录访问顶点
# 遍历该顶点的所有邻接顶点
for adj_vet in graph.adj_list[vet]:#`graph.adj_list.get(vertex)` 用于获取 `vertex` 的邻居节点列表
if adj_vet in visited:
continue # 跳过已被访问的顶点
que.append(adj_vet) # 只入队未访问的顶点
visited.add(adj_vet) # 标记该顶点已被访问
# 返回顶点遍历序列
return res
注意: 广度优先遍历顺序不唯一。广度优先遍历只要求按“由近及远”的顺序遍历,而多个相同距离的顶点的遍历顺序允许被任意打乱。如 0->1 也可以先 0->3。
3.2. 图的深度优先遍历
深度优先遍历是一种优先走到底、无路可走再回头的遍历方式。
方法:
- 利用递推来实现
- 从遍历的第一个顶点开始,入遍历序列,然后搜索它的相邻顶点(有边),然后对于相邻顶点执行同样操作 流程:
- 直虚线代表向下递推,表示开启了一个新的递归方法来访问新顶点。
- 曲虚线代表向上回溯,表示此递归方法已经返回,回溯到了开启此方法的位置。
算法实现2:
- 也需要借助一个哈希集合
visited来记录已被访问的顶点,以避免重复访问顶点。
def dfs(graph: GraphAdjList, visited: set[Vertex], res: list[Vertex], vet: Vertex):
"""深度优先遍历辅助函数"""
res.append(vet) # 记录访问顶点
visited.add(vet) # 标记该顶点已被访问
# 遍历该顶点的所有邻接顶点
for adjVet in graph.adj_list[vet]:
if adjVet in visited:
continue # 跳过已被访问的顶点
# 递归访问邻接顶点
dfs(graph, visited, res, adjVet)
def graph_dfs(graph: GraphAdjList, start_vet: Vertex) -> list[Vertex]:
"""深度优先遍历"""
# 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
# 顶点遍历序列
res = []
# 哈希集合,用于记录已被访问过的顶点
visited = set[Vertex]()
dfs(graph, visited, res, start_vet)
return res
注意:
- 与广度优先遍历类似,深度优先遍历序列的顺序也不是唯一的。给定某顶点,先往哪个方向探索都可以,即邻接顶点的顺序可以任意打乱,都是深度优先遍历。
- 以树的遍历为例,“根 → 左 → 右”“左 → 根 → 右”“左 → 右 → 根”分别对应前序、中序、后序遍历,它们展示了三种遍历优先级,然而这三者都属于深度优先遍历。
3.3. 复杂度分析
- 假设 V 个顶点,E 个边
- 在两种遍历过程中,二者均要遍历每一个顶点,同时均要走两遍边(过去一次,回来一次),因此时间复杂度相同。
- 在遍历过程中,二者均要存储所有的顶点,因此空间复杂度相同。
| 时间复杂度 | 空间复杂度 | |
|---|---|---|
| 广度优先搜索 | ||
| 深度优先搜索 |
4. 小结
- 图由[[#1.1. 图的常见类型与术语|顶点和边]] 组成,可以表示为一组顶点和一组边构成的集合。
- 相较于线性关系(链表)和分治关系(树),网络关系(图)具有更高的自由度,因而更为复杂。
- 有向图的边具有方向性,连通图中的任意顶点均可达,有权图的每条边都包含权重变量。
- [[#1.2.1. 邻接矩阵|邻接矩阵]] 利用矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 1 或 0 表示两个顶点之间有边或无边。邻接矩阵在[[#2.1. 基于邻接矩阵实现|增删改查]] 操作上效率很高,但空间占用较多。
- [[#1.2.2. 邻接表|邻接表]] 使用多个链表来表示图,第 个链表对应顶点 ,其中存储了该顶点的所有邻接顶点。邻接表相对于邻接矩阵更加节省空间,但由于需要遍历链表来[[#2.2. 基于邻接表的实现|查找]] 边,因此时间效率较低。
- 当邻接表中的链表过长时,可以将其转换为[[#5.4.1. 补充知识红黑树:|红黑树]]或[[#六. 哈希表|哈希表]],从而提升查询效率。
- 从算法思想的角度分析,邻接矩阵体现了“以空间换时间”,邻接表体现了“以时间换空间”。
- 图可用于建模各类现实系统,如社交网络、地铁线路等。
- 树是图的一种特例,树的遍历也是图的遍历的一种特例。
- [[#3.1. 图的广度优先遍历|图的广度优先遍历]] 是一种由近及远、层层扩张的搜索方式,通常借助队列实现。
- [[#3.2. 图的深度优先遍历|图的深度优先遍历]] 是一种优先走到底、无路可走时再回溯的搜索方式,常基于递归来实现。
- 路径被视为一个边序列,而不是一个顶点序列。这是因为两个顶点之间可能存在多条边连接,此时每条边都对应一条路径。
- 在非连通图中,从某个顶点出发,至少有一个顶点无法到达。遍历非连通图需要设置多个起点,以遍历到图的所有连通分量。
- 在邻接表中,“与该顶点相连的所有顶点”的顶点顺序可以是任意顺序。但在实际应用中,可能需要按照指定规则来排序,比如按照顶点添加的次序,或者按照顶点值大小的顺序等,这样有助于快速查找“带有某种极值”的顶点。
十. 搜索算法
Footnotes
-
“Vertex 类”通常指图论中的顶点类。它代表图中的一个节点,并存储与该节点相关的信息。一个基本的 Vertex 类通常包含以下属性和方法:
属性:
id: 顶点的唯一标识符。可以是数字、字符串或其他可哈希对象。data: 与顶点关联的数据。可以是任何类型的数据,例如名称、权重、坐标等。neighbors: 顶点的邻居节点。通常使用列表、集合或字典来存储邻居节点的id或Vertex对象本身。选择哪种数据结构取决于具体的图的类型和算法需求。例如,对于无向图,可以使用集合;对于有向图,可以使用字典来存储邻居节点及其对应的边的权重。
方法:
__init__(self, id, data=None): 构造函数,用于初始化顶点的id和data属性。add_neighbor(self, neighbor, weight=None): 添加一个邻居节点。weight参数可选,用于表示边权重(适用于加权图)。get_neighbors(self): 返回顶点的邻居节点列表。get_id(self): 返回顶点的id。get_data(self): 返回顶点的数据data。__str__(self): 返回顶点的字符串表示形式,方便打印输出。
Python 示例代码:
class Vertex: def __init__(self, id, data=None): self.id = id self.data = data self.neighbors = {} # 使用字典存储邻居节点和边权重 def add_neighbor(self, neighbor, weight=1): self.neighbors[neighbor] = weight def get_neighbors(self): return self.neighbors.keys() # 或返回 neighbors.items() 获取邻居和权重 def get_id(self): return self.id def get_data(self): return self.data def get_weight(self, neighbor): return self.neighbors.get(neighbor) # 返回权重,如果邻居不存在则返回 None def __str__(self): return f"Vertex {self.id}: {self.data}" # 示例用法: v1 = Vertex(1, "A") v2 = Vertex(2, "B") v3 = Vertex(3, "C") v1.add_neighbor(v2, 5) v1.add_neighbor(v3, 10) print(v1) # 输出: Vertex 1: A print(v1.get_neighbors()) # 输出: dict_keys([2, 3]) print(v1.get_weight(v2)) # 输出: 5 print(v1.get_weight(4)) # 输出: None扩展:
根据实际需求,
Vertex类可以添加其他属性和方法,例如:- 存储顶点的颜色(用于图的遍历算法)。
- 存储顶点的发现时间和完成时间(用于深度优先搜索)。
- 存储顶点到源点的距离(用于最短路径算法)。
希望这个解释和示例代码能够帮助你理解
Vertex类的概念和用法。如果你还有其他问题,请随时提出。 ↩ -
根据邻接表表示的图:
graph.adj_list = { 0: [1, 3], 1: [0, 2], 2: [1, 5], 3: [0], 4: [5], 5: [2, 4, 6], 6: [5] }从顶点
0开始进行深度优先遍历(DFS),并假设在第二步直接访问顶点3而不是顶点1。初始状态
start_vet = 0visited = set()res = []
第一步:访问顶点
0dfs(graph, visited, res, 0)res = [0]visited = {0}- 遍历
0的邻接顶点[1, 3]
第二步:访问顶点
3dfs(graph, visited, res, 3)res = [0, 3]visited = {0, 3}- 遍历
3的邻接顶点[0]0已被访问,跳过
第三步:回溯到顶点
0,访问顶点1dfs(graph, visited, res, 1)res = [0, 3, 1]visited = {0, 1, 3}- 遍历
1的邻接顶点[0, 2]0已被访问,跳过- 访问
2
第四步:访问顶点
2dfs(graph, visited, res, 2)res = [0, 3, 1, 2]visited = {0, 1, 2, 3}- 遍历
2的邻接顶点[1, 5]1已被访问,跳过- 访问
5
第五步:访问顶点
5dfs(graph, visited, res, 5)res = [0, 3, 1, 2, 5]visited = {0, 1, 2, 3, 5}- 遍历
5的邻接顶点[2, 4, 6]2已被访问,跳过- 访问
4
第六步:访问顶点
4dfs(graph, visited, res, 4)res = [0, 3, 1, 2, 5, 4]visited = {0, 1, 2, 3, 4, 5}- 遍历
4的邻接顶点[5]5已被访问,跳过
第七步:回溯到顶点
5,访问顶点6dfs(graph, visited, res, 6)res = [0, 3, 1, 2, 5, 4, 6]visited = {0, 1, 2, 3, 4, 5, 6}- 遍历
6的邻接顶点[5]5已被访问,跳过
最终结果
res = [0, 3, 1, 2, 5, 4, 6]
总结
通过改变第二步的访问顺序(先访问
3而不是1),最终的遍历顺序发生了变化,但所有顶点都被访问到了。DFS 的结果依赖于访问邻接顶点的顺序,不同的访问顺序会导致不同的遍历路径。 ↩