47 阅读17分钟

www.hello-algo.com/chapter_gra…

1. 图

图(graph)是一种非线性数据结构,由顶点(vertex)和边(edge)组成。我们可以将图 G 抽象地表示为一组顶点 V 和一组边 E 的集合。将顶点看作节点,将边看作连接各个节点的引用(指针),图为网络关系,自由度更高,更为复杂。

image.png

1.1. 图的常见类型与术语

图通过是否有方向,是否是全连通在一起的,边是否有权重,分为三种类型

1.1.1. 无向图和有向图

image.png

1.1.2. 连通图和非连通图

image.png

1.1.3. 有权图和无权图

image.png

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

image.png

特性:

  • 顶点不可与自己连接,主对角线无意义
  • 对于无向图,两方向边等价;对于有向图,1->2 时对应元素为 1,2->1 的对应元素为 0.
  • 邻接矩阵的元素从 1 和 0 替换为权重,则可表示有权图。
  • 直接访问矩阵元素即可获得边信息,增删改查时间复杂度为 O(1),空间复杂度为 O(n2)O(n_2)
1.2.2. 邻接表

邻接表(adjacency list)使用 n个链表来表示图,链表节点表示顶点。第 i 个链表对应顶点 i ,其中存储了该顶点的所有邻接顶点(与该顶点相连的顶点)。 image.png

相较于邻接列表,邻接表存储的仅实际存在的边,空间复杂度更低,时间复杂度会变高。 邻接表结构与哈希表中的“[[#2.1. 链式地址|链式地址]]”非常相似 但是可以将链表转为 [[#5. AVL 树:|AVL树]] 或红黑树提高效率,从而将时间效率从 O(n) 优化至 O(log⁡n) ; 还可以把链表转换为哈希表,从而将时间复杂度降至 O(1) 。

1.3. 图的应用

顶点图计算问题
社交网络用户好友关系潜在好友推荐
地铁线路站点站点间的连通性最短路线推荐
太阳系星体星体间的万有引力作用行星轨道计算

2. 图的基本操作

基本操作分为对边的操作和对顶点的操作 实现方式分为邻接矩阵实现和邻接表实现 以无向图为例,设图中共有 n 个顶点和 m 条边,

2.1. 基于邻接矩阵实现

image.png

  • 初始化图:初始化为[[#1.2.1. 邻接矩阵|邻接矩阵]]。传入 n个顶点,初始化长度为 vertices 的顶点列表,初始化矩阵 adjMat(大小 n×n)
  • 添加或删除边:添加边时,对应索引置 1,如 n[0,2]=n[2,0,]=1n[0,2] = n[2,0,] =1;删除时对应索引置 0,如 n[0,1]=n[1,0,]=0n[0,1] = n[1,0,] =0.
  • 添加顶点:矩阵尾部新增一行一列且均为 0
  • 删除顶点:对应的一行一列置 0,需要注意的是,当删除首行首列时达到最差情况,需要将 (n1)2(n−1)^2 个元素“向左上移动”,从而使用 O(n2)O(n^2) 时间。
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 , image.png

  • 初始化图:初始化为[[#1.2.2. 邻接表|邻接表]]。在邻接表中创建 n 个顶点和 2m 条边,这里是 2m 的原因是,图中一条边能连接两个顶点,无需双向,但是当它初始化为一个邻接表的话,就需要双向的连接,即,在第一个链表中 1——>3,在第二个链表中同样要有 3——>1,此时使用 O(n+m)O(n+m) 时间。
  • 添加或删除边:添加边时,对应的链表末尾添加边即可;删除时在对应链表中先查后删指定边即可,可以类似于链表的删除。注意这里面添加和删除都要同时处理两个链表
  • 添加顶点:添加一个链表,该链表仅有一个顶点(链表头)
  • 删除顶点:对应的链表删除到仅有一个链表头,与之相应的每一个链表都要删除与之对应的边。
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. 效率对比

邻接矩阵邻接矩阵邻接表(链表)邻接表(哈希表)
判断是否邻接O(1)O(1)O(m)O(m)O(1)O(1)
添加边O(1)O(1)O(1)O(1)O(1)O(1)
删除边O(1)O(1)O(m)O(m)O(1)O(1)
添加顶点O(n)O(n)O(1)O(1)O(1)O(1)
删除顶点O(n2)O(n^2)O(n+m)O(n+m)O(n)O(n)
内存空间占用O(n2)O(n^2)O(n+m)O(n+m)O(n+m)O(n+m)
邻接表(哈希表)的时间效率与空间效率最优。但实际上,在邻接矩阵中操作边的效率更高,只需一次数组访问或赋值操作即可。综合来看,邻接矩阵体现了“以空间换时间”的原则,而邻接表体现了“以时间换空间”的原则。

3. 图的遍历

树:一对多 图:任意多对多 图和树的关系:树是一个特殊的图 树的遍历方式:[[#2.1. 层序遍历|广度优先遍历]] 和[[#2.2. 前序、中序、后序遍历|深度优先遍历]] 。 图的遍历方式:广度优先遍历和深度优先遍历

3.1. 图的广度优先遍历

image.png

在实现时,通常利用队列的先入先出属性来实现,为了防止重复遍历顶点,我们需要借助一个哈希集合 visited 来记录哪些节点已被访问。

image.png

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. 图的深度优先遍历

深度优先遍历是一种优先走到底、无路可走再回头的遍历方式

image.png

方法:

  • 利用递推来实现
  • 从遍历的第一个顶点开始,入遍历序列,然后搜索它的相邻顶点(有边),然后对于相邻顶点执行同样操作 流程:
  • 直虚线代表向下递推,表示开启了一个新的递归方法来访问新顶点。
  • 曲虚线代表向上回溯,表示此递归方法已经返回,回溯到了开启此方法的位置。

image.png

算法实现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 个边
  • 在两种遍历过程中,二者均要遍历每一个顶点,同时均要走两遍边(过去一次,回来一次),因此时间复杂度相同。
  • 在遍历过程中,二者均要存储所有的顶点,因此空间复杂度相同。
时间复杂度空间复杂度
广度优先搜索O(V+E)O(\|V\|+\|E\|)O(V)O(\|V\|)
深度优先搜索O(V+E)O(\|V\|+\|E\|)O(V)O(\|V\|)

4. 小结

  • 图由[[#1.1. 图的常见类型与术语|顶点和边]] 组成,可以表示为一组顶点和一组边构成的集合。
  • 相较于线性关系(链表)和分治关系(树),网络关系(图)具有更高的自由度,因而更为复杂。
  • 有向图的边具有方向性,连通图中的任意顶点均可达,有权图的每条边都包含权重变量。
  • [[#1.2.1. 邻接矩阵|邻接矩阵]] 利用矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 1 或 0 表示两个顶点之间有边或无边。邻接矩阵在[[#2.1. 基于邻接矩阵实现|增删改查]] 操作上效率很高,但空间占用较多。
  • [[#1.2.2. 邻接表|邻接表]] 使用多个链表来表示图,第 ii 个链表对应顶点 ii ,其中存储了该顶点的所有邻接顶点。邻接表相对于邻接矩阵更加节省空间,但由于需要遍历链表来[[#2.2. 基于邻接表的实现|查找]] 边,因此时间效率较低。
  • 当邻接表中的链表过长时,可以将其转换为[[#5.4.1. 补充知识红黑树:|红黑树]]或[[#六. 哈希表|哈希表]],从而提升查询效率。
  • 从算法思想的角度分析,邻接矩阵体现了“以空间换时间”,邻接表体现了“以时间换空间”。
  • 图可用于建模各类现实系统,如社交网络、地铁线路等。
  • 树是图的一种特例,树的遍历也是图的遍历的一种特例。
  • [[#3.1. 图的广度优先遍历|图的广度优先遍历]] 是一种由近及远、层层扩张的搜索方式,通常借助队列实现。
  • [[#3.2. 图的深度优先遍历|图的深度优先遍历]] 是一种优先走到底、无路可走时再回溯的搜索方式,常基于递归来实现。
  • 路径被视为一个边序列,而不是一个顶点序列。这是因为两个顶点之间可能存在多条边连接,此时每条边都对应一条路径。
  • 在非连通图中,从某个顶点出发,至少有一个顶点无法到达。遍历非连通图需要设置多个起点,以遍历到图的所有连通分量。
  • 在邻接表中,“与该顶点相连的所有顶点”的顶点顺序可以是任意顺序。但在实际应用中,可能需要按照指定规则来排序,比如按照顶点添加的次序,或者按照顶点值大小的顺序等,这样有助于快速查找“带有某种极值”的顶点。

十. 搜索算法


Footnotes

  1. “Vertex 类”通常指图论中的顶点类。它代表图中的一个节点,并存储与该节点相关的信息。一个基本的 Vertex 类通常包含以下属性和方法:

    属性:

    • id: 顶点的唯一标识符。可以是数字、字符串或其他可哈希对象。
    • data: 与顶点关联的数据。可以是任何类型的数据,例如名称、权重、坐标等。
    • neighbors: 顶点的邻居节点。通常使用列表、集合或字典来存储邻居节点的 idVertex 对象本身。选择哪种数据结构取决于具体的图的类型和算法需求。例如,对于无向图,可以使用集合;对于有向图,可以使用字典来存储邻居节点及其对应的边的权重。

    方法:

    • __init__(self, id, data=None): 构造函数,用于初始化顶点的 iddata 属性。
    • 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 类的概念和用法。如果你还有其他问题,请随时提出。

  2. 根据邻接表表示的图:

    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 = 0
    • visited = set()
    • res = []

    第一步:访问顶点 0
    dfs(graph, visited, res, 0)
    
    • res = [0]
    • visited = {0}
    • 遍历 0 的邻接顶点 [1, 3]

    第二步:访问顶点 3
    dfs(graph, visited, res, 3)
    
    • res = [0, 3]
    • visited = {0, 3}
    • 遍历 3 的邻接顶点 [0]
      • 0 已被访问,跳过

    第三步:回溯到顶点 0,访问顶点 1
    dfs(graph, visited, res, 1)
    
    • res = [0, 3, 1]
    • visited = {0, 1, 3}
    • 遍历 1 的邻接顶点 [0, 2]
      • 0 已被访问,跳过
      • 访问 2

    第四步:访问顶点 2
    dfs(graph, visited, res, 2)
    
    • res = [0, 3, 1, 2]
    • visited = {0, 1, 2, 3}
    • 遍历 2 的邻接顶点 [1, 5]
      • 1 已被访问,跳过
      • 访问 5

    第五步:访问顶点 5
    dfs(graph, visited, res, 5)
    
    • res = [0, 3, 1, 2, 5]
    • visited = {0, 1, 2, 3, 5}
    • 遍历 5 的邻接顶点 [2, 4, 6]
      • 2 已被访问,跳过
      • 访问 4

    第六步:访问顶点 4
    dfs(graph, visited, res, 4)
    
    • res = [0, 3, 1, 2, 5, 4]
    • visited = {0, 1, 2, 3, 4, 5}
    • 遍历 4 的邻接顶点 [5]
      • 5 已被访问,跳过

    第七步:回溯到顶点 5,访问顶点 6
    dfs(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 的结果依赖于访问邻接顶点的顺序,不同的访问顺序会导致不同的遍历路径。