1. 从“点”与“线”开始:为什么我们需要图?
如果你已经熟悉了链表、栈、队列、树这些数据结构,可能会觉得它们已经能解决大部分问题了。链表处理线性关系,树处理层次关系,那什么时候会用到图呢?想象一下你手机里的地图导航,它要处理无数个路口(点)和道路(线),并为你找出最短或最快的路径;再想想微信好友关系,你和你的朋友是“点”,你们之间的好友关系是“线”,整个社交网络就是一个巨大的图;又或者,在项目管理中,任务A必须在任务B完成后才能开始,这种依赖关系也可以用图来表示。这些场景的共同特点是:数据元素之间的关系是多对多的、错综复杂的,无法简单地用线性或树形结构来刻画。这就是图(Graph)数据结构存在的意义。
在Java面试中,尤其是中高级岗位,图相关的问题几乎是必考项。从基础的图遍历(深度优先DFS、广度优先BFS),到最短路径算法(Dijkstra, Floyd),再到拓扑排序、最小生成树,面试官通过这些问题,考察的不仅仅是你对数据结构的记忆,更是你对复杂问题建模和抽象的能力。很多朋友在刷LeetCode时,遇到“岛屿数量”、“课程表”、“网络延迟时间”这类题目感到棘手,其根源往往是对图的表示和基本操作不熟练。今天,我们就抛开那些枯燥的理论书,直接动手,用Java从零开始实现一个实用的图数据结构,并探讨其核心操作。我会结合我这些年面试别人和被面试的经验,把那些容易踩坑的细节和性能优化的关键点都讲清楚。
2. 图的两种核心表示法:邻接矩阵与邻接表
实现图的第一步,是决定如何在计算机内存中表示它。这直接决定了后续所有操作的效率和实现的复杂度。主流有两种方法:邻接矩阵和邻接表。选择哪一种,取决于你的图是“稠密”还是“稀疏”。
2.1 邻接矩阵:用二维数组直白地表示连接
邻接矩阵的思想非常简单粗暴。假设我们有
n
个顶点,我们就用一个
n x n
的二维数组(在Java里就是二维
int
或
boolean
数组)
matrix
来表示。如果顶点
i
和顶点
j
之间有一条边,那么就把
matrix[i][j]
设为
1
(或者边的权重值);如果没有边,就设为
0
(或一个特殊值如
Integer.MAX_VALUE
)。
public class GraphWithMatrix {
private int[][] adjacencyMatrix;
private int numVertices;
// 构造函数,初始化一个n个顶点的图,无边
public GraphWithMatrix(int n) {
this.numVertices = n;
adjacencyMatrix = new int[n][n];
// 初始化,所有边都不存在,这里用0表示无边,用特定值(如Integer.MAX_VALUE)表示无穷大在有权图中更常见
for (int i = 0; i < n; i++) {
Arrays.fill(adjacencyMatrix[i], 0); // 无权图
// 对于有权图,通常初始化对角线为0,其他为INF
// for (int j = 0; j < n; j++) {
// adjacencyMatrix[i][j] = (i == j) ? 0 : Integer.MAX_VALUE;
// }
}
}
// 添加一条从v到w的边(无权图)
public void addEdge(int v, int w) {
if (v >= 0 && v < numVertices && w >= 0 && w < numVertices) {
adjacencyMatrix[v][w] = 1;
// 如果是无向图,还需要对称地添加
// adjacencyMatrix[w][v] = 1;
}
}
// 检查从v到w是否有边
public boolean hasEdge(int v, int w) {
if (v >= 0 && v < numVertices && w >= 0 && w < numVertices) {
return adjacencyMatrix[v][w] == 1;
}
return false;
}
}
邻接矩阵的优缺点分析:
-
优点:
- 直观,实现简单 :检查任意两个顶点间是否存在边,时间复杂度是 O(1),只需要一次数组访问。
- 对于 稠密图 (边数接近顶点数的平方)来说,空间利用率高。
-
缺点:
- 空间复杂度高 :O(V²),其中V是顶点数。对于顶点数上万,但每个顶点只连接少数几个邻居的稀疏图(比如社交网络),这会造成巨大的空间浪费。一个10万个顶点的图,矩阵就需要100亿个存储单元,绝大部分是0。
- 添加/删除顶点操作昂贵 :需要重新创建并拷贝整个矩阵。
- 遍历一个顶点的所有邻居效率低 :需要扫描一整行,即使该顶点只有一两个邻居,也需要检查V次。
踩坑提示 :在面试中,如果面试官描述的场景是“顶点很多,但每个顶点的连接数有限”(例如城市道路网,每个路口通常只连接3-4条路),你首先应该想到邻接表,而不是邻接矩阵。直接回答用邻接矩阵可能会被追问空间复杂度问题。
2.2 邻接表:用“数组+链表”高效存储稀疏连接
邻接表是更常用、更灵活的表示方法。它维护一个大小为V的数组(或列表),数组的每个位置
i
对应顶点
i
,而这个位置存储的是一个链表(或
ArrayList
),这个链表里存放了所有与顶点
i
直接相连的邻居顶点。
import java.util.LinkedList;
public class GraphWithAdjList {
private int numVertices;
private LinkedList<Integer>[] adjacencyList;
// 构造函数
@SuppressWarnings("unchecked")
public GraphWithAdjList(int n) {
this.numVertices = n;
adjacencyList = new LinkedList[n];
for (int i = 0; i < n; i++) {
adjacencyList[i] = new LinkedList<>();
}
}
// 添加一条从v到w的边(有向图)
public void addEdge(int v, int w) {
if (v >= 0 && v < numVertices && w >= 0 && w < numVertices) {
adjacencyList[v].add(w);
// 如果是无向图,需要添加两条边
// adjacencyList[w].add(v);
}
}
// 获取顶点v的所有邻居
public Iterable<Integer> getNeighbors(int v) {
if (v >= 0 && v < numVertices) {
return adjacencyList[v];
}
return new LinkedList<>(); // 返回空列表
}
// 打印图的邻接表
public void printGraph() {
for (int v = 0; v < numVertices; v++) {
System.out.print("Vertex " + v + ": ");
for (Integer neighbor : adjacencyList[v]) {
System.out.print(neighbor + " ");
}
System.out.println();
}
}
}
邻接表的优缺点分析:
-
优点:
- 空间效率高 :对于稀疏图,空间复杂度是 O(V + E),其中E是边数。这比邻接矩阵的 O(V²) 好得多。
- 遍历一个顶点的所有邻居效率高 :直接遍历对应的链表即可,时间复杂度与该顶点的度(邻居数)成正比。
-
易于添加顶点
:在数组末尾添加新链表即可(如果使用
ArrayList存储顶点列表)。
-
缺点:
-
检查任意两个顶点间是否有边效率较低
:需要遍历其中一个顶点的邻居链表,最坏情况 O(V)。(可以通过将链表换成
HashSet来优化到平均 O(1),但牺牲一些空间和顺序)。 - 实现比邻接矩阵稍复杂。
-
检查任意两个顶点间是否有边效率较低
:需要遍历其中一个顶点的邻居链表,最坏情况 O(V)。(可以通过将链表换成
如何选择?
- 邻接矩阵 :适用于稠密图,或者需要频繁检查任意两点间是否存在边的场景。
- 邻接表 :适用于绝大多数场景,尤其是稀疏图。这也是算法竞赛和实际工程中最主流的选择。
在接下来的实现中,我们将基于 邻接表 来构建一个功能更完整的图类,因为它更通用,也更符合面试和实际应用的需求。
3. 构建一个功能完整的图类:支持有向/无向、带权/无权
一个健壮的图类不能只支持一种类型的图。我们需要考虑图是否有方向(有向图/无向图),边是否有权重(带权图/无权图)。我们将设计一个通用的
Graph
类,使用邻接表作为底层存储,并通过泛型和内部类来增强其灵活性。
3.1 定义顶点和边
首先,我们不再简单地把顶点当作整数索引。虽然用索引(0, 1, 2...)在算法中很方便,但真实的顶点可能是一个字符串(城市名)、一个对象(用户)。我们可以用泛型
V
来表示顶点类型。同时,我们用一个内部类
Edge
来封装一条边的信息,特别是权重。
import java.util.*;
public class Graph<V> {
// 内部类:表示一条边
private class Edge {
V from; // 起点(在有向图中明确)
V to; // 终点
int weight; // 权重,对于无权图可以默认为1或忽略
Edge(V from, V to, int weight) {
this.from = from;
this.to = to;
this.weight = weight;
}
// 重写equals和hashCode,便于在集合中比较
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Edge edge = (Edge) o;
return weight == edge.weight &&
Objects.equals(from, edge.from) &&
Objects.equals(to, edge.to);
}
@Override
public int hashCode() {
return Objects.hash(from, to, weight);
}
}
// 核心数据结构:邻接表
// Map的Key是顶点,Value是该顶点出发的所有边(对于无向图,一条边会存储在两个顶点的列表中)
private Map<V, List<Edge>> adjacencyList;
private boolean isDirected; // 标识是否为有向图
// 构造函数
public Graph(boolean isDirected) {
this.adjacencyList = new HashMap<>();
this.isDirected = isDirected;
}
public Graph() {
this(false); // 默认创建无向图
}
}
3.2 实现核心操作方法
接下来,我们实现添加顶点、添加边、获取邻居等核心方法。这里的关键是处理有向图和无向图的区别。
// 添加一个顶点
public void addVertex(V vertex) {
if (!adjacencyList.containsKey(vertex)) {
adjacencyList.put(vertex, new ArrayList<>());
}
// 如果顶点已存在,什么也不做(或者可以抛出异常,根据需求)
}
// 添加一条边(无权,默认权重为1)
public void addEdge(V from, V to) {
addEdge(from, to, 1);
}
// 添加一条带权重的边
public void addEdge(V from, V to, int weight) {
// 确保顶点存在
addVertex(from);
addVertex(to);
Edge edge = new Edge(from, to, weight);
adjacencyList.get(from).add(edge);
// 如果是无向图,需要添加反向边
if (!isDirected) {
Edge reverseEdge = new Edge(to, from, weight); // 注意from和to调换
adjacencyList.get(to).add(reverseEdge);
}
}
// 获取从某个顶点出发的所有边
public List<Edge> getEdgesFrom(V vertex) {
return adjacencyList.getOrDefault(vertex, new ArrayList<>());
}
// 获取某个顶点的所有邻居顶点(对于带权图,此方法只返回顶点,不包含权重信息)
public List<V> getNeighbors(V vertex) {
List<V> neighbors = new ArrayList<>();
for (Edge edge : getEdgesFrom(vertex)) {
neighbors.add(edge.to);
}
return neighbors;
}
// 获取图中所有顶点
public Set<V> getVertices() {
return adjacencyList.keySet();
}
// 获取图中所有边(注意:对于无向图,每条边会被返回两次)
public List<Edge> getAllEdges() {
List<Edge> allEdges = new ArrayList<>();
for (List<Edge> edges : adjacencyList.values()) {
allEdges.addAll(edges);
}
return allEdges;
}
// 检查图中是否存在某个顶点
public boolean hasVertex(V vertex) {
return adjacencyList.containsKey(vertex);
}
// 检查从from到to是否存在边(对于无向图,双向都算)
public boolean hasEdge(V from, V to) {
if (!hasVertex(from) || !hasVertex(to)) {
return false;
}
for (Edge edge : getEdgesFrom(from)) {
if (edge.to.equals(to)) {
return true;
}
}
return false;
}
实操心得 :在实现
addEdge时,对于无向图添加反向边,我强烈建议创建一个新的Edge对象,而不是复用原来的边对象并简单调换from和to。虽然从逻辑上看,无向边是一条边,但在邻接表表示法中,从顶点A的列表和顶点B的列表分别引用同一个边对象,可能会在后续修改边权重时带来意想不到的副作用(修改一个,另一个也变了)。创建新对象虽然消耗一点内存,但保证了数据的独立性和安全性,这是工程中更稳妥的做法。
4. 图的遍历算法:深度优先搜索与广度优先搜索
遍历是图算法的基础,几乎所有复杂的图算法都建立在遍历之上。两种最经典的遍历方式是深度优先搜索(DFS)和广度优先搜索(BFS)。它们的核心区别在于探索新节点的策略:DFS像探险家,一条路走到黑,再回溯;BFS像雷达波,一层一层向外扩散。
4.1 深度优先搜索:递归与栈的视角
DFS的思路是,从起点开始,沿着一条路径尽可能深地探索,直到尽头,然后回溯到上一个分叉点,选择另一条未探索的路径继续。实现DFS有两种等价的方式:递归和显式使用栈。
递归实现(最直观):
// 递归实现的DFS
public void dfsRecursive(V startVertex) {
if (!hasVertex(startVertex)) return;
Set<V> visited = new HashSet<>();
dfsHelper(startVertex, visited);
}
private void dfsHelper(V current, Set<V> visited) {
// 访问当前顶点
System.out.print(current + " ");
visited.add(current);
// 递归访问所有未访问过的邻居
for (Edge edge : getEdgesFrom(current)) {
V neighbor = edge.to;
if (!visited.contains(neighbor)) {
dfsHelper(neighbor, visited);
}
}
}
栈实现(手动模拟递归栈):
// 使用栈实现的DFS(非递归)
public void dfsWithStack(V startVertex) {
if (!hasVertex(startVertex)) return;
Set<V> visited = new HashSet<>();
Deque<V> stack = new ArrayDeque<>(); // 使用双端队列作为栈
stack.push(startVertex);
// 也可以在入栈时标记访问,这里采用出栈时标记,两种方式都可,但需注意重复入栈问题
// visited.add(startVertex); // 方式一:入栈即标记
while (!stack.isEmpty()) {
V current = stack.pop();
// 方式二:出栈时检查并标记
if (visited.contains(current)) {
continue; // 如果已经访问过,跳过
}
System.out.print(current + " ");
visited.add(current);
// 将邻居逆序入栈,保证遍历顺序与递归版近似(先入后出)
List<Edge> edges = getEdgesFrom(current);
// 为了与递归顺序一致,需要反向遍历邻居列表并入栈
for (int i = edges.size() - 1; i >= 0; i--) {
V neighbor = edges.get(i).to;
if (!visited.contains(neighbor)) {
stack.push(neighbor);
// 如果采用方式一,在这里标记 visited.add(neighbor);
}
}
}
}
注意事项 :非递归的栈实现中, “何时标记顶点为已访问” 是一个关键细节。上面代码展示了两种方式。方式二(出栈时标记)更直观,但可能导致同一个顶点被多次压入栈中(如果它有多个未访问的邻居都指向它)。方式一(入栈时标记)可以避免重复入栈,效率稍高,但访问顺序会略有不同。在解决诸如“寻找路径”等问题时,需要根据具体情况选择,并理解其差异。我个人的习惯是使用方式一,因为它更节省栈空间。
4.2 广度优先搜索:队列的应用
BFS的思路是,从起点开始,先访问所有距离为1的邻居,再访问所有距离为2的邻居,依此类推。它天然适合寻找最短路径(在无权图中)。实现BFS必须使用队列(Queue)。
// 使用队列实现的BFS
public void bfs(V startVertex) {
if (!hasVertex(startVertex)) return;
Set<V> visited = new HashSet<>();
Queue<V> queue = new LinkedList<>();
queue.offer(startVertex);
visited.add(startVertex); // BFS通常在入队时标记访问
while (!queue.isEmpty()) {
V current = queue.poll();
System.out.print(current + " "); // 处理当前顶点
// 遍历所有邻居
for (Edge edge : getEdgesFrom(current)) {
V neighbor = edge.to;
if (!visited.contains(neighbor)) {
queue.offer(neighbor);
visited.add(neighbor); // 入队即标记,防止重复入队
}
}
}
}
DFS vs BFS 应用场景对比:
| 特性 | 深度优先搜索 (DFS) | 广度优先搜索 (BFS) |
|---|---|---|
| 数据结构 | 栈 (递归调用栈或显式栈) | 队列 |
| 遍历顺序 | 纵深优先 | 层序优先 |
| 空间复杂度 | O(h),h为递归深度/图的最大深度,通常小于顶点数。 | O(w),w为图的最大宽度,在最坏情况下(如完全图)接近顶点数。 |
| 经典应用 | 拓扑排序、寻找连通分量、解决迷宫问题(找到一条路径)、判断图中是否有环。 | 无权图的最短路径 、社交网络中查找“度”关系(如三度人脉)、广播网络(如洪水填充)。 |
| 类比 | 走迷宫,右手扶墙法。 | 水波纹扩散。 |
面试高频考点 :面试官常会问“用BFS求二叉树的最小深度”,其本质就是图BFS的应用。在图论中,BFS第一次访问到目标节点时所经过的层数(或步数),就是起点到该节点的最短路径长度(在无权图中)。这个性质非常重要。
4.3 处理不连通图与记录遍历路径
上面的遍历示例假设图是连通的(从起点能到达所有顶点)。对于不连通图,我们需要用一个外层循环来确保每个顶点都被访问到。
// 遍历整个图(包括所有连通分量)
public void traverseAllDFS() {
Set<V> visited = new HashSet<>();
for (V vertex : getVertices()) {
if (!visited.contains(vertex)) {
System.out.print("新的连通分量: ");
dfsHelper(vertex, visited); // 调用之前的递归辅助函数
System.out.println();
}
}
}
有时我们不仅需要遍历,还需要记录从起点到某个节点的路径。这可以通过在DFS/BFS过程中维护一个
parent
映射来实现。
// BFS 并记录路径(反向)
public List<V> bfsWithPath(V start, V target) {
if (!hasVertex(start) || !hasVertex(target)) return null;
Map<V, V> parent = new HashMap<>(); // 记录每个节点的前驱节点
Queue<V> queue = new LinkedList<>();
Set<V> visited = new HashSet<>();
queue.offer(start);
visited.add(start);
parent.put(start, null); // 起点的前驱是null
while (!queue.isEmpty()) {
V current = queue.poll();
if (current.equals(target)) {
// 找到目标,回溯构建路径
return buildPath(parent, target);
}
for (Edge edge : getEdgesFrom(current)) {
V neighbor = edge.to;
if (!visited.contains(neighbor)) {
queue.offer(neighbor);
visited.add(neighbor);
parent.put(neighbor, current); // 记录邻居的前驱是当前节点
}
}
}
return null; // 没有路径
}
private List<V> buildPath(Map<V, V> parent, V target) {
LinkedList<V> path = new LinkedList<>();
V step = target;
while (step != null) {
path.addFirst(step); // 反向添加,构建从起点到终点的路径
step = parent.get(step);
}
return path;
}
这个
bfsWithPath
方法就是解决“单词接龙”、“打开转盘锁”这类LeetCode题目的核心。理解并熟练实现这个带路径记录的BFS模板,能帮你解决一大类图搜索问题。

2475

被折叠的 条评论
为什么被折叠?



