NOI C++ 课程体系 / 数据结构篇 / 第十讲
数据结构 · 图

图的定义与存储:邻接矩阵与邻接表
点边抽象连接,矩阵表与链表两种存法

掌握图 G=(V,E) 的基本概念,以及邻接矩阵、邻接表两种存储方式与 DFS/BFS 遍历、握手定理。

核心 点+边矩阵 g[u][v]邻接表 vector数组DFS/BFS 深搜/广搜定理 总度=2E
本讲 · 第 50/56 讲图的定义与存储:邻接矩阵与邻接表
01

情景导航

从“如何用计算机表示一张地铁线路图、朋友圈关系图”出发,理解图结构怎样抽象现实中的“点对连接”,以及两种最常用存法。

📖 引入故事:一张地铁线路图

地铁图上是站点(点)和线路(线)。问“从 A 站到 B 站怎么换乘最少”“哪两个站之间最堵”,本质上都是在问一张图上的问题。现实里还有太多“图”:微信好友(人是点、好友关系是线)、城市路网、比赛对阵表……

这些问题的共同点是:关心的不是“点排成一排”,而是“谁和谁连着”。于是计算机用一种叫“图(Graph)”的结构来抽象它们——这也是数据结构里最通用、最强大的一种。

🎯 生活类比:微信好友关系

把每个人画成一个点,如果两个人是好友,就在两点之间连一条线。这样一张“点+线”的网就是图。线有没有方向?好友关系是相互的(无向);但“关注”微博博主是单向的(有向)——这就引出了无向图和有向图。

关键约束:图只关心“连接关系”,不关心点画在纸上的位置。同一张图可以有无数种画法,只要连边一样就是同一张图。

📝 编程定义

图 G = (V, E):V 是顶点(vertex)集合,E 是边(edge)集合。顶点也叫“结点”,边表示两个顶点之间的关系。

无向边:用 (u, v) 表示,u、v 没有顺序之分(如好友)。有向边:用 <u, v> 表示,从 u 指向 v(如关注)。

权值:边上可带数字(如路长、费用),叫“带权图”。度(degree):无向图中与顶点相连的边数;有向图分“入度”(指向它的边)和“出度”(从它指出去的边)。

核心目标用“点+边”抽象任意连接关系,并选好存法以便遍历与查询

🔑 核心口诀

“点叫顶点边叫边,有向无向看箭头;邻接矩阵二维表,邻接表链表串;遍历 DFS 往深走,BFS 一层层铺;度数加起来,两倍是边数。”

🗺️ 本讲知识地图

知识点你要掌握难度
图的基本概念顶点、边、有向/无向、权、度★
邻接矩阵二维表 g[u][v]=1/权★★
邻接表每个顶点挂一个邻居链表★★
DFS / BFS深度优先 / 广度优先遍历★★
度数与握手定理总度数 = 2 × 边数★
两种存法对比稠密用矩阵、稀疏用邻接表★★
02

知识探究

先看图的基本术语,再学两种存法(邻接矩阵、邻接表)并各配一次遍历,最后用“度数”把图的大小量化。

① 图的基本术语

看下面这张无向图:5 个顶点、6 条边。顶点集 V={1,2,3,4,5},边集 E={(1,2),(1,3),(2,4),(3,4),(3,5),(4,5)}。

图· 例:5 个顶点、6 条边的无向图(顶点 = 城市,边 = 道路)
12345
  • 无向图:边没有方向,(u,v) 与 (v,u) 是同一回事。
  • 有向图:边有方向,<u,v> 只表示从 u 到 v。
  • 顶点的度:和它相连的边数。例如上图中顶点 3 连了 1、4、5 三条边,度 = 3。
  • 路径 / 连通:顺着边能走到的顶点序列叫路径;任意两点都能互相到达则图“连通”。
性质无向图:所有顶点度数之和 = 2 × 边数(“握手定理”)

② 邻接矩阵:用一个二维表存边

开一个二维数组 g[u][v]:若 u、v 之间有边就记 1(带权图记权值),否则记 0。无向图的矩阵沿主对角线对称。

12345
101100
210010
310011
401101
500110

优点:判断“u、v 是否相连”只要 O(1) 查表;缺点:即使边很少也要开 n×n 的空间,稀疏图浪费大。配合 DFS(深度优先搜索)可遍历全图。

C++邻接矩阵存图 + DFS 遍历
#include <iostream>
using namespace std;
int g[6][6];      // 1-based,5 个顶点
bool vis[6];
void dfs(int u) {
    vis[u] = true;
    cout << u << " ";
    for (int v = 1; v <= 5; v++)
        if (g[u][v] && !vis[v]) dfs(v);
}
int main() {
    // 无向图边:1-2, 1-3, 2-4, 3-4, 3-5, 4-5
    int e[][2] = {{1,2},{1,3},{2,4},{3,4},{3,5},{4,5}};
    for (auto& x : e) { g[x[0]][x[1]] = 1; g[x[1]][x[0]] = 1; }
    cout << "邻接矩阵(5 个顶点,无向图,1 表示相连):" << endl;
    for (int i = 1; i <= 5; i++) {
        for (int j = 1; j <= 5; j++) cout << g[i][j] << " ";
        cout << endl;
    }
    cout << "从顶点 1 出发 DFS:";
    dfs(1);
    cout << endl;
    return 0;
}
运行结果
邻接矩阵(5 个顶点,无向图,1 表示相连):
0 1 1 0 0
1 0 0 1 0
1 0 0 1 1
0 1 1 0 1
0 0 1 1 0
从顶点 1 出发 DFS:1 2 4 3 5

DFS 沿着一条路走到头再回头(回溯),输出 1 2 4 3 5。

③ 邻接表:每个顶点挂一串邻居

为每个顶点维护一个“邻居列表”(常用 vector<int> adj[N])。顶点 u 的列表里放所有与它相邻的 v。无向图里 (u,v) 要在 u 和 v 两边各记一次。

1
→
23
2
→
14
3
→
145
4
→
235
5
→
34

优点:只存实际存在的边,空间 O(V+E),稀疏图极省;遍历某个顶点的邻居很快。缺点是“判断 u、v 是否相连”要扫一遍邻居列表。配合 BFS(广度优先搜索)用队列一层层扩展。

C++邻接表存图 + BFS 遍历
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
vector<int> adj[6];   // 1-based 邻接表
int main() {
    int e[][2] = {{1,2},{1,3},{2,4},{3,4},{3,5},{4,5}};
    for (auto& x : e) { adj[x[0]].push_back(x[1]); adj[x[1]].push_back(x[0]); }
    cout << "邻接表(每个顶点的邻居):" << endl;
    for (int i = 1; i <= 5; i++) {
        cout << i << " : ";
        for (int v : adj[i]) cout << v << " ";
        cout << endl;
    }
    bool vis[6] = {false};
    queue<int> q;
    q.push(1); vis[1] = true;
    cout << "从顶点 1 出发 BFS:";
    while (!q.empty()) {
        int u = q.front(); q.pop();
        cout << u << " ";
        for (int v : adj[u]) if (!vis[v]) { vis[v] = true; q.push(v); }
    }
    cout << endl;
    return 0;
}
运行结果
邻接表(每个顶点的邻居):
1 : 2 3
2 : 1 4
3 : 1 4 5
4 : 2 3 5
5 : 3 4
从顶点 1 出发 BFS:1 2 3 4 5

BFS 像水波一层层扩散,先访问离起点近的点,输出 1 2 3 4 5。

④ 度数与“握手定理”

每条边连着两个顶点,因此所有顶点的度数加起来,正好是边数的两倍。这叫握手定理,常用来快速检查“一张图是否可能存在”(出现奇度顶点个数为奇数时不可能)。

C++统计各顶点度数并验证握手定理
#include <iostream>
#include <vector>
using namespace std;
vector<int> adj[6];
int main() {
    int e[][2] = {{1,2},{1,3},{2,4},{3,4},{3,5},{4,5}};
    for (auto& x : e) { adj[x[0]].push_back(x[1]); adj[x[1]].push_back(x[0]); }
    cout << "各顶点的度(无向图 = 邻居个数):" << endl;
    for (int i = 1; i <= 5; i++)
        cout << "顶点 " << i << " 的度 = " << adj[i].size() << endl;
    int sum = 0;
    for (int i = 1; i <= 5; i++) sum += adj[i].size();
    cout << "总度数 = " << sum << " = 2 × 边数(" << 6 << ")" << endl;
    return 0;
}
运行结果
各顶点的度(无向图 = 邻居个数):
顶点 1 的度 = 2
顶点 2 的度 = 2
顶点 3 的度 = 3
顶点 4 的度 = 3
顶点 5 的度 = 2
总度数 = 12 = 2 × 边数(6)

度数 2+2+3+3+2 = 12 = 2×6,完美吻合。

⑤ 两种存法怎么选

对比项邻接矩阵邻接表
空间O(V²)O(V+E)
查“是否相连”O(1) 直接查O(度) 扫邻居
遍历所有边O(V²)O(V+E)
适用稠密图、要频繁查边稀疏图(CSP-J 常用)

初赛与复赛中,邻接表(vector 数组)是最常用写法;邻接矩阵适合顶点少(如 ≤ 2000)且边密的情况。

03

实践应用

三道分析编程题:换一张小图写邻接矩阵并 DFS、用邻接表 BFS、统计度数验证握手定理。均给出可编译运行的参考实现。

🧩 题1:4 顶点图(边 1-2,1-3,2-3,3-4),写邻接矩阵并 DFS

把边填进 4×4 矩阵,再从顶点 1 深度优先遍历。

C++邻接矩阵 + DFS(4 顶点)
#include <iostream>
using namespace std;
int g[5][5];      // 4 个顶点
bool vis[5];
void dfs(int u) {
    vis[u] = true;
    cout << u << " ";
    for (int v = 1; v <= 4; v++)
        if (g[u][v] && !vis[v]) dfs(v);
}
int main() {
    // 4 顶点边:1-2, 1-3, 2-3, 3-4
    int e[][2] = {{1,2},{1,3},{2,3},{3,4}};
    for (auto& x : e) { g[x[0]][x[1]] = 1; g[x[1]][x[0]] = 1; }
    cout << "邻接矩阵(4 个顶点):" << endl;
    for (int i = 1; i <= 4; i++) {
        for (int j = 1; j <= 4; j++) cout << g[i][j] << " ";
        cout << endl;
    }
    cout << "从顶点 1 出发 DFS:";
    dfs(1);
    cout << endl;
    return 0;
}
运行结果
邻接矩阵(4 个顶点):
0 1 1 0
1 0 1 0
1 1 0 1
0 0 1 0
从顶点 1 出发 DFS:1 2 3 4

DFS 输出 1 2 3 4,矩阵沿对角线对称。

🧩 题2:同一张 4 顶点图,用邻接表 BFS

换用邻接表存储,再用队列做广度优先遍历。

C++邻接表 + BFS(4 顶点)
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
vector<int> adj[5];   // 4 个顶点
int main() {
    int e[][2] = {{1,2},{1,3},{2,3},{3,4}};
    for (auto& x : e) { adj[x[0]].push_back(x[1]); adj[x[1]].push_back(x[0]); }
    bool vis[5] = {false};
    queue<int> q;
    q.push(1); vis[1] = true;
    cout << "邻接表 BFS(从顶点 1 出发):";
    while (!q.empty()) {
        int u = q.front(); q.pop();
        cout << u << " ";
        for (int v : adj[u]) if (!vis[v]) { vis[v] = true; q.push(v); }
    }
    cout << endl;
    return 0;
}
运行结果
邻接表 BFS(从顶点 1 出发):1 2 3 4

BFS 输出 1 2 3 4,一层层扩散。

🧩 题3:统计该图度数,验证握手定理

数每个顶点的邻居数,再把度数相加看是否等于 2×边数。

C++度数统计 + 握手定理验证
#include <iostream>
#include <vector>
using namespace std;
vector<int> adj[5];
int main() {
    int e[][2] = {{1,2},{1,3},{2,3},{3,4}};
    for (auto& x : e) { adj[x[0]].push_back(x[1]); adj[x[1]].push_back(x[0]); }
    cout << "各顶点的度:" << endl;
    for (int i = 1; i <= 4; i++)
        cout << "顶点 " << i << " 的度 = " << adj[i].size() << endl;
    int sum = 0;
    for (int i = 1; i <= 4; i++) sum += adj[i].size();
    cout << "总度数 = " << sum << " = 2 × 边数(" << 4 << ")" << endl;
    return 0;
}
运行结果
各顶点的度:
顶点 1 的度 = 2
顶点 2 的度 = 2
顶点 3 的度 = 3
顶点 4 的度 = 1
总度数 = 8 = 2 × 边数(4)

度数 2+2+3+1 = 8 = 2×4,握手定理成立。

04

总结提升

把图术语、两种存法、遍历与度数规律一次性理清。

📌 核心要点总结

结论说明
图 G=(V,E)顶点集 + 边集;边有向 / 无向
邻接矩阵g[u][v],O(V²),查边 O(1)
邻接表vector 数组,O(V+E),稀疏图省
DFS递归/栈,往深走,回溯
BFS队列,一层层扩散
握手定理总度数 = 2 × 边数

🚀 拓展:提高内容(CSP-J 不要求,了解即可)

  • 最短路:Dijkstra、Floyd 在图上求“两点最短距离”,依赖邻接矩阵/表。
  • 连通分量 / 拓扑排序:判断图是否连通、给有向无环图排任务顺序。
  • 链式前向星:竞赛中比 vector 邻接表更快的静态存边方式,属提高写法。

⚠️ “图的定义 + 邻接矩阵/邻接表 + DFS/BFS”属 CSP-J 应知;最短路、拓扑排序等属提高,初赛可能考概念、复赛一般不要求手写。

💡 思考与启发

  • 为什么无向图的邻接矩阵一定对称?有向图也对称吗?举反例。
  • 同样一张图,DFS 和 BFS 的输出可能不同——它们各自适合解决哪类问题?
  • 若一张图有 7 个顶点、度分别为 3,3,3,3,2,2,2,它可能真实存在吗?用握手定理论证。
05

学习检测

5 大易错点 + 速查表 + 笔记卡,考前一眼回顾。

⚠️ 5 大易错点

易错1

无向图只在矩阵里记一次边。无向边 (u,v) 必须同时写 g[u][v]=1 和 g[v][u]=1,否则遍历会漏半边。

易错2

邻接表下标用错(0-based 还是 1-based)。要么统一从 1 开始,要么统一从 0 开始,别混用。

易错3

DFS/BFS 忘记“标记已访问”。不标记会在线圈里反复横跳、死循环。

易错4

以为“图”的顶点有固定位置。图只关心连边关系,画法任意、连边相同即同一图。

易错5

把“有向图的度”和“无向图”混算。有向图分入度/出度;握手定理对无向图成立,有向图则是“入度总和 = 出度总和 = 边数”。

📑 速查表

概念要点
图G=(V,E);边有向/无向
邻接矩阵g[u][v],O(V²)
邻接表vector 数组,O(V+E)
DFS/BFS都要 visited 标记
握手定理无向:总度数=2×边数

📌 笔记卡

图 Graph = (V, E)

① 顶点 + 边;边有向 / 无向。

② 邻接矩阵:g[u][v],查边 O(1),占 O(V²)。

③ 邻接表:vector 数组,占 O(V+E),稀疏图省。

④ DFS 递归往深走;BFS 队列一层层。

⑤ 无向图总度数 = 2 × 边数(握手定理)。

课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增,点击展开答案与解析。

🟢 A组 · 基础(4题)

A1基础·概念

用一句话说明“图”由什么组成,并解释有向图和无向图的区别。

点 + 边。

查看参考答案

图 G=(V,E):V 是顶点集、E 是边集。无向图的边 (u,v) 无方向(双向可达);有向图的边 <u,v> 有方向(仅 u→v)。

思路:图 G = (V, E):V 是顶点集、E 是边集。无向图的边 (u, v) 双向可达;有向图的边 只能从 u 到 v 单向前进。

易错:在有向图里也把一条边当成双向的,重复统计边或度数。

A2基础·矩阵

一张 5 顶点无向图,边为 1-2,2-3,3-4,4-5,1-5,写出它的邻接矩阵(1 表示相连)。

对称矩阵,对角线为 0。

查看参考答案

5×5 矩阵:g[1][2]=g[2][1]=1,g[2][3]=g[3][2]=1,g[3][4]=g[4][3]=1,g[4][5]=g[5][4]=1,g[1][5]=g[5][1]=1,其余为 0。

思路:无向图的邻接矩阵是对称的,每条无向边 (u, v) 要在 g[u][v] 与 g[v][u] 两处都置 1。

易错:只填了 g[u][v] 一个位置,漏掉对称的那一格。

A3基础·度数

上题图中,每个顶点的度分别是多少?总度数等于多少、符合什么定理?

数邻居个数。

查看参考答案

顶点 1、2、3、4、5 的度均为 2;总度数 = 10 = 2×5(边数 5),符合握手定理。

思路:无向图中顶点的度数 = 邻接矩阵该行 1 的个数 = 邻接表中邻居的个数;所有顶点度数之和 = 2 × 边数,这就是握手定理。

易错:把总度数直接等同于边数,忘了每条无向边为总度数贡献 2。

A4基础·遍历

DFS 和 BFS 各适合回答什么类型的问题?为什么都要标记已访问?

深搜 vs 广搜。

查看参考答案

DFS 适合“是否存在路径、找一条路”;BFS 适合“最少步数/最短层数”。标记 visited 防止在环里重复访问、死循环。

思路:DFS 沿一条路走到底再回溯,适合判连通、找一条路径;BFS 逐层向外扩展,适合求最少步数/最短层数。visited 用来防止在含环的图里反复绕圈。

易错:图上遍历时忘记标记 visited,一遇到环就会死循环或重复入队。

🟡 B组 · 提高(4题)

B1提高·矩阵

4 顶点无向图边为 1-2,1-4,2-3,3-4,写出邻接矩阵并从顶点 1 DFS(只写结果序列)。

填矩阵后 DFS。

查看参考答案

矩阵:g[1][2]=g[1][4]=g[2][3]=g[3][4]=1(对称)。一种 DFS 结果:1 2 3 4。

思路:按边集把矩阵对应的两个对称位置置 1;DFS 从顶点 1 出发,依次尝试尚未访问的邻居,沿一条路走到底再回溯。

易错:DFS 序列并不唯一,逐位去比“标准答案”没有意义;只要满足“走到底再回溯、不重复访问”就都是合法结果。

B2提高·邻接表

同一张 4 顶点图,用邻接表存储并写出每个顶点的邻居列表。

两边各记一次。

查看参考答案

adj[1]={2,4},adj[2]={1,3},adj[3]={2,4},adj[4]={1,3}。

思路:邻接表 adj[u] 存下所有与 u 相邻的顶点;无向图中每条边要在两端的表里各存一次。

易错:只往 adj[u] 里加了 v,忘记同时往 adj[v] 里加 u,导致反向走不通。

B3提高·BFS

对 5 顶点环 1-2-3-4-5-1,从顶点 1 做 BFS,写出访问序列。

队列一层层。

查看参考答案

1 的邻居是 2、5;然后 2 的邻居 3、5 的邻居 4(若 2 先于 5 扩展):1 2 5 3 4。

思路:BFS 用队列逐层推进:起点 1 先入队,每弹出一个结点,就把它尚未访问的邻居按顺序入队;先扩展的邻居,它们的下一层也先出队。

易错:把 BFS 当成“一条路走到底”,写成了 DFS 风格的序列。

B4提高·思路

用文字 + 伪代码说明“邻接表 + BFS”的核心思路(不要求完整 C++)。

队列 + visited。

查看参考答案

队列 q 初始 push(起点) 并标记访问;while(q 非空){ u=q.front();弹出;对每个未访问邻居 v 标记访问并入队 }。输出即 BFS 序。

思路:队列先放入起点并标记访问;每轮弹出队头 u,遍历它的所有邻居 v,把尚未访问的标记后入队;结点出队的顺序就是 BFS 序。

易错:在结点“入队时”忘记标记 visited,导致同一个结点被反复入队(应在入队瞬间就标记)。

🔴 C组 · 挑战(4题)

C1挑战·定理

证明握手定理:无向图所有顶点度数之和 = 2 × 边数。

每条边贡献 2 度。

查看参考答案

每条无向边恰好连接两个顶点,给这两个顶点各贡献 1 度,故每条边贡献 2 度;n 条边共贡献 2n 度,即总度数 = 2×边数。

思路:每条无向边连接两个顶点,对总度数的贡献恰好是 2;求和时每条边被两个端点各计一次,所以所有顶点度数之和 = 2 × 边数。

易错:只按一个端点计一次,错误地得出总度数等于边数。

C2挑战·判定

已知一张图有 6 个顶点,度数分别为 5,3,3,3,2,2。它可能真实存在吗?说明理由。

用握手定理 + 奇度个数。

查看参考答案

总度数=18 为偶数(满足 2×边数=9),但奇度顶点有 1,3,3,3 共 4 个(偶数个),单看握手定理不排除;然而顶点 1 度=5 意味着与其余 5 个全连,剩余 5 顶点间至少需凑出各自度数,可构造(如 1 连全部,再在 2-3-4-5-6 间配 2+2+...),故可能。注:握手定理只排除“总度数奇数”或“奇度个数为奇数”的情况。

思路:先用握手定理做必要条件检查:总度数必须为偶数,且奇度顶点的个数也必须是偶数(此处 18 和 4 都满足);再做构造——顶点 1 度为 5 就连满其余 5 个点,剩下的度数也能在局部凑出,故可能。

易错:只看总度数是偶数就下结论,漏掉“奇度顶点个数必须为偶数”这一条。

C3挑战·编码

手写 C++:读入 n 和 m 条边,建邻接表,从 1 出发做 DFS 并输出访问序列。

复用本讲链表 + DFS。

查看参考答案

vector<int> adj[N];读边双向 push_back;bool vis[N];dfs(u) 标记后递归访问未访问邻居;main 调 dfs(1)。

思路:用 vector 数组做邻接表,读边时双向 push_back;vis 数组标记访问,dfs(u) 进入时先标记,再遍历所有尚未访问的邻居递归下去。

易错:无向图只 push 了一次单向边,导致反向走不通;或进入 dfs 时忘记标记,造成重复递归。

C4挑战·应用

手写 C++:读入 n、m 和边,分别用邻接矩阵和邻接表存储,输出“边数”和“每个顶点的度”(两种方法结果应一致)。

两种方法对照。

查看参考答案

矩阵法:int g[N][N],读边双向置 1,度=第 i 行 1 的个数。邻接表法:度=adj[i].size()。两者边数都=m,度数应一致。

思路:矩阵法:读边时把 g[u][v]、g[v][u] 都置 1,顶点度数 = 该行 1 的个数。邻接表法:度数 = adj[i].size()。两种方法得到的总边数与各顶点度数应当完全一致。

易错:统计度数时把矩阵整行求和(若要含自身 g[i][i] 就会偏大),或邻接表只加了单向边导致度数偏小。

🏆 洛谷原题(20道)

1P5318【深基18.例3】查找文献入门
2P3916图的遍历(反向 BFS)普及-
3P1363幻象迷宫(DFS/连通)提高-
4P3371[模板] 单源最短路径(Dijkstra)提高-
5P4779[模板] 单源最短路径(标准)提高-
6P3366[模板] 最小生成树提高-
7P3915小团体的成员(并查集/连通)普及-
8P8603[蓝桥杯2022] 统计子图(图遍历)普及-
9P8645[蓝桥杯2022] 扫雷(图 BFS)普及-
10P2853[USACO] 牛的电信(割点)提高-
11P3386[模板] 二分图最大匹配提高-
12P1127词组接龙(DFS)提高-
13P114101 迷宫(BFS 连通块)普及-
14P1330封锁阳光大学(二分图染色)提高-
15P8655[蓝桥杯2022] 选数(图/DP)普及-
16P8659[蓝桥杯2022] 地铁背包(图)提高-
17P3919主席树(可持久化,拓展)省选-
18P3387[模板] 缩点(Tarjan)提高-
19P2341[HAOI2006] 受欢迎的牛(强连通)提高-
20P1197[JSOI2008] 星球大战(离线+并查集)提高-