④ 数据结构与算法 — 第 8 课
sort 拆开,看到了分治的威力。图只有两个东西:
一张图记作 G = (V, E):V 是顶点集合,E 是边集合。就这么简单。
| 类型 | 含义 | 例子 |
|---|---|---|
| 无向图 | 边没有方向,u 连着 v 就等于 v 连着 u | 好友关系、地铁双向线路 |
| 有向图 | 边有方向,u → v 不代表 v → u | 网页链接、课程先修关系、文献引用 |
| 带权图 | 每条边带一个数字(代价) | 两站之间要 3 分钟、这条路要 20 块油钱 |
| 不连通图 | 分成好几块,块之间没有任何边 | 两个互不相识的朋友圈子 |
图不是线性的,不能像数组那样一排就存好。有三种主流存法,各有各的适用场景。
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 之间有没有边
vector<pair<int, int>> edges; // 每条边存成一个 pair
edges.push_back({u, v});
核心想法:对每个顶点,单独记一份「我的邻居名单」。
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) → 只有半个图,遍历时到不了对面const int N 开太小 → 越界 RE。看题目数据范围,通常开 1e5 或 1e6
先回忆 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。这就是「一条路走到黑,走不通再退回来」。
如果图分成了好几块,从 1 号点出发只能走完 1 所在的那一块。要遍历整张图,就在外面再套一层循环:
for (int i = 1; i <= n; i++) {
if (!vis[i]) dfs(i); // 每发现一个新连通块,就从它开始走
}
「外面套这一层循环,套了几次就有几个连通块」——这是数连通分量的标准写法(并查集能干同一件事,以后会学)。
每个顶点最多被访问一次,每条边最多被看两次(无向图两个方向各一次)。所以:
邻接表 DFS = O(V + E)
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 最值钱的性质。
就用 2.3 节那张 8 个点的图,从 1 号点出发。邻居的枚举顺序按名单从左到右,这样推出来的结果正好是上面那两个输出。
| 步 | 出队 | 这一步新入队 | 队列(队头在左) | 访问顺序 |
|---|---|---|---|---|
| 初始 | — | 1 | 1 | — |
| 1 | 1 | 2、3 | 2 3 | 1 |
| 2 | 2 | 4、5(1 已访问) | 3 4 5 | 1 2 |
| 3 | 3 | 6、7(1 已访问) | 4 5 6 7 | 1 2 3 |
| 4 | 4 | 8(2 已访问) | 5 6 7 8 | 1 2 3 4 |
| 5 | 5 | —(2、8 都已访问) | 6 7 8 | 1 2 3 4 5 |
| 6 | 6 | —(3、7 都已访问) | 7 8 | 1 2 3 4 5 6 |
| 7 | 7 | —(3、6 都已访问) | 8 | 1 2 3 4 5 6 7 |
| 8 | 8 | —(4、5 都已访问) | (空) | 1 2 3 4 5 6 7 8 |
盯住「队列」那一列:每个点最多出现一次。这就是「入队时标记」的直接效果——如果等出队再标记,4 会在第 2 步和第 3 步各被塞进去一次。
| 步 | 访问 | 递归栈(栈底 → 栈顶) | 为什么走到这里 |
|---|---|---|---|
| 1 | 1 | 1 | 起点 |
| 2 | 2 | 1 2 | 1 的邻居按名单取第一个 |
| 3 | 4 | 1 2 4 | 2 的邻居里 1 已访问,取 4 |
| 4 | 8 | 1 2 4 8 | 4 的邻居里 2 已访问,取 8 |
| 5 | 5 | 1 2 4 8 5 | 8 的邻居 4、5 中 4 已访问,取 5(5 是从 8 进去的) |
| 6 | 3 | 1 3 | 5 的邻居 2、8 都访问过 → 一路退到 1(退 5→8→4→2),再取 3 |
| 7 | 6 | 1 3 6 | 3 的邻居里 1 已访问,取 6 |
| 8 | 7 | 1 3 6 7 | 6 的邻居里 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 节一致。自己拿张纸把两张表盖住重推一遍,图就真的进脑子里了。
| DFS(深度优先) | BFS(广度优先) | |
|---|---|---|
| 用什么装 | 递归(系统的栈)或自己开 stack | queue 队列 |
| 走的顺序 | 一条路走到黑,不行再退 | 一层一层往外扩 |
| 空间 | O(最深路径长度) | O(最宽那一层的大小) |
| 能不能求最短路 | 不能(找到的不一定最短) | 能(无权图 / 每步代价相同) |
| 典型用途 | 连通性、数路径条数、走迷宫(找一条路)、拓扑排序、回溯搜索 | 最短步数、层次关系、扩散/感染模拟 |
题意:n 个点、m 条有向边(文献引用关系),从 1 号点出发,输出 DFS 序和 BFS 序;同一层的点,编号小的先访问。
三个关键点:
g[u].push_back(v),千万别加反向边for (int i = 1; i <= n; i++) {
sort(g[i].begin(), g[i].end()); // 保证枚举邻居时从小到大
}
vis.assign(n+1, false) 最省事。所以整题的形状是:读边 → 给每个 g[i] 排序 → 跑一遍 DFS 输出一行 → 清空 vis → 跑一遍 BFS 输出一行。
复杂度:排序 O(V log V),遍历 O(V + E),合起来完全够用。
迷宫、棋盘这类题,图上没有「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) 做事
}
这套骨架,迷宫、岛屿数量、连通块计数、洪水填充……都长这样。换的只是「能不能走」的判断条件。
这题问的是「从起点到终点一共有多少条路径」。
注意 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。
「从起点骑马到每个格子最少几步」——看到「最少步数」,条件反射就该想到 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)。)
| 存法 | 空间 | 遍历一遍 | 适合 |
|---|---|---|---|
| 邻接矩阵 | O(V²) | O(V²) | V ≤ 1000、稠密图、要 O(1) 查边 |
| 边表 | O(E) | — | 只枚举边(Kruskal 等) |
| 邻接表 | O(V + E) | O(V + E) | 绝大多数情况,默认选它 |
1e5 个点、2e5 条边 → 邻接表毫无压力;邻接矩阵会开出几百 G 的内存,直接爆。
vis[n] 越界;两次遍历共用 vis 没清空,第二次什么都输出不了stack(n+2) × (m+2));P1605 忘记撤销 vis,路径数偏小;二维 vector 忘了第二个参数,d(n+1, vector<int>(m+1, -1))vector<int> g[N] 存无向图,读入一条边 (u, v) 之后要 push_back 几次?按顺序做,一题一题来。第一题把存图 + 遍历跑通,后两题把两种思路分别练到手熟。
N 开够大。vis 开的是 n + 1 吗?顶点编号是 1..n。vector<int> g[N] 是默认选择,空间和遍历都是 O(V + E)vis 和邻接表,下一课一行都不用改。