11 图论基础:存图 / DFS / BFS / 拓扑排序
GESP 7 级CSP-J / NOIP 核心CSP-S
级别标注:GESP 7 级(考级要求)/ CSP-J、NOIP 核心(竞赛进阶:最短路、最小生成树、拓扑、图论建模)。
一、图的基本概念
图由顶点(vertex/节点)和边(edge)组成:G(V, E)。
- 有向图:边有方向(A→B);无向图:边无方向(A-B 即双向)。
- 权值:边可以带权重(距离/代价)。
- 度:无向图节点连接的边数;有向图分入度/出度。
- 连通:无向图任意两点可达称连通;强连通指有向图任意两点互相可达。
- 若 n 个顶点(无重边无自环):无向图最多 n(n-1)/2 条边,有向图最多 n(n-1) 条。
形象理解图像城市地图:顶点是城市,边是道路。有向图是单行道,无向图是双向路;边权像路程公里数。连通像“能不能从 A 城开车到 B 城”,强连通像“每条路都能双向走通”。
考级视角必背边数公式:n 个顶点无向图最多 n(n-1)/2 条边(每对点连一条);有向图最多 n(n-1) 条。GESP 7 级初赛常考“判断最大边数/稠密图”,套公式秒答。
二、存图方式
2.1 邻接矩阵(适合小规模 / 稠密图)
C++ 实例
#include <iostream>
using namespace std;
const int N = 1005;
int g[N][N]; // 邻接矩阵:g[i][j] = 边权,0 表示无边
int main() {
int n, m; cin >> n >> m; // n 个点,m 条边
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
g[u][v] = w; // 有向边 u→v;无向图再加 g[v][u] = w
}
// 邻接矩阵的优势:判断任意两点是否相连 O(1)
if (g[1][2]) cout << "有边" << endl;
return 0;
}空间 O(n²),n ≤ 2000 左右可用;主要用于简单题/稠密图。
2.2 邻接表(vector 存,竞赛主流)
C++ 实例
#include <iostream>
#include <vector>
using namespace std;
const int N = 100005;
struct Edge { int to, w; }; // 一条出边:终点 to、权值 w
vector<Edge> adj[N]; // 邻接表:adj[u] 存 u 的所有出边
int main() {
int n, m; cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
adj[u].push_back({v, w}); // u → v
adj[v].push_back({u, w}); // 无向图再加反向边
}
// 遍历 u 的所有邻居
for (auto &e : adj[1]) {
cout << "1 -> " << e.to << " w=" << e.w << endl;
}
return 0;
}空间 O(n+m),几乎万能,推荐默认使用。遍历某点邻居 O(度数)。
2.3 数组模拟邻接表(头部插入,链式前向星)
C++ 实例
#include <iostream>
using namespace std;
const int N = 100005;
struct E { int to, w, next; } edges[N * 2]; // 链式前向星:边数组
int head[N]; // head[u] 为 u 的第一条边在 edges 中的下标
int en = 1; // 边计数,从 1 开始(0 表示"无")
// 头插法加边:新边指向 u 原来的第一条边,再把自己设为第一条
void addEdge(int u, int v, int w) {
edges[en] = {v, w, head[u]};
head[u] = en++;
}
int main() {
addEdge(1, 2, 5);
addEdge(1, 3, 2);
// 遍历 1 的所有出边:沿 next 链走
for (int e = head[1]; e != 0; e = edges[e].next) {
cout << "1 -> " << edges[e].to << " w=" << edges[e].w << endl;
}
return 0;
}链式前向星常用于 OI 圈,省内存且快;理解"head + next + to"结构即可。
形象理解邻接矩阵像点名册表格:第 i 行第 j 列写“i 到 j 有没有路、多远”,查任意两点 O(1) 但占地方。邻接表像每个城市挂一块“可直达城市”牌子:
adj[u] 列出 u 能直达的所有城市,省空间。易错点有向图只加一条边,无向图必须加两条(u→v 和 v→u)。漏加反向边是图论题最常见的低级错误,样例能过、大数据全错。写
addEdge 时养成习惯:无向图调用两次。三、图的 DFS 遍历
C++ 实例
#include <iostream>
#include <vector>
using namespace std;
const int N = 100005;
vector<int> adj[N]; // 邻接表
bool vis[N]; // 访问标记数组
// 图的深度优先搜索(DFS):一路走到底再回头
void dfs(int u) {
vis[u] = true; // 先标记,防止重复访问
cout << u << " ";
for (int v : adj[u]) { // 遍历邻居
if (!vis[v]) dfs(v); // 没访问过就递归
}
}
int main() {
int n, m; cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v; cin >> u >> v;
adj[u].push_back(v); // 无向图双向建边
adj[v].push_back(u);
}
dfs(1); // 从节点 1 开始
cout << endl;
return 0;
}DFS 可做的事:连通块计数、环检测、树/图的路径搜索、拓扑排序(有向无环图后序)。
形象理解DFS 像迷宫探险家:走进一条路一直走到底,走不通就退回来换一条(回溯),走过的路口做记号(vis)防止绕圈。
易错点DFS 必须在进入节点时立刻标记 vis(先标记后递归)。如果等递归返回再标记,环状图会无限递归直到爆栈。这是笔试读程序题最爱埋的坑。
四、图的 BFS 遍历(最短路无权图)
C++ 实例
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int N = 100005;
vector<int> adj[N];
int dist[N]; // 到起点的距离(边权为 1 时即"最少步数")
// 广度优先搜索(BFS):按层扩展,第一次到达即最短距离
void bfs(int s) {
fill(dist, dist + N, -1); // -1 表示未访问
queue<int> q;
q.push(s);
dist[s] = 0; // 起点距离 0
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) { // 第一次访问
dist[v] = dist[u] + 1; // 距离 = 父距离 + 1
q.push(v);
}
}
}
}
int main() {
int n, m; cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v; cin >> u >> v;
adj[u].push_back(v); adj[v].push_back(u);
}
bfs(1);
for (int i = 1; i <= n; i++) cout << dist[i] << " ";
cout << endl; // 输出到 1 的距离,-1 表示不可达
return 0;
}BFS 求无权最短路的原理:队列按"层"扩展,第一次到达某节点时的距离即最短路。
形象理解BFS 像湖面丢石子泛开的涟漪:从起点一圈一圈向外扩散,先碰到某点的圈数就是它到起点的最短距离(边权为 1 时)。
考级视角BFS 求无权最短路是 CSP-J 必考模板:
dist[v] == -1 判断未访问,dist[v] = dist[u] + 1 更新。注意 BFS 只在边权相同(或为 1)时才能求最短路,正权图要上 Dijkstra。五、拓扑排序(有向无环图 DAG)
对有向图顶点排序,使每条边 u -> v 都满足 u 排在 v 前。只有 DAG 才有拓扑序。思路:不断取出入度为 0 的顶点。
C++ 实例
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int N = 100005;
vector<int> adj[N]; // 邻接表(有向图)
int indeg[N]; // indeg[u]:节点 u 的入度(被指向次数)
// 拓扑排序:给 DAG 排出一个合法先后顺序
int main() {
int n, m; cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v; cin >> u >> v;
adj[u].push_back(v);
indeg[v]++; // 统计入度
}
queue<int> q;
for (int i = 1; i <= n; i++)
if (indeg[i] == 0) q.push(i); // 入度为 0 的点先入队
vector<int> order; // 存储拓扑序列
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : adj[u]) {
if (--indeg[v] == 0) q.push(v); // 删除 u 后 v 入度归零即可入队
}
}
// 若输出的点数 < n,说明有环(无法拓扑排序)
if ((int)order.size() != n) cout << "存在环,无拓扑序" << endl;
else {
for (int x : order) cout << x << " ";
cout << endl;
}
return 0;
}拓扑排序应用:课程安排(前驱约束)、任务调度、判 A 是否依赖 B、DAG 上 DP(见 15 章)。
形象理解拓扑排序像排课程表:必须先学完前置课才能选后续课。把“入度为 0(没有前置课)”的课先排,上完就把“解锁”的后续课加入候选,直到排完。
易错点拓扑排序只能用于 DAG(有向无环图)。判环方法:排完
order.size() != n 说明有环。笔试常问“有环图能否拓扑排序”——不能。六、进阶图论模型预告(NOIP/CSP 高阶)
- 单源最短路:Dijkstra(正权,堆优化)O((n+m)log n)、SPFA(可负权,一般 O(km))、Bellman-Ford。
- 多源最短路:Floyd O(n³),n ≤ 500 用。
- 最小生成树:Kruskal(并查集 + 边排序)、Prim。
- 判断连通/环:DFS 与并查集结合。
- 图上 DP:DAG 最长路。
C++ 实例
// Dijkstra 堆优化模板(必背,CSP-J/NOIP 高频)
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int N = 100005;
const long long INF = 1e18; // 无穷大:用 long long 防溢出
struct Edge { int to, w; };
vector<Edge> adj[N]; // 邻接表
long long dist[N]; // 到起点的最短距离
int n, m;
void dijkstra(int s) {
fill(dist, dist + N, INF);
dist[s] = 0; // 起点到自己是 0
// 小根堆:按 (距离, 节点) 排序,每次取当前最近的点
priority_queue<pair<long long, int>, vector<pair<long long, int>>, greater<pair<long long, int>>> pq;
pq.push(make_pair(0, s));
while (!pq.empty()) {
pair<long long, int> top = pq.top(); pq.pop();
long long d = top.first; int u = top.second;
if (d != dist[u]) continue; // 过期元素(已被更短路径更新过)跳过
for (auto &e : adj[u]) { // 松弛所有出边
if (dist[u] + e.w < dist[e.to]) {
dist[e.to] = dist[u] + e.w; // 找到更短路就更新
pq.push(make_pair(dist[e.to], e.to));
}
}
}
}
int main() {
cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v, w; cin >> u >> v >> w;
adj[u].push_back({v, w});
}
dijkstra(1); // 求 1 到所有点的最短路
for (int i = 1; i <= n; i++) cout << dist[i] << " ";
cout << endl;
return 0;
}形象理解Dijkstra 像逐圈扩大的“已知最短路”范围:每次从已确定最短路的点里挑距离最小的,去“松弛”它的邻居——好比修路时每修好一段就更新周边村子的最短到达时间。
易错点Dijkstra 堆优化模板三坑:①
d != dist[u] 要过滤堆里的过期元素,否则可能 TLE;② 距离数组用 long long,INF 开到 1e18;③ 有负权边不能用 Dijkstra,要用 SPFA/Bellman-Ford。七、易错点 / 常见坑点
- ⚠️ 有向图别忘只加一条边;无向图要加两条(或写
addEdge两次)。 - ⚠️ BFS/DFS 漏标记
vis会死循环(重复处理),先标记后入队/递归。 - ⚠️ 节点编号从 1 还是 0 开始,数组大小和循环边界要一致。
- ⚠️ 邻接矩阵
n很大时内存爆炸(n=10^5 不可用矩阵,必须邻接表)。 - ⚠️ Dijkstra 用堆时判
d != dist[u]过滤旧元素,否则可能 TLE。 - ⚠️ 拓扑排序结果不唯一;判环看
order.size() != n。 - ⚠️ BFS 求最短路只适用边权相同或为 1;正权用 Dijkstra。
八、考级要求 vs 竞赛进阶
- 考级要求(GESP 7 级):图的基本概念、三种存图方式、DFS/BFS 遍历、简单连通性问题、拓扑排序的基本实现。初赛笔试常考"建图后遍历输出顺序"。
- 竞赛进阶(CSP-J/NOIP):Dijkstra 堆优化(CSP-J 提高难度)、BFS 最短路的变式(多源、分层图)、拓扑排序 + DP、最小生成树 Kruskal、图论建模思维(把题目抽象成图)。
九、课内练习建议
- 手写邻接表建图并完成 DFS/BFS 输出遍历顺序。
- 给定无权图求从 1 到每个点的最短距离(BFS)。
- 做课程表拓扑排序并判断是否有环。
- 手写 Dijkstra 堆优化模板并测一组数据。
- 用并查集实现 Kruskal 最小生成树(衔接 12 章贪心)。