📖 第 5.1 章 ⏱️ 约 75 分钟 🎯 中级

第 5.1 章:图的基础

📝 前置条件: 熟悉数组、向量和基础 C++(第 2–4 章)。了解 struct(第 2.4 章)和 vectorpair 等 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 节点图用邻接表和邻接矩阵存储的方式:

Graph Basics — Adjacency List

Graph Adjacency List Detail

Graph Basics — Adjacency Matrix

矩阵中,绿色 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、有向图和稠密图。

有向图 vs 无向图对比

DAG 有向无环图与拓扑排序

拓扑排序:Kahn 算法(BFS 入度法)

二部图 2-染色验证


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。掌握树等于掌握了图论的一半。

是满足以下所有条件的图(这些条件互相等价):

  1. 连通且恰好有 N − 1 条边
  2. 连通且无环
  3. 任意两个顶点之间恰好有一条简单路径
  4. 最小连通:去掉任意一条边就会断开

Tree Structure

         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]←vadj[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 MSTKruskal 用边列表;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)