CSP-J/CSP-S 深度优先搜索(DFS)完全讲义
本文约7500字,系统讲解DFS的核心思想、三种枚举模式、回溯与剪枝技巧、记忆化搜索、迭代加深等进阶内容,并附有经典例题解析与代码模板。
第一章 深度优先搜索概述
1.1 什么是深度优先搜索
深度优先搜索(Depth First Search,简称DFS),顾名思义就是按照深度优先 的顺序对"问题状态空间"进行搜索的算法。它的核心思想可以用一句话概括:一条路走到黑,走不通再回头。
具体来说,DFS从某个状态出发,沿着一条路径一直深入探索,直到无法继续前进(到达叶子节点或遇到不合法状态),然后回溯到上一个状态,选择另一条路径继续探索,直到遍历完所有可能的状态。
我们可以把"问题空间"类比为一张图 ,其中的状态类比为结点,状态之间的联系就用图中的边来表示。那么使用DFS求解问题,就相当于在一张图上进行深度优先遍历。
1.2 DFS与递归、栈的关系
DFS通常通过递归来实现。"递"的过程就是往下搜索,对应着深度的增加;"归"的过程就是回溯,退回到上一级。
递归函数在执行时,系统会使用调用栈 来保存每一层函数的状态(局部变量、返回地址等)。因此,DFS本质上就是在利用系统栈来实现"深度优先"的遍历顺序。我们也可以手动使用栈(stack)来模拟递归过程,但递归写法更加简洁直观。
注意 :"递归"是一种单纯的遍历/计算方法,而"深搜"是一类包括遍历形式、状态记录与检索、剪枝优化等算法整体设计的统称。
1.3 搜索树
在对问题进行DFS时,所有的状态和状态之间的转移会构成一棵搜索树:
-
根节点:初始状态
-
内部节点:搜索过程中的中间状态
-
叶子节点:终止状态(找到了答案或无法继续)
-
边:从一个状态到另一个状态的转移
整个DFS算法就是基于这棵搜索树完成的------我们沿着树的边向下深入,到达叶子后回溯。
1.4 什么时候用DFS
搜索本质上就是枚举 。但普通的for循环只能处理一层枚举,当问题需要分多步决策时,就需要用到搜索。
DFS适合解决以下几类问题:
-
枚举类问题:全排列、子集、组合等
-
路径/连通性问题:迷宫寻路、图的遍历、连通块计数
-
棋盘/布局问题:N皇后、数独、八数码
-
最优解问题:在搜索空间中寻找最优方案(配合剪枝)
在CSP竞赛中,DFS是一个极其重要 的算法,因为它不仅本身是高频考点,还可以用来骗部分分 ,有时还能写成记忆化搜索来代替DP。
第二章 DFS的三种基本枚举模式
在CSP-J/S中,DFS最常见的应用是解决三种基本的枚举问题:指数型枚举 、排列型枚举 和组合型枚举。掌握这三种模式是写好DFS的基础。
2.1 指数型枚举(子集枚举)
问题描述:给定n个数,枚举所有子集(每个数选或不选)。
每个数都有选 和不选 两种状态,因此总共有2^n种方案,时间复杂度为指数级。
核心思路:递归到第x个数时,分别尝试"选"和"不选"两个分支。
代码模板:
cpp
#include <iostream>
using namespace std;
int n;
int vis[20]; // 1表示选,2表示不选
void dfs(int x) {
// 已经处理完所有n个数,输出一种方案
if (x > n) {
for (int i = 1; i <= n; i++) {
if (vis[i] == 1) cout << i << " ";
}
cout << endl;
return;
}
// 分支1:不选第x个数
vis[x] = 2;
dfs(x + 1);
vis[x] = 0; // 回溯
// 分支2:选第x个数
vis[x] = 1;
dfs(x + 1);
vis[x] = 0; // 回溯
}
int main() {
cin >> n;
dfs(1);
return 0;
}
关键点 :每次递归返回后要恢复状态(回溯),否则会影响后续分支的搜索。
2.2 排列型枚举(全排列)
问题描述:给定n个不同的数,输出所有排列方案。
排列是区分顺序 的,1 2 3和1 3 2是两种不同的方案。总共有n!种方案。
核心思路 :用一个vis数组标记哪些数已经被选过。每层递归从所有未被选的数中选一个放入当前位置。
代码模板:
cpp
#include <iostream>
#include <iomanip>
using namespace std;
int n;
int vis[15]; // vis[i]=1表示数字i已被选
int a[15]; // 存储当前排列
void dfs(int x) {
// 已经选完了n个数
if (x > n) {
for (int i = 1; i <= n; i++) {
cout << setw(5) << a[i];
}
cout << endl;
return;
}
// 枚举所有可选的数字
for (int i = 1; i <= n; i++) {
if (!vis[i]) {
vis[i] = 1; // 标记已选
a[x] = i; // 放入当前位置
dfs(x + 1); // 继续选下一个
vis[i] = 0; // 回溯:撤销标记
// a[x] = 0; // 可以不写,因为下次会被覆盖
}
}
}
int main() {
cin >> n;
dfs(1);
return 0;
}
关键点 :排列型枚举的每一层都要遍历所有未被选过的数 ,因此需要使用vis数组来避免重复选择。
2.3 组合型枚举
问题描述:从n个不同元素中取出m个元素的所有组合。
组合不区分顺序 ,1 2 3和1 3 2是同一组方案。
核心思路 :为了避免重复(顺序不同的组合被重复计数),我们规定后选的数必须比先选的大 。因此递归函数需要两个参数:当前选了几个数(x),以及从哪个数开始选(start)。
代码模板:
cpp
#include <iostream>
using namespace std;
int n, m;
int a[15];
void dfs(int x, int start) {
// 已经选了m个数
if (x > m) {
for (int i = 1; i <= m; i++) {
cout << a[i] << " ";
}
cout << endl;
return;
}
// 从start开始选,保证后面的数比前面的大
for (int i = start; i <= n; i++) {
a[x] = i;
dfs(x + 1, i + 1); // 下一个数从i+1开始选
// 这里不需要显式回溯,因为a[x]会被覆盖
}
}
int main() {
cin >> n >> m;
dfs(1, 1);
return 0;
}
2.4 三种枚举模式总结
| 类型 | 状态数 | 是否区分顺序 | 关键特征 |
|---|---|---|---|
| 指数型枚举 | 2^n | --- | 每个元素选/不选 |
| 排列型枚举 | n! | 是 | 用vis标记已选 |
| 组合型枚举 | C(n,m) | 否 | 用start控制起始位置 |
第三章 回溯(Backtracking)
3.1 什么是回溯
回溯是DFS中最重要的概念之一。当搜索到某一步发现走不通(不合法),或者已经走到路径的尽头时,就退回上一步重新选择。
回溯在代码层面的体现就是:在递归调用返回后,恢复之前修改的状态。
3.2 为什么需要回溯
考虑全排列的例子:当我们已经选择了1 2 3这个排列后,需要回到选择1 2的状态,然后尝试选择4。如果不恢复vis[3]的状态,那么3就永远被标记为"已选",导致无法生成1 2 4这个排列。
回溯就是为了解决搜索时路径之间的信息互相干扰的问题。
3.3 回溯三部曲
回溯算法可以总结为三个步骤:
-
确定终止条件:什么时候得到一个完整的答案?
-
确定递归逻辑:在当前层要做什么选择?
-
进行回溯(撤销):递归返回后,撤销当前层所做的修改
3.4 通用DFS/回溯模板
cpp
void dfs(int x) { // x表示当前进行到第几步/第几层
// 1. 终止条件
if (x > n) {
// 处理或输出答案
return;
}
// 2. 枚举当前层的所有可能选择
for (int i = 0; i < choices; i++) {
// 做选择(修改状态)
state = new_state;
// 递归进入下一层
dfs(x + 1);
// 3. 回溯(恢复状态)
state = old_state;
}
}
这个模板来源于洛谷官方DFS教程。
第四章 剪枝优化(Pruning)
DFS的本质是暴力枚举 ,在最坏情况下会遍历整个搜索树。当搜索空间很大时,直接搜索会超时。剪枝就是通过提前排除不可能产生答案的分支,从而减少搜索量。
剪枝的核心思想是:在搜索过程中,如果能够证明当前分支不可能得到合法答案(或不可能比已有答案更优),就立即停止搜索这个分支。
4.1 优化搜索顺序
原则 :优先搜索分支较少的节点,这样可以尽早排除不可能的分支。
示例 :在"小猫爬山"问题中,将小猫按重量从大到小排序后再搜索。因为重量大的猫选择更少(能装它的缆车更少),先处理它们可以减少搜索树的分支。
示例 :在数独问题中,每次选择可选数字最少的空格进行填充。
4.2 可行性剪枝
定义 :如果当前状态已经不可能达到合法答案,就提前终止。
常见形式:
-
边界检查:当前位置越界 → 剪枝
-
约束检查:当前累计值已经超过限制 → 剪枝
-
剩余判断:即使把剩下的所有资源都用上也无法达到目标 → 剪枝
示例:在背包问题中,如果当前物品总重量已经超过背包容量,则剪枝。
4.3 最优性剪枝
定义 :对于求最优解 的问题,如果当前方案的代价已经不小于当前已知的最优解,那么继续搜索不可能得到更优答案,应该剪枝。
代码形式:
cpp
int ans = INF; // 全局最优解
void dfs(int u, int cost) {
// 最优性剪枝
if (cost >= ans) return;
if (u == n) {
ans = min(ans, cost);
return;
}
// ... 继续搜索
}
4.4 排除等效冗余
定义 :如果多个搜索分支会到达等效的状态(即后续的搜索完全一样),那么只需要搜索其中一个。
示例 :在组合枚举中,1 2 3和1 3 2是同一组方案。通过规定start参数强制数字递增,就排除了顺序不同造成的等效冗余。
4.5 记忆化搜索(Memoization)
记忆化搜索是一种特殊的剪枝------通过记录已经计算过的状态,避免重复计算。
核心思想:把递归理解为"带有备忘录的递归"------每次递归返回时,将结果存入备忘录;每次进入递归时,先查备忘录里有没有当前状态的结果。
适用条件:
-
重叠子问题:不同的递归路径会重复计算同一个子问题
-
最优子结构:一个问题的最优解可以由子问题的最优解推导而来
实现框架:
cpp
#include <bits/stdc++.h>
using namespace std;
// 1. 定义备忘录数组
int f[N][M]; // 存储已计算的状态结果,初始化为-1表示未计算
// 2. DFS递归函数
int dfs(int i, int j) {
// 边界条件
if (边界条件) return 边界值;
// 3. 查备忘录
if (f[i][j] != -1) return f[i][j];
// 4. 状态转移:搜索所有可能的选择
int ans = 初始值;
for (所有可能的选择) {
ans = max(ans, 子问题的结果);
}
// 5. 存备忘录
return f[i][j] = ans;
}
int main() {
memset(f, -1, sizeof(f)); // 初始化备忘录
cout << dfs(起始状态) << endl;
return 0;
}
实现记忆化搜索的关键是:按照 "可变参数 → 返回值" 的格式创建备忘录。
记忆化搜索 vs 传统DP:
| 维度 | 记忆化搜索 | 传统DP(迭代) |
|---|---|---|
| 实现方式 | 递归,代码简洁 | 迭代,需手动规划顺序 |
| 适用场景 | 状态转移复杂 | 状态转移简单 |
| 空间开销 | 额外递归栈 | 无递归栈 |
| 计算方式 | 按需计算 | 可能计算无用状态 |
4.6 剪枝策略总结
| 剪枝类型 | 核心思想 | 典型应用 |
|---|---|---|
| 优化搜索顺序 | 先搜分支少的 | 小猫爬山、数独 |
| 可行性剪枝 | 提前识别死胡同 | 迷宫、约束满足问题 |
| 最优性剪枝 | 不比最优解更好就停 | 最优化问题 |
| 排除等效冗余 | 避免重复搜索相同状态 | 组合枚举 |
| 记忆化搜索 | 用空间换时间 | 有重叠子问题的递归 |
第五章 进阶搜索技巧
5.1 迭代加深搜索(IDDFS)
问题背景 :普通的DFS会一头搜到底,当搜索树的深度未知 或可能很深时,DFS可能会在一条很深的路径上浪费大量时间。而BFS虽然能保证找到最优解,但需要存储大量状态。
核心思想 :迭代加深搜索(Iterative Deepening DFS)结合了DFS和BFS的优点------每次限制搜索的最大深度,从1开始逐渐增加深度限制,直到找到答案。
实现方式:
cpp
bool dfs(int u, int depth, int max_depth) {
if (u > max_depth) return false; // 超过限制深度,剪枝
if (找到答案) return true;
for (所有可能的选择) {
if (dfs(u + 1, depth + 1, max_depth)) return true;
}
return false;
}
int main() {
for (int max_depth = 1; ; max_depth++) {
if (dfs(0, 0, max_depth)) {
cout << max_depth << endl;
break;
}
}
return 0;
}
适用场景:
-
搜索树很深但答案深度不大
-
需要求最小步数的问题
-
状态空间太大,BFS会爆内存
经典例题:UVA529 Addition Chains、POJ3134 Power Calculus
5.2 双向搜索(Meet-in-the-Middle)
核心思想 :将搜索空间对半分割 ,分别从两端搜索,然后在中间"相遇"。这可以将指数级复杂度从O(2^n)降低到O(2^(n/2))。
适用场景:搜索空间巨大,但可以将问题分成两个独立的部分。
5.3 DFS序
定义:DFS序(DFS Order)是指对一棵树进行深度优先搜索时,访问节点的顺序序列。
DFS序在树形问题中有广泛应用:
-
求LCA(最近公共祖先)
-
求k级祖先
-
子树区间查询(将子树转化为连续区间)
第六章 经典例题解析
6.1 小猫爬山(AcWing 165)
题目描述:有N只小猫要坐缆车下山,每辆缆车的最大承重为W。给出每只小猫的重量,求最少需要多少辆缆车。
数据范围:N ≤ 18
解题思路:
-
用DFS依次处理每只小猫
-
对于每只小猫,有两种选择:放入已有的某辆缆车,或新开一辆缆车
-
状态表示:
dfs(u, k)表示处理到第u只小猫,已经用了k辆缆车
剪枝策略:
-
优化搜索顺序:将小猫按重量从大到小排序
-
最优性剪枝 :如果
k >= ans,直接返回(不可能更优)
核心代码:
cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 20;
int w[N], sum[N]; // sum[i]表示第i辆缆车当前载重
int ans = 1e9;
int n, W;
bool cmp(int a, int b) { return a > b; }
void dfs(int u, int k) {
// 最优性剪枝
if (k >= ans) return;
// 所有猫都处理完了
if (u == n) {
ans = k;
return;
}
// 尝试放入已有的缆车
for (int i = 0; i < k; i++) {
if (sum[i] + w[u] <= W) { // 可行性剪枝
sum[i] += w[u];
dfs(u + 1, k);
sum[i] -= w[u]; // 回溯
}
}
// 新开一辆缆车
sum[k] += w[u];
dfs(u + 1, k + 1);
sum[k] -= w[u]; // 回溯
}
int main() {
cin >> n >> W;
for (int i = 0; i < n; i++) cin >> w[i];
sort(w, w + n, cmp); // 优化搜索顺序
dfs(0, 0);
cout << ans << endl;
return 0;
}
6.2 数独(AcWing 166)
题目描述:填充一个9×9的数独,使得每行、每列、每个3×3宫格内数字1-9各出现一次。
解题思路:
-
用DFS依次填充空格
-
每个空格尝试填入1-9中可行的数字
-
填完所有空格即得到一个解
剪枝策略:
-
优化搜索顺序 :每次选择可选数字最少的空格进行填充
-
位运算优化:用9位二进制数表示每行/列/宫格中哪些数字可用
6.3 N皇后问题
题目描述:在N×N的棋盘上放置N个皇后,使得它们互不攻击(不在同一行、同一列、同一对角线)。
解题思路:
-
逐行放置皇后(每行恰好一个)
-
用三个数组分别标记列 、主对角线 、副对角线是否已被占用
-
主对角线:
row - col为常数;副对角线:row + col为常数
核心代码:
cpp
int n, ans;
bool col[20], diag1[40], diag2[40]; // 列、主对角线、副对角线
void dfs(int row) {
if (row == n) {
ans++;
return;
}
for (int i = 0; i < n; i++) {
if (!col[i] && !diag1[row - i + n] && !diag2[row + i]) {
col[i] = diag1[row - i + n] = diag2[row + i] = true;
dfs(row + 1);
col[i] = diag1[row - i + n] = diag2[row + i] = false; // 回溯
}
}
}
6.4 迷宫寻路
题目描述:在n×m的迷宫中,从起点(S)走到终点(E),求最短路径步数。
解题思路:
-
用DFS搜索所有从起点到终点的路径
-
维护当前步数,更新最短路径
-
可用最优性剪枝:当前步数 ≥ 已知最短步数时剪枝
6.5 连通块(Flood Fill)
题目描述:在二维网格中,统计有多少个连通的同色区域。
解题思路:
-
遍历所有格子,遇到未访问的目标格子就启动DFS
-
DFS向四个(或八个)方向扩展,标记所有连通的格子
-
每次DFS完成,连通块计数+1
这是DFS在图论中最基础的应用之一。
第七章 竞赛实战建议
7.1 如何判断一道题该用DFS
-
数据范围小:n ≤ 20左右时,可以考虑指数级搜索
-
需要枚举所有方案:求方案数、求所有解
-
分多步决策:每一步的选择会影响后面的状态
-
图/树的遍历:连通性、路径查找
7.2 DFS的调试技巧
-
打印搜索树:在递归入口打印当前深度和状态,观察搜索过程
-
检查回溯:确保每次递归返回后状态都被正确恢复
-
先写暴力再优化:先写出无剪枝的版本确保正确性,再逐步加入剪枝
7.3 复杂度分析
-
无剪枝的DFS:时间复杂度等于搜索树的节点数
-
指数型枚举:O(2^n)
-
排列型枚举:O(n!)
-
组合型枚举:O(C(n,m))
-
剪枝后:实际运行时间远小于理论最坏情况
7.4 常见错误
-
忘记回溯:导致状态污染,搜索结果错误
-
回溯不彻底:只恢复了部分状态
-
剪枝条件写错:剪掉了可能产生答案的分支
-
递归深度过大:导致栈溢出(注意n的范围)
-
全局变量使用不当:多组数据时忘记初始化
第八章 总结
深度优先搜索是CSP-J/S竞赛中最核心的算法之一。掌握DFS需要理解以下几个层次:
第一层(入门) :
-
理解DFS的"一条路走到黑"思想
-
掌握三种基本枚举模式(指数型、排列型、组合型)
-
能够写出基本的递归DFS代码
第二层(进阶) :
-
理解回溯的本质和必要性
-
掌握常见的剪枝策略(优化搜索顺序、可行性剪枝、最优性剪枝)
-
能够分析搜索树的规模并进行优化
第三层(精通) :
-
掌握记忆化搜索及其与DP的关系
-
掌握迭代加深搜索等进阶技巧
-
能够在复杂问题中灵活运用DFS+多种剪枝策略
记住:DFS不仅仅是一个算法,更是一种解决问题的思维方式------将复杂问题分解为多步决策,在状态空间中进行系统性的搜索。通过大量的练习,你会逐渐培养出"搜索直觉",能够在竞赛中快速判断何时使用DFS、如何设计状态、怎样进行剪枝。
愿你在CSP-J/S的征程中,用DFS攻克一道道难题!