7 深度优先搜索
GESP C++ 六级:深度优先搜索(DFS)
核心目标:理解深度优先搜索的“一条路走到黑”思想,掌握递归与栈两种实现方式,熟练在树、图、网格上应用 DFS,掌握回溯、剪枝与记忆化技巧,能辨析 DFS 与 BFS 的适用场景。
DFS 就是“能往深处走就往深处走,走不通再回头”;递归是天然实现,栈是手动模拟;树/图/网格是三大应用场景;回溯是“选-递归-撤销”,剪枝是“提前判断不可行就返回”。
一、考纲定位
根据 CCF 官方考纲,GESP 六级明确要求掌握深度优先搜索(DFS) 和广度优先搜索(BFS) 的基本原理与实现,能够解决树、图、网格等结构上的搜索问题。
DFS 在六级考试中的考查分布:
| 考点 | 重要程度 | 典型考法 |
|---|---|---|
| DFS 基本思想与递归实现 | ★★★★★ | 选择题、代码填空 |
| 树上的 DFS(遍历、深度、子树) | 编程题、程序阅读 | |
| 图上的 DFS(邻接表、连通分量) | 编程题 | |
| 网格上的 DFS(洪水填充、岛屿) | 编程题高频 | |
| 回溯法(全排列、组合、N 皇后) | 编程题、代码填空 | |
| 剪枝优化 | ★★★★☆ | 编程题 |
| 记忆化搜索 | ||
| DFS 与 BFS 的区别 | ★★★★★ | 选择题高频 |
二、DFS 基本概念
2.1 什么是 DFS
深度优先搜索(Depth-First Search)是一种用于遍历或搜索树、图、网格等结构的算法。它的核心思想是:沿着一条路径尽可能深入地走,直到无法继续,然后回溯到上一个节点,尝试其他分支。
直观比喻:走迷宫时,你选择一条路一直走到底,如果遇到死胡同就退回到上一个岔路口,换一条路继续走,直到找到出口或遍历完所有可能。
2.2 DFS 的核心要素
| 要素 | 作用 | 示例 |
|---|---|---|
| 访问标记 | 防止重复访问 | visited[] 数组 |
| 终止条件 | 何时停止深入 | 越界、已访问、达到目标 |
| 递归分支 | 下一步可以往哪走 | 上下左右、相邻节点 |
| 回溯 | 撤销当前选择,尝试其他分支 | visited[x] = false |
2.3 DFS 与 BFS 的区别
| 对比项 | DFS | BFS |
|---|---|---|
| 数据结构 | 栈 / 递归 | 队列 |
| 遍历顺序 | 一条路走到黑 | 逐层扩展 |
| 最短路径 | ❌ 不保证 | ✅ 保证(无权图) |
| 空间复杂度 | O(深度) | O(宽度) |
| 适用场景 | 连通性、回溯、排列组合、树遍历 | 最短路径、层序、迷宫最短路 |
三、DFS 的两种实现方式
3.1 递归实现(最常用)
void dfs(当前状态) {
if (终止条件) {
处理结果;
return;
}
标记当前状态已访问;
for (所有可能的下一步) {
if (下一步合法且未访问) {
dfs(下一步);
}
}
取消标记; // 回溯时可能需要
}
3.2 栈实现(非递归)
void dfsStack(起点) {
stack<状态> st;
st.push(起点);
visited[起点] = true;
while (!st.empty()) {
状态 cur = st.top(); st.pop();
处理 cur;
for (所有相邻状态) {
if (!visited[相邻]) {
visited[相邻] = true;
st.push(相邻);
}
}
}
}
注意:栈实现的 DFS 遍历顺序与递归不完全一致,但都能完成遍历。六级以递归实现为主。
四、树上的 DFS
4.1 树的存储
使用邻接表存储树(无向图,但无环):
vector<int> adj[N]; // adj[u] 存储 u 的所有孩子/邻居
void addEdge(int u, int v) {
adj[u].push_back(v);
adj[v].push_back(u);
}
4.2 树的前序遍历(DFS)
void dfs(int u, int parent) {
cout << u << " "; // 访问节点
for (int v : adj[u]) {
if (v != parent) { // 避免回到父节点
dfs(v, u);
}
}
}
关键:树是无环的,只需要避免回到父节点即可,不需要 visited 数组。
4.3 求树的深度
int depth(int u, int parent) {
int maxDepth = 0;
for (int v : adj[u]) {
if (v != parent) {
maxDepth = max(maxDepth, depth(v, u));
}
}
return maxDepth + 1;
}
4.4 求子树大小
int subtreeSize(int u, int parent) {
int size = 1;
for (int v : adj[u]) {
if (v != parent) {
size += subtreeSize(v, u);
}
}
return size;
}
4.5 求树的重心(了解)
树的重心是指删除该节点后,剩余各连通块大小的最大值最小的节点。可以通过 DFS 计算子树大小后判断。
五、图上的 DFS
5.1 图的存储
邻接表(最常用):
vector<int> adj[N]; // adj[u] 存储 u 的所有邻居
void addEdge(int u, int v) {
adj[u].push_back(v);
adj[v].push_back(u); // 无向图
}
5.2 图的 DFS 遍历
bool visited[N];
void dfs(int u) {
visited[u] = true;
cout << u << " ";
for (int v : adj[u]) {
if (!visited[v]) {
dfs(v);
}
}
}
5.3 求连通分量个数
int countComponents(int n) {
int cnt = 0;
for (int i = 1; i <= n; i++) {
if (!visited[i]) {
dfs(i); // 一次 DFS 遍历一个连通分量
cnt++;
}
}
return cnt;
}
5.4 判断图是否有环
bool hasCycle(int u, int parent) {
visited[u] = true;
for (int v : adj[u]) {
if (!visited[v]) {
if (hasCycle(v, u)) return true;
} else if (v != parent) {
return true; // 遇到已访问且不是父节点,说明有环
}
}
return false;
}
注意:无向图判环用 parent 避免误判;有向图判环需要用颜色标记(白/灰/黑)。
六、网格上的 DFS(洪水填充)
6.1 问题模型
给定一个二维网格,每个格子有不同状态(如陆地/水),求连通块数量、最大连通块面积等。
6.2 方向数组
int dx[] = {-1, 1, 0, 0};
int dy[] = {0, 0, -1, 1};
6.3 岛屿数量(经典题)
问题:1 表示陆地,0 表示水,求岛屿数量(上下左右相连的陆地算一个岛)。
int n, m;
int grid[105][105];
bool visited[105][105];
void dfs(int x, int y) {
visited[x][y] = true;
for (int i = 0; i < 4; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
if (nx >= 0 && nx < n && ny >= 0 && ny < m &&
!visited[nx][ny] && grid[nx][ny] == 1) {
dfs(nx, ny);
}
}
}
int countIslands() {
int cnt = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (!visited[i][j] && grid[i][j] == 1) {
dfs(i, j);
cnt++;
}
}
}
return cnt;
}
6.4 最大连通块面积
int dfs(int x, int y) {
visited[x][y] = true;
int area = 1;
for (int i = 0; i < 4; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
if (nx >= 0 && nx < n && ny >= 0 && ny < m &&
!visited[nx][ny] && grid[nx][ny] == 1) {
area += dfs(nx, ny);
}
}
return area;
}
6.5 迷宫可达性
bool canReach(int x, int y) {
if (x < 0 || x >= n || y < 0 || y >= m) return false;
if (grid[x][y] == '#') return false;
if (visited[x][y]) return false;
if (x == ex && y == ey) return true;
visited[x][y] = true;
for (int i = 0; i < 4; i++) {
if (canReach(x + dx[i], y + dy[i])) return true;
}
return false;
}
七、回溯法(DFS 的重要应用)
7.1 回溯法模板
回溯法是 DFS 的一种应用,核心是“选择 → 递归 → 撤销选择”。
void backtrack(路径, 选择列表) {
if (满足结束条件) {
记录结果;
return;
}
for (选择 : 选择列表) {
做出选择;
backtrack(路径, 新的选择列表);
撤销选择;
}
}
7.2 全排列
int n, a[10];
bool used[10];
void dfs(int step) {
if (step == n) {
for (int i = 0; i < n; i++) cout << a[i] << " ";
cout << endl;
return;
}
for (int i = 1; i <= n; i++) {
if (!used[i]) {
used[i] = true;
a[step] = i;
dfs(step + 1);
used[i] = false; // 回溯
}
}
}
7.3 组合数(从 n 个数中选 k 个)
int n, k, a[20];
void dfs(int step, int start) {
if (step == k) {
for (int i = 0; i < k; i++) cout << a[i] << " ";
cout << endl;
return;
}
for (int i = start; i <= n; i++) {
a[step] = i;
dfs(step + 1, i + 1); // 下一层从 i+1 开始,避免重复
}
}
7.4 N 皇后
int n, ans;
int col[20], diag1[40], diag2[40];
void dfs(int row) {
if (row == n) {
ans++;
return;
}
for (int c = 0; c < n; c++) {
if (!col[c] && !diag1[row + c] && !diag2[row - c + n]) {
col[c] = diag1[row + c] = diag2[row - c + n] = 1;
dfs(row + 1);
col[c] = diag1[row + c] = diag2[row - c + n] = 0;
}
}
}
八、剪枝优化
8.1 什么是剪枝
剪枝是在 DFS 过程中,提前判断当前分支不可能产生有效解,直接返回,避免无效搜索。
8.2 常见剪枝类型
| 剪枝类型 | 说明 | 示例 |
|---|---|---|
| 可行性剪枝 | 当前状态已不满足约束 | 越界、已访问、和超过目标 |
| 最优性剪枝 | 当前代价已超过已知最优解 | 步数 ≥ 当前最优 |
| 顺序剪枝 | 调整搜索顺序,优先搜更可能的分支 | 先搜约束多的 |
| 记忆化剪枝 | 记录已搜索过的状态,避免重复 | 记忆化搜索 |
8.3 记忆化搜索
int dp[N];
bool vis[N];
int dfs(int u) {
if (vis[u]) return dp[u];
vis[u] = true;
// 计算 dp[u]
return dp[u];
}
九、GESP 六级 DFS 真题精讲
真题1:连通块计数(编程题)
给定一个 n×m 的网格,
#表示障碍,.表示空地。求空地连通块的数量。
#include <iostream>
using namespace std;
int n, m;
char grid[105][105];
bool vis[105][105];
int dx[] = {-1, 1, 0, 0};
int dy[] = {0, 0, -1, 1};
void dfs(int x, int y) {
vis[x][y] = true;
for (int i = 0; i < 4; i++) {
int nx = x + dx[i], ny = y + dy[i];
if (nx >= 0 && nx < n && ny >= 0 && ny < m &&
!vis[nx][ny] && grid[nx][ny] == '.') {
dfs(nx, ny);
}
}
}
int main() {
cin >> n >> m;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
cin >> grid[i][j];
int cnt = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
if (!vis[i][j] && grid[i][j] == '.') {
dfs(i, j);
cnt++;
}
cout << cnt << endl;
return 0;
}
真题2:树的重心(程序阅读)
给定一棵树,求删除哪个节点后,剩余最大连通块最小。
思路:DFS 计算每个节点的子树大小,然后对每个节点计算“删除后最大连通块”。
int n, sz[N], ans[N];
void dfs(int u, int parent) {
sz[u] = 1;
int maxPart = 0;
for (int v : adj[u]) {
if (v != parent) {
dfs(v, u);
sz[u] += sz[v];
maxPart = max(maxPart, sz[v]);
}
}
maxPart = max(maxPart, n - sz[u]);
ans[u] = maxPart;
}
真题3:全排列(代码填空)
下面代码输出 1~n 的全排列,横线处应填入( )。
void dfs(int step) { if (step == n) { for (int i = 0; i < n; i++) cout << a[i] << " "; cout << endl; return; } for (int i = 1; i <= n; i++) { if (!used[i]) { used[i] = true; a[step] = i; dfs(step + 1); ______; } } }答案:
used[i] = false;
真题4:DFS 与 BFS 区别(选择题)
以下关于 DFS 和 BFS 的说法,正确的是( )。
A. DFS 适合求无权图最短路径
B. BFS 适合求连通分量
C. DFS 用队列实现,BFS 用栈实现
D. DFS 不保证最短路径,BFS 保证无权图最短路径
答案:D
十、易错点汇总
- 忘记标记已访问:导致重复访问,甚至死循环。
- 回溯时忘记撤销标记:导致其他分支无法访问该节点。
- 网格 DFS 忘记边界检查:
nx >= 0 && nx < n && ny >= 0 && ny < m不能漏。 - 树 DFS 忘记传父节点:
dfs(v, u)避免回到父节点。 - 图 DFS 连通分量计数错误:外层循环遍历所有节点,未访问的才启动 DFS。
- 递归深度过大导致栈溢出:树退化为链时深度为 n,需注意数据范围。
- 回溯法组合问题起点写错:组合从
start开始,排列用used数组。 - 剪枝条件写错:可行性剪枝和最优性剪枝要分清。
- 记忆化数组未初始化:
vis[]和dp[]要正确初始化。 - DFS 与 BFS 混淆:DFS 用栈/递归,BFS 用队列;DFS 不保证最短路。
十一、速查表
| 需求 | 写法 | 关键注意 |
|---|---|---|
| DFS 递归模板 | void dfs(状态) { if (终止) return; 标记; for (分支) dfs(新状态); 撤销; } |
终止条件不能漏 |
| DFS 栈模板 | stack; push(起点); while (!empty) { pop; 处理; push 邻居; } |
标记在入栈时 |
| 树 DFS | dfs(u, parent) |
用 parent 避免回头 |
| 图 DFS | dfs(u) + visited[] |
连通分量外层循环 |
| 网格 DFS | dfs(x, y) + 方向数组 |
边界检查 + 已访问 |
| 全排列 | used[i] 标记 + 回溯 |
撤销 used[i] = false |
| 组合 | dfs(step, start) |
下一层从 i+1 开始 |
| N 皇后 | 列、主对角线、副对角线标记 | 三个数组 |
| 剪枝 | 提前 return | 可行性 + 最优性 |
| 记忆化 | vis[] + dp[] |
避免重复计算 |
| 连通分量 | 外层遍历 + 内层 DFS | 未访问才启动 |
十二、总结
深度优先搜索是 GESP 六级搜索算法的核心内容。
- DFS 的核心是“一条路走到黑,走不通再回头”。
- 递归是 DFS 最自然的实现,栈是手动模拟。
- 树 DFS 用
parent避免回头,图 DFS 用visited避免重复。 - 网格 DFS 用方向数组 + 边界检查,是洪水填充的基础。
- 回溯法 = 选择 → 递归 → 撤销选择,全排列、组合、N 皇后都是典型应用。
- 剪枝是提前判断不可行就返回,能大幅减少无效搜索。
- 记忆化搜索用
vis[]+dp[]避免重复计算。 - DFS 不保证最短路径,BFS 保证无权图最短路径。
- 递归深度过大可能导致栈溢出,数据范围大时要改用栈或 BFS。
- 多练“连通块计数”“全排列”“树的重心”“N 皇后”四类题。
复习建议:先把网格 DFS 和树 DFS 的模板写熟,确保闭卷能默写;再重点练习回溯法(全排列、组合、N 皇后),理解“选择-递归-撤销”的流程;然后掌握连通分量计数和剪枝优化;最后用真题做限时训练。考试中先判断题目考的是树、图还是网格,再确定用哪种 DFS 模板,最后检查边界、标记和回溯。