数据结构 —— 图

图(Graph)是一种比树更复杂的非线性数据结构,它由顶点(Vertex)边(Edge) 组成,用于描述事物之间的多对多关系(树仅能描述一对多的层级关系)。从社交网络的 "好友关系" 到地图的 "路线连接",从电路的 "元件连接" 到互联网的 "节点通信",图的应用贯穿生活与技术的方方面面,核心价值是 "建模复杂关联关系并支持高效查询"。

一、图中的基本概念

  • 顶点(Vertex):图中的核心元素,也称为 "节点",用于表示一个实体(如社交网络中的用户、地图中的地点)。
  • 边(Edge):连接两个顶点的线,用于表示顶点之间的关系(如社交网络中的 "好友"、地图中的 "道路")。
  • 邻接(Adjacent):若两个顶点之间存在边,则称这两个顶点互为邻接顶点(如顶点 A 和 B 之间有边,则 A 邻接于 B,B 邻接于 A)。
  • 度(Degree) :一个顶点拥有的边的数量。在有向图中,度分为入度(In-degree,指向该顶点的边数)出度(Out-degree,从该顶点出发的边数)

在具有 n 个顶点、e 条边的无向图中,始终有 总度数 = 2e,即无向图的全部顶点的度的和等于边数的 2 倍,因为每条变和两个顶点相关联。

对于有向图,始终有 总度数 = 入度 + 出度;在具有 n 个顶点、e 条边的有向图中,有向图的全部顶点的入度之和与出度之和相等,并且等于边数。

  • **简单图:**一个图 G 若满足:①不存在重复边;②不存在顶点到自身的边,则称图G 为简单图。
  • **多重图:**若图G中某两个结点之间的边数多于一条,又允许顶点通过同一条边和自己关联,则G为多重图。
  • **完全图:**对于无向图,∣ E ∣的取值范围是0到n ( n − 1 ) / 2 ,有n ( n − 1 ) / 2条边的无向图称为完全图,在完全图中任意两个顶点之间都存在边。对于有向图,∣ E ∣的取值范围是0到n ( n − 1 ) ,有n ( n − 1 ) 条弧的有向图称为有向完全图,在有向完全图中任意两个顶点之间都存在方向相反的两条弧。
  • **子图:**设有两个图G = ( V , E )和G ′ = ( V ′ , E ′ ) , 若V ′ 是V的子集,且E ′ 是E的子集,则称G ′ 是G的子图。若有满足V ( G ′ ) = V ( G )的子图G ′ ,则称其为G的生成子图。
  • 路径(Path):从一个顶点到另一个顶点的边的序列(如顶点 A→B→C,路径长度为 2,即边的数量)。
  • 路径长度: 路径上边的数目称为路径长度
  • 环(Cycle):起点和终点相同的路径(如顶点 A→B→C→A,形成一个环);无环的图称为 "无环图"。若一个图有n 个顶点,并且有大于n − 1条边,则此图一定有环。
  • 权重(Weight):边的附加信息,用于表示关系的 "代价" 或 "强度"(如地图中道路的长度、社交网络中好友的亲密度)
  • 网: 边上带有权值的图称为带权图 ,也称网。
  • **稠密图、稀疏图:**边数很少的图称为稀疏图,反之称为稠密图。稀疏和稠密本身是模糊的概念,稀疏图和稠密图常常是相对而言的。一般当图G满足∣ E ∣ < ∣V∣ log∣V∣ 时,可以将G视为稀疏图。
  • **简单路径:**在路径序列中,顶点不重复出现的路径称为简单路径。
  • **简单回路:**除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。
  • **距离:**从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离。若从u到v根本不存在路径,则记该距离为无穷( ∞ )。
  • **有向树:**一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树。
  • **生成树:**连通图的生成树是包含图中全部顶点的一个极小连通子图。若图中顶点数为n,则它的生成树含有n−1条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。
  • **生成森林:**在非连通图中,连通分量的生成树构成了非连通图的生成森林。图的一个生成树如下图所示。

注意:包含无向图中全部顶点的极小连通子图,只有生成树满足条件,因为砍去生成树的任一条边,图将不再连通。

  • **连通、连通图、连通分量:**在无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的。若图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图。无向图中的极大连通子图称为连通分量。若一个图有n个顶点,并且边数小于n-1,则此图必是非连通图。如下图(a)所示, 图G4有3个连通分量,如图(b)所示。

注意:弄清连通、连通图、连通分量的概念非常重要。首先要区分极大连通子图和极小连通子图,极大连通子图是无向图的连通分量,极大即要求该连通子图包含其所有的边;极小连通子图是既要保持图连通又要使得边数最少的子图。

  • 强连通图、强连通分量: 在有向图中,若从顶点v到顶点w和从顶点w到项点v之间都有路径,则称这两个顶点是强连通的。若图中任何一对顶点都是强连通的,则称此图为强连通图。有向图中的极大强连通子图称为有向图的强连通分量,图G1的强连通分量如下图所示。

注意:强连通图、强连通分量只是针对有向图而言的。一般在无向图中讨论连通性,在有向图中考虑强连通性。

二、图的分类

|----------|-------------------------|----------------------------------------------|------------------|
| 分类维度 | 具体类型 | 核心特点 | 示例场景 |
| 边是否有方向 | 无向图(Undirected Graph) | 边无方向,若 A、B 存在,则 A 可到 B、B 也可到 A (边用(A,B)表示) | 社交网络的好友关系、无向道路 |
| | 有向图(Directed Graph) | 边有方向,若 A→B 存在,不代表 B→A 存在(边用<A,B>表示) | 互联网的 URL 跳转、任务依赖 |
| 边是否有权重 | 无权图(Unweighted Graph) | 边无附加信息,仅表示 "是否关联" | 社交网络的关注关系 |
| | 加权图(Weighted Graph) | 边有权重,用于表示 "关联的代 价"(也称为 "网") | 地图的路线长度、物流成本 |
| 顶点是否连通 | 连通图(Connected Graph) | 无向图中任意两个顶点都存在路径(有向图中称为 "强连通图",需双向可达) | 完整的城市交通网 |
| | 非连通(Disconnected Graph) | 存在至少两个顶点无路径连通,图由多个 "连通分量" 组成 | 多个独立的局域网 |
| 其他特殊类型 | 树(Tree) | 无环的连通图(边数 = 顶点数 - 1),是图的特殊子集 | 组织架构、文件系统 |
| | 有向无环图(DAG) | 无环的有向图,常用于 "拓扑排 序" | 项目任务调度、课程依赖关系 |

三、图的存储方式

图的存储需高效表达 "顶点集合" 和 "边的关联关系",常见的存储方式有两种:邻接矩阵(Adjacency Matrix)邻接表(Adjacency List),二者在空间复杂度和时间复杂度上各有优劣。

一、邻接矩阵

图的邻接矩阵存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。

1.注意:

①在简单应用中,可直接用二维数组作为图的邻接矩阵(顶点信息等均可省略)。

②当邻接矩阵中的元素仅表示相应的边是否存在时,EdgeType可定义为值为0和1的枚举类型。

③无向图的邻接矩阵是对称矩阵,对规模特大的邻接矩阵可采用压缩存储。

④邻接矩阵表示法的空间复杂度为O ( n^2 ), 其中n为图的顶点数∣ V ∣。

⑤ 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有多少条边,则必须按行、按列对每个元素进行检测,所花费的时间代价很大。

⑥ 稠密图适合使用邻接矩阵的存储表示。

可以看出:

  1. 无向图的邻接矩阵一定是一个对称矩阵(即从矩阵的左上角到右下角的主对角线为轴,右上角的元与左下角相对应的元全都是相等的)。 因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素。

  2. 对于无向图,邻接矩阵的第i 行(或第i 列)非零元素(或非∞元素)的个数正好是第i 个顶点的度TD ( v i )。比如顶点v1的度就是1 + 0 + 1 + 0 = 2 。

3.对于有向图,邻接矩阵不一定会对称;邻接矩阵的第i 行和第i 列非零元素的和为该顶点的度。

  1. 求顶点vi 的所有邻接点就是将矩阵中第i行元素扫描一遍,A [ i ] [ j ] 为 1就是邻接点。

2.具体代码实现:

java 复制代码
public class GraphMatrix {
    private int vertexCount; // 顶点数量
    private int[][] matrix;   // 邻接矩阵
    private static final int INF = Integer.MAX_VALUE; // 表示无边

    // 构造函数:初始化顶点数和邻接矩阵
    public GraphMatrix(int vertexCount) {
        this.vertexCount = vertexCount;
        matrix = new int[vertexCount][vertexCount];
        // 初始化矩阵:对角线为0(自身无连接),其他为INF(无边)
        for (int i = 0; i < vertexCount; i++) {
            for (int j = 0; j < vertexCount; j++) {
                if (i == j) matrix[i][j] = 0;
                else matrix[i][j] = INF;
            }
        }
    }

    // 添加边(无向加权图)
    public void addEdge(int from, int to, int weight) {
        if (from < 0 || from >= vertexCount || to < 0 || to >= vertexCount) {
            throw new IllegalArgumentException("顶点索引无效");
        }
        matrix[from][to] = weight;
        matrix[to][from] = weight; // 无向图:双向边
    }

    // 打印邻接矩阵
    public void printMatrix() {
        for (int i = 0; i < vertexCount; i++) {
            for (int j = 0; j < vertexCount; j++) {
                if (matrix[i][j] == INF) {
                    System.out.print("∞ ");
                } else {
                    System.out.print(matrix[i][j] + " ");
                }
            }
            System.out.println();
        }
    }

    // 测试
    public static void main(String[] args) {
        GraphMatrix graph = new GraphMatrix(3);
        graph.addEdge(0, 1, 2);
        graph.addEdge(0, 2, 5);
        graph.addEdge(1, 2, 1);
        graph.printMatrix();
        // 输出:
        // 0 2 5 
        // 2 0 1 
        // 5 1 0 
    }
}

二、邻接表

所谓邻接表,是指对图G 中的每个顶点vi 建立一个单链表,第i 个单链表中的结点表示依附于顶点vi 的边(对于有向图则是以顶点vi 为尾的弧),这个单链表就称为顶点vi 的边表(对于有向图则称为出边表)。

1.注意:

边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点表结点和边表结点。

邻接表是用 "数组 + 链表(或集合)" 存储图的方式:

  • 用一个数组(或列表)存储所有顶点,数组索引对应顶点编号;
  • 每个顶点对应一个 "邻接列表",存储该顶点的所有邻接顶点(及边的权重,若为加权图)。
  • 适用于元素比较少的稀疏图。

另外,对于带权值的网图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可。

2.具体代码实现:

java 复制代码
// 边的实体类:存储邻接顶点和边的权重
class Edge {
    int to;     // 邻接顶点的编号
    int weight; // 边的权重

    public Edge(int to, int weight) {
        this.to = to;
        this.weight = weight;
    }

    @Override
    public String toString() {
        return "(" + to + ", " + weight + ")";
    }
}
java 复制代码
import java.util.ArrayList;
import java.util.List;

public class GraphList {
    private int vertexCount; // 顶点数量
    private List<List<Edge>> adjList; // 邻接表:索引=顶点,值=邻接边列表

    // 构造函数:初始化顶点数和邻接表
    public GraphList(int vertexCount) {
        this.vertexCount = vertexCount;
        adjList = new ArrayList<>(vertexCount);
        // 为每个顶点初始化空的邻接列表
        for (int i = 0; i < vertexCount; i++) {
            adjList.add(new ArrayList<>());
        }
    }

    // 添加边(无向加权图)
    public void addEdge(int from, int to, int weight) {
        if (from < 0 || from >= vertexCount || to < 0 || to >= vertexCount) {
            throw new IllegalArgumentException("顶点索引无效");
        }
        adjList.get(from).add(new Edge(to, weight));
        adjList.get(to).add(new Edge(from, weight)); // 无向图:双向添加
    }

    // 打印邻接表
    public void printAdjList() {
        for (int i = 0; i < vertexCount; i++) {
            System.out.print("顶点" + i + "的邻接列表:" + adjList.get(i));
            System.out.println();
        }
    }

    // 测试
    public static void main(String[] args) {
        GraphList graph = new GraphList(3);
        graph.addEdge(0, 1, 2);
        graph.addEdge(0, 2, 5);
        graph.addEdge(1, 2, 1);
        graph.printAdjList();
        // 输出:
        // 顶点0的邻接列表:[(1, 2), (2, 5)]
        // 顶点1的邻接列表:[(0, 2), (2, 1)]
        // 顶点2的邻接列表:[(0, 5), (1, 1)]
    }
}

四、图的遍历

图的遍历是和树的遍历类似,我们希望从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次, 这一过程就叫做图的遍历(Traversing Graph)。

对于图的遍历来,通常有两种遍历次序方案:它们是深度优先搜索(DFS)和广度优先搜索(BFS)。

一、深度优先遍历(Depth First Search)

DFS 的核心思想是 "递归 / 栈":从起点出发,优先访问当前顶点的邻接顶点,直到无法继续深入,再回溯到上一顶点,选择其他未访问的邻接顶点继续深入(类似 "迷宫探索")。

1.深度搜索过程

深度优先搜索类似于树的先序遍历。如其名称中所暗含的意思一样,这种搜索算法所遵循的搜索策略是尽可能"深"地搜索一个图。它的基本思想如下:首先访问图中某一起始顶点v,然后由v出发,访问与v邻接且未被访问的任一顶点w1,再访问与w1邻接且未被访问的任一顶点...重复上述过程。当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直至图中所有顶点均被访问过为止。

2.算法实现(递归实现)

  1. 初始化一个visited数组,标记顶点是否已访问;
  2. 从起点start出发,标记start为已访问;
  3. 遍历start的所有邻接顶点,若未访问,则递归调用 DFS 访问该顶点。

3. 代码实现:基于 GraphList 类扩展

java 复制代码
// DFS:递归实现,从start顶点开始遍历
public void dfs(int start, boolean[] visited) {
    // 标记当前顶点为已访问
    visited[start] = true;
    System.out.print(start + " "); // 访问顶点(此处为打印,实际可替换为业务逻辑)

    // 遍历当前顶点的所有邻接顶点
    for (Edge edge : adjList.get(start)) {
        int neighbor = edge.to;
        if (!visited[neighbor]) { // 若邻接顶点未访问,递归遍历
            dfs(neighbor, visited);
        }
    }
}

// 对外提供的DFS方法(初始化visited数组)
public void dfs(int start) {
    if (start < 0 || start >= vertexCount) {
        throw new IllegalArgumentException("起点索引无效");
    }
    boolean[] visited = new boolean[vertexCount]; // 初始均为false
    dfs(start, visited);
}

// 测试DFS:从顶点0开始遍历
public static void main(String[] args) {
    GraphList graph = new GraphList(4);
    graph.addEdge(0, 1, 1);
    graph.addEdge(0, 2, 1);
    graph.addEdge(1, 3, 1);
    graph.addEdge(2, 3, 1);
    System.out.print("DFS遍历结果(从0开始):");
    graph.dfs(0); // 输出:0 1 3 2(或0 2 3 1,取决于邻接列表顺序)
}

非递归实现(栈)

递归实现可能因图深度过大导致栈溢出,可改用 "栈" 模拟递归过程:

java 复制代码
// DFS:非递归实现(栈)
public void dfsNonRecursive(int start) {
    if (start < 0 || start >= vertexCount) {
        throw new IllegalArgumentException("起点索引无效");
    }
    boolean[] visited = new boolean[vertexCount];
    Stack<Integer> stack = new Stack<>();

    // 起点入栈并标记
    stack.push(start);
    visited[start] = true;

    while (!stack.isEmpty()) {
        int current = stack.pop(); // 弹出当前顶点
        System.out.print(current + " "); // 访问顶点

        // 遍历邻接顶点(注意:栈是LIFO,为保持与递归一致的顺序,需逆序入栈)
        List<Edge> neighbors = adjList.get(current);
        for (int i = neighbors.size() - 1; i >= 0; i--) {
            int neighbor = neighbors.get(i).to;
            if (!visited[neighbor]) {
                visited[neighbor] = true;
                stack.push(neighbor);
            }
        }
    }
}

4.DFS 算法的性能分析

DFS算法是一个递归算法,需要借助一个递归工作栈,故其空间复杂度为O(V)。对于n个顶点e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此都需要O(V^2)的时间。而邻接表做存储结构时,找邻接点所需的时间取决于顶点和边的数量,所以是O(V+E)。 显然对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高。

对于有向图而言,由于它只是对通道存在可行或不可行,算法上没有变化,是完全可以通用的。

5.DFS 应用场景

  • 连通性判断(如判断两个顶点是否可达);
  • 拓扑排序(有向无环图 DAG);
  • 迷宫求解、路径查找(如深度优先的路径探索)。

6.深度优先的生成树和生成森林

深度优先搜索会产生一棵深度优先生成树。 当然,这是有条件的,即对连通图调用DFS才能产生深度优先生成树,否则产生的将是深度优先生成森林,如下图所示。基于邻接表存储的深度优先生成树是不唯一的 。

二、广度优先搜索(Breadth First Search)

BFS 的核心思想是 "队列":从起点出发,先访问当前顶点的所有邻接顶点(一层),再依次访问这些邻接顶点的邻接顶点(下一层),类似 "水波扩散"。

1.广度搜索过程

**图的广度优先遍历就类似于树的层序遍历了。**广度优先搜索是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有往回退的情况,因此它不是一个递归的算法。为了实现逐层的访问,算法必须借助一个辅助队列,以记忆正在访问的顶点的下一层顶点。

2.算法实现(非递归实现)

  1. 初始化一个visited数组,标记顶点是否已访问;
  2. 从起点start出发,标记start为已访问,将其加入队列;
  3. 队列不为空时,弹出队首顶点,遍历其所有邻接顶点,若未访问,则标记并加入队列。

3.代码实现(基于 GraphList 类扩展)

java 复制代码
import java.util.LinkedList;
import java.util.Queue;

// BFS:非递归实现(队列)
public void bfs(int start) {
    if (start < 0 || start >= vertexCount) {
        throw new IllegalArgumentException("起点索引无效");
    }
    boolean[] visited = new boolean[vertexCount];
    Queue<Integer> queue = new LinkedList<>();

    // 起点入队并标记
    queue.offer(start);
    visited[start] = true;

    while (!queue.isEmpty()) {
        int current = queue.poll(); // 弹出队首顶点
        System.out.print(current + " "); // 访问顶点

        // 遍历当前顶点的所有邻接顶点
        for (Edge edge : adjList.get(current)) {
            int neighbor = edge.to;
            if (!visited[neighbor]) {
                visited[neighbor] = true;
                queue.offer(neighbor); // 未访问的邻接顶点入队
            }
        }
    }
}

// 测试BFS:从顶点0开始遍历
public static void main(String[] args) {
    GraphList graph = new GraphList(4);
    graph.addEdge(0, 1, 1);
    graph.addEdge(0, 2, 1);
    graph.addEdge(1, 3, 1);
    graph.addEdge(2, 3, 1);
    System.out.print("BFS遍历结果(从0开始):");
    graph.bfs(0); // 输出:0 1 2 3(或0 2 1 3,取决于邻接列表顺序)
}

4.BFS算法性能分析

无论是邻接表还是邻接矩阵的存储方式,BFS 算法都需要借助一个辅助队列Q, n个顶点均需入队一次,在最坏的情况下,空间复杂度为O(V)。

采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次),在搜索任一顶点的邻接点时,每条边至少访问一次,算法总的时间复杂度为O (V+E)。采用邻接矩阵存储方式时,查找每个顶点的邻接点所需的时间为O(V),故算法总的时间复杂度为O(V^2)。

5.BFS 应用场景

  • 最短路径查找(无向无权图中,BFS 的层数即为最短路径长度);
  • 层次遍历(如按 "距离起点的远近" 访问顶点);
  • 广播通信(如网络中的消息扩散)。

6.不同存储方式的特性

注意:图的邻接矩阵表示是唯一的,但对于邻接表来说,若边的输入次序不同,生成的邻接表也不同。因此,对于同样一个图,基于邻接矩阵的遍历所得到的DFS序列和BFS序列是唯一的,基于邻接表的遍历所得到的DFS序列和BFS序列是不唯一的。

三、DFS 与 BFS 对比

|----------|-------------------------|---------------------------|
| 对比维度 | DFS(深度优先) | BFS(广度优先) |
| 数据结构 | 递归栈(或手动栈) | 队列 |
| 访问顺序 | 深度优先,先深入再回溯 | 广度优先,先访问一层再下一层 |
| 空间复杂度 | O (h)(h 为图的深度,最坏 O (n)) | O (w)(w 为图的最大宽度,最坏 O (n)) |
| 最短路径 | 无向无权图中不保证找到最短路径 | 无向无权图中可找到最短路径 |
| 适用场景 | 连通性判断、拓扑排序、迷宫求解 | 最短路径、层次遍历、广播通信 |

相关推荐
代码游侠1 小时前
数据结构——线性表
linux·c语言·数据结构·学习·算法
潼心1412o1 小时前
数据结构(长期更新)第10讲:堆
数据结构
吃着火锅x唱着歌1 小时前
LeetCode 3371.识别数组中的最大异常值
数据结构·算法·leetcode
dringlestry1 小时前
B树的最大/最小高度
数据结构·b树
黎梨梨梨_1 小时前
双向链表的实现
数据结构·链表
量子炒饭大师1 小时前
【一天一个计算机知识】—— 【编程百度】悬空指针
c语言·数据结构·c++·git·安全·github·dubbo
吃着火锅x唱着歌1 小时前
LeetCode 624.数组列表中的最大距离
数据结构·算法·leetcode
fei_sun2 小时前
【数据结构】2019年真题
数据结构
Mz122110 小时前
day05 移动零、盛水最多的容器、三数之和
数据结构·算法·leetcode