在算法的世界里,搜索算法就像是一把万能钥匙,能帮我们打开各类问题的大门。而深度优先搜索(DFS),作为搜索家族中的核心成员,更是凭借其 “一条路走到黑,走不通再回头” 的特性,成为解决枚举、组合、排列等问题的利器。今天,我们就聚焦于DFS 的递归型枚举,并聊聊回溯与剪枝这两个让 DFS 效率倍增的关键技巧,通过具体的例题一步步拆解,带你走进 DFS 的奇妙世界。下面就让我们正式开始吧!
在正式讲解 DFS 之前,我们先搞清楚一个基础问题:什么是搜索?
简单来说,搜索就是一种枚举策略,通过穷举所有可能的情况,找到问题的最优解或者统计合法解的个数。因为要 “穷举”,所以搜索也常被戏称为 “暴搜”。而搜索主要分为两大分支:深度优先搜索(DFS)和宽度优先搜索(BFS)。本文的主角,就是 DFS。
这里还要区分两个容易混淆的概念:遍历和搜索。遍历是形式,是 “走过所有节点” 的动作;搜索是目的,是 “找到目标解” 的过程。在实际应用中,我们通常不会严格区分二者,把 DFS 遍历当作 DFS 搜索来处理即可。
而 DFS 之所以能高效解决枚举问题,离不开两个核心操作:回溯和剪枝。
接下来,我们将通过枚举子集、组合型枚举、枚举排列和全排列四个经典问题,手把手教你用 DFS 实现递归型枚举,同时理解回溯与剪枝的实际应用。
题目链接如下:https://www.luogu.com.cn/problem/B3622

这是洛谷的经典入门题B3622 枚举子集(递归实现指数型枚举),题目大意是:给定 n 位同学,从中选出任意名同学参加合唱,输出所有可能的选择方案。每个方案用字符串表示,第 i 位为 Y 表示第 i 名同学参加,为 N 表示不参加,结果按字典序输出。
比如输入 n=3 时,输出是:
NNN
NNY
NYN
NYY
YNN
YNY
YYN
YYY解决 DFS 问题的第一步,往往是画决策树。决策树能直观地展示出每一步的选择,帮我们找到重复的子问题。
对于 n=3 的情况,我们从第一个同学开始,每一步都有两个选择:选(Y)或不选(N)。决策树如下:

从决策树中能清晰看到,重复的子问题是:对第 pos 位同学,选择选或不选。而要保证字典序输出,我们需要先考虑 “不选”,再考虑 “选”。
接下来就是用递归实现这个决策过程:
pos表示当前处理到第几位同学。pos > n时,说明已经处理完所有同学,此时输出当前的选择方案。#include <iostream>
#include <string>
using namespace std;
int n;
string path; // 记录递归过程中的决策路径
void dfs(int pos) {
// 递归终止:处理完所有同学
if (pos > n) {
cout << path << endl;
return;
}
// 第一步:不选当前同学
path += 'N';
dfs(pos + 1);
path.pop_back(); // 回溯,删除最后一个字符
// 第二步:选当前同学
path += 'Y';
dfs(pos + 1);
path.pop_back(); // 回溯,恢复现场
}
int main() {
cin >> n;
dfs(1); // 从第1位同学开始处理
return 0;
}path.pop_back()是整个代码的灵魂。因为递归调用后,path 会保留上一次的选择,必须删除最后一个字符,才能让下一次选择不受影响。比如处理完 “不选 1” 的所有情况后,要把 'N' 删掉,才能开始处理 “选 1” 的情况。题目链接:https://www.luogu.com.cn/problem/P10448

洛谷题目P10448 组合型枚举要求:从 1~n 这 n 个整数中随机选出 m 个,按字典序输出所有可能的组合方案。同一行内的数升序排列,不同行按字典序排序。
比如输入 n=5,m=3 时,输出是:
1 2 3
1 2 4
1 2 5
1 3 4
1 3 5
1 4 5
2 3 4
2 3 5
2 4 5
3 4 5组合问题和子集问题的区别在于:组合要求选固定数量的元素,且不考虑顺序(比如 1 2 3 和 3 2 1 是同一个组合)。
我们以 n=4,m=3 为例画决策树:

这里的重复子问题是:当前位置要放的数,必须是上一个数的下一位(保证升序,避免重复组合)。同时,我们能看到决策树中有一些 “无效分支”,比如选了 3 之后,剩下的数只有 4,无法凑够 3 个元素,这些分支可以提前剪掉,这就是剪枝的思想。
递归函数的设计要点:
begin表示当前选择的数从哪个数开始(避免重复),path记录已选的数。path.size() == m时,输出组合方案。begin到 n 遍历数,将当前数加入 path,递归处理下一个位置(起始数为i+1)。#include <iostream>
#include <vector>
using namespace std;
int n, m;
vector<int> path; // 记录组合的路径
void dfs(int begin) {
// 递归终止:选够了m个数
if (path.size() == m) {
for (auto x : path) {
cout << x << " ";
}
cout << endl;
return;
}
// 从begin开始枚举,避免重复组合
for (int i = begin; i <= n; i++) {
path.push_back(i);
dfs(i + 1); // 下一个数从i+1开始
path.pop_back(); // 回溯
}
}
int main() {
cin >> n >> m;
dfs(1); // 从1开始选
return 0;
}在上面的代码中,其实还有优化空间。比如当剩下的数的数量小于 “还需要选的数的数量” 时,就没必要继续搜索了。比如 n=5,m=3,当我们选到 4 的时候,还需要选 2 个数,但剩下的数只有 5,显然无法满足,这时候可以直接剪枝。
优化后的代码(添加剪枝条件):
#include <iostream>
#include <vector>
using namespace std;
int n, m;
vector<int> path;
void dfs(int begin) {
if (path.size() == m) {
for (auto x : path) {
cout << x << " ";
}
cout << endl;
return;
}
// 剪枝:剩下的数不够选,直接退出
// 还需要选的数:m - path.size()
// 剩下的数:n - i + 1
for (int i = begin; i <= n - (m - path.size()) + 1; i++) {
path.push_back(i);
dfs(i + 1);
path.pop_back();
}
}
int main() {
cin >> n >> m;
dfs(1);
return 0;
}这个剪枝条件能大幅减少递归的次数,尤其是当 n 和 m 较大时,效率提升非常明显。
题目链接如下:https://www.luogu.com.cn/problem/B3623

洛谷题目B3623 枚举排列(递归实现排列型枚举)要求:给定 n 名学生,选出 k 人排成一列拍照,按字典序输出所有可能的排列方式。
比如输入 n=3,k=2 时,输出是:
1 2
1 3
2 1
2 3
3 1
3 2排列问题和组合问题的核心区别是:排列考虑顺序,组合不考虑顺序。因此,排列的决策树需要考虑 “选过的数不能再选”,这就需要一个标记数组来记录哪些数已经被选过,这也是一种剪枝。
以 n=3,k=2 为例画决策树:

其中,紫色的分支(选过的数再次被选)需要剪掉,比如选了 1 之后,不能再选 1,因此 1 下面的 1 分支要剪掉。
递归函数的设计要点:
begin,而是用st数组标记数是否被选过,path记录排列的路径。path.size() == k时,输出排列方案。#include <iostream>
#include <vector>
using namespace std;
const int N = 15;
int n, k;
vector<int> path;
bool st[N]; // 标记数是否被选过
void dfs() {
// 递归终止:选够了k个数
if (path.size() == k) {
for (auto x : path) {
cout << x << " ";
}
cout << endl;
return;
}
// 从1到n枚举所有数
for (int i = 1; i <= n; i++) {
if (st[i]) continue; // 剪枝:跳过已选的数
st[i] = true; // 标记为已选
path.push_back(i);
dfs();
// 回溯:取消标记,删除最后一个数
st[i] = false;
path.pop_back();
}
}
int main() {
cin >> n >> k;
dfs();
return 0;
}题目链接如下:https://www.luogu.com.cn/problem/P1706

洛谷题目P1706 全排列问题要求:按字典序输出自然数 1 到 n 的所有不重复的排列,每个数字占 5 个场宽。
比如输入 n=3 时,输出是:
1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1 全排列是排列问题的特例,即 k=n 的情况。因此,递归的思路和枚举排列基本一致,只是终止条件变为path.size() == n。

另外,C++ 的 STL 中提供了next_permutation函数,可以直接生成全排列,但作为学习 DFS 的过程,我们还是要掌握手动实现的方法。
#include <iostream>
#include <vector>
using namespace std;
const int N = 15;
int n;
vector<int> path;
bool st[N]; // 标记数是否被选过
void dfs() {
// 递归终止:选够了n个数(全排列)
if (path.size() == n) {
for (auto x : path) {
printf("%5d", x); // 每个数字占5个场宽
}
cout << endl;
return;
}
for (int i = 1; i <= n; i++) {
if (st[i]) continue; // 剪枝:跳过已选的数
st[i] = true;
path.push_back(i);
dfs();
// 回溯
st[i] = false;
path.pop_back();
}
}
int main() {
cin >> n;
dfs();
return 0;
} 如果想快速实现全排列,可以使用next_permutation函数,代码如下:
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int main() {
int n;
cin >> n;
vector<int> arr(n);
for (int i = 0; i < n; i++) {
arr[i] = i + 1;
}
do {
for (auto x : arr) {
printf("%5d", x);
}
cout << endl;
} while (next_permutation(arr.begin(), arr.end()));
return 0;
} next_permutation会自动生成下一个字典序的排列,直到没有下一个排列为止。但需要注意的是,使用该函数前,数组必须是有序的,否则会从当前排列开始生成。
通过上面四个例题,我们已经掌握了 DFS 递归型枚举的基本方法,也初步接触了回溯和剪枝。这里总结一下核心要点:
回溯是 “试错” 的过程:在递归中做出选择,若发现选择无效,就撤销选择,回到上一步重新选择。回溯的关键是恢复现场,比如删除 path 中的最后一个元素、取消 st 数组的标记等。没有恢复现场,递归就会陷入混乱,因为上一次的选择会影响后续的决策。
在 DFS 中,剪枝是提升效率的关键,常见的剪枝类型有:
解决这类问题,我们可以遵循一个固定的套路:
在实际应用中,DFS 的递归型枚举还可以结合更多技巧,比如记忆化搜索(将已经计算过的状态保存起来,避免重复计算)、状态压缩(用二进制数表示状态)等。后续我们会继续讲解这些进阶技巧,让你的 DFS 功力更上一层楼。 最后,送给大家一句话:DFS 的核心是 “敢想敢写”,先画出决策树,再一步步实现,回溯和剪枝都是在这个基础上的优化。希望大家多动手敲代码,多尝试不同的问题,真正掌握 DFS 的精髓!