第 08 讲

图:术语与存储结构

线性表是「一对一」,树是「一对多」,而图是「多对多」——它是数据结构里最一般、也是现实世界中最常见的关系模型。 社交网络里的好友关系、地图上的道路、编译器中模块的依赖、课程之间的先修要求,全都是图。 本章先把图的语言(顶点、边、度、路径、连通、生成树)一次讲清,再把两种主流存储结构 (邻接矩阵、邻接表)连同链式前向星、十字链表、邻接多重表一起摊开对比, 最后用 DFS 与 BFS 两种遍历把它们串起来。

预计 120 分钟 前置:第 07 讲树与二叉树、第 06 讲数组与矩阵压缩 关键词:G=(V,E) · 握手定理 · 邻接矩阵 · 邻接表 · DFS · BFS
本章导读
  • 8.1 图的定义与分类 —— 把 G = (V, E) 讲透,弄清 ne 的取值范围。
  • 8.2 术语体系 —— 本章第一道坎:度、入度出度、握手定理、路径回路、连通分量、强连通分量、生成树,一个都不能含糊。
  • 8.3 邻接矩阵 —— 最直观的存储方式,配动画看「一条边写两格」的对称性,以及 0x3f3f3f3f 这个经典技巧。
  • 8.4 邻接表家族 —— 邻接表、逆邻接表、链式前向星(竞赛主流)、十字链表、邻接多重表。
  • 8.5 两种存储结构的九维对比 —— 考试最爱在这里出选择题。
  • 8.6 / 8.7 DFS 与 BFS —— 遍历是后面所有图论算法的骨架,必须能手工推出访问序列。
  • 8.8 连通分量与生成树 —— 为下一讲的 Prim / Kruskal / Dijkstra 铺路。
  • 8.9 / 8.10 经典小题速览、工程视角 —— 真实图的规模、CSR 存储、DFS/BFS 的工程落点与选型对比表。
  • 8.11 本章小结、易错点、考点与自测。

8.1 图是什么:定义、分类与规模

8.1.1 从线性表、树到图:逻辑结构的第三次跳跃

回顾一下我们走过的路。第 02 讲的线性表里,每个元素最多有一个直接前驱和一个直接后继, 元素之间的关系是「一对一」的一条链;第 07 讲的里,每个结点可以有多个孩子但只有一个双亲, 关系是「一对多」的层次结构。它们都有一个共同的前提:元素之间不允许随便乱连。 可是现实世界并不这么听话——城市之间的航线是对称的、课程之间的先修关系是有方向的、 人与人之间的好友关系既不对称也不分层。

当元素之间的关系变成任意的「多对多」时,线性表和树都不够用了,于是有了图(graph)。 请注意用词:树和图都是「非线性结构」,但树其实是图的一个特例—— 一棵有 n 个结点的树,就是「有 n−1 条边、连通、无回路」的无向图。 换句话说,图是比树更一般的结构,树是加了限制条件的图。 理解了这一点,你就会发现本讲很多术语(路径、连通、生成树)在树那一讲其实已经见过,只是当时不需要强调。

线性表:一对一

每个元素至多一个前驱、至多一个后继。
例:数组、链表、栈、队列。
关系可以画成一条链。

树:一对多

每个结点至多一个双亲,可以有多个孩子。
例:目录树、表达式树、堆。
关系画出来是层次图,无环

图:多对多

任意两个元素之间都可能有关系,方向可有可无。
例:社交网络、路网、依赖图。
允许有环,也允许不连通。

8.1.2 图的严格定义:G = (V, E)

一个图由两部分组成,记作

G = (V, E)  其中 V 是顶点集(vertex set),E 是边集(edge set)

一个常见的追问是:「图里到底存不存顶点本身的数据?」 答案是:数学意义上的图只关心「谁和谁连着」,不关心顶点上挂的信息。 但工程实现时(比如导航软件)我们会给顶点加上名字、经纬度,给边加上长度、限速—— 这些叫权(weight),加了权的图叫网(network),见 8.2.6 节。

用集合的方式描述图,有个好处:顶点编号完全可以重新排列。 把 {0,1,2} 改叫 {A,B,C},图的性质一点都不变。这叫图的同构(isomorphism)思想—— 以后你写程序时看到「图长得不一样但答案一样」,就不必惊讶了。

8.1.3 有向图、无向图、简单图、多重图、自环

按边的性质,图可以分成几类,这些分类决定了后面存储结构和算法细节的选择:

无向图 undirected graph
每条边都是无向边,(v, w)(w, v) 是同一条边。例:好友关系、无向路网。
有向图 directed graph / digraph
每条边都是有向边(弧),<v, w><w, v> 是两条不同的弧。例:微博关注、课程先修、网页链接。
简单图 simple graph
满足两条:不存在自环,且任意两点之间至多一条边(有向图里是至多一条同向弧)。本讲后面谈「完全图」「邻接矩阵」时,默认都是简单图。
多重图 multigraph
允许两个顶点之间有多条边(平行边 / 重边)。例:两座城市之间有多条航线;此时邻接矩阵只能存「边的条数」或「最小权值」,不能只存 0/1。
自环 self-loop
边的两个端点重合,即 (v, v)。自环让该顶点的度增加 2(无向图),在邻接矩阵里表现为对角线上的 1。
稀疏图 / 稠密图
边数远小于 n² 的叫稀疏图,接近 n² 的叫稠密图。这只是「感觉」,工程上常取 e < n log n(或 e < n²/10)作为分界。
易错:无向边写圆括号,有向边写尖括号 教材里 (v, w) 表示无向边、<v, w> 表示有向弧,括号形状就是方向信息。 考试时把 <v, w> 写成 (v, w),在「求强连通分量」「求拓扑序」这类题里会直接导致答案错误—— 因为 (v, w) 意味着 w 也能走到 v。写代码时同理:无向图一条边调用两次 addEdge, 有向图只调用一次,这两个「一次」和「两次」的差别贯穿全章。

8.1.4 完全图、稠密图与稀疏图:n 与 e 的取值范围

一张有 n 个顶点的简单图,最多能有多少条边?这就是完全图(complete graph)要回答的问题。

无向完全图:任意两个顶点之间都恰好有一条边。数一数:n 个顶点两两配对,共有 C(n, 2) = n(n−1)/2 条边。所以简单无向图的边数范围是

0 ≤ e ≤ n(n−1)/2 (无向简单图)

有向完全图:任意两个顶点之间都有一对方向相反的弧。每一对顶点贡献 2 条弧,所以边数上限翻倍:

0 ≤ e ≤ n(n−1) (有向简单图,n ≥ 1 时)

例如 n = 8 时,无向完全图有 8×7/2 = 28 条边,有向完全图有 8×7 = 56 条弧。 而本讲贯穿全篇的示例图只有 9 条边——它是典型的稀疏图(9 远远小于 28)。

① 无向完全图 K4:e = n(n−1)/2 = 6 AB CD ② 无向完全图 K5:e = 5×4/2 = 10 123 45 ③ 有向完全图:e = n(n−1) = 6 ABC 边数范围:无向简单图 0 ≤ e ≤ n(n−1)/2;有向简单图 0 ≤ e ≤ n(n−1)。超出这个上界,就说明出现了重边或自环,不再是简单图。 稀疏图:e 远小于 n²,工程上常以 e < n log n 为界(本讲示例图 n=8、e=9,8×log₂8 = 24);稠密图:e 接近 n²,常以 e > n²/10 为界。 为什么要区分疏密?因为邻接矩阵的空间是 O(n²) 与 e 无关,而邻接表的空间是 O(n + e)——稠密图两者相当,稀疏图里邻接表完胜。
图 8-1 完全图与边数上界:无向完全图、有向完全图、稀疏与稠密的分界
考点:边数范围的推导 无向完全图的边数 n(n−1)/2 来自组合数 C(n,2):从 n 个顶点中任取 2 个就确定一条边。 有向完全图则要先选 2 个顶点再定方向,共 C(n,2) × 2 = n(n−1) 条。 记住这两个数,很多判断题就能秒杀,例如「n 个顶点的无向连通图至少有多少条边?」—— 答案是 n−1(此时它是一棵树),再多一条边就必然出现回路。

8.2 图的术语体系:把「图的语言」说利索

8.2.1 邻接点与度

如果两个顶点之间有一条边,就说它们互为邻接点(adjacent),这条边与这两个顶点相关联(incident)。 在无向图里,「相邻」是对称的:v 与 w 相邻 ⟺ w 与 v 相邻。

度(degree) 是图论里出现频率最高的概念:顶点 v 的度 d(v),就是与它相关联的边的条数。 一个小坑:自环对度的贡献是 2,因为这条边的两个端点都是 v,它「进出各算一次」。 度数为 0 的顶点叫孤立点(isolated vertex),度数为 1 的顶点叫悬挂点(pendant vertex), 与悬挂点相连的那条边叫悬挂边

在有向图里,「度」被拆成两个方向:入度(in-degree)ID(v) 是以 v 为弧头的弧的条数, 出度(out-degree)OD(v) 是以 v 为弧尾的弧的条数,二者之和就是该顶点的度 d(v) = ID(v) + OD(v)。注意自环在有向图里会同时给入度和出度各加 1。

下面图 8-2 把所有术语都标在了同一张图上。请对照着把每个数字自己算一遍—— 这一步花三分钟,后面所有算法都会轻松很多。

示例图 G(无向、简单、连通):n = 8,e = 9 012 345 67 d=2 d=3 d=2 d=2 d=2 d=3 d=2 d=2 虚线 = 不在生成树上的边 V = {0,1,2,3,4,5,6,7} n = |V| = 8(顶点数) e = |E| = 9(边数) d(0)=2 d(1)=3 d(2)=2 d(3)=2 d(4)=2 d(5)=3 d(6)=2 d(7)=2 Σd(v) = 18 = 2e(握手定理) 奇数度顶点:1、5,共 2 个 adj(1) = {0, 2, 7} 路径 0→1→2→3,长度 3 回路 0-1-2-3-4-5-6-0 粗线:一棵生成树,7 = n−1 条边 任意两点互相可达 → 连通图 子图、网(带权图)、DAG 见正文
图 8-2 无向示例图 G 的术语全标注:顶点集、边集、度数、邻接点、路径、回路、生成树

8.2.2 入度与出度:有向图的两套账

有向图必须把「度」拆成入度和出度,因为方向决定了「能不能走出去」。 一个出度为 0 的顶点叫汇点(sink),一个入度为 0 的顶点叫源点(source); 这两类点在拓扑排序、网络流里都是重点关注对象。

有向图 D:5 个顶点、5 条弧 ABC DE in=1, out=2 in=1, out=1 in=1, out=1 in=1, out=1 in=1, out=0(汇点) 回路 A→B→C→A(长度 3) Σ出度 = 2+1+1+1+0 = 5 = e  Σ入度 = 1+1+1+1+1 = 5 = e A、B、C 两两互相可达 → {A,B,C} 是一个强连通分量 整张图不是强连通图(E 走不回去) 缩点后 {ABC} → D → E 无环,是 DAG A 可以到达 B、C、D、E(可达) E 的出度为 0,无法到达任何点 有向图求度:出度看「以我为尾」的弧,入度看「以我为头」的弧。
图 8-3 有向图的入度 / 出度、回路、强连通分量与可达性
一句话记住入度和出度 出度 = 「我主动连出去的边数」,入度 = 「别人连到我头上的边数」。 社交网络里,关注别人的人数就是出度,粉丝数就是入度——微博大 V 的特征是入度极大而出度很小。

8.2.3 握手定理:证明与例题

整章最重要的一个定量结论来了。它有一个形象的名字:握手定理(handshaking lemma)—— 想象一次聚会,每握一次手都要用到两只手,所以「所有人握手的次数总和」一定是偶数。

无向图:Σv∈V d(v) = 2e  (所有顶点的度数之和等于边数的两倍)
有向图:Σv∈V ID(v) = Σv∈V OD(v) = e (入度之和 = 出度之和 = 弧数)

证明(无向图,用「算两次」的方法):

  1. 把图中所有顶点 v 的度数 d(v) 加起来,得到 S = Σ d(v)
  2. 换个角度数同一个 S:每一条边 (u, w) 的端点有两个,它给 d(u) 贡献 1,也给 d(w) 贡献 1,所以每条边对 S 的贡献恰好是 2
  3. e 条边总共贡献 2e,于是 S = 2e。∎

第 2 步里「每条边被数了两次」,正是「握手要两只手」的严格表述。这个证明短得出奇,但它有三个威力巨大的推论:

推论 1:度数之和必为偶数

因为 Σd(v) = 2e 一定是偶数。
所以「n 个顶点度数分别是 3,3,3」这种描述在 n 为奇数时必然矛盾——3n 是奇数。

推论 2:奇度顶点的个数必为偶数

Σd(v) 分成奇度部分与偶度部分:偶度部分之和是偶数, 总和也是偶数,所以奇度部分之和必为偶数,而若干奇数相加为偶数 ⟺ 奇数的个数是偶数。

推论 2 有个漂亮的应用:任何一张图的奇度顶点个数都是偶数,所以「只有 1 个奇度顶点」的图不存在。 这也直接决定了欧拉路径的判定条件(见 8.9.4)。

例题(握手定理的直接应用)

题目:一个无向简单图有 10 个顶点、15 条边,且每个顶点的度数都相等,求每个顶点的度数; 并判断「10 个顶点中有 9 个度数为 3、剩下 1 个度数为 2」的无向图是否存在。

解答:

  1. 由握手定理,Σd(v) = 2e = 30,10 个顶点度数相同,故每个顶点的度为 30/10 = 3
  2. 若 9 个顶点度为 3、1 个度为 2,则 Σd(v) = 9×3 + 2 = 29,是奇数, 而度数之和必须是偶数,矛盾,所以这样的图不存在

(补充:10 个顶点、每点度数为 3 的图叫 3-正则图,是存在的,例如 Petersen 图与五棱柱图。)

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   握手定理的代码验证:Σdeg(v) == 2e
   顺便演示"能否构成无向图"的判定

   竞赛写法:链式前向星(数组模拟邻接表),
   与第 08 讲 8.4 节、第 09 讲 graph_base.cpp 完全同一套模板:
       head[u] = u 的第一条出边下标(-1 表示没有)
       to[i] / nxt[i] = 第 i 条边的终点 / 同起点的下一条边
   另外用 eu[] / ev[] 存下每条边的两个端点,deg[] 顺手统计度数。
   ============================================================ */

const int MAXN = 100005;        /* 顶点数上限 */
const int MAXM = 200005;        /* 边数上限(无向图要开 2 倍) */

int head[MAXN], to[MAXM], nxt[MAXM];
int eu[MAXM], ev[MAXM];         /* 第 i 条边的两个端点(无向边只记一次) */
int deg[MAXN];                  /* deg[v] = 顶点 v 的度数 */
int n, etot = 0;                /* n 个顶点,etot 条边 */

void initGraph(int n_) {
    n = n_;
    memset(head, -1, sizeof(head));    /* -1 表示"链表为空",加边前必须做 */
    memset(deg, 0, sizeof(deg));
    etot = 0;
}

/* 加一条无向边:给两个端点各 +1 度,注意在无向图里这是一条边,不是两条 */
void addEdge(int u, int v) {
    eu[etot] = u; ev[etot] = v;

    to[etot] = v; nxt[etot] = head[u]; head[u] = etot; ++etot;   /* u -> v */
    to[etot] = u; nxt[etot] = head[v]; head[v] = etot; ++etot;   /* v -> u */

    deg[u]++; deg[v]++;                /* 无向边给两个端点各 +1 */
}

int sumDeg() {
    int s = 0;
    for (int v = 0; v < n; ++v) s += deg[v];
    return s;
}

/* 给定度数序列,判断是否存在一个无向简单图(只用必要条件做快速否定) */
bool possible(const vector<int>& d) {
    int cnt = d.size(), s = 0, odd = 0;
    for (int x : d) { s += x; if (x & 1) odd++; if (x < 0 || x >= cnt) return false; }
    if (s % 2 != 0) return false;        // 度数之和必须是偶数
    if (odd % 2 != 0) return false;      // 奇度顶点个数必须是偶数(上一条的推论)
    return true;                         // 只是必要条件,充分性要用 Havel-Hakimi
}

int main() {
    initGraph(8);
    int E[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 9; ++i) addEdge(E[i][0], E[i][1]);

    cout << "e = " << etot / 2 << "\n";                     // 9(无向边存了两条弧,别忘除以 2)
    for (int v = 0; v < n; ++v) cout << deg[v] << " ";      // 2 3 2 2 2 3 2 2
    cout << "\nΣdeg = " << sumDeg() << " , 2e = " << 2 * (etot / 2) << "\n";  // 18 18

    /* 顺带验证链式前向星没建错:顶点 0 的邻接点个数应当等于 deg[0] */
    int cnt0 = 0;
    for (int i = head[0]; i != -1; i = nxt[i]) ++cnt0;
    if (cnt0 != deg[0]) printf("forward star error!\n");   /* 正常情况不会打印 */

    vector<int> d1 = {3,3,3,3,3,3,3,3,3,3};
    vector<int> d2 = {3,3,3,3,3,3,3,3,3,2};
    cout << possible(d1) << "\n";        // 1:10 个 3 度点,和为 30,可行
    cout << possible(d2) << "\n";        // 0:和为 29 是奇数,不可能
    return 0;
}

8.2.4 路径、简单路径、回路与简单回路

路径(path)是从顶点 v 出发,经过一系列边到达顶点 w 的顶点序列, 例如 0 → 1 → 2 → 3。路径上边的条数叫路径长度(length)—— 注意教材里「长度」数的是而不是顶点,别数错了。

易错:路径长度数边不数点 顶点序列 0→1→2→3 有 4 个顶点、3 条边,路径长度是 3。 另外,「简单路径」与「简单回路」都要求顶点不重复;很多同学只记住「不重复」,做题时把 0-1-7-5-1-2 这种顶点 1 出现两次的走法也算成简单路径,就错了。

8.2.5 连通、连通图、连通分量、强连通分量

连通(connected)是只对无向图说的:如果顶点 u 到 v 存在路径,就说 u 与 v 连通。 若图中任意两个顶点都连通,则称这张无向图是连通图(connected graph)。 图 8-2 的示例图就是连通的,你可以验证任意两点之间都能走通。

连通分量(connected component)是无向图里「极大的连通子图」: 把图拆成若干块,每块内部连通,块与块之间不连通,这些块就是连通分量。 关键词是极大——只要能再加一个顶点进去还保持连通,就说明还没取到极大。 连通图的连通分量个数是 1;孤立点自己单独构成一个连通分量。

有向图不能简单说「连通」,要分成三个层次:

强连通 strongly connected
有向图中,若对任意两个顶点 u、v,既有 u 到 v 的路径,也有 v 到 u 的路径,则称图强连通。注意是「双向可达」,缺一不可。
强连通分量 SCC
有向图的极大强连通子图,记作 强连通分量(Strongly Connected Component)。任何一个有向图都可以唯一地分解成若干强连通分量。求 SCC 的两大算法是 Kosaraju 与 Tarjan,属于下一讲的内容。
单向连通 / 弱连通
若任意两点之间至少有一个方向可达,称单向连通;若把弧都当成无向边后图是连通的,称弱连通。层次关系:强连通 ⟹ 单向连通 ⟹ 弱连通。

图 8-3 中的有向图:{A,B,C} 内部两两可达,是一个强连通分量; DE 各自单独成为强连通分量(单个顶点永远强连通)。 把每个强连通分量「缩」成一个点,得到的图一定是 DAG(有向无环图)——这是缩点法的理论基础。

无向图 vs 有向图:连通用词的对照表
概念无向图有向图
两点之间连通(互相可达)可达(有方向)
整体性质连通图强连通 / 单向连通 / 弱连通
极大子图连通分量强连通分量
求法DFS / BFS 一遍扫描Tarjan / Kosaraju(下一讲)

8.2.6 子图、生成树、生成森林、网与 DAG

子图 subgraph
从原图中选出部分顶点和部分边构成的图,记作 G' ⊆ G:要求 V' ⊆ VE' ⊆ E,且 E' 中的边端点都在 V' 里。若 V' = V,叫生成子图。
生成树 spanning tree
连通无向图的一个生成子图,它同时是一棵树(连通 + 无回路)。n 个顶点的连通图,其生成树恰好有 n−1 条边。图 8-2 里的粗线就是一棵生成树。
生成森林 spanning forest
非连通图的每个连通分量各取一棵生成树,合起来就是生成森林,共 n − k 条边(k 为连通分量个数)。
网 network / 带权图
边上带有数值(权值,如距离、费用、时间)的图叫。带权图的邻接矩阵存的不再是 0/1 而是权值,无边处存 ∞。最短路径、最小生成树都是网上的问题。
有向无环图 DAG
Directed Acyclic Graph,不存在有向回路的图。它是「可以拓扑排序」的等价说法,用于任务调度、依赖解析、动态规划的状态转移图。
稠密图与稀疏图
见 8.1.3。选择邻接矩阵还是邻接表,第一步就是判断图的疏密。

8.2.7 术语总表(考前速查)

下面这张表把本章出现的术语一次收齐,建议对照图 8-2、图 8-3 逐个指着念一遍:

顶点 vertex
图的基本元素,记 n = |V|
边 / 弧 edge / arc
顶点间的连线,记 e = |E|。无向边 (v,w),有向弧 <v,w>
邻接点 adjacent
被同一条边连接的两个顶点互为邻接点。
度 degree
无向图中与 v 关联的边数 d(v);自环贡献 2。
入度 in-degree
有向图中以 v 为弧头的弧数 ID(v)
出度 out-degree
有向图中以 v 为弧尾的弧数 OD(v)
握手定理
Σd(v) = 2e;有向图 ΣID = ΣOD = e。推论:奇度顶点个数为偶数。
路径 path
顶点与边的交替序列;长度 = 边的条数。
简单路径
顶点不重复出现的路径。
回路 cycle
起点与终点相同的路径,也叫环。
简单回路
除起终点外顶点不重复的回路。
连通 connected
无向图中两点之间存在路径。
连通图
任意两点都连通的无向图。
连通分量
无向图的极大连通子图;个数用 DFS/BFS 扫描求得。
强连通图
有向图中任意两点互相可达。
强连通分量 SCC
有向图的极大强连通子图;缩点后得到 DAG。
子图 subgraph
顶点集与边集都是原图子集的图。
生成树
连通图的极小连通生成子图,恰有 n−1 条边。
生成森林
各连通分量生成树的并,共 n−k 条边。
网 network
边上带权的图;邻接矩阵中无边处记 ∞。
DAG
有向无环图,可拓扑排序。
完全图
任意两点间都有边;无向 n(n−1)/2 条,有向 n(n−1) 条。
稀疏图 / 稠密图
按 e 相对 n² 的大小划分,常以 e < n log n 为稀疏的分界。
自环 / 重边
端点重合的边 / 两点间的多条边;两者都会破坏「简单图」的前提。
欧拉路径 / 欧拉回路
经过每条边恰好一次的路径 / 回路;判定见 8.9.4。
割点 / 桥
删掉后使连通分量个数增加的顶点 / 边;见 8.9.2。

8.3 存储结构(一):邻接矩阵

8.3.1 定义:用一个二维数组记录「谁和谁相邻」

图的逻辑结构是「多对多」,而计算机的内存是一维线性的,所以我们必须想办法把关系「压平」。 最朴素的想法是:把所有顶点两两之间的关系都记下来,做成一张 n×n 的表格。这就是邻接矩阵(adjacency matrix)

设图 G = (V, E)V = {v0, v1, …, vn−1},用一个二维数组 A[n][n] 表示:

无权图:A[i][j] = 1(当 (i,j) ∈ E 或 <i,j> ∈ E);否则 A[i][j] = 0
带权图(网):A[i][j] = wij(边的权值);无边时 A[i][j] = ∞(代码里用一个大数表示)
约定:A[i][i] = 0(自己到自己没有边,权值也为 0)

注意「顶点必须编号」。矩阵的行列下标就是顶点编号,所以建图前要先给顶点编好 0..n−1 的号 (或者把字符串名字用 map<string,int> 映射成整数),这也是几乎所有图论题目的第一步。

无向图:矩阵一定对称

无向边 (u, v) 既是 u 的边也是 v 的边,所以在矩阵里要写 两格A[u][v] = 1A[v][u] = 1。于是无向图的邻接矩阵必然满足 A[i][j] = A[j][i],即关于主对角线对称

反过来,有向图的邻接矩阵一般不对称A[i][j] = 1 表示有弧 i→j, 至于 j 能不能走到 i,要看 A[j][i] 是不是 1。这个差别让「求度」的公式也分成了两套(见 8.3.3)。

无向图 G(n = 8,e = 9) 012 345 67 无向图的邻接矩阵一定是对称矩阵: A[i][j] = A[j][i] (一条边写两格) 对称矩阵可以压缩存储:只存下三角,空间从 n² 降到 n(n+1)/2(第 06 讲的内容)。 不过图论题目里 n 通常不大,为了代码简单, 一般就直接开 n×n 的二维数组,不做压缩。 j0 j1 j2 j3 j4 j5 j6 j7 i0 01000010 i1 10100001 i2 01010000 i3 00101000 i4 00010100 i5 00001011 i6 10000100 i7 01000100 度 d(v) 23222322 第 i 行之和 = 顶点 i 的度;矩阵中 1 的个数 = 2e = 18。
图 8-4 无向图的邻接矩阵:对称、行和等于度

有向图:行和是出度,列和是入度

有向图的矩阵把方向信息直接编码进了下标顺序:A[i][j] = 1 读作「有一条从 i 出发、 指向 j 的弧」。所以第 i 行非零元素的个数就是 i 的出度,第 i 列非零元素的个数就是 i 的入度。 这个「行出列入」的口诀请务必背下来,考试里求入度出度的题基本都靠它。

有向图 D(n = 5,e = 5) ABC DE 弧:A→B,B→C,C→A,A→D,D→E 矩阵一般不对称(除非正反两个方向都有弧) 求度口诀:行和 = 出度,列和 = 入度 Σ行和 = Σ列和 = e = 5(有向图的握手定理) j0j1 j2j3 j4 行和=出度 i0 01010 2 i1 00100 1 i2 10000 1 i3 00001 1 i4 00000 0 列和=入度 11111 A 的出度 = 2(行和 = 1+1) C 的入度 = 1(列和 = 1) E 所在行全 0 → 出度为 0,是汇点
图 8-5 有向图的邻接矩阵:行和是出度、列和是入度

8.3.2 与第 06 讲的对称矩阵压缩存储接上头

第 06 讲我们讲过:如果一个 n×n 矩阵满足 A[i][j] = A[j][i],它就是对称矩阵, 可以把 个元素压缩进 n(n+1)/2 个单元,只存下三角(含对角线)。 映射公式是(行优先,下标从 0 开始):

k = i(i+1)/2 + j (当 i ≥ j 时)

无向图的邻接矩阵天然满足对称性,所以理论上也能这么压。n = 1000 时, n² = 1,000,000n(n+1)/2 ≈ 500,500,省了一半。 但是——实际写图论程序时几乎没人压缩,原因有三个:

  1. 压缩后取值要算下标(多一次乘法与除法),代码复杂度上去了,常数也变大了;
  2. 稀疏图用邻接矩阵本来就是错的选择,压缩只是把「浪费」从 100% 降到 50%,治标不治本;
  3. 带权图要存 ∞,压缩后还要额外判断「这一格到底存过没存过」,得不偿失。

所以考试里「无向图的邻接矩阵是对称矩阵,可压缩存储」这句话你要会判断, 但写代码时请老老实实用二维数组或者干脆换邻接表。

8.3.3 从矩阵能读出什么:度、邻接判断、邻接点枚举

要问的问题无向图有向图复杂度
i 与 j 是否相邻?A[i][j] != 0A[i][j] != 0(i→j)O(1)
顶点 i 的度第 i 行之和O(n)
顶点 i 的出度第 i 行之和O(n)
顶点 i 的入度第 i 列之和O(n)
枚举 i 的所有邻接点扫描第 i 行扫描第 i 行O(n)
遍历整张图(DFS/BFS)每个顶点都要扫一整行O(n²)
易错:O(1) 与 O(n) 的对比要分清 「判断两点是否相邻」是 O(1)(一次数组访问),这是邻接矩阵最大的优势; 但「枚举某点的所有邻接点」必须扫描整行,即使该点只有 1 个邻接点也要扫 n 次,是 O(n)。 很多人只记住「邻接矩阵查边快」,却忘了它在枚举邻居上很慢——这正是邻接表要补的短板。

8.3.4 空间复杂度 O(n²):与边数无关的代价

邻接矩阵占 个存储单元,与边数 e 完全无关。 这意味着:一张有 10000 个顶点、却只有 100 条边的稀疏图(比如一个社交网络的局部), 用邻接矩阵要开 108 个 int,约 400 MB——直接内存超限; 而用邻接表只需要 n + 2e ≈ 10200 个单元,差了一万倍。

反过来,如果图很稠密(e ≈ n²/2),两者空间同阶,邻接矩阵因为实现简单、 访问连续反而更快。所以选择存储结构的第一原则永远是:先看图的疏密,再看需要的操作

还有一个非常实用的技巧要在这里讲清楚:0x3f3f3f3f 表示无穷大 INF。 带权图的邻接矩阵里,两个不相邻的顶点之间的距离要填 ∞。C++ 里现成的 INT_MAX2147483647,看起来正合适,但它在做松弛运算时会出事:

#include <climits>
#include <cstring>
#include <iostream>
using namespace std;

/* ============================================================
   带权图的邻接矩阵(网)与 INF 的选取
   ============================================================ */
const int MAXN = 105;
const int INF  = 0x3f3f3f3f;        // 1061109567

int W[MAXN][MAXN];

/* ---------- 为什么不用 INT_MAX? ----------
   Dijkstra / Floyd 里到处都有 W[u][v] + W[v][w] 这样的松弛运算。
   若用 INT_MAX:
       INT_MAX + INT_MAX = 4294967294  →  超出 int 上限,有符号溢出(未定义行为)
       INT_MAX + 一条正常边权         →  同样溢出
   若用 0x3f3f3f3f:
       0x3f3f3f3f + 0x3f3f3f3f = 2122219134 < INT_MAX = 2147483647  →  安全
       “两个 INF 相加仍是(更大的)有限数,不会变成负数”是它最大的价值。 */

void initWeighted(int n) {
    memset(W, 0x3f, sizeof(W));     // 逐字节填 0x3f,每个 int 恰好变成 0x3f3f3f3f
    for (int i = 0; i < n; ++i) W[i][i] = 0;   // 自己到自己的距离为 0
}

void addWeightedEdge(int u, int v, int w) {    // 无向网:对称写两格
    W[u][v] = W[v][u] = w;
}

int main() {
    initWeighted(5);
    addWeightedEdge(0, 1, 4);
    addWeightedEdge(1, 2, 3);
    addWeightedEdge(0, 2, 9);
    addWeightedEdge(2, 3, 7);

    cout << "W[0][1] = " << W[0][1] << "\n";                 // 4
    cout << "W[0][4] = " << W[0][4] << "  (即 INF)\n";       // 1061109567
    cout << "INF*2  = " << INF * 2 << "\n";                  // 2122219134,未溢出
    cout << "INT_MAX= " << INT_MAX << "\n";                 // 2147483647
    cout << "W[0][4] + W[4][0] = " << W[0][4] + W[4][0] << "  ← 两个 INF 相加仍安全\n";
    return 0;
}
INF 选取的三个候选
  • 0x3f3f3f3f = 1061109567:最推荐。两倍不溢出,可用 memset(W, 0x3f, sizeof(W)) 一口气刷满,还能用 W[u][v] == INF 判断无边。
  • 0x7fffffff(INT_MAX):不能参与加法,一加就溢出成负数,导致「无穷大居然比有限值还小」的诡异 bug。
  • 1e9(约 10 亿):够大也够安全,缺点是只能用循环赋值,不能 memset。

8.3.5 邻接矩阵的完整 C++ 实现

下面这份代码把「建图 → 求度 → 判相邻 → DFS → BFS」串成了一条完整链路,可以直接编译运行。 注意 addEdge 里的对称赋值,以及矩阵版 DFS 为什么必须扫描整行。

#include <iostream>
#include <queue>
#include <vector>
using namespace std;

/* ============================================================
   邻接矩阵版:无向无权图
   A[i][j] = 1 表示有边,0 表示无边
   ============================================================ */
const int MAXN = 105;
int A[MAXN][MAXN];              // 全局数组,自动全部清零
int n = 8, e = 0;               // 顶点数、边数

void addEdge(int u, int v) {
    if (A[u][v]) return;        // 已经有边(重边)就不再计数
    A[u][v] = A[v][u] = 1;      // 无向图:对称写两格
    ++e;
}

int degree(int u) {             // 无向图求度:第 u 行之和
    int s = 0;
    for (int v = 0; v < n; ++v) s += A[u][v];
    return s;
}

bool adjacent(int u, int v) { return A[u][v] != 0; }   // O(1) 判相邻

/* ---------- 邻接矩阵上的 DFS:每个顶点要扫一整行 ---------- */
void dfs(int u, vector<bool>& vis, vector<int>& order) {
    vis[u] = true;
    order.push_back(u);
    for (int v = 0; v < n; ++v)          // 注意是 O(n),不是 O(deg(u))
        if (A[u][v] && !vis[v]) dfs(v, vis, order);
}

/* ---------- 邻接矩阵上的 BFS:顺带求无权最短路 ---------- */
vector<int> bfs(int s) {
    vector<int> dist(n, -1), order;
    queue<int> q;
    dist[s] = 0; q.push(s);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (int v = 0; v < n; ++v)
            if (A[u][v] && dist[v] == -1) { dist[v] = dist[u] + 1; q.push(v); }
    }
    cout << "dist[] = ";
    for (int x : dist) cout << x << " ";
    cout << "\n";
    return order;
}

int main() {
    int E[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 9; ++i) addEdge(E[i][0], E[i][1]);

    cout << "n = " << n << ", e = " << e << "\n";
    int sum = 0;
    for (int u = 0; u < n; ++u) { int d = degree(u); sum += d; cout << "deg(" << u << ")=" << d << " "; }
    cout << "\nΣdeg = " << sum << " = 2e = " << 2 * e << "\n";
    cout << "0 与 1 相邻? " << (adjacent(0, 1) ? "yes" : "no") << "\n";   // yes
    cout << "0 与 2 相邻? " << (adjacent(0, 2) ? "yes" : "no") << "\n";   // no

    vector<bool> vis(n, false);
    vector<int> ord;
    dfs(0, vis, ord);
    cout << "DFS: "; for (int x : ord) cout << x << " "; cout << "\n";     // 0 1 2 3 4 5 6 7
    cout << "BFS: "; for (int x : bfs(0)) cout << x << " "; cout << "\n";  // 0 1 6 2 7 5 3 4
    return 0;
}

下面用同一张示例图,逐条边演示矩阵是怎么被填出来的。注意每一步都同时点亮两格——这就是对称性的来源:

考点:邻接矩阵的三个常数
  1. 空间O(n²),与 e 无关。n = 1000 的无权图约需 1 MB(按 boolchar 存), 但若是 int 且 n = 10000,就已经是 400 MB。
  2. 遍历时间O(n²)。因为每个顶点都要扫描一整行,与边数无关。
  3. 求度时间O(n)。虽然比邻接表的 O(deg) 慢,但对稠密图来说差别不大。

8.4 存储结构(二):邻接表家族

8.4.1 邻接表:只存真正存在的边

邻接矩阵的浪费来自「把不存在的边也记下来了」。邻接表的思路非常自然: 为每个顶点挂一条链表,链表中只存它真正的邻接点。 具体由两部分组成:

于是「枚举顶点 u 的所有邻接点」就变成了「遍历 u 的链表」,花费的时间正比于 u 的度, 而不是 n。这就是邻接表相对邻接矩阵最本质的优势。

邻接表(无向图 G):顶点表 vertex[0..7] + 边结点链 [adjvex | next] 顶点表 边结点(每个对应一条“半边”,无向图共 2e = 18 个) 0 1 2 3 4 5 6 7 1 6 0 2 7 1 3 2 4 3 5 4 6 7 0 5 1 5 顶点 0 的邻接点:1, 6 → 度 = 2 顶点 1 的邻接点:0, 2, 7 → 度 = 3 顶点 2 的邻接点:1, 3 → 度 = 2 顶点 4 的邻接点:3, 5 → 度 = 2 顶点 5 的邻接点:4, 6, 7 → 度 = 3 无向图:边结点数 = 2e = 18 有向图:边结点数 = e(只存出边) 链表中邻接点的顺序取决于插入顺序,与顶点编号无关。
图 8-6 邻接表:顶点表 + 每个顶点的边结点链(无向图需要 2e 个边结点)

8.4.2 边结点数、求度,以及「入度不方便」引出的逆邻接表

邻接表有一个必须记牢的数字:无向图的边结点数是 2e,有向图是 e。 原因很简单——无向图的一条边 (u,v) 在数学上是一个对象,但在邻接表里必须 在 u 的链表和 v 的链表中各出现一次;有向图的弧 u→v 只出现在 u 的链表里, 所以正好 e 个。这也解释了 8.5 节对比表里的空间公式 O(n + 2e) = O(n + e)

操作邻接表(无向)邻接表(有向)复杂度
求顶点 u 的度 / 出度遍历 u 的链表计数遍历 u 的链表计数(出度)O(deg(u))
求顶点 u 的入度必须扫描全部 n 条链表,看谁指向 uO(n + e)
枚举 u 的所有邻接点遍历链表遍历链表(出边)O(deg(u))
判断 u、v 是否相邻要在链表中查找要在链表中查找O(deg(u))
遍历整张图所有链表长度之和 = O(n + e)O(n + e)

注意倒数第二行:判断两点是否相邻,邻接表反而比邻接矩阵慢。 这是「没有免费午餐」的典型例子——邻接表省了空间、快了遍历,代价是查边变慢。

有向图的入度问题怎么解决?答案是再建一张逆邻接表(inverse adjacency list): 它的链表里存的是指向自己的那些顶点。有了逆邻接表,求入度就变成「遍历自己的逆链表」, 也是 O(deg) 了。当然代价是空间翻倍,所以通常只在确实需要频繁问入度时才建 (例如拓扑排序的 Kahn 算法、Tarjan 求强连通分量)。

小技巧:如果图是静态的、不常改动,也可以干脆同时维护正向与逆向两套 vector, 建图时各 push_back 一次,代码几乎不增加复杂度,却把出入度都变成了 O(1) 级访问。

8.4.3 邻接表的 C++ 实现(vector 版,最省心)

在 C++ 里,最省心的邻接表写法是 vector<vector<int> >: 外层 vector 对应顶点表,内层 vector 就是那条链表。 它不需要手写指针、不需要预先知道边数、还能用范围 for 循环遍历,非常适合教学与刷题。

#include <iostream>
#include <queue>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   邻接表版(vector<vector<int> >):最省心的写法
   无向图:addEdge 调用两次     有向图:只调用一次
   ============================================================ */
const int MAXN = 100005;
vector<int> g[MAXN];            // g[u] 里存 u 的所有邻接点
int n = 8;

void addEdge(int u, int v) {    // 无向边
    g[u].push_back(v);
    g[v].push_back(u);
}
/* 若是有向图,只写 g[u].push_back(v); 一行即可 */

int degree(int u) { return (int)g[u].size(); }   // 无向图的度 = 链表长度

void dfs(int u, vector<bool>& vis, vector<int>& order) {
    vis[u] = true;
    order.push_back(u);
    for (int v : g[u])                       // 只遍历真实存在的边 → O(deg(u))
        if (!vis[v]) dfs(v, vis, order);
}

vector<int> bfs(int s) {
    vector<int> dist(n, -1), order;
    queue<int> q;
    dist[s] = 0; q.push(s);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (int v : g[u])
            if (dist[v] == -1) { dist[v] = dist[u] + 1; q.push(v); }
    }
    return order;
}

int main() {
    int E[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 9; ++i) addEdge(E[i][0], E[i][1]);
    for (int u = 0; u < n; ++u) sort(g[u].begin(), g[u].end());   // 升序枚举

    int total = 0;
    for (int u = 0; u < n; ++u) {
        cout << u << " 的邻接点: ";
        for (int v : g[u]) cout << v << " ";
        cout << " 度=" << degree(u) << "\n";
        total += degree(u);
    }
    cout << "所有链表长度之和 = " << total << " = 2e = " << 2 * 9 << "\n";

    vector<bool> vis(n, false); vector<int> ord;
    dfs(0, vis, ord);
    cout << "DFS: "; for (int x : ord) cout << x << " "; cout << "\n";
    cout << "BFS: "; for (int x : bfs(0)) cout << x << " "; cout << "\n";
    return 0;
}
易错:链表中邻接点的顺序是不确定的 vector 版邻接表里,邻接点的顺序就是插入顺序;如果用头插法的链式前向星, 顺序恰好反过来。所以同一张图,用不同的存储方式跑 DFS,得到的访问序列可能不同, 但都是合法的 DFS 序列。考试时若题目没有规定访问顺序,通常约定「按顶点编号从小到大访问」, 做题前先确认这一点,否则会和标准答案对不上。

8.4.4 链式前向星:竞赛选手的主流写法

vector<vector<int> > 虽然好写,但有两个隐患: 一是每个顶点的 vector 都要单独申请堆内存,内存不连续、缓存不友好, 在 106 级别的数据上常数明显偏大;二是频繁 push_back 可能触发扩容。 于是竞赛圈广泛使用一种「用数组模拟链表」的写法——链式前向星(forward star)

它只需要三个(带权时四个)一维数组:

head[u]
以 u 为起点的「第一条边」在边数组中的下标;没有出边时为 −1。
to[idx]
第 idx 条边的终点(弧头)。
nxt[idx]
与第 idx 条边同起点的下一条边的下标,构成链表的后继指针;没有则为 −1。
w[idx]
第 idx 条边的权值(无权图可省略)。

插入一条边 u→v 只有三行:

to[idx] = v; nxt[idx] = head[u]; head[u] = idx; ++idx;

三行的含义分别是「记录终点」「把新结点接在原来的链表头部」「让 head 指向新结点」。 因为是头插法,枚举顺序与插入顺序相反——这一点在需要固定遍历顺序时要注意。 遍历顶点 u 的所有出边,写成一行经典的 for 循环:

#include <cstring>
#include <iostream>
#include <queue>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   链式前向星(数组模拟邻接表)—— 竞赛主流写法
   head[u] : u 的第一条出边下标(-1 表示没有)
   to[i]   : 第 i 条边的终点
   nxt[i]  : 与第 i 条边同起点的下一条边
   w[i]    : 第 i 条边的权值
   ============================================================ */
const int MAXN = 100005;      // 顶点数上限
const int MAXM = 200005;      // 边数上限(无向图要开 2 倍)

int head[MAXN], to[MAXM], nxt[MAXM], w[MAXM];
int idx = 0;                  // 下一个可用的边结点下标

void initGraph() {
    memset(head, -1, sizeof(head));   // -1 表示“链表为空”
    idx = 0;
}

/* 加一条有向边 u -> v,权值为 c;无向边请调用两次 */
void addEdge(int u, int v, int c = 1) {
    to[idx]  = v;
    nxt[idx] = head[u];       // 新结点接在头部
    w[idx]   = c;
    head[u]  = idx;
    ++idx;
}

/* 遍历 u 的所有出边:这是链式前向星最经典的循环 */
void listEdges(int u) {
    cout << u << " 的出边: ";
    for (int i = head[u]; i != -1; i = nxt[i])
        cout << "(" << u << "->" << to[i] << ",w=" << w[i] << ") ";
    cout << "\n";
}

void dfs(int u, vector<bool>& vis, vector<int>& order) {
    vis[u] = true;
    order.push_back(u);
    for (int i = head[u]; i != -1; i = nxt[i])
        if (!vis[to[i]]) dfs(to[i], vis, order);
}

vector<int> bfs(int s, int n) {
    vector<int> dist(n, -1), order;
    queue<int> q;
    dist[s] = 0; q.push(s);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (int i = head[u]; i != -1; i = nxt[i]) {
            int v = to[i];
            if (dist[v] == -1) { dist[v] = dist[u] + 1; q.push(v); }
        }
    }
    return order;
}

int main() {
    int n = 8;
    initGraph();
    int E[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int k = 0; k < 9; ++k) {         // 无向图 → 每条边插两次
        addEdge(E[k][0], E[k][1]);
        addEdge(E[k][1], E[k][0]);
    }
    cout << "边结点总数 = " << idx << "(无向图 = 2e = 18)\n";   // 18

    for (int u = 0; u < n; ++u) listEdges(u);

    vector<bool> vis(n, false); vector<int> ord;
    dfs(0, vis, ord);
    cout << "DFS: "; for (int x : ord) cout << x << " "; cout << "\n";
    cout << "BFS: "; for (int x : bfs(0, n)) cout << x << " "; cout << "\n";
    return 0;
}
链式前向星:head[8] + to[18] + nxt[18](无向图 9 条边 → 18 个边结点) head[] 26810 12161517 0123 4567 to[] 1060 2171 3243 5465 75 nxt[] -1-10-1 1-14-1 5-19-1 11-1133 147 0123 4567 891011 12131415 1617 以顶点 5 为例,顺着 head[5] = 16 一路走 nxt: #16 to=7 #14 to=6 #13 to=4 nxt = -1(结束) 于是顶点 5 的邻接点是 7、6、4——注意顺序与插入顺序(4,6,7)相反,因为每次都是头插法。 插入一条边只要三行:to[idx]=v; nxt[idx]=head[u]; head[u]=idx; ++idx; 复杂度 O(1)。 遍历 u 的出边:for (int i = head[u]; i != -1; i = nxt[i]) … 总时间 O(n + e)。 优点:三个一维数组、内存连续、无指针、无 vector 开销,常数极小;缺点:头插导致顺序逆序,删除边不方便。 代价:必须预先估计边数上限(无向图要开 2e),开小了会数组越界。
图 8-7 链式前向星:head / to / nxt 三个数组如何拼出邻接表

下面这个动画用同一张示例图,把 18 个边结点一条一条插进去,你可以盯着 head 的变化看—— 它永远指向最新插入的那条边:

三种邻接表写法怎么选
  • 教学 / 小数据 / 需要按序枚举vector<vector<int> >,最省心。
  • 竞赛 / 大数据(n, e ≥ 105:链式前向星,常数最小,但要注意开够数组。
  • 需要频繁求入度:额外建一张逆邻接表,或者用「正向 + 反向」两套 vector

8.4.5 十字链表:有向图的「出入两全」结构

邻接表存有向图的痛点是:出边很好找,入边很难找。能不能让每个顶点同时掌握自己的出边链和入边链? 能,这就是十字链表(orthogonal list / cross list)。 它把邻接表与逆邻接表「交叉」在一起,所以叫十字链表。

结构分两部分:

顶点结点
三个域:data(数据)、firstIn(指向第一条以我为弧头的边结点,即入边链)、firstOut(指向第一条以我为弧尾的边结点,即出边链)。
边结点(弧结点)
四个域:tailVex(弧尾顶点下标)、headVex(弧头顶点下标)、hLink(指向同一个弧头的下一条弧,串起入边链)、tLink(指向同一个弧尾的下一条弧,串起出边链)。

记忆口诀:t 开头管尾巴(tail),h 开头管脑袋(head)。 于是:顺着 firstOuttLink,得到该顶点的全部出边; 顺着 firstInhLink,得到该顶点的全部入边。 求入度和出度都变成了 O(deg),而且每条弧只存一个结点,空间仍是 O(n + e)。

代价是每个边结点要多一个指针域,指针维护也更复杂,所以十字链表主要用于 需要频繁修改、且同时关心出入边的有向图场景(例如某些编译器中的依赖图、网络流建模)。

十字链表:有向图 D(弧 0→1、0→2、1→2) 顶点结点:data | firstIn | firstOut 弧结点:tailVex | headVex | hLink | tLink v0→ A v1→ A→ C v2→ B 01→ B 02→ C 12 弧 A = 0→1 弧 B = 0→2 弧 C = 1→2 firstOut[0] firstOut[1] tLink(A)→B firstIn[1] firstIn[2] hLink(B)→C 蓝色箭头 = 出边链(firstOut 起步,沿 tLink 走):v0 → A → B;v1 → C。 橙色箭头 = 入边链(firstIn 起步,沿 hLink 走):v1 → A;v2 → B → C。 结论:每条弧只占一个结点(空间 O(n+e)),而每个顶点的入度、出度都能在 O(deg) 内求出——邻接表做不到这一点。
图 8-8 十字链表:firstIn / firstOut 与 hLink / tLink 如何织出一张「十字」
#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   十字链表(orthogonal list)—— 有向图专用
   顶点结点:data + firstIn + firstOut
   弧结点  :tailVex + headVex + hLink + tLink
   每条弧只存一个结点;出边链沿 tLink 走,入边链沿 hLink 走
   ============================================================ */
struct ArcBox {                  // 弧结点
    int tailVex, headVex;        // 弧尾、弧头顶点下标
    int hLink, tLink;            // 同弧头的下一条弧 / 同弧尾的下一条弧(数组下标,-1 表示空)
    int weight;
};

struct VexNode {                 // 顶点结点
    int data;
    int firstIn, firstOut;
};

vector<VexNode> vex;
vector<ArcBox>  arc;

void initOL(int n) {
    vex.assign(n, VexNode());
    for (int i = 0; i < n; ++i) { vex[i].data = i; vex[i].firstIn = -1; vex[i].firstOut = -1; }
    arc.clear();
}

/* 插入一条弧 u -> v */
void addArc(int u, int v, int w = 1) {
    ArcBox a;
    a.tailVex = u; a.headVex = v; a.weight = w;
    a.tLink = vex[u].firstOut;    // 头插到 u 的出边链
    a.hLink = vex[v].firstIn;     // 头插到 v 的入边链
    arc.push_back(a);
    int id = (int)arc.size() - 1;
    vex[u].firstOut = id;
    vex[v].firstIn  = id;
}

void printOut(int u) {            // 枚举 u 的所有出弧(顺 tLink)
    cout << "v" << u << " 的出弧: ";
    int c = 0;
    for (int i = vex[u].firstOut; i != -1; i = arc[i].tLink) {
        cout << arc[i].tailVex << "->" << arc[i].headVex << " ";
        ++c;
    }
    cout << " 出度=" << c << "\n";
}

void printIn(int u) {             // 枚举 u 的所有入弧(顺 hLink)
    cout << "v" << u << " 的入弧: ";
    int c = 0;
    for (int i = vex[u].firstIn; i != -1; i = arc[i].hLink) {
        cout << arc[i].tailVex << "->" << arc[i].headVex << " ";
        ++c;
    }
    cout << " 入度=" << c << "\n";
}

int main() {
    initOL(3);
    addArc(0, 1);
    addArc(0, 2);
    addArc(1, 2);

    for (int u = 0; u < 3; ++u) { printOut(u); printIn(u); }
    /* 输出:
       v0 的出弧: 0->2 0->1  出度=2
       v0 的入弧:             入度=0
       v1 的出弧: 1->2        出度=1
       v1 的入弧: 0->1        入度=1
       v2 的出弧:             出度=0
       v2 的入弧: 1->2 0->2   入度=2   */
    cout << "弧结点总数 = " << arc.size() << " = e = 3\n";
    return 0;
}

8.4.6 邻接多重表:无向图里每条边只存一个结点

无向图的邻接表有个「冗余」:一条边要存两个结点。如果要做的操作是 「删除一条边」「给边打标记(比如判断某条边是否已经走过)」,就得同时找到两份并分别删除,很麻烦。 邻接多重表(adjacency multilist)正是为解决这个问题而生: 每条边只存一个结点,两个端点各自通过自己的指针链指向它。

边结点
五个域:mark(标记位,可用于「这条边是否访问过」)、ivexilink(顶点 ivex 的下一条关联边)、jvexjlink(顶点 jvex 的下一条关联边)。
顶点结点
data + firstEdge(指向第一条依附于该顶点的边结点)。

于是 3 个顶点的完全图只需要 3 个边结点(邻接表要 6 个),空间从 O(n + 2e) 降到 O(n + e),而且删除一条边只需摘掉一个结点。 代价是每个结点多两个指针域,结构更绕,所以它主要用于需要频繁对边做标记 / 删除的无向图算法 (例如欧拉路径的求解中给边打「已走过」标记)。

邻接多重表:无向完全图 K3(3 个顶点、3 条边、只有 3 个边结点 顶点结点:data | firstEdge v0→ E0 v1→ E0 v2→ E1 边结点:mark | ivex | ilink | jvex | jlink 00→E11→E2 002→E2 012 E0 = 边 (0,1) E1 = 边 (0,2) E2 = 边 (1,2) ilink(E0)→E1 jlink(E0)→E2 jlink(E1)→E2 从 v2 出发枚举邻接点:firstEdge[2] = E1(0,2) → E1 的 jvex = 2,所以另一端是 0;沿 jlink 到 E2(1,2)。 E2 的 jvex = 2,另一端是 1;jlink = ∧,结束。于是 v2 的邻接点是 0、1 —— 3 条边、3 个结点,一条边没有被存两遍。 适用场景:需要对边打标记(是否走过)、需要频繁删边的无向图;求欧拉路径时特别好用。
图 8-9 邻接多重表:无向图的每条边只占一个结点,mark 域便于给边做标记
注意:图上的链顺序与代码输出的顺序可能相反 图 8-9 为了便于阅读,把每个顶点的关联边链按 ivex 升序画出(v2 先看到边 (0,2) 再看到 (1,2)), 所以读出「邻接点是 0、1」;而下面这份代码用的是头插法e.ilink = mv[u].firstEdge;), 后插入的边结点会跑到链首,实际会打印「v2 的邻接点: 1 0」。 两者是同一张表、同一组邻接点,只是链表的物理顺序不同——这也是所有「数组模拟链表」结构的通病, 凡是不要求顺序的算法(遍历、求度、连通性)都不受影响;一旦题目要求按编号输出,就必须先排序或改用有序插入
#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   邻接多重表 —— 无向图专用,每条边只存一个结点
   边结点:mark + ivex + ilink + jvex + jlink
   ============================================================ */
struct EdgeBox {
    bool mark;                   // 标记:这条边是否被访问过(欧拉路径等算法用)
    int ivex, ilink;             // 端点 ivex 及其下一条关联边
    int jvex, jlink;             // 端点 jvex 及其下一条关联边
    int weight;
};

struct VexNode2 { int data; int firstEdge; };

vector<VexNode2> mv;
vector<EdgeBox>  me;

void initML(int n) {
    mv.assign(n, VexNode2());
    for (int i = 0; i < n; ++i) { mv[i].data = i; mv[i].firstEdge = -1; }
    me.clear();
}

/* 插入无向边 (u,v):只生成一个边结点,分别挂到两个端点的链上 */
void addEdgeML(int u, int v, int w = 1) {
    EdgeBox e;
    e.mark = false;
    e.ivex = u; e.jvex = v; e.weight = w;
    e.ilink = mv[u].firstEdge;   // 挂到 u 的链
    e.jlink = mv[v].firstEdge;   // 挂到 v 的链
    me.push_back(e);
    int id = (int)me.size() - 1;
    mv[u].firstEdge = id;
    mv[v].firstEdge = id;
}

/* 枚举顶点 u 的所有邻接点:注意先判断自己站在 ivex 还是 jvex 这一侧 */
void neighbors(int u) {
    cout << "v" << u << " 的邻接点: ";
    int deg = 0;
    for (int i = mv[u].firstEdge; i != -1; ) {
        EdgeBox& e = me[i];
        if (e.ivex == u) { cout << e.jvex << " "; i = e.ilink; }
        else             { cout << e.ivex << " "; i = e.jlink; }
        ++deg;
    }
    cout << " 度=" << deg << "\n";
}

int main() {
    initML(3);
    addEdgeML(0, 1);
    addEdgeML(0, 2);
    addEdgeML(1, 2);

    for (int u = 0; u < 3; ++u) neighbors(u);
    cout << "边结点总数 = " << me.size() << " = e = 3(邻接表需要 2e = 6 个)\n";

    /* 给边打标记只需改一个结点:把边 (0,2) 标记为已访问 */
    for (size_t i = 0; i < me.size(); ++i)
        if ((me[i].ivex == 0 && me[i].jvex == 2) || (me[i].ivex == 2 && me[i].jvex == 0))
            me[i].mark = true;
    cout << "边 (0,2) 已标记\n";
    return 0;
}
考点:五种存储结构的适用场景
结构适用图关键特征
邻接矩阵稠密图、需要 O(1) 判相邻空间 O(n²),对称
邻接表稀疏图、需要枚举邻接点无向 2e 个边结点,有向 e 个
逆邻接表有向图、频繁求入度与邻接表互补,可同时建
十字链表有向图、出入边都要快tailVex/headVex + hLink/tLink
邻接多重表无向图、频繁删边或标记边一条边一个结点,含 mark 域

8.5 邻接矩阵 vs 邻接表:九个维度的大对比

前面两节把两种存储结构分别讲完了,现在把它们放到同一张表里逐项对比。 这张表是本讲最可能在选择题里出现的内容,建议先自己填一遍,再对照答案。 设图有 n 个顶点、e 条边,deg(u) 表示顶点 u 的度(有向图指出度)。

对比维度邻接矩阵邻接表(含链式前向星)
空间复杂度 O(n²),与 e 无关 O(n + e)(无向图 O(n + 2e),同阶)
判断两点是否相邻 A[u][v],O(1) 遍历 u 的链表查找,O(deg(u))
求某点的所有邻接点 扫描整行,O(n) 遍历链表,正比于度数 O(deg(u))
求无向图的度 第 u 行求和,O(n) 链表长度,可 O(1)(维护一个 deg 数组)或 O(deg)
求出度 / 入度(有向) 出度 = 行和 O(n);入度 = 列和 O(n) 出度 = 链表长 O(deg);入度需扫全部链表 O(n+e),或者另建逆邻接表
增加一条边 O(1),A[u][v] = A[v][u] = 1 O(1)(vector 版均摊 O(1);链式前向星头插 O(1))
删除一条边 O(1) 要在链表中找到并摘除,O(deg(u))
增加 / 删除一个顶点 矩阵要扩容或搬移,O(n²) / O(n) 动的是数组与链表,O(1)~O(n),相对容易
遍历整张图(DFS/BFS) O(n²) O(n + e)
适用图类型 稠密图(e ≈ n²)、需要频繁判相邻;实现最直观,适合教学与小数据 稀疏图(e ≪ n²)、需要枚举邻接点;绝大多数图论算法的首选
额外优点 可做矩阵运算: 的元素 (A²)[i][j] 就是 i 到 j 长度为 2 的路径条数;配合 Floyd 天然合适 每条边只存一次(有向图),天然支持「按边枚举」的算法(Kruskal、Tarjan)
考点:三句话总结这张表
  1. 空间看疏密:稀疏图选邻接表(O(n+e)),稠密图两者都行。
  2. 查边看矩阵:只有邻接矩阵能 O(1) 判断「u、v 之间有没有边」。
  3. 遍历看邻接表:DFS/BFS 用邻接表是 O(n+e),用矩阵是 O(n²),这是最常考的对比。
工程上怎么选?
  • n ≤ 1000 且图稠密:直接上邻接矩阵,代码短、不易错,Floyd 求多源最短路也方便。
  • n, e ≥ 105:必须邻接表 / 链式前向星,否则内存超限或超时。
  • 需要同时求出度和入度:建正向 + 反向两套邻接表,或使用十字链表。
  • 需要频繁删边或给边打标记:无向图考虑邻接多重表。
  • 动态图(顶点边不断增加)vector<vector<int> > 最省事;链式前向星不能删边,要慎用。

8.6 图的遍历(一):深度优先搜索 DFS

8.6.1 一句话本质:一条路走到黑,撞墙再回头

深度优先搜索(Depth-First Search, DFS)的思想可以用走迷宫来类比: 每到一个岔路口,先随便选一条没走过的路往前走;如果走不通(或者走到已经去过的地方), 就退回上一个岔路口,换一条路继续试,直到所有路都试完。

形式化地描述:从起点 s 出发,

  1. 访问 s,并把它标记为「已访问」;
  2. 依次检查 s 的每个邻接点 v:若 v 未被访问,就以 v 为新起点递归地做同样的事;
  3. 当 s 的所有邻接点都检查完,就回溯(退回上一层)。

这里有三个细节必须注意,它们决定了你的代码对不对:

DFS 与第 07 讲学的二叉树先序遍历本质上是同一个东西:都是「先访问自己,再递归子树」。 只不过二叉树的「子结点」只有两个且有左右之分,而图的邻接点有任意多个、还没有顺序。 所以可以说:DFS 是「先序遍历」在图上的推广,图是「去掉左右与层次约束」的树

8.6.2 手推完整过程:访问序列与每次回溯

我们仍然用本讲的示例图 G(n = 8、e = 9),从顶点 0 出发,约定邻接点按编号从小到大访问。 请拿一张纸跟着下面的表格一步步走,重点看「什么时候进、什么时候退」。

步骤当前顶点看邻接表动作访问序列递归栈(底→顶)
10[1, 6]访问 0,取邻接点 1(未访问)→ 递归0[0]
21[0, 2, 7]访问 1;0 是父结点 → 跳过;取 2(未访问)→ 递归0 1[0, 1]
32[1, 3]访问 2;1 是父结点 → 跳过;取 3(未访问)→ 递归0 1 2[0, 1, 2]
43[2, 4]访问 3;2 是父结点 → 跳过;取 4(未访问)→ 递归0 1 2 3[0, 1, 2, 3]
54[3, 5]访问 4;3 是父结点 → 跳过;取 5(未访问)→ 递归0 1 2 3 4[0, 1, 2, 3, 4]
65[4, 6, 7]访问 5;4 是父结点 → 跳过;取 6(未访问)→ 递归0 1 2 3 4 5[0, 1, 2, 3, 4, 5]
76[0, 5]访问 6;0 已访问且不是父结点 → 回边 (0,6),说明有环;5 是父结点 → 跳过0 1 2 3 4 5 6[0, 1, 2, 3, 4, 5, 6]
86邻接表扫完 → 回溯,弹栈回到 50 1 2 3 4 5 6[0, 1, 2, 3, 4, 5]
95[4, 6, 7]继续看 5 的邻接表:6 已访问;7 未访问 → 递归0 1 2 3 4 5 6 7[0, 1, 2, 3, 4, 5, 7]
107[1, 5]1 已访问且不是父结点 → 回边 (1,7);5 是父结点 → 跳过;扫完 → 回溯0 1 2 3 4 5 6 7[0, 1, 2, 3, 4, 5]
115→4→3→2→1→0一路回溯:每个顶点的邻接表都已扫完,依次弹栈0 1 2 3 4 5 6 7[]

结论:DFS 访问序列 = 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7; 共走了 7 条树边(0-1、1-2、2-3、3-4、4-5、5-6、5-7),恰好构成一棵生成树; 另外发现 2 条回边(0-6、1-7),它们说明图中存在环。 数一数:树边 + 回边 = 7 + 2 = 9 = e,一条不多一条不少——DFS 会把每条边恰好检查一遍(无向图是两遍,每个方向一遍)。

左:DFS 对边的分类  右:DFS 生成树(把回边去掉) 012 345 67 绿实线 = 树边(7 条) 红虚线 = 回边 (0,6)、(1,7) 访问顺序:0 1 2 3 4 5 6 7 012 345 67 DFS 生成树的树高 = 8 层 树边 7 条 = n − 1 回边 2 条 = e − (n − 1) 本图的 DFS 树退化成一条链, 因为邻接点总是按编号升序被 访问,于是一路向深处钻。 顶点 5 的两个孩子:6 与 7
图 8-10 DFS 生成树与回边:树边 7 条,回边 2 条,合计恰好是全部 9 条边

下面的动画把整个过程逐帧展开,包括递归栈的变化、访问序列的生长、以及每一条边被判定为树边还是回边:

易错:DFS 序列不是唯一的,但必须能推 如果你把邻接点按降序枚举,从 0 出发得到的序列会变成 0 → 6 → 5 → 7 → 1 → 2 → 3 → 4(自己验证一下:0 先看 6,接着 6 看 5,5 先看 7……)。 两种都是正确答案,关键在于题目是否规定了枚举顺序。 考研 408 与教材习题通常规定「邻接点按编号从小到大」,请以题目为准; 答题时最好把枚举顺序写清楚,避免争议。

8.6.3 DFS 的递归实现(邻接表版)

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   邻接表 + 递归 DFS:同时完成三件事
     1) 记录访问序列
     2) 记录每个顶点的父结点(parent)
     3) 对边分类:树边 / 回边,从而判断有没有环
   ============================================================ */
const int MAXN = 100005;
vector<int> g[MAXN];
vector<bool> vis;
vector<int>  ord, parentNode;
vector<pair<int,int> > treeEdges, backEdges;

void dfs(int u, int p) {
    vis[u] = true;
    parentNode[u] = p;
    ord.push_back(u);                       // 进入时记录 → 这就是 DFS 序列
    for (size_t i = 0; i < g[u].size(); ++i) {
        int v = g[u][i];
        if (!vis[v]) {
            treeEdges.push_back(make_pair(u, v));
            dfs(v, u);                      // 递归深入
        } else if (v != p) {
            /* 已访问且不是父结点 → 回边(无向图中回边等价于“有环”)
               注意:无向图每条边存了两次,v == p 的那一次必须排除 */
            int a = min(u, v), b = max(u, v);
            bool dup = false;
            for (size_t k = 0; k < backEdges.size(); ++k)
                if (backEdges[k].first == a && backEdges[k].second == b) dup = true;
            if (!dup) backEdges.push_back(make_pair(a, b));
        }
    }
    /* 这里可以做“离开 u”的收尾工作,例如拓扑排序的逆序输出 */
}

int main() {
    int n = 8;
    int E[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 9; ++i) { g[E[i][0]].push_back(E[i][1]); g[E[i][1]].push_back(E[i][0]); }
    for (int u = 0; u < n; ++u) sort(g[u].begin(), g[u].end());  // 保证升序枚举

    vis.assign(n, false);
    parentNode.assign(n, -1);
    dfs(0, -1);

    cout << "DFS 序列: ";
    for (int x : ord) cout << x << " ";              // 0 1 2 3 4 5 6 7
    cout << "\n树边 " << treeEdges.size() << " 条: ";
    for (size_t i = 0; i < treeEdges.size(); ++i)
        cout << "(" << treeEdges[i].first << "," << treeEdges[i].second << ") ";
    cout << "\n回边 " << backEdges.size() << " 条: ";
    for (size_t i = 0; i < backEdges.size(); ++i)
        cout << "(" << backEdges[i].first << "," << backEdges[i].second << ") ";   // (0,6) (1,7)
    cout << "\n";
    cout << "图中有环? " << (backEdges.empty() ? "没有" : "有") << "\n";
    return 0;
}
易错:递归深度可能爆栈 C++ 的递归深度受栈空间限制,一般只有几 MB。当图退化成一条长度为 105~106 的链时, dfs 的递归深度就等于 n,会直接栈溢出(RE)。 竞赛里遇到这种数据,要么改写成非递归(8.6.4),要么在编译时调大栈、或者用「手写栈」模拟。 另外,把 vector 按值传参也会让每层递归拷贝一次,务必传引用。

8.6.4 DFS 的非递归实现(用栈模拟递归)

递归的本质是「系统帮你维护了一个栈」,所以改成非递归就是「自己维护一个栈」。 但这里有一个非常经典的坑:如果只是简单地把邻接点全部压栈,得到的访问序列 和递归版不一样。原因是「入栈时就打访问标记」会让某个顶点被过早地标记, 从而在栈里排到后面才弹出来,顺序就乱了。

下面给出两个版本,请务必对比它们输出的差别:

#include <iostream>
#include <stack>
#include <vector>
#include <algorithm>
using namespace std;

const int MAXN = 100005;
vector<int> g[MAXN];
int n = 8;

/* ---------- 版本 A:常见但“不等价”的写法 ----------
   入栈时打标记,邻接点全部压栈(为了和递归顺序接近,要逆序压)。
   它能正确地遍历所有顶点,但访问序列与递归版不同,也不能直接用来求“回溯顺序”。 */
vector<int> dfsStackSimple(int s) {
    vector<int> order;
    vector<bool> vis(n, false);
    stack<int> st;
    st.push(s); vis[s] = true;             // 入栈即标记,避免同一个点被压多次
    while (!st.empty()) {
        int u = st.top(); st.pop();
        order.push_back(u);
        for (int i = (int)g[u].size() - 1; i >= 0; --i) {   // 逆序压栈
            int v = g[u][i];
            if (!vis[v]) { vis[v] = true; st.push(v); }
        }
    }
    return order;
}

/* ---------- 版本 B:与递归严格等价的写法 ----------
   栈里保存“栈帧”:(当前顶点 u, 下一次要从它的第几个邻接点开始看)。
   只有当 u 的邻接表全部扫完时,才真正弹栈——这正是递归里“函数返回”的时刻。 */
vector<int> dfsStackExact(int s) {
    vector<int> order;
    vector<bool> vis(n, false);
    vector<pair<int,int> > st;             // (u, next index)
    vis[s] = true; order.push_back(s);
    st.push_back(make_pair(s, 0));
    while (!st.empty()) {
        int u = st.back().first;
        int& i = st.back().second;         // 引用:直接改栈顶那一帧
        if (i >= (int)g[u].size()) { st.pop_back(); continue; }   // 扫完 → 回溯
        int v = g[u][i++];                 // 取下一个邻接点,并把游标前移
        if (!vis[v]) {
            vis[v] = true;
            order.push_back(v);            // 进入 v 的那一刻就记录
            st.push_back(make_pair(v, 0));
        }
    }
    return order;
}

int main() {
    int E[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 9; ++i) { g[E[i][0]].push_back(E[i][1]); g[E[i][1]].push_back(E[i][0]); }
    for (int u = 0; u < n; ++u) sort(g[u].begin(), g[u].end());

    cout << "递归版     : 0 1 2 3 4 5 6 7\n";
    cout << "版本A(简单): "; for (int x : dfsStackSimple(0)) cout << x << " "; cout << "\n";
    /* 输出 0 1 2 3 4 5 7 6 —— 顶点 6 被提前标记,弹栈顺序与递归不同 */
    cout << "版本B(等价): "; for (int x : dfsStackExact(0)) cout << x << " "; cout << "\n";
    /* 输出 0 1 2 3 4 5 6 7 —— 与递归版完全一致 */
    return 0;
}
考点:栈版 DFS 的两个结论
  1. 教材上「用栈实现 DFS」通常指版本 B(栈里存顶点 + 游标,或等价地存「边」), 它和递归严格等价;只存顶点、入栈即标记的版本 A 得到的序列可能不同。
  2. 如果题目只要求「判断连通性 / 求连通分量」,两个版本都对; 但如果题目要求输出指定的 DFS 序列回溯顺序,就必须用版本 B 或递归。

8.6.5 DFS 的复杂度分析

存储结构时间复杂度为什么空间复杂度
邻接表 O(n + e) 每个顶点恰好访问一次(进入时),每条边恰好被检查两次(无向图)/ 一次(有向图) O(n):visited 数组 + 递归栈最深 O(n)
邻接矩阵 O(n²) 每个顶点进入时都要扫描整行 n 个元素,共 n 次 O(n)

解释一下「每条边恰好检查两次」:在无向图的 DFS 里,对边 (u,v), 当我们在 u 的邻接表中看到 v 时检查一次,在 v 的邻接表中看到 u 时又检查一次 (第二次会被 v == pvis[v] 挡掉)。所以总的检查次数是 2e, 加上 n 次「进入顶点」的开销,就是 O(n + e)

如果图是非连通的,需要在外层对每个顶点做一次「未访问就 DFS」的循环, 总复杂度依然是 O(n + e)——因为每个顶点最多被当作起点一次,每次 DFS 只碰自己分量内的边。

8.6.6 DFS 的六大应用

① 求连通分量个数(最直接的应用)

外层循环对每个顶点检查一次:若未访问,说明发现了新分量,计数器 +1,并从这个顶点 DFS 把整个分量吃掉。 详见 8.8.1 的完整代码。

② 判断无向图是否有环:看「是否访问过父结点」

无向图中,DFS 遇到一条「指向已访问顶点、且该顶点不是自己的父结点」的边,就说明存在回路。 为什么必须排除父结点?因为无向图的每条边在邻接表里出现两次, 从 u 走到 v 之后,v 一定会看到 u,如果不排除就会把这条来路误判成环,导致「任何一条边都被判成环」。

还有一个更简单的判据:n 个顶点的无向图,若 DFS 产生过回边,则 e ≥ n; 反之,若 e ≥ n 且图连通,则必有环(因为生成树只有 n−1 条边,多出来的边必然成环)。

③ 判断有向图是否有环:三色标记法

有向图的判环不能用「父结点」那一套(因为有向边没有对称性),标准做法是三色标记: 白色 = 未访问,灰色 = 正在访问(在递归栈上),黑色 = 已完成。 如果在 DFS 中遇到一条指向灰色顶点的边,就说明找到了一个有向环。

#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   两种判环:
     无向图 —— DFS + “排除父结点”
     有向图 —— DFS + 三色标记(白 0 / 灰 1 / 黑 2)
   ============================================================ */
const int MAXN = 100005;
vector<int> ug[MAXN], dg[MAXN];      // 无向图 / 有向图的邻接表
int color[MAXN];                     // 0 未访问 1 在栈上 2 已完成

/* ---------- 无向图判环 ---------- */
bool undirectedHasCycle(int u, int parent, vector<bool>& vis) {
    vis[u] = true;
    for (size_t i = 0; i < ug[u].size(); ++i) {
        int v = ug[u][i];
        if (!vis[v]) {
            if (undirectedHasCycle(v, u, vis)) return true;
        } else if (v != parent) {
            return true;                 // 撞到已访问且不是父结点 → 有环
        }
    }
    return false;
}

/* ---------- 有向图判环(三色标记) ---------- */
bool directedHasCycle(int u) {
    color[u] = 1;                        // 灰:进入
    for (size_t i = 0; i < dg[u].size(); ++i) {
        int v = dg[u][i];
        if (color[v] == 1) return true;              // 指向栈上的祖先 → 有向环
        if (color[v] == 0 && directedHasCycle(v)) return true;
    }
    color[u] = 2;                        // 黑:离开,后续再遇到它不算环
    return false;
}

int main() {
    /* 无向图:示例图 G 有环(0-1-2-3-4-5-6-0) */
    int E[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 9; ++i) { ug[E[i][0]].push_back(E[i][1]); ug[E[i][1]].push_back(E[i][0]); }
    vector<bool> vis(8, false);
    cout << "无向图有环? " << (undirectedHasCycle(0, -1, vis) ? "有" : "没有") << "\n";  // 有

    /* 无向图:一棵树(去掉两条边)应当无环 */
    for (int i = 0; i < 8; ++i) ug[i].clear();
    int T[7][2] = {{0,1},{1,2},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 7; ++i) { ug[T[i][0]].push_back(T[i][1]); ug[T[i][1]].push_back(T[i][0]); }
    vector<bool> vis2(8, false);
    cout << "这棵树有环? " << (undirectedHasCycle(0, -1, vis2) ? "有" : "没有") << "\n";  // 没有

    /* 有向图:0→1→2→0 有环;0→1→2 无环 */
    dg[0].push_back(1); dg[1].push_back(2); dg[2].push_back(0);
    cout << "有向图 0→1→2→0 有环? " << (directedHasCycle(0) ? "有" : "没有") << "\n";     // 有
    for (int i = 0; i < 3; ++i) { dg[i].clear(); color[i] = 0; }
    dg[0].push_back(1); dg[1].push_back(2);
    cout << "有向图 0→1→2 有环? " << (directedHasCycle(0) ? "有" : "没有") << "\n";       // 没有
    return 0;
}

④ 求割点与桥(Tarjan 的 low 值)

割点(articulation point)是「删掉它之后连通分量个数增加」的顶点; 桥(bridge)是「删掉它之后连通分量个数增加」的边。 DFS 过程中记录两个量:dfn[u](时间戳,第几个被访问)与 low[u] (u 及其子树能通过回边到达的最小时间戳),就能线性求出全部割点与桥。 判定规则:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   Tarjan 求割点与桥(无向连通图,顶点编号从 1 开始)
   dfn[u]:u 被访问的时间戳
   low[u]:u 及其子树通过回边能到达的最小 dfn
   ============================================================ */
const int MAXN = 100005;
vector<int> g[MAXN];
int dfn[MAXN], low[MAXN], timer = 0;
bool isCut[MAXN];
vector<pair<int,int> > bridges;
int n, m;

void tarjan(int u, int parent) {
    dfn[u] = low[u] = ++timer;
    int child = 0;
    for (size_t i = 0; i < g[u].size(); ++i) {
        int v = g[u][i];
        if (!dfn[v]) {                       // v 还没访问 → 树边
            ++child;
            tarjan(v, u);
            low[u] = min(low[u], low[v]);
            if (low[v] > dfn[u]) bridges.push_back(make_pair(min(u,v), max(u,v)));   // 桥
            if (parent != 0 && low[v] >= dfn[u]) isCut[u] = true;                     // 非根割点
        } else if (v != parent) {            // 回边(重边场景需改用边编号判断)
            low[u] = min(low[u], dfn[v]);
        }
    }
    if (parent == 0 && child >= 2) isCut[u] = true;   // 根结点:两个孩子以上才是割点
}

int main() {
    n = 6;
    int E[6][2] = {{1,2},{2,3},{3,1},{3,4},{4,5},{5,6}};
    m = 6;
    for (int i = 0; i < m; ++i) { g[E[i][0]].push_back(E[i][1]); g[E[i][1]].push_back(E[i][0]); }

    tarjan(1, 0);

    cout << "割点: ";
    for (int i = 1; i <= n; ++i) if (isCut[i]) cout << i << " ";     // 割点: 3 4 5
    cout << "\n桥: ";
    for (size_t i = 0; i < bridges.size(); ++i)
        cout << "(" << bridges[i].first << "," << bridges[i].second << ") ";
    cout << "\n";
    /* 实际输出:桥: (5,6) (4,5) (3,4) —— 顺序是 DFS 发现的先后,
       但桥的集合就是 {(3,4), (4,5), (5,6)}:这条路 3-4-5-6 上每条边都是桥。 */
    return 0;
}

⑤ 走迷宫与「一笔画」的路径搜索

迷宫问题把每个格子看成一个顶点,相邻可走的格子之间连边,于是「能不能走出去」就变成了 「起点与终点是否连通」,「最短步数」就变成了「无权图最短路」。 DFS 能找到一条可行路径(但不保证最短),BFS 才能保证最短——这是搜索类题目最基本的分工。

⑥ 与二叉树先序遍历的关系

DFS 的前序写法(进入即访问)与二叉树先序遍历完全一致:先访问自己,再依次递归每个孩子。 后序写法(离开时才处理)对应二叉树后序遍历,它在拓扑排序强连通分量(Kosaraju)树形 DP 的答案回传里都是关键;中序则在一般图上没有对应物,因为一般图没有「左右子树」的概念。

考点:DFS 的应用清单
  • 连通分量计数:外层循环 + DFS,O(n+e)。
  • 判环:无向图排除父结点;有向图三色标记。
  • 割点与桥:Tarjan 的 dfn / low。
  • 二分图判定:DFS 染色(与 BFS 染色等价)。
  • 欧拉路径:DFS 找环并拼接(Hierholzer)。
  • 拓扑排序:DFS 后序的逆序。
  • 强连通分量:Tarjan / Kosaraju(下一讲)。

8.7 图的遍历(二):广度优先搜索 BFS

8.7.1 一句话本质:一圈一圈向外扩散

广度优先搜索(Breadth-First Search, BFS)像往水里丢一颗石子: 波纹从源点开始,一层一层向外扩散。算法描述只有三步:

  1. 把源点 s 入队,并标记 visited[s] = true
  2. 每次从队头取出一个顶点 u,把它的所有未访问邻接点依次入队并打标记;
  3. 重复第 2 步直到队列为空。

与 DFS 最大的区别是:DFS 用(后进先出,所以一路深入),BFS 用队列(先进先出,所以逐层扩散)。 数据结构一换,遍历的性质就全变了——这正是第 03、04 讲学栈与队列的用处。

BFS 有一个 DFS 没有的重要性质在无权图中,BFS 第一次到达某顶点时经过的边数,就是它到源点的最短距离。 原因很直观:BFS 是按「距离源点 0 步、1 步、2 步……」的顺序访问顶点的, 第一次访问 v 时,所有可能更短的路径早就应该把它访问掉了。这个性质是「无权图单源最短路」的全部理论基础。

易错:入队时标记,不是出队时标记 如果偷懒写成「出队时才 visited[u] = true」,同一个顶点可能在入队前被多个邻居重复入队, 队列膨胀、复杂度退化,甚至在某些实现里造成重复计数。 正确做法:一入队就立刻打标记visited[v] = true 写在 push 旁边)。 有向图还需要注意:BFS 沿弧的方向走,不能走回头路。

8.7.2 手推过程:队列变化与分层

仍然用示例图 G,从顶点 0 出发(邻接点按升序入队):

步骤出队 u扫描 u 的邻接点入队的顶点队列(front → back)访问序列
0初始化:源点入队0[0]
101(未访问)、6(未访问)1, 6[1, 6]0
210(已访问)、2(未访问)、7(未访问)2, 7[6, 2, 7]0 1
360(已访问)、5(未访问)5[2, 7, 5]0 1 6
421(已访问)、3(未访问)3[7, 5, 3]0 1 6 2
571、5 都已访问[5, 3]0 1 6 2 7
654(未访问)、6(已访问)、7(已访问)4[3, 4]0 1 6 2 7 5
732(已访问)、4(已在队列中 → 已访问)[4]0 1 6 2 7 5 3
843、5 都已访问[]0 1 6 2 7 5 3 4

BFS 访问序列:0 → 1 → 6 → 2 → 7 → 5 → 3 → 4,它按层严格递增:

第 0 层

{0} dist = 0

第 1 层

{1, 6} dist = 1

第 2 层

{2, 7, 5} dist = 2

第 3 层

{3, 4} dist = 3

注意第 7 步:顶点 4 在顶点 3 出队之前就已经被 5 放进了队列, 所以 3 看到 4 时不能重复入队——这正是「dist[v] == -1 判断」的作用。 如果忘记判断,4 会被入队两次,队列长度失控。

BFS 的层次划分与 BFS 生成树(源点 0) 016 275 34 层的划分(dist 值) L0 = {0} dist = 0 L1 = {1, 6} dist = 1 L2 = {2, 7, 5} dist = 2 L3 = {3, 4} dist = 3 绿实线 = BFS 生成树的边(7 = n − 1 条) 橙虚线 = 不出现在 BFS 树中的边:3-4、5-7 BFS 序列:0 1 6 2 7 5 3 4 dist[] = [0, 1, 2, 3, 3, 2, 1, 2] 第 3 层的顶点 3、4 之间有一条边 (3,4): 它连接同一层的两个顶点,不会缩短任何距离, 所以不可能进入 BFS 生成树。
图 8-11 BFS 的层次划分与 BFS 生成树(同层边不会成为树边)

下面是 BFS 的逐帧动画,请对照队列表格观察队列的进出与 dist[] 的写入时刻:

8.7.3 BFS 的复杂度

与 DFS 完全一致,因为「每个顶点入队出队各一次、每条边检查常数次」这个事实没有变:

存储结构时间复杂度空间复杂度说明
邻接表O(n + e)O(n)队列最多同时装 O(n) 个顶点;visited / dist 数组各 O(n)
邻接矩阵O(n²)O(n)每个顶点出队时扫描一整行

注意空间:BFS 的队列规模在最坏情况下可能接近 n(例如星形图,源点一次把所有叶子入队), 所以空间是 O(n) 而不是 O(1)。这也是 BFS 在超大规模图(如整个互联网的网页图)上不如 DFS 省内存的原因之一。

8.7.4 重头戏:无权图的单源最短路(含路径还原)

「单源最短路(Single Source Shortest Path, SSSP)」是图论里最重要的问题家族。 带权图要用 Dijkstra / Bellman-Ford / SPFA(下一讲的内容), 但如果是无权图(或者每条边权都相同),用 BFS 就够了,而且更快、更简单:

无权图单源最短路 = BFS + 一个 dist[] 数组 + 一个 pre[] 数组

dist[v] 记录源点到 v 的最短边数,pre[v] 记录「v 是从谁走过来的」(前驱)。 有了 pre[],从终点一路 v = pre[v] 倒推到源点,把经过的顶点倒序输出,就得到了完整路径。 这两个数组是所有路径类问题的标配,务必熟练掌握。

#include <iostream>
#include <queue>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   无权图单源最短路:BFS
   返回 dist[];同时用 pre[] 还原出 s 到 t 的具体路径
   时间 O(n + e),空间 O(n)
   ============================================================ */
const int MAXN = 100005;
const int INF  = 0x3f3f3f3f;
vector<int> g[MAXN];
int n = 8;

vector<int> dist, pre;

void bfsShortest(int s) {
    dist.assign(n, INF);          // INF 表示“还没到达”
    pre.assign(n, -1);
    queue<int> q;
    dist[s] = 0;
    q.push(s);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (size_t i = 0; i < g[u].size(); ++i) {
            int v = g[u][i];
            if (dist[v] == INF) {          // 第一次到达 → 这就是最短距离
                dist[v] = dist[u] + 1;
                pre[v]  = u;               // 记录前驱,供路径还原
                q.push(v);
            }
        }
    }
}

/* 还原 s → t 的路径(正序);若不可达返回空数组 */
vector<int> getPath(int s, int t) {
    vector<int> path;
    if (dist[t] == INF) return path;       // 不可达
    for (int v = t; v != -1; v = pre[v]) path.push_back(v);
    reverse(path.begin(), path.end());
    if (path[0] != s) return vector<int>();   // 安全校验
    return path;
}

int main() {
    int E[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 9; ++i) { g[E[i][0]].push_back(E[i][1]); g[E[i][1]].push_back(E[i][0]); }
    for (int u = 0; u < n; ++u) sort(g[u].begin(), g[u].end());

    bfsShortest(0);

    cout << "从 0 出发的 dist[]: ";
    for (int i = 0; i < n; ++i) cout << dist[i] << " ";       // 0 1 2 3 3 2 1 2
    cout << "\n";

    vector<int> p = getPath(0, 4);
    cout << "0 → 4 的最短路径: ";
    for (size_t i = 0; i < p.size(); ++i) cout << p[i] << (i + 1 < p.size() ? " -> " : "");
    cout << " 长度 " << dist[4] << "\n";                     // 0 -> 6 -> 5 -> 4 长度 3

    p = getPath(0, 3);
    cout << "0 → 3 的最短路径: ";
    for (size_t i = 0; i < p.size(); ++i) cout << p[i] << (i + 1 < p.size() ? " -> " : "");
    cout << " 长度 " << dist[3] << "\n";                     // 0 -> 1 -> 2 -> 3 长度 3
    return 0;
}

注意上面 0 → 30 → 4 的两条路径长度都是 3,但走法完全不同—— BFS 求的是长度,最短路径本身可能有多条,BFS 只会还原出「按照邻接表枚举顺序」找到的那一条。 如果题目要求输出字典序最小的最短路,或者要求统计最短路径条数, 就要在 dist[v] == dist[u] + 1 时另行处理(累加计数 / 比较前驱编号)。

下面的动画展示 dist[] 的逐层写入与最后的路径还原过程:

考点:BFS 最短路的三个必答点
  1. 为什么 BFS 能求最短路?因为队列保证了「按距离非递减的顺序」访问顶点, 第一次访问 v 时的路径长度必然最小。
  2. 只适用于无权图(或等权图)。一旦边权不同,第 k 层到达不代表路径最短,必须换 Dijkstra。
  3. 路径还原靠 pre[],从终点倒推到源点再反转,时间 O(路径长度)。

8.7.5 BFS 的另外两个经典应用

① 二分图判定(染色法)

二分图(bipartite graph)是可以把顶点分成两个集合 X、Y,使得每条边的两个端点 分别落在不同集合里的图。等价说法:可以用两种颜色给顶点染色,使每条边两端颜色不同

判定方法非常自然:从任意未染色顶点出发,染成颜色 0,然后 BFS/DFS,把它的邻居染成颜色 1, 邻居的邻居染成颜色 0……如果在过程中遇到一条边,两个端点颜色相同,就说明不是二分图。 本质上,这等价于判断「图中是否存在奇环(长度为奇数的环)」—— 一张图是二分图 ⟺ 它不含奇环。示例图 G 中含有一个长度为 7 的环 0-1-2-3-4-5-6-0,是奇环,所以 G 不是二分图(你可以试着染一下,一定会在某个地方冲突)。

#include <iostream>
#include <queue>
#include <vector>
using namespace std;

/* ============================================================
   二分图判定:BFS 染色(0 / 1 两种颜色,-1 表示未染色)
   注意外层循环:非连通图要对每个连通分量分别染色
   ============================================================ */
const int MAXN = 100005;
vector<int> g[MAXN];
int color[MAXN];                 // -1 未染色,0 / 1 两种颜色
int n = 8;

bool isBipartite() {
    for (int i = 0; i < n; ++i) color[i] = -1;
    for (int s = 0; s < n; ++s) {
        if (color[s] != -1) continue;
        color[s] = 0;
        queue<int> q;
        q.push(s);
        while (!q.empty()) {
            int u = q.front(); q.pop();
            for (size_t i = 0; i < g[u].size(); ++i) {
                int v = g[u][i];
                if (color[v] == -1) {           // 未染色 → 染成相反色
                    color[v] = color[u] ^ 1;
                    q.push(v);
                } else if (color[v] == color[u]) {
                    return false;               // 相邻同色 → 不是二分图
                }
            }
        }
    }
    return true;
}

int main() {
    /* 图 1:一个偶环(0-1-2-3-0)→ 二分图 */
    int E1[4][2] = {{0,1},{1,2},{2,3},{3,0}};
    for (int i = 0; i < 4; ++i) { g[E1[i][0]].push_back(E1[i][1]); g[E1[i][1]].push_back(E1[i][0]); }
    cout << "偶环图是二分图? " << (isBipartite() ? "是" : "不是") << "\n";    // 是

    /* 图 2:加一条弦 0-2,出现三角形(奇环)→ 不是二分图 */
    g[0].push_back(2); g[2].push_back(0);
    cout << "带三角形的图是二分图? " << (isBipartite() ? "是" : "不是") << "\n";  // 不是

    /* 图 3:本讲示例图 G(含 7 元奇环)→ 不是二分图 */
    for (int i = 0; i < n; ++i) g[i].clear();
    int E3[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 9; ++i) { g[E3[i][0]].push_back(E3[i][1]); g[E3[i][1]].push_back(E3[i][0]); }
    cout << "示例图 G 是二分图? " << (isBipartite() ? "是" : "不是") << "\n";   // 不是
    return 0;
}

② 层序类比:从二叉树层序遍历到图的 BFS

第 07 讲二叉树的层序遍历就是 BFS 的特例:用一个队列,先放根结点, 每次取出一个结点并把它的左右孩子入队,取出的顺序就是「一层一层」的。 图的 BFS 只是把「左右孩子」换成了「邻接表里的所有邻接点」, 把「树的唯一路径」换成了「需要 visited 去重的任意图」。

既然 BFS 天然分层,那么只要在出队时记录「当前层有几个结点」(q.size()), 就能一次处理一整层。这个技巧在「求二叉树最大宽度」「求图中距离为 k 的结点个数」 「多源 BFS(把所有源点同时入队)」里反复出现:

#include <iostream>
#include <queue>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   BFS 的三种常见写法
     1) 基础版:求访问序列
     2) 分层版:一次处理一整层(可统计层数、层宽)
     3) 多源版:多个起点同时入队(例如“火势蔓延”“最近的出口”)
   ============================================================ */
const int MAXN = 100005;
vector<int> g[MAXN];
int n = 8;

/* ---------- 1) 基础版 ---------- */
vector<int> bfs(int s) {
    vector<int> order;
    vector<bool> vis(n, false);
    queue<int> q;
    vis[s] = true; q.push(s);              // 入队即打标记
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (size_t i = 0; i < g[u].size(); ++i) {
            int v = g[u][i];
            if (!vis[v]) { vis[v] = true; q.push(v); }
        }
    }
    return order;
}

/* ---------- 2) 分层版 ---------- */
void bfsByLevel(int s) {
    vector<int> dist(n, -1);
    queue<int> q;
    dist[s] = 0; q.push(s);
    int level = 0;
    while (!q.empty()) {
        int sz = q.size();                 // 当前层的结点个数
        cout << "第 " << level << " 层(" << sz << " 个): ";
        for (int k = 0; k < sz; ++k) {     // 只处理这一层
            int u = q.front(); q.pop();
            cout << u << " ";
            for (size_t i = 0; i < g[u].size(); ++i) {
                int v = g[u][i];
                if (dist[v] == -1) { dist[v] = dist[u] + 1; q.push(v); }
            }
        }
        cout << "\n";
        ++level;
    }
}

/* ---------- 3) 多源版 ---------- */
vector<int> bfsMultiSource(const vector<int>& srcs) {
    vector<int> dist(n, -1);
    queue<int> q;
    for (size_t i = 0; i < srcs.size(); ++i) { dist[srcs[i]] = 0; q.push(srcs[i]); }  // 源点同时入队
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (size_t i = 0; i < g[u].size(); ++i) {
            int v = g[u][i];
            if (dist[v] == -1) { dist[v] = dist[u] + 1; q.push(v); }
        }
    }
    return dist;
}

int main() {
    int E[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 9; ++i) { g[E[i][0]].push_back(E[i][1]); g[E[i][1]].push_back(E[i][0]); }
    for (int u = 0; u < n; ++u) sort(g[u].begin(), g[u].end());

    cout << "BFS 序列: ";
    vector<int> o = bfs(0);
    for (size_t i = 0; i < o.size(); ++i) cout << o[i] << " ";        // 0 1 6 2 7 5 3 4
    cout << "\n";
    bfsByLevel(0);
    /* 第 0 层(1 个): 0
       第 1 层(2 个): 1 6
       第 2 层(3 个): 2 7 5
       第 3 层(2 个): 3 4 */

    vector<int> srcs; srcs.push_back(0); srcs.push_back(3);
    vector<int> d = bfsMultiSource(srcs);
    cout << "多源 dist(源点 0 与 3): ";
    for (int i = 0; i < n; ++i) cout << d[i] << " ";
    cout << "\n";
    return 0;
}

8.8 连通分量与生成树

8.8.1 用 DFS / BFS 求连通分量个数

这是遍历最直接的应用:外层循环扫一遍所有顶点,遇到没访问过的就启动一次遍历, 并把计数器 +1。每一次遍历会把「一个完整的连通分量」全部标记为已访问, 所以计数器的值就是连通分量的个数。时间复杂度 O(n + e),空间 O(n)。

为了演示多分量,我们在示例图 G 的基础上追加两个顶点: 顶点 8顶点 9 之间连一条边,顶点 10 谁都不连(孤立点)。 记这张图为 G′:n = 11、e = 10,它的连通分量有 3 个: {0,1,2,3,4,5,6,7}{8,9}{10}

G′ = G + 边(8,9) + 孤立点 10:n = 11,e = 10,连通分量个数 k = 3 012 345 67 89 10 分量 1:{0,1,2,3,4,5,6,7},8 个顶点 分量 2:{8,9},2 个顶点 分量 3:{10},1 个顶点(孤立点) 计数方法:外层 for 扫全部顶点, 遇到 visited[i] == false 就 cnt++, 并从 i 出发 DFS/BFS 吃掉整个分量。 cnt 的值 = 外层真正发起遍历的次数。 生成森林的边数 = n − k = 11 − 3 = 8。 e = 10 ≥ n − k = 8,符合连通性下界。 单个顶点也是连通分量; 空图(n = 0)的连通分量个数约定为 0。
图 8-12 连通分量的计算:颜色即分量编号,孤立点单独成一分量

下面这个动画逐帧展示「从一个未访问顶点出发,把这个分量整个吃掉并染色」的过程:

#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   求无向图的连通分量个数(以及每个顶点属于哪个分量)
   时间 O(n + e),空间 O(n)
   ============================================================ */
const int MAXN = 100005;
vector<int> g[MAXN];
int n, m;
vector<bool> vis;
vector<int>  comp;              // comp[v] = v 所属分量编号(从 0 开始)

void dfs(int u, int cid) {
    vis[u] = true;
    comp[u] = cid;
    for (size_t i = 0; i < g[u].size(); ++i)
        if (!vis[g[u][i]]) dfs(g[u][i], cid);
}

/* 若只要个数,把 comp 相关代码删掉即可;这里保留是为了能输出分量成员 */
int countComponents() {
    vis.assign(n, false);
    comp.assign(n, -1);
    int cnt = 0;
    for (int i = 0; i < n; ++i) {
        if (!vis[i]) {                 // 发现新分量
            dfs(i, cnt);               // 一次 DFS 吃掉整个分量
            ++cnt;
        }
    }
    return cnt;
}

int main() {
    n = 11;                            // G′:8 个顶点的示例图 + 8-9 一条边 + 孤立点 10
    int E[10][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7},{8,9}};
    for (int i = 0; i < 10; ++i) { g[E[i][0]].push_back(E[i][1]); g[E[i][1]].push_back(E[i][0]); }

    int k = countComponents();
    cout << "连通分量个数 = " << k << "\n";        // 3
    for (int c = 0; c < k; ++c) {
        cout << "分量 " << c + 1 << ": ";
        int sz = 0;
        for (int v = 0; v < n; ++v) if (comp[v] == c) { cout << v << " "; ++sz; }
        cout << "(" << sz << " 个顶点)\n";
    }
    /* 分量 1: 0 1 2 3 4 5 6 7 (8 个顶点)
       分量 2: 8 9 (2 个顶点)
       分量 3: 10 (1 个顶点)  */
    return 0;
}
另一种做法:并查集 连通分量也可以用并查集(Union-Find)求:把每条边的两个端点 union 起来, 最后统计有多少个不同的根,就是连通分量个数,复杂度 O(e·α(n))。 并查集的好处是能动态加边(DFS 不能),坏处是不能求具体路径。 两种方法都要会,考试里「判断图是否连通」用哪种都行。

8.8.2 生成树:定义与「n−1 条边」的证明

生成树(spanning tree):连通无向图 G 的一个生成子图,它本身是一棵树。 换句话说,它包含 G 的全部 n 个顶点、n−1 条边,而且是连通的、无回路的。 若图不连通,则各连通分量各取一棵生成树,合起来叫生成森林

定理:n 个顶点的连通图,其任意生成树恰有 n−1 条边。证明用「夹逼」的思路最清楚:

  1. 连通性给出下界:e ≥ n−1。 对 n 做归纳。n = 1 时平凡成立。设 n−1 个顶点的连通图至少 n−2 条边, 考虑 n 个顶点的连通图 G:若存在度为 1 的顶点 v(悬挂点),删去 v 及其悬挂边后剩下的图仍连通, 由归纳假设它有 ≥ n−2 条边,故 G 有 ≥ n−1 条边; 若不存在度为 1 的顶点,则所有顶点度 ≥ 2,由握手定理 2e = Σd(v) ≥ 2n,即 e ≥ n > n−1,也成立。
  2. 无回路给出上界:e ≤ n−1。 从「n 个孤立顶点」(n 个连通块、0 条边)出发,每加一条边,最多把两个连通块合并成一个, 所以连通块数最多减少 1。要从 n 块变成 1 块(连通),至少要加 n−1 条边; 而如果加的边造成了回路,连通块数根本没减少,反而浪费了一条边。 因此一个「连通且无回路」的图,其边数恰好是 n−1(少于 n−1 不可能连通,多于 n−1 必然有回路)。

两条合起来:生成树的边数恰好n−1。这个结论还有几个常用推论:

8.8.3 DFS 生成树与 BFS 生成树

遍历一张连通图时,我们只会「沿着某些边」走到新顶点,这些边就是树边, 它们连同全部顶点构成一棵生成树。用 DFS 得到的叫 DFS 生成树, 用 BFS 得到的叫 BFS 生成树,两者的形状差别很大:

对比项DFS 生成树BFS 生成树
形状细长、深度大(本讲例子里退化成一条链)矮胖、层数最少
树高可能达到 n−1等于源点到最远顶点的最短距离(最小可能的树高)
非树边的类型无向图只有回边(连到祖先);有向图还有前向边、横叉边无向图只有同层边或跨一层边(绝不会跨两层以上)
典型用途求割点桥、强连通分量、拓扑排序无权最短路、层次分析、二分图判定
边的条数都是 n−1(连通图)

本讲示例图的结果:DFS 生成树的树边是 0-1, 1-2, 2-3, 3-4, 4-5, 5-6, 5-7(7 条),非树边是 0-6, 1-7; BFS 生成树的树边是 0-1, 0-6, 1-2, 1-7, 6-5, 2-3, 5-4(7 条), 非树边是 3-4, 5-7。 两棵树的边集完全不同,但边数都是 7 = n−1,这就是「生成树不唯一」的最直接例证。

易错:BFS 生成树里不可能出现「跨多层」的非树边 设非树边连接 u、v,则 |dist[u] − dist[v]| ≤ 1。 反证:若 dist[v] ≥ dist[u] + 2,那么 u 出队时 v 必然还没被访问, BFS 一定会沿 (u,v) 把 v 的距离更新为 dist[u]+1,矛盾。 本讲例子里 (3,4) 就是同层边:dist[3] = dist[4] = 3。

8.8.4 生成树不唯一,最小生成树也不唯一(但权值和唯一)

从 8.8.3 的例子里已经看到:同一张图的生成树有很多棵。 如果给边加上权值(这时图叫),我们就会问: 哪一棵生成树的总权值最小?这就是最小生成树(Minimum Spanning Tree, MST)问题, 求法有 Prim 与 Kruskal 两种经典算法,是下一讲的主角。

这里先埋三个伏笔,下一讲会正式证明:

  1. 最小生成树不一定唯一。当图中存在权值相同的边时,可能有多棵不同的最小生成树。
  2. 但最小生成树的权值和一定唯一。不管选出哪一棵,总权值都相同——这是 MST 最重要的性质, 也是很多题目「只问权值和」的底气所在。
  3. 切性质(cut property):把顶点任意分成两部分,横跨这两部分的所有边中权值最小的那条, 一定属于某棵最小生成树。Prim 与 Kruskal 都是这条性质的推论。
带权图(网):A-B=1、A-C=1、B-C=3、B-D=2、C-D=2 AB CD 1 1 3 2 2 最小:A-B(1) + A-C(1) + B-D(2) = 4 最小:A-B(1) + A-C(1) + C-D(2) = 4 非最小:A-B(1) + B-C(3) + B-D(2) = 6 非最小:A-C(1) + B-C(3) + C-D(2) = 6 结论 1:最小生成树不唯一(上面两棵都是) 结论 2:但最小权值和唯一,都是 4 原因:D 必须经 B-D(2) 或 C-D(2) 接入, A、B、C 之间只要选权值和最小的两条边 即 A-B 与 A-C(1+1),总和恒为 4。 下一讲:Prim(加点)与 Kruskal(加边 + 并查集)。
图 8-13 带权图上的生成树:不唯一,但最小权值和唯一
考点:生成树相关结论速记
  • n 个顶点的连通图,生成树有且仅有 n−1 条边。
  • n 个顶点、k 个连通分量的图,生成森林有 n−k 条边。
  • 连通图的生成树不唯一(除非它本身是一棵树)——只要图中有回路,就能删掉回路上任意一条边得到另一棵生成树
  • 最小生成树的权值和唯一,但树本身可能不唯一。
  • 「加一条边成环,删一条边连通」是生成树的两个等价刻画。

8.9 图的经典小题速览

这一节把图论里最常见的几个「小问题」集中过一遍。它们本身不难,但每一个都对应着一类 考试题与竞赛模板,而且都会在后面的算法课里反复出现。这里只给结论与一句话思路, 完整的推导留给对应的专题。

8.9.1 判断二分图

结论:一张图是二分图 ⟺ 它不含长度为奇数的环(奇环)。 判定方法就是 BFS/DFS 染色:相邻顶点必须异色,一旦发现相邻同色就失败。 时间复杂度 O(n + e)(邻接表)。

直觉理解:沿着一条边颜色必然翻转,所以绕一个环走一圈回到起点时,颜色翻转的次数 = 环长。 环长为偶数时颜色能回到原样,为奇数时必然冲突。二分图的典型应用是任务分配冲突检测(例如「把学生分成两组,每组内部不能有矛盾关系」)。

8.9.2 求无向图的割点与桥

割点是删掉后连通分量变多的顶点,是删掉后连通分量变多的边。 一句话思路:DFS 时记 dfn(时间戳)与 low(子树能回到的最小时间戳), low[v] ≥ dfn[u] 说明 v 的子树「绕不回去」,u 就是割点; low[v] > dfn[u] 则说明这条边是桥。 根结点要单独判断(孩子数 ≥ 2 才是割点)。完整代码见 8.6.6 的 graph_cut_vertex.cpp

应用场景:网络中的关键路由器(删掉它网络就分裂)、交通网中的咽喉要道、 社交网络中的「桥接人物」。这个算法也是 Tarjan 系列(强连通分量、双连通分量、2-SAT)的入门。

8.9.3 有向图的可达性

「从 u 能不能走到 v」是最基础的有向图问题。如果只问一对点,一次 DFS/BFS 就够(O(n+e)); 如果要把所有点对的可达性都算出来(叫传递闭包),有三种做法:

做法复杂度适用
对每个点各做一次 DFS/BFSO(n(n + e))稀疏图、n 中等
bitset 优化O(n(n + e)/64)n ≤ 5000 左右的稠密图,写法极短
Floyd 思想(reach[i][j] |= reach[i][k] && reach[k][j]O(n³)n ≤ 500,最省心

传递闭包还有一个漂亮的等价说法:把 reach 矩阵看成布尔矩阵, 它等于 I ∨ A ∨ A² ∨ … ∨ An−1。这也顺便解释了 8.5 节里提到的 「 的元素是长度为 2 的路径条数」这一性质。

#include <bitset>
#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   有向图的可达性(传递闭包)
   这里给出 bitset 版:reach[u] 的第 v 位为 1 表示 u 可达 v
   ============================================================ */
const int MAXN = 2005;
vector<int> g[MAXN];
bitset<MAXN> reach[MAXN];
int n = 5;

/* 从 s 出发 DFS,把所有可达点写进 reach[s] */
void dfsReach(int u, int s) {
    reach[s].set(u);                       // 进入即标记“可达”
    for (int v : g[u])
        if (!reach[s].test(v)) dfsReach(v, s);
}

/* Floyd 版:n ≤ 500 时最省事 */
void floydReach(vector<vector<bool>>& r) {
    for (int k = 0; k < n; ++k)
        for (int i = 0; i < n; ++i)
            if (r[i][k])
                for (int j = 0; j < n; ++j)
                    if (r[k][j]) r[i][j] = true;
}

int main() {
    /* 有向图:0→1, 1→2, 2→3, 0→3, 3→4 */
    g[0].push_back(1); g[1].push_back(2); g[2].push_back(3);
    g[0].push_back(3); g[3].push_back(4);

    for (int s = 0; s < n; ++s) dfsReach(s, s);
    for (int u = 0; u < n; ++u) {
        cout << u << " 可达: ";
        for (int v = 0; v < n; ++v) if (reach[u].test(v)) cout << v << " ";
        cout << "\n";
    }
    /* 0 可达: 0 1 2 3 4
       1 可达: 1 2 3 4
       2 可达: 2 3 4
       3 可达: 3 4
       4 可达: 4      */

    cout << "0 可达 4 ? " << (reach[0].test(4) ? "是" : "否") << "\n";   // 是
    cout << "4 可达 0 ? " << (reach[4].test(0) ? "是" : "否") << "\n";   // 否(单向)
    cout << "整张图强连通? " << ([&]{ for (int i = 0; i < n; ++i) if ((int)reach[i].count() != n) return false; return true; }() ? "是" : "否") << "\n";
    return 0;
}

8.9.4 欧拉路径与欧拉回路(一笔画问题)

欧拉路径(Euler path):经过图中每条边恰好一次的路径(顶点可以重复经过)。 欧拉回路(Euler circuit):起点与终点相同的欧拉路径。 这就是小时候玩的「一笔画」——笔不离开纸,每条线只画一次。

判定条件(无向图)要先保证「所有度数大于 0 的顶点在同一个连通块里」,然后数奇度顶点的个数:

奇度顶点个数结论说明
0 个存在欧拉回路可以从任意顶点出发并回到它,也叫欧拉图
2 个存在欧拉路径(不是回路)必须从其中一个奇度顶点出发,在另一个结束
其它(4、6、…)不存在由握手定理的推论,奇度顶点个数必为偶数

有向图的判定稍作修改:把「度数为偶数」换成「每个顶点的入度 = 出度」(欧拉回路), 或者「恰有一个顶点出度 = 入度+1(起点)、一个顶点入度 = 出度+1(终点),其余入度 = 出度」(欧拉路径), 再加上「把有向边当成无向边后图连通」。

看看我们的示例图 G:度数为 2,3,2,2,2,3,2,2,奇度顶点恰好是 1 和 5,共 2 个,且图连通, 所以存在欧拉路径,必须从 1 出发、到 5 结束。这条路径是: 1 → 0 → 6 → 5 → 4 → 3 → 2 → 1 → 7 → 5,正好走完 9 条边、每条一次。

#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   无向图的欧拉路径 / 欧拉回路:判定 + Hierholzer 求路径
   判定:① 所有度 > 0 的顶点连通  ② 奇度顶点个数为 0(回路)或 2(路径)
   Hierholzer:DFS 走到底,回溯时把顶点压栈,最后逆序输出即为欧拉路径
   ============================================================ */
const int MAXN = 1005;
const int MAXM = 10005;
int deg[MAXN];
vector<pair<int,int> > g[MAXN];      // (邻接点, 边编号)
bool usedEdge[MAXM];
bool visV[MAXN];
vector<int> ans;
int n = 8, m = 0;

void addEdge(int u, int v) {
    g[u].push_back(make_pair(v, m));
    g[v].push_back(make_pair(u, m));
    deg[u]++; deg[v]++;
    ++m;
}

void dfsConnect(int u) {                  // 只关心有边的顶点是否连通
    visV[u] = true;
    for (size_t i = 0; i < g[u].size(); ++i)
        if (!visV[g[u][i].first]) dfsConnect(g[u][i].first);
}

void hierholzer(int u) {
    for (size_t i = 0; i < g[u].size(); ++i) {
        int v = g[u][i].first, id = g[u][i].second;
        if (usedEdge[id]) continue;       // 每条边只走一次
        usedEdge[id] = true;
        hierholzer(v);
    }
    ans.push_back(u);                     // 回溯时才记录 → 逆序输出即为路径
}

int main() {
    int E[9][2] = {{0,1},{0,6},{1,2},{1,7},{2,3},{3,4},{4,5},{5,6},{5,7}};
    for (int i = 0; i < 9; ++i) addEdge(E[i][0], E[i][1]);

    /* ① 连通性检查 */
    int startV = -1;
    for (int v = 0; v < n; ++v) if (deg[v] > 0) { startV = v; break; }
    dfsConnect(startV);
    bool connected = true;
    for (int v = 0; v < n; ++v) if (deg[v] > 0 && !visV[v]) connected = false;

    /* ② 数奇度顶点 */
    int odd = 0, first = -1;
    for (int v = 0; v < n; ++v)
        if (deg[v] % 2) { ++odd; if (first < 0) first = v; }

    if (!connected || (odd != 0 && odd != 2)) {
        cout << "不存在欧拉路径\n";
        return 0;
    }
    int s = (odd == 2) ? first : startV;          // 有奇度点就必须从奇度点出发
    cout << (odd == 0 ? "存在欧拉回路,起点 " : "存在欧拉路径,起点 ") << s << "\n";
    hierholzer(s);
    cout << "路径: ";
    for (int i = (int)ans.size() - 1; i >= 0; --i)
        cout << ans[i] << (i ? " -> " : "\n");
    /* 存在欧拉路径,起点 1
       路径: 1 -> 0 -> 6 -> 5 -> 4 -> 3 -> 2 -> 1 -> 7 -> 5  */
    cout << "共经过 " << m << " 条边,每条恰好一次\n";
    return 0;
}
易错:欧拉路径 ≠ 哈密顿路径 欧拉路径要求「每条恰好走一次」(顶点可重复),判定只需数度数,有 O(n+e) 的算法; 哈密顿路径要求「每个顶点恰好经过一次」,判定是 NP 完全问题,没有多项式算法。 这两个名字很像、条件却完全相反,考试里经常放在一起辨析,务必分清。

8.10 工程视角:图存储与遍历撑起的系统

前九节我们一直在和一张 8 个顶点、9 条边的小图打交道。可一旦走出教室,图的规模会立刻换一个量级—— 社交网络、网页链接、编译器的模块依赖、GC 眼里的内存,全都是图。这一节换一个视角: 不再问「这张图的 DFS 序列是什么」,而是问「当图大到 108 个顶点时,那些结构还撑得住吗」。 答案会有点意外:课本上的两种存储结构和两种遍历,几乎都被「换过一次零件」才真正上线。

8.10.1 真实图的规模:从 8 个顶点到 30 亿用户

先看几个真实数字(记数量级即可):

现在做最关键的推算:1 亿个顶点(n = 108)的图,用邻接矩阵要多少空间? 矩阵有 n² = 1016 个格子。就算每格只占 1 个 bit,也要 1016 bit = 1.25×1015 字节 = 1.25 PB;按 int 存是 40 PB。 而这只是装下它——遍历一遍是 O(n²) = 1016 次操作。

同一个图换成 CSR(下节讲):行指针 rowPtr(108+1)×4 B ≈ 400 MB, 109 条边的列下标 colIdx4 GB,合计约 4.4 GB。 差距是 1.25 PB 对 4.4 GB,约 28 万倍。所以工程第一条铁律是: 稀疏图必须用邻接表家族(邻接表 / 链式前向星 / CSR),邻接矩阵的 O(n²) 在真实规模上根本放不下。 8.4 节的邻接表、8.4.4 节的链式前向星,不是「另一种写法」,而是唯一可行的写法。

但矩阵没有死,它在三类场景里仍然稳赢:

  1. 稠密图。e 接近 n² 时 O(n+e)O(n²) 已是同一量级, 而矩阵内存连续、没有间接寻址,常数更小。
  2. 需要反复 O(1) 判边。邻接表、前向星、CSR 判「u 与 v 相邻吗」都要扫一遍 u 的邻居(最坏 O(n)), 矩阵一次寻址。
  3. 算法本身长在二维数组上。Floyd、传递闭包的位运算、稠密图上的 Prim 都是这样, 它们本身就是 O(n³),只适用于 n ≤ 500:n = 500 时是 1.25×108 次操作还能忍,n = 104 就是 1012,出局。

一句话:先看 n 和 e 谁大,再看你要「判边」还是要「遍历邻居」。 8.5 节那张九维对比表是考试视角,这里补的是规模视角。

8.10.2 三种存法的空间账:矩阵八成的格子是浪费

把一张 6 个顶点、7 条弧的稀疏图用三种方式各存一遍:

同一张稀疏图(6 个顶点、7 条弧)的三种存法:格子数、有效数据与浪费 ① 图本身 012 345 7 条弧:0→1、0→2、1→2、2→3、3→4、3→5、4→5。 邻接矩阵为它准备 36 个格子,只有 7 个装东西。 ② 邻接矩阵:6×6 = 36 格 0000 00000 00000 0000 00000 000000 111 1111 012 345 012 345 7 个 1:真正的有效数据 29 个 0:永远为 0,占比 29/36 = 80.6% 按 int 存 = 36×4 B = 144 B;按位存 = 36 bit;与边数无关。 ③ CSR:三个一维数组 1111 111 values[] 0123 456 1223 455 colIdx[] 0123 456 023 77 46 rowPtr[] 0123 456 values[] 无权图可以整个省掉,带权图才需要它。 colIdx[] 7 个 int(7 条弧);rowPtr[] 7 个 int(n+1)。 顶点 3 的邻居 = colIdx[rowPtr[3] .. rowPtr[4]−1] = colIdx[4..5] = {4, 5} 区间长度 rowPtr[u+1] − rowPtr[u] 就是出度,O(1) 读出。 邻居在内存里连续排布,顺序扫一段即可。 算账:邻接矩阵用 36 个 int 换 7 个 1,其中 80.6% 的格子永远是 0;CSR 只用 colIdx 的 7 个 int + rowPtr 的 7 个 int = 14 个 int。 放大到 n = 10⁸:矩阵要 10¹⁶ 个格子,按位存 1.25 PB、按 int 存 40 PB;同样 10⁸ 个顶点、10⁹ 条边的 CSR 只要 400 MB + 4 GB ≈ 4.4 GB。 对照:链式前向星每条边要 2 个 int(to + nxt),CSR 无权图每条边只要 1 个 int(colIdx)——10⁹ 条边是 8 GB 对 4 GB。 代价:CSR 是「一次成型」的数组,插一条边要整体后移甚至重建;要 O(1) 判边就得退回矩阵或哈希。
图 8-14 同一张稀疏图的三种存法:邻接矩阵 36 格只有 7 个 1(浪费 80.6%),CSR 只存 7 条弧

最该记住的是那个比例:1 亿个顶点、10 亿条边时,矩阵的浪费率是 1 − 109/1016 = 99.99999%——每 1000 万个格子才 1 个真数据。

8.10.3 CSR 与链式前向星:一个一次成型,一个支持动态加边

CSR(Compressed Sparse Row,压缩稀疏行)就是邻接表的工业化形态:把所有顶点的邻居链拆开、 按顶点顺序拼成一条大数组,再用行指针数组记住每段起点。rowPtrn+1 个整数, 顶点 u 的邻居是 colIdx[rowPtr[u]]colIdx[rowPtr[u+1]−1] 这一段; colIdxe 个整数,按源点分组、组内连续;values 只有带权图才需要。

和 8.4.4 节的链式前向星相比,相同点很多:都是「一个数组存边 + 一个数组记起点」,都没有指针, 都远比「每个顶点一个 vector」缓存友好,遍历邻居都是 O(deg(u))。差别有三条:

一、邻居连不连续。链式前向星用 nxt 把同一起点的边串成链表,这些边可能散落在 数组任何位置,遍历时是一次次「跳」;CSR 里同一起点的边严格连续,遍历就是顺序扫一段内存。 一条缓存行能装 16 个 int,顺序扫几乎次次命中,跳着走则经常重取缓存行。 这是 CSR 在图计算里胜出的根本原因,也是 Ligra、GraphX 都以 CSR 为默认格式的理由。

二、能不能动态加边。链式前向星是头插法,加一条边三行、O(1);CSR 是一整块压好的数组, 中间插一个数要把后面全部后移,所以它是「一次成型」的:先把边收集齐、按源点排序、再压实。 常见的折中是双缓冲——写入走邻接表,攒够一批整体重建一次 CSR,读走 CSR。

三、每条边的开销。链式前向星每条边要 2 个 inttonxt); CSR 无权图上每条边只要 1 个 intcolIdx),行指针只占 n+1 个。 109 条边的有向图:链式前向星 8 GB,CSR 是 4 GB—— 同样的图,边数据只有一半。

CSR 只能快查出边;要连入边一起快查(PageRank 既要沿出边送分、又要沿入边收分), 得再存一份转置 CSC,多花一倍存储换掉每轮一次全图转置。

8.10.4 DFS 的工程落点:依赖图、调用图与垃圾回收

8.6.6 节列了 DFS 的六大应用,那是算法视角的清单。现在把它们放回真实系统里,看各自长在哪里。

① 编译器的依赖图环检测与拓扑排序

把每个源文件、每个编译目标看成顶点,#include 或模块依赖看成边,就得到一张有向图。 图里一旦有环,make 会打印「Circular dependency dropped」,Bazel 直接拒绝构建。检测方法正是 8.6.6 节的有向图三色标记:DFS 中遇到指向「灰色」(还在递归栈上)顶点的边,就找到了环。 判完环,把 DFS 的后序序列逆序输出就是构建顺序,make -j 的并行调度、包管理器的 解析顺序用的是同一个套路。另外注意:拓扑序不唯一,调度器会在这些合法解里挑「关键路径最短」 的那一个来压缩总工时(第 09 讲)。

② 程序分析的调用图可达性

把函数当顶点、调用关系当边,就是调用图。优化器要做的第一件事是「从 main 出发 DFS, 标记所有可达函数」,没被标记的就是死代码,可以从二进制里删掉——链接器的 --gc-sections、Java 的类加载分析都是这个思路,代价 O(n+e)。 大型 C++ 程序的调用图有 106 量级顶点、107 量级边,听着便宜;但要做 上下文敏感的精确分析(区分「是谁调用的」),状态数会指数上升——这就是「O(n+e) 的可达性」 与「能落地的可达性」的差距。

③ 网页链接图上的 PageRank 迭代

PageRank 给每个网页打分,更新式是 r(v) = (1−d)/n + d × Σu→v r(u)/outdeg(u)。 把 r 看成向量、把链接看成矩阵 A,这正是一次 稀疏矩阵乘向量r ← (1−d)/n + d·AT·(r/outdeg)。用 CSR 实现,一轮迭代就是遍历一遍所有边, O(n+e)。搜索引擎要处理几十亿网页、上万亿链接,一轮是 1012 次边访问, 还要迭代 20~30 轮才收敛——这就是「必须 CSR + 并行 + 分片」的全部理由。 注意它和 8.8 节的连通分量不同:那边只问「能不能到达」,一次遍历就结束;这边要反复迭代到分数稳定, 属于迭代式图计算

④ 垃圾回收器里的引用图:递归 DFS 的滑铁卢

堆里每个对象是顶点,对象里的指针是边,「还有没有用」=「从根集合(栈、全局变量、寄存器)出发 能不能到达」,所以 GC 的标记阶段本质上就是一次 DFS/BFS。但这里有一个课本不会提的硬约束: 对象图的形状不受控,深度可以等于对象个数。一个 100 万结点的链表就能让递归 DFS 立刻爆栈, 而 GC 跑在别人的进程里,更不能崩。所以生产级 GC 一律不用递归:

结论:教科书上的递归 DFS 在工程上不能用。它在小图上正确优美,但只要图的深度上到 105 就当场崩掉,而真实的图动辄 108 个顶点。

8.10.5 迭代 DFS:把调用栈搬到堆上

下面这段程序把这件事做实:同一张图,递归版与迭代版各跑一遍。图用 CSR 存,显式栈用 stack<int>(换成 int stk[MAXN] + top 指针完全等价, 一层只花 1 个 int;第 03 讲那种链栈就是它的原理)。

#include <cstdio>
#include <stack>
using namespace std;

/* ============================================================
   工程级 DFS:为什么生产代码用「显式栈」而不是递归
   图用 CSR 存:rowPtr[u]..rowPtr[u+1]-1 是 u 的出边在 colIdx 中的区间。
   ============================================================ */
const int MAXN = 1000005;      /* 顶点数上限 */
const int MAXM = 2000005;      /* 边数上限   */
int rowPtr[MAXN];              /* 行指针:n + 1 个 int */
int colIdx[MAXM];              /* 列下标:e 个 int     */
bool vis[MAXN];                /* 访问标记     */

int n = 0;                     /* 当前顶点数 */

/* ---------- 建图一:0 → 1 → 2 → … → n−1 的一条长链 ---------- */
void buildChain(int nodes) {
    n = nodes;
    rowPtr[0] = 0;
    for (int u = 0; u + 1 < n; ++u) {
        colIdx[rowPtr[u]] = u + 1;        /* u 的唯一出边指向 u+1 */
        rowPtr[u + 1] = rowPtr[u] + 1;
    }
    rowPtr[n] = n - 1;                    /* 末尾顶点没有出边 */
}

/* ---------- 建图二:梳状图:脊 0 → 1 → … → spine−1,每脊点再挂一片叶子 ----------
   两条出边刻意「先压叶子、后压下一脊点」,叶子被留在栈底一路累积,
   显式栈深度会涨到 spine。 */
void buildComb(int spine, int leaves) {
    n = spine + leaves;
    rowPtr[0] = 0;
    for (int i = 0; i < spine; ++i) {
        int deg = 0;
        if (i < leaves) { colIdx[rowPtr[i] + deg] = spine + i; ++deg; }  /* 先压叶子 */
        if (i + 1 < spine) { colIdx[rowPtr[i] + deg] = i + 1; ++deg; }   /* 后压脊点 */
        rowPtr[i + 1] = rowPtr[i] + deg;
    }
    for (int i = spine + 1; i <= n; ++i) rowPtr[i] = rowPtr[spine];      /* 叶子没有出边 */
}

void clearVis() { for (int i = 0; i < n; ++i) vis[i] = false; }

/* 写法 A:教科书递归版。每层递归都要在【调用栈】上留一个栈帧。 */
long long dfsRec(int u) {
    vis[u] = true;
    long long cnt = 1;
    for (int i = rowPtr[u]; i < rowPtr[u + 1]; ++i) {
        int v = colIdx[i];
        if (!vis[v]) cnt += dfsRec(v);
    }
    return cnt;
}

/* 写法 B:迭代版。把「调用栈」换成显式 stack<int>(在堆上),
   深度上限只受内存限制。等价写法:int stk[MAXN] + top 指针。 */
long long dfsIter(int s, int &maxDepth) {
    stack<int> st;
    vis[s] = true;
    st.push(s);                       /* 入栈即打标记 */
    maxDepth = 0;
    long long cnt = 0;
    while (!st.empty()) {
        int u = st.top(); st.pop();
        ++cnt;
        if ((int)st.size() + 1 > maxDepth) maxDepth = (int)st.size() + 1;   /* 含当前结点 */
        for (int i = rowPtr[u]; i < rowPtr[u + 1]; ++i) {
            int v = colIdx[i];
            if (!vis[v]) { vis[v] = true; st.push(v); }
        }
    }
    return cnt;
}

int main() {
    /* 1) 小图对拍:两种写法必须给出同一个答案 */
    buildChain(1000);
    clearVis();
    long long a = dfsRec(0);
    clearVis();
    int d1 = 0;
    long long b = dfsIter(0, d1);
    printf("1) n=1000 长链    :递归版 %lld 个,迭代版 %lld 个,%s\n",
           a, b, (a == b ? "一致" : "不一致"));

    /* 2) 10^6 结点的长链:只跑迭代版 */
    buildChain(1000000);
    clearVis();
    int d2 = 0;
    long long c = dfsIter(0, d2);
    printf("2) n=1000000 长链 :迭代版访问 %lld 个,显式栈最大深度 %d\n", c, d2);

    /* 3) 10^6 结点的梳状图:显式栈真的压到 50 万层 */
    buildComb(500000, 500000);
    clearVis();
    int d3 = 0;
    long long e = dfsIter(0, d3);
    printf("3) n=1000000 梳状 :迭代版访问 %lld 个,显式栈最大深度 %d\n", e, d3);

    /* 4) 递归版在这里会怎样?——不真跑,只说明
       把第 2 步换成 dfsRec(0),程序不会打印任何结果,而是直接以栈溢出收场。
       本机实测(MinGW-w64 g++ -O2):递归 75000 层还能返回,80000 层进程
       以 0xC00000FD(STATUS_STACK_OVERFLOW)崩溃。10^6 的链要 10^6 层,必挂。 */
    printf("4) 递归版在 10^6 层深度下会爆栈(实测约 7.5 万层触顶),故跳过不跑\n");
    return 0;
}

真实输出(MinGW-w64 g++ -std=c++17 -O2):

1) n=1000 长链    :递归版 1000 个,迭代版 1000 个,一致
2) n=1000000 长链 :迭代版访问 1000000 个,显式栈最大深度 1
3) n=1000000 梳状 :迭代版访问 1000000 个,显式栈最大深度 500000
4) 递归版在 10^6 层深度下会爆栈(实测约 7.5 万层触顶),故跳过不跑

第二行反直觉——100 万结点的长链,迭代版访问了 100 万次,可显式栈最深只有 1 层,因为链上 每个顶点只有一条出边,压进去立刻被弹出。这说明递归深度不由「算法需要多少信息」决定,而由 「图里最长的那条路径有多长」决定:递归把这份路径记忆塞进调用栈,迭代把它写在明面上。 第三行才是重点:梳状图上显式栈压到 50 万层,占的是堆上的 500000 × 4 B ≈ 2 MB;同样深度换成递归,就是 50 万个栈帧压在线程栈上。

两个会让程序当场死掉的坑:递归爆栈与漏写 visited
  • 坑一:递归 DFS 在深图 / 长链图上爆栈。递归深度 = 当前路径长度,不是顶点数。 一条 106 个顶点的链就要 106 层调用栈:本机实测 75000 层还能返回、 80000 层就以 0xC00000FDSTATUS_STACK_OVERFLOW)崩溃; Linux 默认线程栈 8 MB、栈帧约 48 B,上限也只是 17 万层左右。更阴的是它不一定报「栈溢出」: 踩穿栈底后往往表现为任意位置的段错误或数据被悄悄改坏。所以顶点数超过 105、 或者图的形状不可控(GC、爬虫、编译器前端),一律用显式栈的迭代版。
  • 坑二:漏写 visited,轻则指数爆炸,重则死循环。有环图上 DFS/BFS 会绕着环永远转下去, 程序直接卡死;无环但有多条路径的图(例如第 13 讲 DP 里常见的分层图)上,同一个顶点会被 从不同路径反复访问,访问次数等于「从起点到它的路径条数」——一张每层 2 个顶点、层间全连接的 n 层图,路径数是 2n,n = 60 时是 1.15×1018 条。 还要注意标记时机:BFS 必须「入队即标记」,若等出队才标记,同一个顶点会被多个邻居反复入队, 队列长度从 O(n) 涨到 O(e)

8.10.6 BFS 的工程落点:六度分隔、爬虫队列与消息扩散

8.7.4 与 8.7.5 讲了 BFS 的最短路与两个经典应用。BFS 在工程上还有一批「长得不像 BFS」的落点, 共同形态是:一层一层向外扩散,用一个队列维持当前边界

① 六度分隔与 N 度人脉

「你和任何一个陌生人之间最多隔着六个人」来自 1967 年 Milgram 的连锁信实验,翻译成图论就是: 以你为源点做一次 BFS,最远那一层的层号是几。2016 年 Facebook 用 15.9 亿用户的社交图算过, 任意两人的平均分离度是 3.57,平均 4 跳就连上。可这张图上一次 BFS 要碰 109 个顶点、 1011 条边,不可能每个查询都跑一次全图 BFS。工程做法是离线预计算 + 双向 BFS + 地标索引: 预先算好一批「地标」到所有点的距离,任意两点的距离用 mink(dist(k,u) + dist(k,v)) 估出来,毫秒级返回,代价是结果只是上界

② 网络爬虫的 URL 待抓队列

爬虫就是跑在网页链接图上的一次 BFS:种子 URL 入队,出队一个就下载、解析链接、把没见过的入队。 三个改造点:

③ P2P / 区块链的邻居扩散与路由跳数

比特币节点默认维持 8 条出站连接,新交易、新区块先发给这几个邻居,邻居再转发出去, 这叫 gossip(疫情扩散)协议:每轮把消息随机发给 k 个邻居,覆盖全网的期望轮数是 O(log n),n = 106、k = 8 时约 7 轮就能覆盖全网。关键在去重要极便宜—— 每个节点只为每条消息存几十字节的哈希(极简的 visited 集合),否则同一笔交易会被反复转成广播风暴。

路由协议 RIP 把每条链路看成权为 1 的边,用距离向量迭代求「到目的地最少几跳」,本质就是分布式 BFS。 它有一条著名的工程约束:最大跳数 15,16 表示不可达,因为距离向量算法遇到坏消息收敛极慢 (计数到无穷),只能用小上限兜底。想要权值就得换 OSPF——每个路由器用 Dijkstra 算最短路。 这一个例子把「BFS 只管无权图,带权要 Dijkstra」的边界画得清清楚楚(第 09 讲正题)。

8.10.7 选错容器,算法就错了

第 03 讲讲栈、第 04 讲讲队列时,它们是「两种受限的线性表」;到了图遍历,容器直接决定算法:

换句话说,这三个算法的代码骨架几乎是同一段,唯一的区别就是那个容器。更要命的是: 把 BFS 的队列换成栈,它就不再是 BFS,最短路的结论立刻失效,但访问序列仍然合法—— 算法「错了」不会报错,只会给出一个看起来合理的错误答案

标记本身用什么存也是工程问题,这个「小小的布尔数组」在真实规模下并不小:

8.10.8 图不一定在内存里:外存、图数据库与分布式

前面都偷偷假设了一件事:整张图能装进一台机器的内存。这个假设破了,工程上有三条路。

① 外存图算法(半外存 / semi-external)

网页图有 1012 条边,每条边两个 int 就是 8 TB,只能放磁盘。 半外存的意思是:顶点相关的 O(n) 数组(visited、dist)留在内存,边表放磁盘, 算法按「一遍一遍扫边」来组织。代价很实在:一次 BFS 的每一层都要扫一遍整张边表, 8 TB 按 500 MB/s 的读带宽算约 4.4 小时这时 I/O 成了唯一瓶颈, O(n+e) 完全失去意义——要优化的是「扫几遍」,不是「比较几次」。

② 图数据库:为什么要专门的存储引擎

用关系数据库存图当然可以:一张 friend(a, b) 表。但「查朋友的朋友」在 SQL 里是 friend 表自己 JOIN 自己,查询深度每加 1,JOIN 的次数就加 1,代价随深度成倍增长。 Neo4j 这类图数据库用的是 免索引邻接(index-free adjacency):每条关系记录直接存着两端结点 记录的物理位置,从 A 走到邻居就是顺着指针跳一下,成本是一次指针解引用,而不是一次索引查找或 JOIN,于是深度增加时代价只是线性叠加。代价也明确:为了「哪个方向都能 O(1) 跳过去」, 关系要双向各存一份,写入时同一份数据改多处,写放大明显;它也只擅长「顺着边走」的查询。 没有免费的午餐:图数据库是用写入代价和存储冗余,买下了遍历时的 O(1) 邻接。

③ 分布式图计算:以顶点为中心的 Pregel 模型

图大到单机内存绝对装不下时(109 顶点、1011 条边,CSR 要 400 GB 以上), 只能把图切开分到多台机器上迭代。Google 2010 年的 Pregel 定下了这个模型:每一轮叫一个 superstep,每个顶点读上一轮收到的消息、更新自己的值、给邻居发消息;一轮结束后有一个 全局同步点(barrier),所有顶点都停下才进入下一轮;顶点可「投票停机」,全部停机则结束。 以 PageRank 为例,更新就是 rank(v) = (1−d)/n + d × Σ rank(u)/outdeg(u),每条消息 就是一个数。好处是:程序员只写「一个顶点怎么处理消息」,不用操心怎么分片;Spark 上的 GraphX 是同一模型的实现。

代价也说清楚:同步屏障的长尾效应(一轮的时间由最慢的机器决定)、通信量惊人 (每条边一轮一条消息,1011 条边 × 4 B × 30 轮 ≈ 12 TB 网络流量)、以及 容错只能靠检查点加回滚重算——这也是真实图集群要上万兆网、分片时尽量把边留在本机的原因。

8.10.9 工程选型对比表

把这一节的结论压成一张表。考试看 8.5 节那张九维对比,工程落地看这张:

存储方案空间复杂度判边速度遍历邻居速度 能否动态加边适合的规模典型系统举例
邻接矩阵 O(n²)(按位 n²/8 字节) O(1) O(n),要扫一整行 能,O(1) n ≤ 几千,且稠密(e 接近 n²) Floyd 全源最短路、小规模传递闭包、稠密图上的 Prim
邻接表(vector 数组) O(n + e) O(deg(u)) O(deg(u)),邻居分散在多块堆内存里 能,均摊 O(1) n 到 106 的通用场景 算法竞赛、教学、中小规模图分析与可视化
链式前向星 O(n + 2e)(有向 n + 2e,无向 n + 4e O(deg(u)) O(deg(u)),跟 nxt 跳,顺序为插入逆序 能,头插 O(1) n、e 到 107 竞赛模板、网络流(需要边编号与反向边)、频繁加边的图
CSR(压缩稀疏行) O(n + e);无权图每条边只占 1 个 int O(deg(u))colIdx 排序后可二分) O(deg(u)),邻居内存连续,顺序扫描最快 不能,一次成型;改图要重建(常用双缓冲) n、e 到 108 及以上 PageRank、Ligra、GraphX、SciPy 的 csr_matrix、图神经网络采样

三条经验:规模先于一切——n ≤ 1000 且稠密就用矩阵,n ≥ 105 且 e 远小于 n² 就用 CSR 或链式前向星;读多写少用 CSR,写多用邻接表 / 前向星别过早优化——绝大多数业务问题的图 只有几千个顶点,瓶颈在数据库和网络。真正需要 CSR、外存和 Pregel 的,就是社交网络、搜索引擎、 编译器和 GC 这几个「图就是主角」的领域——也正是本章开头列的那几个。

8.11 本章小结、易错点与自测

8.11.1 必须记住的十件事

概念与结论

  1. G = (V, E)n = |V|e = |E|; 无向简单图 0 ≤ e ≤ n(n−1)/2,有向 0 ≤ e ≤ n(n−1)
  2. 握手定理Σd(v) = 2e;有向图 Σ入度 = Σ出度 = e; 推论:奇度顶点个数为偶数。
  3. 无向图矩阵对称,有向图矩阵一般不对称;求度口诀「行出列入」。
  4. 邻接矩阵空间 O(n²)、遍历 O(n²);邻接表空间 O(n+e)、 遍历 O(n+e)
  5. 无向图邻接表边结点 2e 个,有向图 e 个。

算法与结构

  1. DFS 用(递归),BFS 用队列;都要「进入 / 入队时立刻打标记」。
  2. DFS / BFS 的时间:邻接表 O(n+e),邻接矩阵 O(n²)
  3. 无权图单源最短路 = BFS + dist[];路径还原靠 pre[] 倒推。
  4. 连通分量个数 = 外层循环真正发起遍历的次数;生成树有 n−1 条边, 生成森林有 n−k 条边。
  5. 链式前向星三行插入:to[idx]=v; nxt[idx]=head[u]; head[u]=idx; ++idx; 遍历:for (i = head[u]; i != -1; i = nxt[i])

8.11.2 易错点清单

易错点(一):存储结构与计数的细节
  • 无向图忘记写两格 / 忘记 addEdge 两次:邻接矩阵只写 A[u][v] 而漏掉 A[v][u];邻接表只 push 一次。结果度算少一半、遍历走不通。
  • 把有向图当无向图存:有向图的 addEdge 只能 g[u].push_back(v) 一次。
  • 自环对度的贡献算成 1:应该是 2;有向图自环同时给入度和出度各 +1。
  • 邻接表边结点数记错:无向图是 2e,有向图是 e
  • 链式前向星数组开小:无向图要开 2e 个边结点,只开 e 会越界 RE。
  • memset(head, -1, sizeof(head)) 却把 head 定义成 vector: 对 vector 用 memset 是未定义行为,必须用循环或 fill
易错点(二):遍历与判定的细节
  • BFS 出队时才打标记:会导致重复入队、队列爆炸,必须「入队即标记」。
  • 无向图判环忘记排除父结点:每条边会被判成环,结论完全错误。
  • 有向图判环用「父结点」那一套:有向边不对称,必须用三色标记或拓扑排序。
  • DFS 递归深度爆栈:链状图 n = 105 就可能在默认栈下 RE。
  • 认为「DFS 序列唯一」:序列取决于邻接点的枚举顺序,答题要写清约定。
  • 把 BFS 当带权最短路用:边权不同时 BFS 失效,必须 Dijkstra。
  • 非连通图只从一个点遍历:外层必须套一层 for 扫描全部顶点,否则漏掉分量。
易错点(三):概念辨析
  • 路径长度数边不数点0→1→2 的长度是 2 而不是 3。
  • 连通 vs 强连通:前者是无向图的概念,后者要求有向图里「双向可达」。
  • 生成树 vs 生成森林:只有连通图才有生成树;非连通图得到的是森林。
  • 欧拉路径 vs 哈密顿路径:一个走遍所有的边,一个走遍所有的点。
  • 稀疏与稠密是相对的:判据是 e 相对 的大小,不是 e 的绝对值。

8.11.3 考点速查

高频考点(选择题 / 填空题 / 简答题)
  1. 度数计算:给邻接矩阵或邻接表求某点的度 / 入度 / 出度,或反过来由度数序列判断图是否存在。
  2. 握手定理:求度数之和、判断给定度数序列是否可行、求边数。
  3. 边数范围:完全图的边数 n(n−1)/2n(n−1); 连通图至少 n−1 条边。
  4. 遍历序列:给定图与起点,写出 DFS 序列 / BFS 序列,或判断某序列是否可能是 DFS/BFS 序列。
  5. 生成树:生成树的边数、生成森林的边数、由遍历得到的生成树是否唯一。
  6. 存储结构对比:空间、判相邻、求度、遍历效率的表格题。
  7. 连通性判断:连通分量个数、强连通分量个数、割点与桥的识别。
  8. 最短路:无权图用 BFS,写出 dist 数组与路径。
答题小技巧 遇到「求 DFS/BFS 序列」的题,先在草稿纸上把邻接表完整写出来(按升序排列), 再照着表走,比直接在图上比划可靠得多。 遇到「求度」的题,先用握手定理 Σd = 2e 做一次校验,能立刻发现算错的地方。

8.11.4 自测题(5 道)

1. 握手定理计算:一个无向图有 10 个顶点、15 条边。求所有顶点的度数之和;若每个顶点的度都相等,这个度是多少?另外判断:是否存在「10 个顶点中 9 个度数为 3、剩下 1 个度数为 2」的无向图?

解:

  1. 由握手定理 Σd(v) = 2e = 2 × 15 = 30
  2. 若每个顶点度数相同,则该度数 = 30 / 10 = 3(这样的图叫 3-正则图)。
  3. 若 9 个顶点度为 3、1 个度为 2,则 Σd(v) = 9×3 + 2 = 29, 是奇数;而度数之和必须是偶数(2e),矛盾。 所以这样的图不存在

(顺带记一个推论:任何图中奇度顶点的个数必为偶数。上题里「3 出现 9 次」本身就是奇数次, 一眼就能否定。)

2. 邻接矩阵求度:有向图 D 的邻接矩阵中,顶点 2 所在的行为 0 1 0 1 0,所在的列为 1 0 0 1 0。求顶点 2 的出度与入度;若把 D 改成无向图(矩阵对称化),顶点 2 的度是多少?

解:

  • 出度 = 行和0+1+0+1+0 = 2,即顶点 2 有两条出弧:2→12→3
  • 入度 = 列和1+0+0+1+0 = 2,即有两条弧指向 2:0→23→2
  • 对称化后,邻接点集合 = 出邻接点 ∪ 入邻接点 = {1,3} ∪ {0,3} = {0,1,3}, 所以度为 3(顶点 3 与 2 之间有两条方向相反的弧,但在无向图里只算一条边)。

易错提醒:对称化时不能把行和 + 列和直接相加(那是 4),因为「两个方向都有弧」的那一对 在无向图中只贡献 1 条边,会被重复计数。

3. DFS 序列:对本章示例图 G(V = {0..7},E = {(0,1),(0,6),(1,2),(1,7),(2,3),(3,4),(4,5),(5,6),(5,7)}),从顶点 3 出发、邻接点按编号从小到大访问,写出 DFS 访问序列、树边与回边。

解:先写出邻接表(升序):

adj(0)={1,6} adj(1)={0,2,7} adj(2)={1,3} adj(3)={2,4} adj(4)={3,5} adj(5)={4,6,7} adj(6)={0,5} adj(7)={1,5}

从 3 出发的递归过程:

  1. 访问 3;邻接点 2 未访问 → 树边 (3,2),进入 2。
  2. 访问 2;邻接点 1 未访问 → 树边 (2,1),进入 1(3 是父结点,跳过)。
  3. 访问 1;邻接点 0 未访问 → 树边 (1,0),进入 0(2 是父结点,跳过)。
  4. 访问 0;邻接点 6 未访问 → 树边 (0,6),进入 6(1 是父结点,跳过)。
  5. 访问 6;邻接点 5 未访问 → 树边 (6,5),进入 5(0 是父结点,跳过)。
  6. 访问 5;邻接点 4 未访问 → 树边 (5,4),进入 4(6 是父结点,跳过)。
  7. 访问 4;邻接点 3 已访问且不是父结点(4 的父结点是 5)→ 回边 (3,4);5 是父结点,跳过。
  8. 4 无路可走 → 回溯到 5;5 的下一个邻接点 6 已访问,7 未访问 → 树边 (5,7),进入 7。
  9. 访问 7;邻接点 1 已访问且不是父结点(7 的父结点是 5)→ 回边 (1,7);5 是父结点,跳过。
  10. 7 回溯 → 5 → 6 → 0 → 1 → 2 → 3 依次回溯,全部结束。

DFS 序列:3 → 2 → 1 → 0 → 6 → 5 → 4 → 7

树边(7 条 = n−1):(3,2)、(2,1)、(1,0)、(0,6)、(6,5)、(5,4)、(5,7)

回边(2 条 = e−(n−1)):(3,4)、(1,7)

4. BFS 与最短路:对同一张示例图 G,从顶点 2 出发做 BFS(邻接点升序入队),写出访问序列与 dist[],并还原 2 到 6 的最短路径。

解:逐层推进:

  • dist[2] = 0,队列 [2]
  • 出队 2 → 1、3 入队,dist[1] = dist[3] = 1,队列 [1, 3]
  • 出队 1 → 0、7 入队,dist[0] = dist[7] = 2,队列 [3, 0, 7]
  • 出队 3 → 4 入队,dist[4] = 2,队列 [0, 7, 4]
  • 出队 0 → 6 入队,dist[6] = 3,队列 [7, 4, 6]
  • 出队 7 → 5 入队,dist[5] = 3,队列 [4, 6, 5]
  • 出队 4、6、5 时邻接点都已访问,队列清空,结束

访问序列:2 → 1 → 3 → 0 → 7 → 4 → 6 → 5

dist[] = [2, 1, 0, 1, 2, 3, 3, 2](按顶点 0..7 排列)

2 → 6 的最短路径:pre[6] = 0,pre[0] = 1,pre[1] = 2, 倒推得 6 ← 0 ← 1 ← 2,反转后为 2 → 1 → 0 → 6,长度 = dist[6] = 3

顺便可以读出各层:L0 = {2},L1 = {1,3},L2 = {0,7,4},L3 = {6,5}。

5. 存储结构选择:一张无向图有 n = 10000 个顶点、e = 20000 条边(稀疏图)。分别估算邻接矩阵与邻接表的存储单元数量与内存占用(每个整型 4 字节),并说明该选哪一种、为什么。

邻接矩阵:需要 n² = 10000² = 108 个存储单元, 按每个 int 4 字节算约 400 MB(即使用 char 存 0/1 也要 100 MB), 而且这个开销与边数无关——20000 条边只占其中极小一部分,浪费率超过 99.9%。

邻接表:

  • 顶点表(表头数组):n = 10000 个单元;
  • 边结点:无向图需要 2e = 40000 个结点,每个结点至少 2 个域(adjvex + next), 共 40000 × 2 = 80000 个单元;
  • 合计约 90000 个单元 ≈ 0.36 MB

结论:邻接表(或等价的链式前向星)。两者内存相差约 108 / 9×104 ≈ 1100 倍;而且用邻接表做 DFS/BFS 是 O(n + e) = 3×104 级别,用邻接矩阵则是 O(n²) = 108 级别,时间上也差了几千倍。

补充:如果 n 只有几百且图很稠密(e ≈ n²/2),那邻接矩阵反而更好写、更快, 因为它判相邻是 O(1)、内存也连续。选结构永远先看数据规模与疏密。

8.11.5 配套编程练习

练习任务提示 / 验收标准
练习 1把本章示例图 G 分别用邻接矩阵、vector 邻接表、链式前向星存三遍,输出每个顶点的度,验证三者一致且 Σdeg = 18熟悉三种建图模板
练习 2用 DFS 与 BFS 分别打印访问序列,并与正文的手推结果对照(DFS: 0 1 2 3 4 5 6 7;BFS: 0 1 6 2 7 5 3 4)若不一致,先检查是否把邻接表排了序
练习 3在示例图上从每个顶点出发各跑一次 BFS,输出 dist 矩阵(即所有点对的最短距离)无权图全源最短路,O(n(n+e))
练习 4给示例图加一条边 (2,7),重新统计奇度顶点个数,判断欧拉路径是否还存在度数变为 2,3,3,2,2,3,2,3 → 4 个奇度点 → 不存在
练习 5实现「判断无向图是否有环」的两个版本:DFS 排除父结点、以及「e ≥ n 且连通」的计数法,并比较两者适用范围计数法只能在「无重边无自环」的简单图上用
练习 6把示例图改为有向图(每条无向边 (u,v) 换成正反两条弧),求每个顶点的入度与出度,并判断图是否强连通强连通则需要双向可达
练习 7用 Tarjan 求示例图的所有割点与桥提示:示例图中 1、5 是割点吗?自己验证
练习 8实现「多源 BFS」:给定 k 个起点,求每个顶点到最近起点的距离所有源点 dist = 0 同时入队
洛谷练习建议 在洛谷题库中用以下关键词搜索即可找到对应题目(题号请以站内搜索结果为准,本页不列出具体编号): 「图的遍历」「DFS 序」「连通块」「欧拉路径」「二分图染色」「割点」「单源最短路径(无权版)」。 建议的刷题顺序是:先把「图的存储与遍历」的模板题各写一遍(邻接矩阵版 + 邻接表版), 再练连通块与二分图,最后挑战割点与欧拉路径。 第 14 讲洛谷题单会给出更完整的分层练习计划。
下一讲预告 本讲解决的是「图怎么存、怎么走」;下一讲进入「图能算什么」: 最小生成树(Prim 加点、Kruskal 加边 + 并查集)、 最短路径(Dijkstra、Bellman-Ford、Floyd)、 拓扑排序强连通分量(Tarjan 缩点)。 它们全部建立在今天的 DFS/BFS 与两种存储结构之上——所以本讲的手推练习一定要做扎实。