第 5.1 章:图的基础
📝 前置条件: 熟悉数组、向量和基础 C++(第 2–4 章)。了解
struct(第 2.4 章)和vector、pair等 STL 容器(第 3.1 章)有帮助。
把图想象成一张地图:城市是节点,城市间的道路是边。图是竞赛编程中最通用的数据结构——它可以模拟人与人之间的友谊、任务间的依赖关系、迷宫中的单元格等等。在 USACO 中,Silver 级别及以上的几乎每道题都以某种形式涉及图。
本章教你如何用图的视角思考,更重要的是用代码存储图。学完后,你能读取任何 USACO 图的输入,毫不犹豫地选择正确的表示方式。
5.1.1 什么是图?
图 G = (V, E) 由两个集合组成:
- 顶点 V(也称节点):「东西」——城市、奶牛、单元格、状态
- 边 E:它们之间的连接——道路、友谊、转换
用 |V| = N 表示顶点数,|E| = M 表示边数。
图如何存储?
在深入术语之前,先快速预览图在代码中如何存储。有两种主要方式(详见 §5.1.2):
邻接表 —— 对每个顶点,存储其邻居列表。这是最常见的表示方式:
adj[0] = {1, 2} ← 节点 0 连接到 1 和 2
adj[1] = {0, 2, 3} ← 节点 1 连接到 0、2 和 3
adj[2] = {0, 1, 4} ← 节点 2 连接到 0、1 和 4
adj[3] = {1, 4} ← 节点 3 连接到 1 和 4
adj[4] = {2, 3} ← 节点 4 连接到 2 和 3
邻接矩阵 —— 用 V×V 的网格,adj[u][v] = 1 表示「u 和 v 之间有边」:
adj: 0 1 2 3 4
0 [ 0 1 1 0 0 ] ← 节点 0 连接到 1 和 2
1 [ 1 0 1 1 0 ] ← 节点 1 连接到 0、2 和 3
2 [ 1 1 0 0 1 ] ← 节点 2 连接到 0、1 和 4
3 [ 0 1 0 0 1 ] ← 节点 3 连接到 1 和 4
4 [ 0 0 1 1 0 ] ← 节点 4 连接到 2 和 3
💡 快速对比: 邻接表内存更少、遍历更快——95% 的情况用它。邻接矩阵支持 O(1) 边查询——V 较小(≤ 1500)或用于 Floyd-Warshall 等算法时有用。
以下两张图展示了同一个 5 节点图用邻接表和邻接矩阵存储的方式:
矩阵中,绿色 1 = 边存在,灰色 0 = 无边。对角线上的格子始终为 0(无自环)。注意矩阵是对称的——因为这是无向图。
关键术语
现在不必全部记住——快速浏览,需要时回头查看。
| 术语 | 定义 | 示例 |
|---|---|---|
| 度(Degree) | 连接到一个顶点的边数 | 节点 2 的度为 3 |
| 路径(Path) | 由边连接的顶点序列 | 1 → 2 → 4 → 6 |
| 环(Cycle) | 起点和终点相同的路径 | 1 → 2 → 3 → 1 |
| 连通(Connected) | 每个顶点都能到达其他所有顶点 | 一个连通分量 |
| 分量(Component) | 最大连通子图 | 节点的「簇」 |
| 稀疏(Sparse) | 边少:M = O(V) | 道路网络 |
| 稠密(Dense) | 边多:M = O(V²) | 完全图 |
握手引理
所有顶点度数之和等于 2M(边数的两倍)。
证明:每条边 (u, v) 对 deg(u) 和 deg(v) 各贡献 +1。
推论: 任何图中奇数度顶点的数量始终是偶数。这可以立即排除题目中的不可能情况。
图的类型
图有多种形式,以下是最常见的:
| 类型 | 描述 | USACO 频率 |
|---|---|---|
| 无向图 | A–B 边意味着 B–A 也存在;道路、牧场连接 | ⭐⭐⭐ 非常常见 |
| 有向图(Digraph) | A→B 不意味着 B→A;依赖关系、流 | ⭐⭐ 常见(Gold+) |
| 加权图 | 每条边有数值代价;道路距离 | ⭐⭐⭐ 常见(Silver+) |
| 树 | 连通、无环、恰好 N−1 条边 | ⭐⭐⭐ 各级别非常常见 |
| DAG | 有向无环图;存在拓扑序 | ⭐⭐ DP 状态常见 |
| 网格图 | 单元格为节点,4 方向边;迷宫 | ⭐⭐⭐ Bronze/Silver 最常见 |
| 完全图 K_n | 每对顶点都连接;N(N−1)/2 条边 | ⭐ 罕见;通常是理论题 |
| 二部图 | 两色顶点,边只在组间 | ⭐⭐ 匹配、2-染色 |
USACO 实际情况: Bronze/Silver 主要用无权无向图和网格图。加权图出现在 Silver 最短路中。树出现在所有级别。Gold 引入 DAG、有向图和稠密图。
5.1.2 图的表示
现在你知道图是什么了,接下来的问题是:如何用代码存储它? 这是图问题中最关键的编码决策。有三种主要表示方式,各有不同的权衡。选错会导致 TLE 或 MLE。
表示方式一:邻接表——默认选择
对每个顶点,存储其邻居列表。这是 95% 的 USACO 题目的首选。
📄 对每个顶点,存储其邻居列表。这是 **95% 的 USACO 题目的首选**。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m; // n 个顶点(1..n),m 条边
cin >> n >> m;
// adj[u] = 与 u 直接相连的顶点列表
vector<vector<int>> adj(n + 1); // 大小 n+1 以使用 1-indexed 顶点
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v); // 无向图:两个方向都要加
adj[v].push_back(u);
}
// 遍历顶点 u 的所有邻居:O(deg(u))——最优
for (int u = 1; u <= n; u++) {
cout << u << " -> ";
for (int v : adj[u]) cout << v << " ";
cout << "\n";
}
return 0;
}
属性:
- 空间:
O(V + E)—— 最优。对 V = 10^5、E = 2×10^5 轻松放进 256 MB。 - 遍历邻居:
O(deg(u))—— 只访问实际邻居,没有浪费工作。 - 检查边 (u, v):
O(deg(u))—— 必须扫描邻居列表。(这是弱点。) - 缓存性能:
vector使用连续内存 → 比链表快 5–10 倍。
表示方式二:邻接矩阵
邻接矩阵将图表示为二维数组,条目 adj[u][v] 回答:「从 u 到 v 的边存在吗?」
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXV = 1001;
// 关键:声明为全局变量!
// 局部的 bool[1001][1001] 在栈上占 ~1 MB → 栈溢出崩溃。
// 全局变量存储在 BSS 段,自动初始化为零。
bool adj[MAXV][MAXV];
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
adj[u][v] = true; // 无向图:两个方向都要设置
adj[v][u] = true;
}
// O(1) 边查询——邻接矩阵的杀手特性
if (adj[2][3]) {
cout << "边 2-3 存在\n";
}
return 0;
}
⚠️ 关键:始终使用全局数组。 局部的
bool adj[1001][1001]消耗约 1 MB 栈空间——通常会崩溃。全局数组在 BSS/数据段中,没有栈大小限制,自动初始化为零。
空间:什么时候可以用邻接矩阵?
V = 100 → bool[100][100] = 10 KB ✅ 没问题
V = 500 → bool[500][500] = 250 KB ✅ 没问题
V = 1000 → bool[1000][1000] = 1 MB ✅ 没问题
V = 1500 → bool[1500][1500] = 2.25 MB ✅ 没问题
V = 3000 → bool[3000][3000] = 9 MB ⚠️ 临界(256 MB 限制)
V = 10000 → bool[10k][10k] = 100 MB ❌ 超出典型限制
V = 10^5 → bool[10^5][10^5] = 10 GB ❌ 不可能
经验法则: V ≤ ~1500 时安全。V > 2000 时切换到邻接表。
完整对比
| 操作 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间 | O(V²) | O(V + E) |
| 检查边 (u, v) | O(1) | O(deg(u)) |
| 遍历 u 的所有邻居 | O(V) 扫整行 | O(deg(u)) |
| 添加边 | O(1) | O(1) 均摊 |
| V ≤ 1000 时最佳 | ✅ 若需 O(1) 边查询 | ✅ 始终可用 |
| V = 10^5 时最佳 | ❌ 内存太大 | ✅ 必须用 |
| Floyd-Warshall | ✅ 自然格式 | ❌ 不能用 |
| Kruskal / BFS / DFS | ❌ 邻居遍历慢 | ✅ 必须用 |
表示方式三:边列表
将图存储为 (u, v) 或 (u, v, w) 元组的普通数组。
📄 将图存储为 `(u, v)` 或 `(u, v, w)` 元组的普通数组。
// 加权图的边结构体
struct Edge {
int u, v, w;
// 按权重升序排序:
bool operator<(const Edge& other) const {
return w < other.w;
}
};
vector<Edge> edges;
// 读取输入:
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
edges.push_back({u, v, w});
}
// 按权重排序——Kruskal MST 需要:
sort(edges.begin(), edges.end());
什么时候用边列表:
| 算法 | 原因 |
|---|---|
| Kruskal MST | 需要按权重排序的边;贪心处理 |
| Bellman-Ford | 对所有 M 条边迭代 N 次 |
| 全局处理所有边 | 不需要顶点结构时 |
什么时候不用:
- BFS/DFS:无法按顶点查询邻居
- 检查「边 (u, v) 是否存在」:O(M) 扫描
如何选择表示方式
V ≤ 1500?
├── 是 → 需要 O(1) 边查询或 Floyd-Warshall?
│ ├── 是 → 邻接矩阵
│ └── 否 → 邻接表
└── 否 → 邻接表(始终)
└── 还需要 Kruskal?→ 同时维护边列表
默认规则: 从邻接表开始。只在 V 明确较小(≤ 1500)或需要 Floyd-Warshall 时才切换到邻接矩阵。
5.1.3 读取图的输入
你已经了解了数据结构——现在把它们与真实的 USACO 输入联系起来。立即识别输入格式可以节省宝贵的竞赛时间。以下是你会遇到的五种格式:
格式一:标准边列表(最常见)
5 4 ← n 个顶点,m 条边(第一行)
1 2 ← 之后每行:一条无向边
2 3
3 4
4 5
int n, m;
cin >> n >> m;
vector<vector<int>> adj(n + 1);
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u); // 有向图省略此行
}
格式二:加权边列表
4 5 ← n 个顶点,m 条边
1 2 10 ← 边 1-2,权重 10
1 3 5 ← 边 1-3,权重 5
2 3 3
2 4 8
3 4 2
📄 C++ 完整代码
int n, m;
cin >> n >> m;
vector<vector<pair<int,int>>> adj(n + 1);
// adj[u] = {邻居, 权重} 对的列表
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
adj[u].push_back({v, w});
adj[v].push_back({u, w}); // 无向加权图
}
// C++17 结构化绑定遍历:
for (auto& [v, w] : adj[1]) {
cout << "1 -> " << v << "(权重 " << w << ")\n";
}
格式三:通过父节点数组表示树
5 ← n 个节点;根始终是节点 1
2 3 1 1 ← 节点 2、3、4、5 的父节点
int n;
cin >> n;
vector<vector<int>> children(n + 1);
vector<int> par(n + 1, 0);
for (int i = 2; i <= n; i++) {
cin >> par[i];
children[par[i]].push_back(i); // 有向:父节点 -> 子节点
}
// par[1] = 0(根节点无父节点)
格式四:网格图(USACO Bronze/Silver 非常常见)
4 5 ← R 行,C 列
..... ← '.' = 可通行,'#' = 墙/障碍
.##..
.....
.....
单元格是节点;相邻的可通行单元格共享一条边。不需要显式邻接表——使用方向 delta 数组:
📄 单元格是节点;相邻的可通行单元格共享一条边。不需要显式邻接表——使用方向 delta 数组:
int R, C;
cin >> R >> C;
vector<string> grid(R);
for (int r = 0; r < R; r++) cin >> grid[r];
// 4 方向:上、下、左、右
const int dr[] = {-1, 1, 0, 0};
const int dc[] = { 0, 0, -1, 1};
// 在单元格 (r, c) 处,遍历有效的可通行邻居:
auto neighbors = [&](int r, int c) {
vector<pair<int,int>> result;
for (int d = 0; d < 4; d++) {
int nr = r + dr[d];
int nc = c + dc[d];
if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] != '#') {
result.push_back({nr, nc});
}
}
return result;
};
专业技巧: 8 方向移动(包括对角线):
const int dr[] = {-1,-1,-1, 0, 0, 1, 1, 1}; const int dc[] = {-1, 0, 1,-1, 1,-1, 0, 1};
将单元格压缩为一个整数(适合 visited 数组):
// 单元格 (r, c) → 整数 ID:r * C + c(0-indexed)
int cellId(int r, int c, int C) { return r * C + c; }
// 反向:id → (id / C, id % C)
格式五:含自环和重边的边列表
📄 查看代码:格式五:含自环和重边的边列表
// 跳过自环(若题目中无意义):
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
if (u == v) continue; // 自环:跳过
adj[u].push_back(v);
adj[v].push_back(u);
}
// 去除重边(只构建简单图):
set<pair<int,int>> seen;
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
if (u > v) swap(u, v); // 规范化:始终 u < v
if (seen.insert({u, v}).second) {
// .second = true 意味着是新插入的(非重复)
adj[u].push_back(v);
adj[v].push_back(u);
}
}
5.1.4 树——图的特殊类型
树是图最重要的特殊情况。它出现在 USACO 的每个级别——Bronze 到 Platinum。掌握树等于掌握了图论的一半。
树是满足以下所有条件的图(这些条件互相等价):
- 连通且恰好有 N − 1 条边
- 连通且无环
- 任意两个顶点之间恰好有一条简单路径
- 最小连通:去掉任意一条边就会断开
1 ← 根节点(深度 0)
/ \
2 3 ← 深度 1
/ \ \
4 5 6 ← 深度 2(4、5、6 是叶节点)
树的术语
| 术语 | 定义 | 示例(上图) |
|---|---|---|
| 根 | 指定的顶部节点(深度 0) | 节点 1 |
| 父节点 | 直接在上方的唯一节点 | parent(4) = 2 |
| 子节点 | 直接在下方的节点 | children(2) = {4, 5} |
| 叶节点 | 没有子节点的节点 | 节点 4、5、6 |
| 深度 | 到根的距离 | depth(6) = 2 |
| 节点 u 的高度 | 从 u 到叶节点的最长路径 | height(2) = 1 |
| 树的高度 | 任意节点的最大深度 | 2 |
| 子树(u) | 节点 u 及其所有后代 | subtree(2) = {2,4,5} |
| u 的祖先 | 从 u 到根路径上的任意节点 | ancestors(6) = {3, 1} |
| LCA(u, v) | 最近公共祖先 | LCA(4, 6) = 1 |
给树确定根(标准 DFS 模板)
几乎所有树问题都需要选定一个根并计算父子关系。标准做法:将树作为无向图读取,然后用 DFS 确定根:
📄 几乎所有树问题都需要选定一个根并计算父子关系。标准做法:将树作为无向图读取,然后用 DFS 确定根:
int n;
cin >> n;
vector<vector<int>> adj(n + 1);
for (int i = 0; i < n - 1; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u); // 无向树边
}
vector<int> parent(n + 1, 0);
vector<int> depth(n + 1, 0);
vector<vector<int>> children(n + 1);
// 以节点 1 为根,用迭代 DFS(N 最大 10^5 时安全)
// 递归 DFS 对链(N = 10^5 深的路径)可能栈溢出
function<void(int, int)> rootTree = [&](int u, int par) {
parent[u] = par;
for (int v : adj[u]) {
if (v != par) { // v != par 避免向上回溯
children[u].push_back(v);
depth[v] = depth[u] + 1;
rootTree(v, u);
}
}
};
rootTree(1, 0); // 根 = 1,哨兵父节点 = 0
rootTree(1, 0) 完成后:
parent[u]= 节点 u 的父节点(若 u 是根则为 0)children[u]= 有根树中 u 的子节点列表depth[u]= u 从根开始的深度
⚠️ 深树的栈溢出警告: 对 10^5 长度的链图做递归 DFS 会溢出默认栈(通常 1–8 MB)。对于大型树,使用带显式栈的迭代 DFS 或增大栈大小。
迭代(栈安全)版本:
📄 C++ 完整代码
// 迭代 rootTree:BFS 顺序,任何树形状都安全
vector<int> order;
queue<int> bfsQ;
vector<bool> visited(n + 1, false);
bfsQ.push(1);
visited[1] = true;
parent[1] = 0;
depth[1] = 0;
while (!bfsQ.empty()) {
int u = bfsQ.front(); bfsQ.pop();
order.push_back(u);
for (int v : adj[u]) {
if (!visited[v]) {
visited[v] = true;
parent[v] = u;
depth[v] = depth[u] + 1;
children[u].push_back(v);
bfsQ.push(v);
}
}
}
// order[] = BFS 遍历顺序(适用于自底向上 DP)
5.1.5 加权图——存储边的代价
目前为止,我们的边是「裸」的——只是说「A 连接到 B」。但许多题目给每条边赋予一个代价(距离、行程时间、容量)。以下是如何存储这些额外信息:
📄 C++ 完整代码
// 方式一:pair<int,int>——紧凑、常用
vector<vector<pair<int,int>>> adj(n + 1);
// adj[u] 存储 {v, w} 对
// 添加无向加权边 u–v,权重 w:
adj[u].push_back({v, w});
adj[v].push_back({u, w});
// 用 C++17 结构化绑定遍历:
for (auto& [v, w] : adj[u]) {
cout << u << " -> " << v << "(代价 " << w << ")\n";
}
📄 C++ 完整代码
// 方式二:struct Edge——对复杂图更清晰
struct Edge {
int to; // 目标顶点
int weight; // 边代价
};
vector<vector<Edge>> adj(n + 1);
// 添加边:
adj[u].push_back({v, w});
// 遍历:
for (const Edge& e : adj[u]) {
relax(u, e.to, e.weight);
}
什么时候用 long long 权重
若边权最大 10^9 且路径可能包含多条边,累积和可能溢出 32 位 int(最大约 2.1×10^9):
最坏情况:N = 10^5 个节点,所有边权 = 10^9
最长路径和 ≈ 10^5 × 10^9 = 10^14 → int 溢出!
Dijkstra / Bellman-Ford 的安全模板:
const long long INF = 2e18; // long long 距离的安全哨兵
vector<vector<pair<int, long long>>> adj(n + 1);
// ^^^^^^^^^^^ long long 权重
vector<long long> dist(n + 1, INF);
dist[src] = 0;
规则: 若边权超过 10^4 且路径可能超过几百条边,用
long long。拿不准时用long long——性能差异可以忽略不计。
5.1.6 常见错误
这些是初学者最常犯的 bug。记住它们——能节省大量调试时间。
⚠️ Bug #1——无向图缺少反向边(最常见!)
// 错误:只加一个方向 adj[u].push_back(v); // 忘了 adj[v].push_back(u) ! // 正确:无向图 = 两个方向 adj[u].push_back(v); adj[v].push_back(u);症状: BFS/DFS 只访问半个图;某些顶点看起来无法到达。
⚠️ Bug #2——邻接表大小差一
// 错误:大小 n,但顶点下标是 1..n → adj[n] 越界! vector<vector<int>> adj(n); // 正确:大小 n+1,用于 1-indexed 顶点 vector<vector<int>> adj(n + 1);
⚠️ Bug #3——局部邻接矩阵崩溃(栈溢出)
// 错误:~1 MB 在栈上 → 栈溢出 int main() { bool adj[1001][1001]; // 局部变量在栈上! } // 正确:全局数组(BSS 段,自动初始化为零) bool adj[1001][1001]; // 在 main() 之外 int main() { ... }
⚠️ Bug #4——V 很大时用邻接矩阵(MLE)
// 错误:V = 100,000 → 10 GB 内存! bool adj[100001][100001]; // 正确:V > 1500 时用邻接表 vector<vector<int>> adj(n + 1);
⚠️ Bug #5——访问网格前未检查边界
// 错误:可能访问 grid[-1][c] → 未定义行为! if (grid[nr][nc] != '#') { ... } // 正确:先检查边界 if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] != '#') { ... }
⚠️ Bug #6——加权图中距离整数溢出
// 错误:边权 = 10^9,路径有 10^5 条边 → 溢出 int dist[MAXN]; // 正确:距离数组用 long long long long dist[MAXN]; const long long INF = 2e18;
本章总结
核心要点
| 概念 | 核心规则 | 为什么重要 |
|---|---|---|
| 无向边 | 同时加 adj[u]←v 和 adj[v]←u | 忘一个方向 = Bug #1 |
| 有向边 | 只加 adj[u]←v | 与无向图不同! |
| 邻接表 | vector<vector<int>> adj(n+1) | 默认;O(V+E) 空间 |
| 邻接矩阵 | 全局 bool adj[MAXV][MAXV] | 只在 V ≤ 1500 时;O(1) 边查询 |
| 加权邻接表 | vector<pair<int,int>> 或结构体 | Dijkstra、Bellman-Ford |
| 加权矩阵 | int dist[MAXV][MAXV],INF 哨兵 | Floyd-Warshall |
| 边列表 | vector<{u,v,w}> 按权重排序 | Kruskal MST 算法 |
| 网格图 | dr[]/dc[] 方向数组 | 无需显式邻接表 |
| 树 | 连通 + N−1 条边 + 无环 | 支持高效子树 DP |
| long long 权重 | 当和可能超过 2×10^9 | 防止路径和溢出 |
与后续章节的联系
| 章节 | 使用本章什么内容 |
|---|---|
| 5.2 BFS & DFS | 本章构建的邻接表;本章是硬前置条件 |
| 5.3 树与 DSU | 树的表示 + 添加并查集数据结构 |
| 5.4 最短路径 | Dijkstra 用加权邻接表;Floyd 用加权矩阵 |
| 6.x 动态规划 | 网格图支持网格 DP;DAG 支持 DAG 上的 DP |
| 8.1 MST | Kruskal 用边列表;Prim 用邻接表 |
| 8.3 树形 DP | §5.1.4 的有根树结构;children[] 数组模式 |
| 8.4 欧拉序/LCA | 在 §5.1.4 的 depth[] 和 parent[] 上构建倍增 |
常见问题
Q:应该用 0-indexed 还是 1-indexed 顶点?
USACO 输入几乎总是 1-indexed(顶点标记为 1 到 N)。用
vector<vector<int>> adj(n + 1),槽位 0 留空不用。这直接与输入对应,避免差一错误。
Q:网格图需要显式邻接表吗?
不需要。网格邻居通过
dr[]/dc[]数组即时计算——内存效率更高,代码通常也更简洁。
Q:什么时候对边权用 long long?
当权重可达 10^9 且可能要对多条边求和时(最短路径、总代价)。10^9 × 路径长度很容易超过 2^31 − 1 ≈ 2.1×10^9。拿不准就用
long long。
练习题
题目 5.1.1 — 度数统计 🟢 简单
题目: 读取一个有 N 个顶点和 M 条边的无向图,打印每个顶点的度数(与它相连的边数)。
样例输入 1:
4 4
1 2
2 3
3 4
4 1
样例输出 1: 2 2 2 2
样例输入 2:
5 3
1 2
1 3
1 4
样例输出 2: 3 1 1 1 0
💡 提示
维护 degree[] 数组。对每条边 (u, v),做 degree[u]++ 和 degree[v]++。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
vector<int> degree(n + 1, 0);
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
degree[u]++;
degree[v]++; // 无向图:两个端点各加 +1
}
for (int u = 1; u <= n; u++) {
cout << degree[u];
if (u < n) cout << " ";
}
cout << "\n";
return 0;
}
// 时间:O(N + M),空间:O(N)
题目 5.1.2 — 是树吗? 🟢 简单
题目: 给定一个有 N 个顶点和 M 条边的连通无向图,判断它是否是一棵树。
样例输入 1:
5 4
1 2
1 3
3 4
3 5
样例输出 1: YES
样例输入 2:
4 4
1 2
2 3
3 4
4 1
样例输出 2: NO
💡 提示
对连通图:当且仅当 M = N − 1 时是树。不需要环检测——对连通图,边数本身就够了。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v; // 读边(此题不需要用它们)
}
cout << (m == n - 1 ? "YES" : "NO") << "\n";
return 0;
}
// 时间:O(M),空间:O(1)
⚠️ 注意: 这只在图被保证连通时有效。对可能不连通的图,还需用 BFS/DFS 验证连通性(第 5.2 章)。有 N−1 条边的不连通图是森林(多棵树),不是单棵树。
题目 5.1.3 — 有向图可达性 🟡 中等
题目: 给定有 N 个顶点、M 条有向边和两个顶点 S、T 的有向图,沿有向边走,T 从 S 可达吗?
样例输入 1:
5 4 1 4
1 2
2 3
3 4
1 5
样例输出 1: YES(路径:1 → 2 → 3 → 4)
样例输入 2:
4 3 4 1
1 2
2 3
3 4
样例输出 2: NO(边只向前 1→2→3→4,不能反向)
💡 提示
构建有向邻接表(只加 adj[u].push_back(v),不加反向边)。从 S 运行 BFS,若 T 被访问则输出 YES。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m, S, T;
cin >> n >> m >> S >> T;
vector<vector<int>> adj(n + 1);
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v); // 有向:只加一个方向
}
vector<bool> visited(n + 1, false);
queue<int> q;
visited[S] = true;
q.push(S);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (!visited[v]) {
visited[v] = true;
q.push(v);
}
}
}
cout << (visited[T] ? "YES" : "NO") << "\n";
return 0;
}
// 时间:O(V + E),空间:O(V + E)
题目 5.1.4 — 叶节点计数 🟢 简单
题目: 有根树共 N 个节点,根 = 节点 1,通过父节点数组给出。统计叶节点(无子节点的节点)数量。
样例输入:
5
1 1 2 2
样例输出: 3(树:1→{2,3},2→{4,5}。叶节点:3、4、5)
💡 提示
若一个节点从未作为父节点出现,它就是叶节点。追踪 hasChild[u],hasChild[u] = false 的节点就是叶节点。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n;
cin >> n;
vector<bool> hasChild(n + 1, false);
for (int i = 2; i <= n; i++) {
int parent;
cin >> parent;
hasChild[parent] = true; // 父节点至少有一个子节点
}
int leaves = 0;
for (int u = 1; u <= n; u++) {
if (!hasChild[u]) leaves++;
}
cout << leaves << "\n";
return 0;
}
// 时间:O(N),空间:O(N)
题目 5.1.5 — 网格边数统计 🟡 中等
题目: 读取一个 N×M 的网格,. = 可通行,# = 墙。统计隐式无向图中边的数量(两个相邻可通行单元格共享一条边)。
样例输入 1:
3 3
...
.#.
...
样例输出 1: 8
💡 提示
对每个可通行单元格,只检查其右和下邻居,避免重复计数。若两个单元格都可通行,计一条边。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
vector<string> grid(n);
for (int r = 0; r < n; r++) cin >> grid[r];
int edges = 0;
for (int r = 0; r < n; r++) {
for (int c = 0; c < m; c++) {
if (grid[r][c] == '#') continue;
// 检查右邻居
if (c + 1 < m && grid[r][c+1] == '.') edges++;
// 检查下邻居
if (r + 1 < n && grid[r+1][c] == '.') edges++;
}
}
cout << edges << "\n";
return 0;
}
// 时间:O(N*M),空间:O(N*M)
题目 5.1.6 — 构建邻接矩阵并查询 🟢 简单
题目: 给定有 N 个顶点(N ≤ 500)和 M 条边的无向无权图,构建邻接矩阵,回答 Q 次查询:对每次查询 (u, v),若边 u–v 存在打印 1,否则打印 0。
样例输入:
4 4
1 2
1 3
2 4
3 4
3
1 2
2 3
1 4
样例输出:
1
0
0
💡 提示
O(M) 构建邻接矩阵,每次查询 O(1)。展示了当 Q 大而 N 小时邻接矩阵优于邻接表(每次查询 O(deg))的场景。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const int MAXV = 501;
bool adj[MAXV][MAXV]; // 全局:自动初始化为零,不会栈溢出
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
adj[u][v] = true;
adj[v][u] = true; // 无向图
}
int q;
cin >> q;
while (q--) {
int a, b;
cin >> a >> b;
cout << (adj[a][b] ? 1 : 0) << "\n"; // 每次查询 O(1)!
}
return 0;
}
// 构建:O(M),每次查询:O(1),总计:O(M + Q)