图是比线性表和树更为复杂的一种非线性数据结构。在线性表中,数据元素之间是线性关系;在树形结构中,元素之间是明显的层次关系(父子节点)。而在图中,任意两个数据元素之间都可能存在关系,这使它能够完美地建模现实世界中各种复杂的关系网络,如社交网络、交通路线、任务依赖等。
一、 图的基本概念
- 顶点 :图中的数据元素,通常称为顶点 或 节点。
- 边 :图中连接两个顶点的线,表示两者之间的关系。边可以是有方向的(有向图 ),也可以是无方向的(无向图)。
- 权 :边可以有一个数值权重,表示连接的代价、距离或强度,这样的图称为带权图。
- 度 :
- 无向图中,一个顶点的度是指与其相连的边的数量。
- 有向图 中,度分为入度 (指向该顶点的边的数量)和出度(从该顶点指出的边的数量)。
- 路径与环 :从一个顶点经过一系列边到达另一个顶点构成的序列称为路径 。如果路径的起点和终点是同一个顶点,则称之为环。
图的存储方式主要有两种:
- 邻接矩阵:使用一个二维数组来存储边的关系。简单直观,但空间复杂度高(O(V²)),适合稠密图。
- 邻接表:为每个顶点维护一个链表,存储所有与其相邻的顶点。空间效率高(O(V+E)),是更常用的表示方法,尤其适合稀疏图。
二、 图的遍历
图的遍历是指从图中某一顶点出发,按照某种策略访问图中所有顶点,且每个顶点仅被访问一次。遍历是许多图算法的基础。主要有两种策略:
1. 广度优先搜索(BFS)
- 核心思想: "一圈一圈"地向外探索。先访问起始顶点,然后访问其所有未访问过的邻接顶点,再按顺序访问这些邻接顶点的邻接顶点,以此类推。
- 数据结构 : 队列。用于存储已被访问但其邻接点尚未被检查的顶点。
- 算法步骤 :
- 将起始顶点标记为已访问,并放入队列。
- 当队列不为空时:
- 从队列中取出一个顶点
v
。 - 访问
v
的所有未被访问过的邻接顶点,将它们标记为已访问并依次放入队列。
- 从队列中取出一个顶点
- 应用: 寻找无权图中的最短路径、检查图的连通性、社交网络中的"好友推荐"。
- 复杂度: 时间复杂度 O(V+E),空间复杂度 O(V)。
代码示例(BFS 伪代码):
java
def BFS(graph, start_vertex):
visited = set() # 记录已访问的顶点
queue = Queue() # 创建一个队列
visited.add(start_vertex)
queue.enqueue(start_vertex)
while not queue.is_empty():
current_vertex = queue.dequeue()
print(f"访问顶点:{current_vertex}") # 处理当前顶点
for neighbor in graph.adjacent_vertices_of(current_vertex):
if neighbor not in visited:
visited.add(neighbor)
queue.enqueue(neighbor)
2. 深度优先搜索(DFS)
- 核心思想: "一条路走到黑"。从起始顶点出发,沿着一条路径尽可能深地探索,直到没有未访问的邻接顶点,然后回溯到上一个顶点,继续探索其他路径。
- 数据结构 : 栈(递归调用栈或显式栈)。体现了"后进先出"的回溯特性。
- 算法步骤(递归版本) :
- 访问当前顶点
v
,并将其标记为已访问。 - 遍历
v
的所有邻接顶点w
:- 如果
w
未被访问,则递归地执行 DFS(w
)。
- 如果
- 访问当前顶点
- 应用: 寻找路径、检测图中是否存在环、拓扑排序。
- 复杂度: 时间复杂度 O(V+E),空间复杂度 O(V)(主要取决于递归深度)。
代码示例(DFS 递归伪代码):
java
def DFS_recursive(graph, v, visited):
visited.add(v)
print(f"访问顶点:{v}") # 处理当前顶点
for w in graph.adjacent_vertices_of(v):
if w not in visited:
DFS_recursive(graph, w, visited)
# 初始化调用
visited = set()
DFS_recursive(graph, start_vertex, visited)
三、 最小生成树(MST)
在一个带权的、连通的、无向图 中,最小生成树是指一个无环的子图,它连接了图中所有的顶点,并且其所有边的权重之和最小。
应用场景: 要在多个城市之间铺设光缆,如何用最短的总线路连接所有城市(顶点)?这就是一个典型的最小生成树问题。
1. 普里姆算法(Prim's Algorithm)
- 核心思想 : 从任意一个顶点开始,一步步"生长"出一棵树。每次将连接当前生成树 与树外顶点的权重最小的边(以及该边对应的新顶点)加入到生成树中。
- 数据结构 : 优先队列(最小堆)。用于高效地找到当前可选的权重最小的边。
- 算法步骤 :
- 任选一个顶点作为起始点,加入生成树。
- 将所有连接生成树内顶点与树外顶点的边加入优先队列。
- 从队列中取出权重最小的边。如果这条边连接的另一个顶点不在生成树中,则将该边和顶点加入生成树。
- 重复步骤2和3,直到所有顶点都加入生成树。
2. 克鲁斯卡尔算法(Kruskal's Algorithm)
- 核心思想: 按权重从小到大考虑所有边,如果加入当前边不会与已选择的边构成环,则将其加入生成树。本质是并查集数据结构的完美应用。
- 数据结构 : 并查集。用于高效地判断两个顶点是否已经在同一连通分量(即加入边后是否会形成环)。
- 算法步骤 :
- 将图中所有边按权重从小到大排序。
- 初始化一个空的生成树。
- 遍历排序后的边:
- 如果当前边连接的两个顶点不在生成树的同一个连通分量中(即加入后不会形成环),则将该边加入生成树。
- 否则,跳过该边。
- 当生成树中有 V-1 条边时,算法结束。
对比:
- Prim算法是"顶点导向"的,适合稠密图(边多)。
- Kruskal算法是"边导向"的,适合稀疏图(边少)。
四、 拓扑排序
拓扑排序是针对有向无环图(DAG) 的一种线性序列。该序列需要满足:对于图中的每一条有向边 u -> v
,在序列中 u
都出现在 v
的前面。
应用场景: 课程选修顺序(必须先修完高数才能修线性代数)、任务调度、编译过程中的依赖解析。
核心思想(Kahn 算法,基于BFS):
- 统计入度: 计算图中每个顶点的入度。
- 初始化队列 : 将所有入度为0的顶点加入队列。这些顶点是"不依赖任何其他任务的任务"。
- 处理队列 :
- 从队列中取出一个顶点
u
,将其输出到拓扑序列中。 - 遍历
u
的所有邻接顶点v
,将v
的入度减1(相当于移除边u->v
)。 - 如果某个顶点
v
的入度减为0,则将其加入队列。
- 从队列中取出一个顶点
- 检查结果: 如果最终的拓扑序列包含了图中所有的顶点,则排序成功;如果序列中顶点数少于总顶点数,说明图中存在环,无法进行拓扑排序。
算法示例 :
假设有如下课程依赖图(A->B 表示修B前需先修A):
数学 -> 物理
数学 -> 编程
物理 -> 电子
编程 -> 电子
编程 -> 算法
一种可能的拓扑排序是:[数学, 物理, 编程, 电子, 算法]
或 [数学, 编程, 物理, 算法, 电子]
。
代码示例(拓扑排序 Kahn 算法伪代码):
java
def topological_sort(graph):
in_degree = {v: 0 for v in graph.vertices} # 初始化入度表
# 计算所有顶点的入度
for u in graph.vertices:
for v in graph.adjacent_vertices_of(u):
in_degree[v] += 1
queue = Queue()
# 将所有入度为0的顶点入队
for v in graph.vertices:
if in_degree[v] == 0:
queue.enqueue(v)
topo_order = []
while not queue.is_empty():
u = queue.dequeue()
topo_order.append(u)
for v in graph.adjacent_vertices_of(u):
in_degree[v] -= 1
if in_degree[v] == 0:
queue.enqueue(v)
if len(topo_order) != len(graph.vertices):
print("错误:图中存在环,无法进行拓扑排序!")
else:
return topo_order
总结
图是一种极其强大和灵活的数据结构,其相关算法是解决许多现实世界复杂问题的关键。
主题 | 核心思想 | 关键数据结构 | 应用场景 |
---|---|---|---|
BFS遍历 | 层层扩散,先广后深 | 队列 | 最短路径(无权)、连通性 |
DFS遍历 | 深度探索,回溯前进 | 栈(递归) | 路径查找、环检测 |
最小生成树 | 用最小代价连接所有点 | Prim:优先队列 / Kruskal:并查集+排序 | 网络搭建、电路设计 |
拓扑排序 | 为有向无环图安排顺序 | 队列、入度表 | 任务调度、依赖管理 |
理解这些经典图算法,不仅能帮助你在技术面试中游刃有余,更能为你提供解决复杂系统设计问题的强大工具箱。