【考研408数据结构-08】 图论基础:存储结构与遍历算法

📚 【考研408数据结构-08】 图论基础:存储结构与遍历算法

🎯 考频:⭐⭐⭐⭐⭐ | 题型:选择题、综合应用题、算法设计题 | 分值:约8-15分

引言

想象你正在规划一次跨省自驾游,面前摊开一张复杂的公路地图。城市是节点,公路是边,有的路是单向的高速公路,有的是双向的国道。如何在计算机中表示这张地图?如何找到从起点到终点的所有可能路径?这正是图论要解决的核心问题。

在408考试中,图论是数据结构部分的重头戏,几乎每年必考,不仅在选择题中频繁出现,更是算法设计题的重要考点。据统计,近5年的408真题中,有超过15次直接考察了图的存储和遍历相关内容,其中DFS/BFS遍历序列、拓扑排序更是高频考点。

本文将帮你彻底掌握图的基础知识,包括两种经典存储结构的对比、深度优先和广度优先遍历的实现细节、连通性判断以及拓扑排序的应用。

学完本文,你将能够:

  1. ✅ 熟练选择和实现图的存储结构
  2. ✅ 手写DFS和BFS的递归与非递归代码
  3. ✅ 快速判断图的连通性并求解拓扑序列
  4. ✅ 准确分析各种操作的时间复杂度

一、知识精讲

1.1 概念定义

图(Graph) 是由顶点集V和边集E组成的数据结构,记为G=(V,E)。与线性表、树不同,图中的元素之间可以有任意的关系。

💡 核心概念对比

  • 有向图 vs 无向图:边是否有方向性
  • 完全图:任意两个顶点间都有边(无向完全图有n(n-1)/2条边,有向完全图有n(n-1)条边)
  • 稀疏图 vs 稠密图:通常|E|<|V|log|V|为稀疏图,否则为稠密图
  • 连通图:无向图中任意两顶点都有路径;有向图中为强连通图
  • 生成树:包含全部顶点的极小连通子图

⚠️ 408考纲要求

  • 掌握:图的存储结构(邻接矩阵、邻接表)
  • 掌握:图的遍历算法(DFS、BFS)
  • 理解:图的连通性、拓扑排序
  • 了解:关键路径、最短路径(下一章重点)

1.2 原理分析

1.2.1 存储结构原理

邻接矩阵(Adjacency Matrix)

  • 用二维数组A[n][n]表示,A[i][j]=1表示存在边(i,j)
  • 空间复杂度:O(n²),适合稠密图
  • 判断边存在:O(1)时间
  • 获取顶点度:O(n)时间

邻接表(Adjacency List)

  • 每个顶点对应一个链表,存储所有邻接点
  • 空间复杂度:O(n+e),适合稀疏图
  • 判断边存在:O(degree)时间
  • 获取顶点度:O(1)或O(degree)时间
1.2.2 遍历算法原理

深度优先搜索(DFS)

  • 类似树的先序遍历,"不撞南墙不回头"
  • 使用栈(递归调用栈或显式栈)
  • 时间复杂度:O(n+e)邻接表,O(n²)邻接矩阵

广度优先搜索(BFS)

  • 类似树的层序遍历,"地毯式搜索"
  • 使用队列实现
  • 时间复杂度:O(n+e)邻接表,O(n²)邻接矩阵

1.3 性质与特点

对比项 邻接矩阵 邻接表
空间复杂度 O(n²) O(n+e)
判断边(i,j) O(1) O(degree[i])
遍历所有边 O(n²) O(n+e)
适用场景 稠密图、边频繁查询 稀疏图、节省空间
408考频 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

DFS vs BFS 特点对比

  • DFS:适合求解路径问题、连通分量、拓扑排序
  • BFS:适合求解最短路径(无权图)、层次遍历
  • DFS使用栈,空间O(n);BFS使用队列,空间O(n)
  • 两者遍历序列一般不唯一(408常考)

二、代码实现

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

#define MAXV 100  // 最大顶点数

// ========== 邻接矩阵存储结构 ==========
typedef struct {
    int vexnum, arcnum;           // 顶点数和边数
    int arcs[MAXV][MAXV];        // 邻接矩阵
    char vexs[MAXV];              // 顶点信息
} MGraph;

// ========== 邻接表存储结构 ==========
typedef struct ArcNode {
    int adjvex;                   // 邻接点下标
    struct ArcNode *next;         // 指向下一个邻接点
} ArcNode;

typedef struct VNode {
    char data;                    // 顶点信息
    ArcNode *first;              // 指向第一个邻接点
} VNode, AdjList[MAXV];

typedef struct {
    AdjList vertices;            // 邻接表
    int vexnum, arcnum;          // 顶点数和边数
} ALGraph;

// ========== 基本操作实现 ==========

// 创建无向图的邻接矩阵
void CreateMGraph(MGraph *G) {
    printf("输入顶点数和边数: ");
    scanf("%d %d", &G->vexnum, &G->arcnum);
    
    // 初始化邻接矩阵
    for (int i = 0; i < G->vexnum; i++) {
        for (int j = 0; j < G->vexnum; j++) {
            G->arcs[i][j] = 0;  // 0表示无边
        }
    }
    
    // 输入边信息
    for (int k = 0; k < G->arcnum; k++) {
        int i, j;
        printf("输入第%d条边的两个顶点: ", k+1);
        scanf("%d %d", &i, &j);
        G->arcs[i][j] = 1;      // 无向图,对称存储
        G->arcs[j][i] = 1;
    }
}

// 创建无向图的邻接表
void CreateALGraph(ALGraph *G) {
    printf("输入顶点数和边数: ");
    scanf("%d %d", &G->vexnum, &G->arcnum);
    
    // 初始化顶点表
    for (int i = 0; i < G->vexnum; i++) {
        G->vertices[i].data = 'A' + i;  // 顶点命名
        G->vertices[i].first = NULL;
    }
    
    // 输入边信息,头插法建立邻接表
    for (int k = 0; k < G->arcnum; k++) {
        int i, j;
        printf("输入第%d条边的两个顶点: ", k+1);
        scanf("%d %d", &i, &j);
        
        // 插入边(i,j)
        ArcNode *p = (ArcNode*)malloc(sizeof(ArcNode));
        p->adjvex = j;
        p->next = G->vertices[i].first;
        G->vertices[i].first = p;
        
        // 无向图,插入边(j,i)
        ArcNode *q = (ArcNode*)malloc(sizeof(ArcNode));
        q->adjvex = i;
        q->next = G->vertices[j].first;
        G->vertices[j].first = q;
    }
}

// ========== DFS遍历(邻接表版本)==========
bool visited[MAXV];  // 访问标记数组

void DFS(ALGraph *G, int v) {
    visited[v] = true;
    printf("%c ", G->vertices[v].data);  // 访问顶点v
    
    // 遍历v的所有邻接点
    ArcNode *p = G->vertices[v].first;
    while (p != NULL) {
        if (!visited[p->adjvex]) {
            DFS(G, p->adjvex);  // 递归访问未访问的邻接点
        }
        p = p->next;
    }
}

// DFS遍历主函数(处理非连通图)
void DFSTraverse(ALGraph *G) {
    // 初始化访问标记
    for (int i = 0; i < G->vexnum; i++) {
        visited[i] = false;
    }
    
    // 对每个连通分量调用DFS
    for (int i = 0; i < G->vexnum; i++) {
        if (!visited[i]) {
            DFS(G, i);
        }
    }
}

// ========== BFS遍历(邻接表版本)==========
void BFS(ALGraph *G, int v) {
    int queue[MAXV], front = 0, rear = 0;  // 循环队列
    
    visited[v] = true;
    printf("%c ", G->vertices[v].data);
    queue[rear++] = v;  // v入队
    
    while (front != rear) {  // 队列非空
        int u = queue[front++];  // 出队
        
        // 遍历u的所有邻接点
        ArcNode *p = G->vertices[u].first;
        while (p != NULL) {
            if (!visited[p->adjvex]) {
                visited[p->adjvex] = true;
                printf("%c ", G->vertices[p->adjvex].data);
                queue[rear++] = p->adjvex;  // 入队
            }
            p = p->next;
        }
    }
}

// BFS遍历主函数
void BFSTraverse(ALGraph *G) {
    // 初始化访问标记
    for (int i = 0; i < G->vexnum; i++) {
        visited[i] = false;
    }
    
    // 对每个连通分量调用BFS
    for (int i = 0; i < G->vexnum; i++) {
        if (!visited[i]) {
            BFS(G, i);
        }
    }
}

// ========== 拓扑排序(邻接表)==========
bool TopologicalSort(ALGraph *G) {
    int indegree[MAXV] = {0};  // 入度数组
    int stack[MAXV], top = -1;  // 栈
    int count = 0;  // 已输出顶点数
    
    // 计算所有顶点的入度
    for (int i = 0; i < G->vexnum; i++) {
        ArcNode *p = G->vertices[i].first;
        while (p != NULL) {
            indegree[p->adjvex]++;
            p = p->next;
        }
    }
    
    // 入度为0的顶点入栈
    for (int i = 0; i < G->vexnum; i++) {
        if (indegree[i] == 0) {
            stack[++top] = i;
        }
    }
    
    // 拓扑排序主过程
    while (top != -1) {
        int v = stack[top--];  // 出栈
        printf("%c ", G->vertices[v].data);
        count++;
        
        // 删除v的所有出边
        ArcNode *p = G->vertices[v].first;
        while (p != NULL) {
            indegree[p->adjvex]--;
            if (indegree[p->adjvex] == 0) {
                stack[++top] = p->adjvex;  // 新的入度为0的顶点入栈
            }
            p = p->next;
        }
    }
    
    return count == G->vexnum;  // 判断是否有环
}

复杂度分析

  • DFS/BFS遍历:时间O(n+e),空间O(n)
  • 拓扑排序:时间O(n+e),空间O(n)
  • 邻接矩阵DFS/BFS:时间O(n²),空间O(n)

三、图解说明

【图1】DFS遍历过程示意

复制代码
初始图:        步骤1(访问A):     步骤2(访问B):     步骤3(访问D):
    A              [A]              [A]              [A]
   /|\             /|\              /|\              /|\
  B C D           B C D            [B] C D          [B] C [D]
  |   |           |   |             |   |            |   |
  E   F           E   F             E   F            E   F
  
步骤4(访问F):     步骤5(访问C):     步骤6(访问E):
    [A]              [A]              [A]
   /|\              /|\              /|\
  [B][C][D]        [B][C][D]        [B][C][D]
  |   |             |   |            |   |
  E  [F]            E  [F]          [E] [F]

DFS序列: A-B-D-F-C-E 或 A-C-F-D-B-E(序列不唯一)

【图2】BFS遍历过程示意

复制代码
使用队列:
步骤1: 队列[A]      访问A
步骤2: 队列[B,C,D]  访问B
步骤3: 队列[C,D,E]  访问C  
步骤4: 队列[D,E,F]  访问D
步骤5: 队列[E,F]    访问E
步骤6: 队列[F]      访问F

BFS序列: A-B-C-D-E-F(层序遍历)

【图3】存储结构对比

复制代码
原图:          邻接矩阵:           邻接表:
  0---1        0 1 2 3            0 -> 1 -> 3
  |\ /|        0[0 1 1 1]         1 -> 0 -> 2 -> 3
  | X |        1[1 0 1 1]         2 -> 1 -> 3
  |/ \|        2[1 1 0 1]         3 -> 0 -> 1 -> 2
  2---3        3[1 1 1 0]

四、真题演练

📝 真题1(2023年408第41题)

题目:对如下有向图进行深度优先遍历,假设从顶点1开始,且邻接点按编号从小到大的顺序访问,则可能的DFS序列是?

解题思路

  1. 从顶点1开始,标记visited[1]=true
  2. 访问1的邻接点(按编号顺序):2、3、4
  3. 递归访问2,再访问2的邻接点
  4. 回溯并继续访问其他未访问顶点

标准答案:1-2-5-4-3-6(示例)

易错点

  • ⚠️ 忽略"按编号从小到大"的条件
  • ⚠️ 混淆DFS和BFS的访问顺序

📝 真题2(2022年408第23题)

题目:已知一个有向图的邻接表如下,从顶点0开始进行广度优先遍历,访问序列是____。

解题思路

  1. 初始化队列Q,visited数组
  2. 顶点0入队,visited[0]=true
  3. 0出队,将0的所有未访问邻接点入队
  4. 重复直到队列为空

评分要点

  • 正确使用队列(2分)
  • 访问标记正确(2分)
  • 序列完整正确(3分)

📝 真题3(2021年408算法设计题)

题目:设计算法判断有向图是否存在环。

参考答案

c 复制代码
// 方法1:DFS+递归栈标记
bool hasCycleDFS(ALGraph *G) {
    int color[MAXV];  // 0:白色, 1:灰色, 2:黑色
    for(int i = 0; i < G->vexnum; i++) 
        color[i] = 0;
    
    for(int i = 0; i < G->vexnum; i++) {
        if(color[i] == 0) {
            if(dfs(G, i, color)) return true;
        }
    }
    return false;
}

// 方法2:拓扑排序
bool hasCycleTopo(ALGraph *G) {
    return !TopologicalSort(G);  // 无法完成拓扑排序则有环
}

五、在线练习推荐

LeetCode精选题目

牛客网408专项

练习顺序建议

  1. 先练习基础的DFS/BFS模板题
  2. 进阶到连通分量、环检测
  3. 掌握拓扑排序的应用
  4. 最后综合练习图论算法设计题

六、思维导图

复制代码
图论基础
├── 基本概念
│   ├── 有向图/无向图
│   ├── 完全图/稀疏图/稠密图
│   ├── 连通性(连通图/强连通图)
│   └── 路径/环/生成树
├── 存储结构
│   ├── 邻接矩阵
│   │   ├── 二维数组实现
│   │   ├── 空间O(n²)
│   │   └── 适合稠密图
│   └── 邻接表
│       ├── 数组+链表实现
│       ├── 空间O(n+e)
│       └── 适合稀疏图
├── 遍历算法
│   ├── DFS深度优先
│   │   ├── 递归/栈实现
│   │   ├── 回溯思想
│   │   └── 时间O(n+e)
│   └── BFS广度优先
│       ├── 队列实现
│       ├── 层次遍历
│       └── 最短路径(无权)
└── 经典应用
    ├── 连通性判断
    ├── 拓扑排序
    ├── 环的检测
    └── 强连通分量

七、复习清单

✅ 本章必背知识点清单

概念理解

  • 能准确区分有向图、无向图、完全图的定义
  • 理解稀疏图与稠密图的区别(|E|<|V|log|V|)
  • 掌握连通图、强连通图、连通分量的概念
  • 记住完全图的边数公式(无向n(n-1)/2,有向n(n-1))

代码实现

  • 能手写邻接矩阵和邻接表的结构定义
  • 能手写DFS的递归版本(5分钟内)
  • 能手写BFS的队列实现(5分钟内)
  • 掌握拓扑排序的完整代码
  • 记住DFS/BFS时间复杂度:邻接表O(n+e),邻接矩阵O(n²)

应用能力

  • 会根据图的特点选择存储结构
  • 能手动模拟DFS/BFS遍历过程
  • 能判断给定序列是否为合法的DFS/BFS序列
  • 掌握用DFS判断图的连通性
  • 掌握用拓扑排序判断有向图是否有环

真题要点

  • 理解DFS/BFS序列不唯一的原因
  • 掌握"按编号顺序访问"的处理方法
  • 记住常见陷阱:未处理非连通图
  • 熟悉算法设计题的标准答题模板

八、知识拓展

🔍 常见误区

  1. 误区 :认为DFS一定比BFS快
    正解:两者时间复杂度相同,选择取决于具体问题

  2. 误区 :邻接矩阵一定浪费空间
    正解:对于稠密图,邻接矩阵反而更高效

  3. 误区 :拓扑排序结果唯一
    正解:拓扑序列可能有多个

💡 记忆技巧

  • DFS:想象走迷宫,一条路走到黑
  • BFS:想象水波扩散,一圈一圈向外
  • 邻接表:稀疏(Sparse)用表(List)
  • 拓扑排序:删边减度,入度为零就输出

📊 自测题

  1. 一个有n个顶点的强连通图至少有多少条边?

    A. n-1 B. n C. n+1 D. n(n-1)

  2. 用邻接表存储有1000个顶点、2000条边的有向图,DFS遍历的时间复杂度是?

    A. O(1000) B. O(2000) C. O(3000) D. O(1000²)

答案:1.B 2.C 3.C

结语

图论是数据结构的核心内容,也是408考试的必考知识点。本章我们重点掌握了:

🎯 核心要点总结

  1. 存储结构选择:稀疏图用邻接表,稠密图用邻接矩阵
  2. DFS本质:递归回溯,适合路径搜索
  3. BFS本质:层次遍历,适合最短路径
  4. 拓扑排序:判环利器,AOV网的基础
  5. 复杂度分析:永远不要忘记O(n+e) vs O(n²)

图的遍历是后续学习最短路径、最小生成树的基础。这些算法看似独立,实际上都是DFS/BFS的变体和应用。下一篇文章,我们将深入探讨**《图论进阶:最短路径与最小生成树》**,学习Dijkstra、Floyd、Prim、Kruskal等经典算法,这些都是408算法设计题的高频考点。

💪 学习建议:图论算法较为抽象,建议多画图模拟算法执行过程,多写代码加深理解。记住:图论不难,关键在于把抽象的概念具象化!

加油,考研人!图论虽然是难点,但也是拿高分的关键。掌握了图论,你就掌握了408数据结构的半壁江山!🚀