Lesson 0408 · 图与图的遍历(BFS/DFS)

④ 数据结构与算法 — 第 8 课

上节课:排序 —— 把 sort 拆开,看到了分治的威力。
这节课:图。数组、链表、栈、队列、树都是「有形状」的结构;图是最自由的一种关系:谁和谁相连都行。地铁线路、好友关系、网页跳转、课程依赖,全都是图。
三句话速览
  1. 图 = 顶点 + 边;邻接表是默认存法,空间 O(V+E)。
  2. DFS 一条路走到黑,BFS 一层一层往外扩。
  3. BFS 的 visited 必须在入队时标,出队再标就重复入队。

1. 图是什么

图只有两个东西:

一张图记作 G = (V, E):V 是顶点集合,E 是边集合。就这么简单。

1.1 四种常见变体

类型含义例子
无向图边没有方向,u 连着 v 就等于 v 连着 u好友关系、地铁双向线路
有向图边有方向,u → v 不代表 v → u网页链接、课程先修关系、文献引用
带权图每条边带一个数字(代价)两站之间要 3 分钟、这条路要 20 块油钱
不连通图分成好几块,块之间没有任何边两个互不相识的朋友圈子

1.2 几个必背的词

树其实是图的一种特例
「连通 + 没有环」的图就是树。所以你会发现:图上的 DFS / BFS 代码,和你 0404 写的树的遍历几乎一模一样——只多了一个 visited 数组。多出来的这一样东西,就是本课的全部难点。

2. 图怎么存进电脑

图不是线性的,不能像数组那样一排就存好。有三种主流存法,各有各的适用场景。

2.1 邻接矩阵:开一个二维数组

int g[N][N];          // g[u][v] = 1 表示 u 到 v 有边

g[u][v] = 1;          // 加一条有向边 u -> v
g[v][u] = 1;          // 无向图再加这条(两个方向都能走)

if (g[u][v]) ...      // O(1) 判断 u、v 之间有没有边

2.2 边表:把所有边列成一张表

vector<pair<int, int>> edges;     // 每条边存成一个 pair

edges.push_back({u, v});

2.3 邻接表:本课主角

核心想法:对每个顶点,单独记一份「我的邻居名单」。

const int N = 100005;
vector<int> g[N];        // g[u] 里面装的是 u 的所有邻居

int main() {
    int n, m;
    cin >> n >> m;                       // n 个点,m 条边
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);                // 有向边 u -> v
        g[v].push_back(u);                // 无向图才要这一行!
    }
}

画出来就是「每个点背后挂一串邻居」:

1 -> [2, 3]
2 -> [1, 4, 5]
3 -> [1, 6, 7]
4 -> [2, 8]
5 -> [2, 8]
6 -> [3, 7]
7 -> [3, 6]
8 -> [4, 5]
三个坑,先记住
① 无向图漏写 g[v].push_back(u) → 只有半个图,遍历时到不了对面
② 有向图多写了这一行 → 把有向图当无向图用了(P5318 就是有向图,写错直接错)
③ const int N 开太小 → 越界 RE。看题目数据范围,通常开 1e5 或 1e6

3. 图的 DFS:比树的 DFS 多一个 visited

先回忆 0404 的树 DFS:从根出发,先钻左子树到底,再回来钻右子树。当时没有 visited,因为树没有环,顺着孩子走永远走不回来。

图不一样:1 → 2 → 3 → 1,这是个三角形。如果不标记走过的点,程序会 1 → 2 → 3 → 1 → 2 → 3 → … 一直转下去,直到栈溢出(RE),或者死循环超时(TLE)。

vector<bool> vis(n + 1, false);      // 注意是 n+1,因为顶点编号是 1..n

void dfs(int u) {
    vis[u] = true;                   // 一到就标记
    cout << u << " ";

    for (int v : g[u]) {             // 枚举 u 的所有邻居
        if (!vis[v]) dfs(v);         // 没走过的才进去
    }
}

用第 2.3 节那张图,从 1 号点出发,实际输出是:

DFS: 1 2 4 8 5 3 6 7

走的过程:1 → 2 → 4 → 8 →(8 的邻居都走过了)→ 退回 4 → 退回 2 → 5 →(都走过)→ 退回 1 → 3 → 6 → 7。这就是「一条路走到黑,走不通再退回来」。

3.1 图不连通怎么办

如果图分成了好几块,从 1 号点出发只能走完 1 所在的那一块。要遍历整张图,就在外面再套一层循环:

for (int i = 1; i <= n; i++) {
    if (!vis[i]) dfs(i);             // 每发现一个新连通块,就从它开始走
}

「外面套这一层循环,套了几次就有几个连通块」——这是数连通分量的标准写法(并查集能干同一件事,以后会学)。

3.2 复杂度

每个顶点最多被访问一次,每条边最多被看两次(无向图两个方向各一次)。所以:

邻接表 DFS = O(V + E)

4. 图的 BFS:用队列,一层一层往外扩

BFS 你已经很熟了(0405 的层序遍历就是)。图上唯一的改动还是 visited,而且标记的时机很关键。

void bfs(int s) {
    queue<int> q;
    q.push(s);
    vis[s] = true;                   // ★ 入队的时候就标记

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        cout << u << " ";

        for (int v : g[u]) {
            if (!vis[v]) {
                vis[v] = true;       // ★ 在这里标,不是出队时标
                q.push(v);
            }
        }
    }
}

同一张图,BFS 输出:

BFS: 1 2 3 4 5 6 7 8

1 是第一层,2 和 3 是第二层,4、5、6、7 是第三层,8 是第四层——整齐地按距离分层。这正是 BFS 最值钱的性质。

「入队时标记」为什么必须如此?
如果改成「出队时才标记」,看这个场景:1 的邻居是 2 和 3,2 和 3 的邻居都是 4。
BFS 处理到 2 时,4 还没被标记 → 4 入队;处理到 3 时,4 还是没被标记(因为 4 还没出队)→ 4 又入队一次。
队列里出现重复点,最坏情况下同一个点被塞进队列很多次,时间从 O(V+E) 退化到 O(E·V),大数据直接 TLE。
一句话:入队即标记,一票否决。

5. 手推:同一张图,队列和栈各走一遍

就用 2.3 节那张 8 个点的图,从 1 号点出发。邻居的枚举顺序按名单从左到右,这样推出来的结果正好是上面那两个输出。

5.1 BFS:队列里有什么,按层展开

步出队这一步新入队队列(队头在左)访问顺序
初始—11—
112、32 31
224、5(1 已访问)3 4 51 2
336、7(1 已访问)4 5 6 71 2 3
448(2 已访问)5 6 7 81 2 3 4
55—(2、8 都已访问)6 7 81 2 3 4 5
66—(3、7 都已访问)7 81 2 3 4 5 6
77—(3、6 都已访问)81 2 3 4 5 6 7
88—(4、5 都已访问)(空)1 2 3 4 5 6 7 8

盯住「队列」那一列:每个点最多出现一次。这就是「入队时标记」的直接效果——如果等出队再标记,4 会在第 2 步和第 3 步各被塞进去一次。

5.2 DFS:栈里是「当前这条路径」

步访问递归栈(栈底 → 栈顶)为什么走到这里
111起点
221 21 的邻居按名单取第一个
341 2 42 的邻居里 1 已访问,取 4
481 2 4 84 的邻居里 2 已访问,取 8
551 2 4 8 58 的邻居 4、5 中 4 已访问,取 5(5 是从 8 进去的)
631 35 的邻居 2、8 都访问过 → 一路退到 1(退 5→8→4→2),再取 3
761 3 63 的邻居里 1 已访问,取 6
871 3 6 76 的邻居里 3 已访问,取 7
9—(空)7 的邻居 3、6 都访问过,一路退回起点,结束

第 5 步最容易看晕:8 的邻居名单是 [4, 5],4 刚走过,所以 5 是「从 8 进去的」,栈里 4 和 8 都还在(1 2 4 8 5)。等 5 也走完(邻居 2、8 全访问过),栈才一路退回 2、再退回 1,然后才轮到 1 的第二个邻居 3。这就是 DFS 的实质:栈里永远只有当前这条路径。

访问顺序 1 2 4 8 5 3 6 7 和 3 节打印的 DFS 结果一致;BFS 的 1 2 3 4 5 6 7 8 也和 4 节一致。自己拿张纸把两张表盖住重推一遍,图就真的进脑子里了。

6. DFS 和 BFS 到底怎么选

DFS(深度优先)BFS(广度优先)
用什么装递归(系统的栈)或自己开 stackqueue 队列
走的顺序一条路走到黑,不行再退一层一层往外扩
空间O(最深路径长度)O(最宽那一层的大小)
能不能求最短路不能(找到的不一定最短)能(无权图 / 每步代价相同)
典型用途连通性、数路径条数、走迷宫(找一条路)、拓扑排序、回溯搜索最短步数、层次关系、扩散/感染模拟
为什么 BFS 能求最短路,DFS 不能?
BFS 是「一层一层扩」的:所有距离为 2 的点全走完,才轮到距离为 3 的点。所以第一次到达某个点时,用的必然是最少步数。
DFS 是「先钻到底」的:从 1 走到 8 可能绕了 5 步,但实际最短只要 2 步,DFS 根本不管。
求最短步数 → BFS;求「有没有路 / 有几条路」 → DFS。

7. 例题思路:P5318 查找文献

题意:n 个点、m 条有向边(文献引用关系),从 1 号点出发,输出 DFS 序和 BFS 序;同一层的点,编号小的先访问。

三个关键点:

  1. 有向图 —— 只写 g[u].push_back(v),千万别加反向边
  2. 「编号小的先访问」 —— 邻接表里邻居的顺序是输入顺序,是乱的。先给每份名单排个序:
for (int i = 1; i <= n; i++) {
    sort(g[i].begin(), g[i].end());   // 保证枚举邻居时从小到大
}
  1. 两次遍历要两个 vis 数组,或者遍历完 DFS 之后把 vis 清空再 BFS。用 vis.assign(n+1, false) 最省事。

所以整题的形状是:读边 → 给每个 g[i] 排序 → 跑一遍 DFS 输出一行 → 清空 vis → 跑一遍 BFS 输出一行。

复杂度:排序 O(V log V),遍历 O(V + E),合起来完全够用。

8. 网格图:把坐标当成点

迷宫、棋盘这类题,图上没有「1 号点、2 号点」,只有格子。这时候把每个格子 (x, y) 看成一个顶点:

既然点变成了坐标,就不开 g[] 了,直接用方向数组现场算邻居:

int dx[4] = {-1, 1, 0, 0};        // 上 下 左 右
int dy[4] = {0, 0, -1, 1};

for (int k = 0; k < 4; k++) {
    int nx = x + dx[k];
    int ny = y + dy[k];

    if (nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 越界
    if (a[nx][ny] == 1) continue;                          // 障碍/不能走
    if (vis[nx][ny]) continue;                             // 走过

    // 接下来对 (nx, ny) 做事
}

这套骨架,迷宫、岛屿数量、连通块计数、洪水填充……都长这样。换的只是「能不能走」的判断条件。

8.1 P1605 迷宫:DFS + 回溯

这题问的是「从起点到终点一共有多少条路径」。

注意 DFS 在这题的写法有个新动作:走完一条路之后,要把标记撤销。因为你要找的是所有路径,走过的那格在「下一条路线」里还得允许再走。

void dfs(int x, int y) {
    if (x == fx && y == fy) {
        ans++;                      // 到达终点,路径数 +1
        return;
    }
    for (int k = 0; k < 4; k++) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
        if (a[nx][ny] == 1 || vis[nx][ny]) continue;

        vis[nx][ny] = true;         // 标记:这一步走过了
        dfs(nx, ny);
        vis[nx][ny] = false;        // ★ 撤销:回溯的灵魂,别忘了
    }
}
为什么「标记 + 撤销」成对出现?
标记是为了防止同一条路径自己绕圈;撤销是为了让「别的路径」还能用这一格。
如果只标记不撤销:第一条路径走过的格子被永久封死,后面的路径全被堵掉,答案会偏小。
如果只撤销不标记:同一条路径里来回横跳,直接死循环。
成对写才安全——写完 vis[nx][ny] = true,紧接着就写它对应的 false。

8.2 P1443 马的遍历:BFS 求最短步数

「从起点骑马到每个格子最少几步」——看到「最少步数」,条件反射就该想到 BFS。

做法:用 d[x][y] 存步数,初始全为 -1(顺便当 visited 用,-1 表示没到过)。

int dx[8] = {-2, -1, 1, 2, 2, 1, -1, -2};    // 马走"日"字
int dy[8] = {1, 2, 2, 1, -1, -2, -2, -1};

vector<vector<int>> d(n + 1, vector<int>(m + 1, -1));
queue<pair<int, int>> q;

q.push({sx, sy});
d[sx][sy] = 0;

while (!q.empty()) {
    auto [x, y] = q.front();
    q.pop();

    for (int k = 0; k < 8; k++) {
        int nx = x + dx[k], ny = y + dy[k];
        if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
        if (d[nx][ny] != -1) continue;        // 到过就别再入队

        d[nx][ny] = d[x][y] + 1;              // 步数 = 上一格 + 1
        q.push({nx, ny});
    }
}

实际跑一张 5×5 的棋盘(从 (1,1) 出发),得到:

0 3 2 3 2
3 4 1 2 3
2 1 4 3 2
3 2 3 2 3
2 3 2 3 4

注意 -1 的格子表示「永远到不了」。输出格式要求每个数占 5 个字符宽度左对齐,用:

cout << setw(5) << left << d[i][j];

(setw 来自 <iomanip>,你在 P1157 用过 setw(3)。)

9. 复杂度小结

存法空间遍历一遍适合
邻接矩阵O(V²)O(V²)V ≤ 1000、稠密图、要 O(1) 查边
边表O(E)—只枚举边(Kruskal 等)
邻接表O(V + E)O(V + E)绝大多数情况,默认选它

1e5 个点、2e5 条边 → 邻接表毫无压力;邻接矩阵会开出几百 G 的内存,直接爆。

10. 坑点清单

11. 小测

Q1. 用 vector<int> g[N] 存无向图,读入一条边 (u, v) 之后要 push_back 几次?
Q2. 图上的 DFS 和树上的 DFS,代码上最大的区别是什么?
Q3. BFS 里 visited 应该在什么时候标记?
Q4. 邻接表存图,一次 DFS 遍历的时间复杂度是?
Q5. 网格题里用 DFS 求「路径条数」,递归返回上一层之前必须做什么?

12. 动手练习

按顺序做,一题一题来。第一题把存图 + 遍历跑通,后两题把两种思路分别练到手熟。

  1. P5318 查找文献 —— 邻接表 + DFS + BFS + 给邻居排序。有向图,别加反向边。做完就说明「图的基本功」过关了
  2. P1605 迷宫 —— 网格 DFS + 回溯。重点体会「标记 / 撤销」成对写
  3. P1443 马的遍历 —— 网格 BFS 求最短步数。重点练方向数组和「步数数组兼当 visited」

做不出来时的自查顺序

  1. 图存对了吗?有向只加一条边,无向要加两条,N 开够大。
  2. vis 开的是 n + 1 吗?顶点编号是 1..n。
  3. BFS 的标记是不是写在入队那一步?出队才标记就会重复入队。
  4. 拿第 5 节那两张表手推一遍,把队列 / 栈里的内容写在纸上,和表对。
  5. 网格题:方向数组的越界判断写全了吗?P1605 有没有成对撤销?
  6. 还是不行就把代码贴过来,我们一起看。

本课小结

下一课预告
0409 图的进阶:连通性与拓扑排序 —— 先把今天的 DFS 变成「数连通块」的工具,再学一种只能用在有向无环图上的排序:拓扑排序。最后在 DAG 上做动态规划(P1113、P1807、P4017)。
今天的 vis 和邻接表,下一课一行都不用改。

← 上一课:排序算法 · 课程对照表

书籍 《算法 (第4版)》第 4.1 节(无向图)· 第 4.2 节(有向图)