在算法世界里,深度优先搜索(DFS)就像一位 “执着的探险家”—— 一旦选择了一条路径,就会义无反顾地走到尽头,遇到死胡同再掉头寻找新的可能。这种 “不撞南墙不回头” 的特性,让 DFS 在枚举、组合、路径规划等问题中大放异彩。 今天这篇文章,我们不搞空洞的理论说教,而是以 “问题驱动” 的方式,逐个拆解这些经典例题。从 “选数求和判素数” 到 “飞机降落调度”,再到 “八皇后”“数独”,每道题都会讲清 “为什么用 DFS”“递归函数怎么设计”“如何剪枝提速”,并附上可直接运行的 C++ 代码。无论你是刚接触 DFS 的新手,还是想巩固实战能力的老手,都能从这篇文章中找到收获。下面就让我们正式开始吧!
题目是这样的:已知 n 个整数和一个整数 k(k < n),从这 n 个整数中任选 k 个相加,计算和为素数的方案有多少种。比如输入4 3和3 7 12 19,所有组合的和分别是 3+7+12=22(非素数)、3+7+19=29(素数)、3+12+19=34(非素数)、7+12+19=38(非素数),所以答案是 1。
题目链接如下:https://www.luogu.com.cn/problem/P1036

这道题看似简单,却包含了 DFS 的核心思想:组合型枚举—— 因为选数的顺序不影响结果(比如选 3、7、19 和选 7、3、19 是同一种方案),所以需要控制选数的顺序,避免重复枚举。
我们可以把选数过程想象成一棵 “决策树”:每一层代表 “选第几个数”,每个分支代表 “选哪个数”。比如 n=4、k=3 时,第一层选第一个数(只能从 1 开始),第二层选第二个数(只能从第一层选的数的下一个开始),第三层选第三个数(同理)。这样就能保证选出来的数是递增的,避免重复。
递归函数需要两个核心参数:
pos:当前要选第几个数(比如 pos=1 表示选第一个数,pos=k 时表示已经选够 k 个,需要判断和是否为素数);begin:当前选数的起始位置(比如上一个数选了 i,这次就从 i+1 开始,保证组合不重复);path记录当前选中数的和(避免用数组存储再求和,提高效率),ret记录符合条件的方案数。判断一个数是否为素数,最常用的是 “试除法”:从 2 到√x 遍历,如果 x 能被其中任何一个数整除,就不是素数。这里要注意两个边界:x≤1 时不是素数,x=2 时是素数。
#include <iostream>
using namespace std;
const int N = 25; // 题目中n≤20,开25足够
int n, k;
int a[N]; // 存储输入的n个整数
int ret = 0; // 记录符合条件的方案数
int path_sum = 0; // 记录当前选中数的和(path_sum比path更直观)
// 试除法判断素数
bool is_prime(int x) {
if (x <= 1) return false; // 1及以下不是素数
// 优化:只需要遍历到√x,因为如果x有因子,必然有一个≤√x
for (int i = 2; i <= x / i; i++) {
if (x % i == 0) return false; // 能整除,不是素数
}
return true;
}
// DFS函数:pos-当前要选第几个数,begin-选数的起始位置
void dfs(int pos, int begin) {
// 递归终止条件:选够k个数了
if (pos > k) {
if (is_prime(path_sum)) { // 判断和是否为素数
ret++;
}
return;
}
// 枚举选数:从begin开始,避免重复
for (int i = begin; i <= n; i++) {
// 选择当前数:累加和
path_sum += a[i];
// 递归:选下一个数(pos+1),下一次从i+1开始选
dfs(pos + 1, i + 1);
// 回溯:撤销选择,恢复现场
path_sum -= a[i];
}
}
int main() {
cin >> n >> k;
for (int i = 1; i <= n; i++) { // 数组从1开始,方便理解
cin >> a[i];
}
dfs(1, 1); // 从选第1个数开始,起始位置是1
cout << ret << endl;
return 0;
} 在 DFS 中,“恢复现场”(比如path_sum -= a[i])是核心操作之一。因为递归函数会多次调用,而path_sum是全局变量 —— 如果不恢复,下一次枚举其他分支时,会带着上一个分支的结果,导致计算错误。
比如选第一个数时,我们加了 a [1],递归处理完所有包含 a [1] 的分支后,必须减去 a [1],才能再去处理包含 a [2] 的分支。这就像探险家走完一条路后,要回到起点再走另一条路,不能带着上一条路的 “行李”。
机场只有一条跑道,N 架飞机要降落。每架飞机有三个参数:T_i(到达时间)、D_i(最大盘旋时间,即最晚降落时间为T_i+D_i)、L_i(降落所需时间)。要求判断是否存在一种降落顺序,让所有飞机都能安全降落(前一架降落后,后一架才能开始降)。
题目链接如下:https://www.luogu.com.cn/problem/P9241

因为飞机的降落顺序是 “排列问题”—— 不同的顺序会导致不同的结果,所以需要枚举所有可能的排列,判断是否存在一种排列让所有飞机都能安全降落。
但 N 的范围是≤10,如果直接枚举全排列,10! = 3628800,这个数量级是可以接受的(1000 万以内的运算,C++ 能轻松处理)。但如果不剪枝,可能会做很多无用功,所以需要加入剪枝优化。
pos:当前要安排第几个飞机(pos=1 表示安排第一个,pos=N+1 表示所有飞机都安排完,返回 true);end_time:上一架飞机降落结束的时间(当前飞机的最早开始时间是 end_time,同时不能晚于当前飞机的 T_i+D_i);st数组标记哪些飞机已经被安排过(避免重复安排)。st[i] = true),直接跳过;#include <iostream>
#include <cstring> // 用于memset
using namespace std;
const int N = 15; // N≤10,开15足够
int n; // 飞机数量
int T[N], D[N], L[N]; // 每架飞机的T_i、D_i、L_i
bool st[N]; // 标记飞机是否已被安排(true表示已安排)
// DFS函数:pos-当前安排第几个飞机,end_time-上一架结束时间
// 返回值:是否存在合法的安排方式
bool dfs(int pos, int end_time) {
// 递归终止条件:所有飞机都安排完了
if (pos > n) {
return true;
}
// 枚举每一架飞机,尝试安排
for (int i = 1; i <= n; i++) {
// 剪枝1:飞机已经安排过,跳过
if (st[i]) continue;
// 剪枝2:当前飞机的最晚开始时间 < 上一架结束时间,无法安排,跳过
if (end_time > T[i] + D[i]) continue;
// 选择当前飞机:标记为已安排
st[i] = true;
// 计算当前飞机的结束时间:最早开始时间是max(end_time, T[i]),加上降落时间L[i]
int new_end = max(end_time, T[i]) + L[i];
// 递归安排下一架飞机,如果找到合法解,直接返回true
if (dfs(pos + 1, new_end)) {
return true;
}
// 回溯:撤销选择,恢复现场
st[i] = false;
}
// 枚举完所有飞机,都没有合法解,返回false
return false;
}
int main() {
int T_case; // 测试数据组数
cin >> T_case;
while (T_case--) {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> T[i] >> D[i] >> L[i];
}
// 每次处理一组数据前,清空st数组(0表示未安排)
memset(st, 0, sizeof st);
// 从安排第1架飞机开始,上一架结束时间是0(初始状态,没有飞机降落过)
if (dfs(1, 0)) {
cout << "YES" << endl;
} else {
cout << "NO" << endl;
}
}
return 0;
} 如果不剪枝,枚举 10 架飞机的全排列需要 10! = 3628800 次递归,虽然能运行,但加上剪枝后,很多无效的分支会被直接跳过,运行速度会快很多。比如当end_time已经超过某架飞机的最晚开始时间时,就不需要再递归处理这架飞机的后续分支了,直接跳过,节省了大量时间。
另外,递归函数返回 bool 类型,一旦找到合法解就立即返回,避免了不必要的枚举 —— 这也是一种重要的剪枝策略(“最优性剪枝” 的变种,这里是 “存在性剪枝”)。
八皇后问题是 DFS 的经典应用:在 n×n 的棋盘上放置 n 个皇后,使得每行、每列、每条对角线上都只有一个皇后。要求按字典序输出前 3 个解,并统计解的总数。
比如 n=6 时,其中一个解是 “2 4 6 1 3 5”,表示第 1 行的皇后在第 2 列,第 2 行在第 4 列,以此类推。
题目链接如下:https://www.luogu.com.cn/problem/P1219

八皇后问题的核心是 “冲突检测”—— 放置一个皇后后,要确保它所在的行、列、对角线没有其他皇后。由于我们是 “一行一行” 放置皇后(每一行放一个),所以行冲突已经天然避免了,只需要检测列和对角线冲突。
col数组标记,col[j] = true表示第 j 列有皇后);j - i + n(比如 n=6 时,-1+6=5,范围是 1~2n-1),用st1数组标记;st2数组标记。 递归函数的参数是x(当前要放置皇后的行号),因为每行只放一个,所以 x 从 1 到 n 遍历:
path数组存储),递归处理下一行 x+1;path数组中的当前列 y。#include <iostream>
#include <vector>
using namespace std;
const int N = 15; // n≤13,开15足够
int n; // 棋盘大小n×n
// 冲突标记数组:col[j]-第j列是否有皇后,st1-主对角线,st2-副对角线
bool col[N], st1[N * 2], st2[N * 2];
int ret = 0; // 解的总数
vector<int> path; // 存储当前解:path[i]表示第i+1行的皇后列号
// DFS函数:x-当前要放置皇后的行号
void dfs(int x) {
// 递归终止条件:所有行都放置了皇后
if (x > n) {
ret++; // 解的数量加1
// 如果是前3个解,输出
if (ret <= 3) {
for (int i = 0; i < path.size(); i++) {
cout << path[i] << " ";
}
cout << endl;
}
return;
}
// 枚举当前行的每一列y,尝试放置皇后
for (int y = 1; y <= n; y++) {
// 检测冲突:列y、主对角线(y-x+n)、副对角线(y+x)是否有皇后
if (col[y] || st1[y - x + n] || st2[y + x]) {
continue; // 有冲突,跳过
}
// 选择当前列y:标记冲突,记录路径
col[y] = true;
st1[y - x + n] = true;
st2[y + x] = true;
path.push_back(y);
// 递归处理下一行
dfs(x + 1);
// 回溯:撤销选择,恢复现场
path.pop_back();
st2[y + x] = false;
st1[y - x + n] = false;
col[y] = false;
}
}
int main() {
cin >> n;
dfs(1); // 从第1行开始放置皇后
cout << ret << endl; // 输出解的总数
return 0;
}主对角线和副对角线的标记是八皇后问题的难点,也是重点。记住这两个规律:
比如 n=6 时,第 1 行第 2 列的皇后:
这种标记方法是高效且直观的,避免了用二维数组遍历检测冲突,大大提高了效率。
数独是大家熟悉的游戏:9×9 的棋盘,部分格子有数字(1~9),要求填充剩余格子,使得每一行、每一列、每一个 3×3 的粗线宫内的数字都不重复,且每个数字都是 1~9。题目保证有唯一解。
比如输入:
800000000
003600000
070090200
050007000
000045700
000100030
001000068
008500010
090000400输出是填充完整的数独矩阵。
题目链接如下:https://www.luogu.com.cn/problem/P1784

数独的难点在于 “多维度冲突检测”(行、列、3×3 宫)和 “搜索顺序优化”(如果盲目枚举,效率会很低)。
row[i][num] = true表示第 i 行已经有数字 num;col[j][num] = true表示第 j 列已经有数字 num;st[x][y][num] = true表示第 (x,y) 个 3×3 宫已经有数字 num(x 是宫的行号,0~2;y 是宫的列号,0~2)。如何确定某个格子 (i,j) 属于哪个宫?x = i / 3,y = j / 3(i 和 j 从 0 开始)。
比如格子 (4,5)(第 5 行第 6 列,从 0 开始):
递归函数的参数是i和j(当前要填充的格子的行号和列号):
数独的搜索顺序很重要,如果盲目枚举,可能会做很多无用功。按 “行优先” 的顺序填充(从左到右,从上到下),可以尽早发现冲突,减少递归深度。比如如果某个格子必须填 5,而前面的格子已经把 5 用了,就可以提前剪枝。
#include <iostream>
using namespace std;
const int N = 10; // 数独是9×9,数组从0开始,开10足够
int a[N][N]; // 存储数独矩阵
// 冲突标记数组:row[i][num]-第i行有num,col[j][num]-第j列有num,st[x][y][num]-第(x,y)宫有num
bool row[N][N], col[N][N], st[N][N][N];
// DFS函数:i-当前格子行号,j-当前格子列号
bool dfs(int i, int j) {
// 换行处理:当前行填完了,填下一行的第0列
if (j == 9) {
i++;
j = 0;
}
// 递归终止条件:所有格子都填完了(找到唯一解)
if (i == 9) {
return true;
}
// 当前格子已有数字,直接处理下一个格子
if (a[i][j] != 0) {
return dfs(i, j + 1);
}
// 枚举1~9的数字,尝试填充当前格子
for (int num = 1; num <= 9; num++) {
// 计算当前格子所属的3×3宫的坐标(x-宫行号,y-宫列号)
int x = i / 3, y = j / 3;
// 检测冲突:行、列、宫是否已有该数字
if (row[i][num] || col[j][num] || st[x][y][num]) {
continue; // 有冲突,跳过
}
// 选择当前数字:标记冲突,填充格子
row[i][num] = true;
col[j][num] = true;
st[x][y][num] = true;
a[i][j] = num;
// 递归处理下一个格子,如果找到解,立即返回true
if (dfs(i, j + 1)) {
return true;
}
// 回溯:撤销选择,恢复现场
a[i][j] = 0;
st[x][y][num] = false;
col[j][num] = false;
row[i][num] = false;
}
// 枚举完所有数字都无法填充,返回false
return false;
}
int main() {
// 输入数独矩阵(0表示空格子)
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
cin >> a[i][j];
int num = a[i][j];
if (num != 0) {
// 标记已有数字的冲突状态
row[i][num] = true;
col[j][num] = true;
int x = i / 3, y = j / 3;
st[x][y][num] = true;
}
}
}
// 从第0行第0列开始填充
dfs(0, 0);
// 输出填充后的数独矩阵
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
cout << a[i][j] << " ";
}
cout << endl;
}
return 0;
}数独的输入是 9 行字符串,每个字符是数字(0~9)。在输入时,我们需要同时初始化冲突标记数组:对于非 0 的数字,要标记它所在的行、列、宫已经有该数字,避免后续填充时冲突。
比如输入第 0 行是 “800000000”,那么 a [0][0] = 8,我们需要标记:
这样,在后续填充时,就不会在第 0 行、第 0 列、第 (0,0) 宫再填 8 了。
通过以上 5 道题(选数、飞机降落、八皇后、数独,还有之前的枚举子集、组合、排列),我们可以总结出 DFS 的核心思想和实战技巧:
path_sum -= a[i],飞机降落问题中的st[i] = false,都是为了让下一次枚举不受当前层选择的影响。当遇到以下类型的问题时,优先考虑 DFS:
DFS 看似简单,但要真正用好,需要大量的实战练习。希望这篇文章能成为你 DFS 实战路上的 “垫脚石”,让你在算法竞赛或工程实践中,能轻松用 DFS 解决问题!