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、图论建模思维(把题目抽象成图)。

九、课内练习建议

  1. 手写邻接表建图并完成 DFS/BFS 输出遍历顺序。
  2. 给定无权图求从 1 到每个点的最短距离(BFS)。
  3. 做课程表拓扑排序并判断是否有环。
  4. 手写 Dijkstra 堆优化模板并测一组数据。
  5. 用并查集实现 Kruskal 最小生成树(衔接 12 章贪心)。
本章目录
一、图的基本概念 二、存图方式 三、图的 DFS 遍历 四、图的 BFS 遍历(最短路无权图) 五、拓扑排序(有向无环图 DAG) 六、进阶图论模型预告(NOIP/CSP 高阶) 七、易错点 / 常见坑点 八、考级要求 vs 竞赛进阶 九、课内练习建议