Java图数据结构实现:邻接表与邻接矩阵详解及DFS/BFS遍历

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;
    }
}

邻接矩阵的优缺点分析:

  • 优点:
    1. 直观,实现简单 :检查任意两个顶点间是否存在边,时间复杂度是 O(1),只需要一次数组访问。
    2. 对于 稠密图 (边数接近顶点数的平方)来说,空间利用率高。
  • 缺点:
    1. 空间复杂度高 :O(V²),其中V是顶点数。对于顶点数上万,但每个顶点只连接少数几个邻居的稀疏图(比如社交网络),这会造成巨大的空间浪费。一个10万个顶点的图,矩阵就需要100亿个存储单元,绝大部分是0。
    2. 添加/删除顶点操作昂贵 :需要重新创建并拷贝整个矩阵。
    3. 遍历一个顶点的所有邻居效率低 :需要扫描一整行,即使该顶点只有一两个邻居,也需要检查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();
        }
    }
}

邻接表的优缺点分析:

  • 优点:
    1. 空间效率高 :对于稀疏图,空间复杂度是 O(V + E),其中E是边数。这比邻接矩阵的 O(V²) 好得多。
    2. 遍历一个顶点的所有邻居效率高 :直接遍历对应的链表即可,时间复杂度与该顶点的度(邻居数)成正比。
    3. 易于添加顶点 :在数组末尾添加新链表即可(如果使用 ArrayList 存储顶点列表)。
  • 缺点:
    1. 检查任意两个顶点间是否有边效率较低 :需要遍历其中一个顶点的邻居链表,最坏情况 O(V)。(可以通过将链表换成 HashSet 来优化到平均 O(1),但牺牲一些空间和顺序)。
    2. 实现比邻接矩阵稍复杂。

如何选择?

  • 邻接矩阵 :适用于稠密图,或者需要频繁检查任意两点间是否存在边的场景。
  • 邻接表 :适用于绝大多数场景,尤其是稀疏图。这也是算法竞赛和实际工程中最主流的选择。

在接下来的实现中,我们将基于 邻接表 来构建一个功能更完整的图类,因为它更通用,也更符合面试和实际应用的需求。

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模板,能帮你解决一大类图搜索问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值