一、什么是DFS?
DFS的本质就是递归,对于不同的问题,在递归的过程中进行的操作也不同。但一般来说,我们很难去精确地模拟递归中每一层进行的操作与各个变量的值的变化,正如oiwiki中的一句话:最重要的是: 千万不要跳进这个函数里面企图探究更多细节,否则就会陷入无穷的细节无法自拔。因此,我们在做DFS相关的问题时,要把整个问题中的关键量提取出来,抽象成对每一层的操作,写出DFS函数,然后完全相信这个函数能完成任务。
DFS的基本思路是选定一个点作为起点,然后一条路走到黑,直到不能继续深入下去,再进行回溯,寻找其他的出口。这一点我们就得通过递归的方式完成。其实只要有递归,我们就得进行回溯,否则一直深入下去往往不会得到任何结果。至于如何回溯,我们一般将题目规定的终点条件作为回溯条件之一,其他的回溯条件需要我们自己根据题目的逻辑来确定。
二、DFS的代码框架
int ans; //答案,一般定义为全局变量
bool vis[]; //记录每个点是否访问过
void dfs(dfs层数, 其他相关参数) { //这里的其他参数需要自己进行抽象模拟
if (条件) { //到达最底层,或者满足更新答案的条件
ans++; //更新答案
return;
}
剪枝操作 //在进行下一步dfs前剪枝
for (枚举下一层可能情况) { //每一个情况继续dfs
if (!vis[i]) { //若未访问过,则对该点dfs
vis[i] = true; //访问该点
dfs(层数 + 1, 其他参数);
vis[i] = false; //恢复现场,不影响其他层对这个状态的使用
}
}
return;
}
以上框架最好在经过了一定的DFS题目练习后再回来体会,无论是具象的迷宫问题DFS,还是抽象的选数问题DFS,以上框架的思想都是适用的,具体DFS函数中如何操作需要具体问题具体分析。
三、两道例题
1.迷宫问题
题目描述
给定一个 N×M 方格的迷宫,迷宫里有 T 处障碍,障碍处不可通过。
在迷宫中移动有上下左右四种方式,每次只能移动一个方格。数据保证起点上没有障碍。
给定起点坐标和终点坐标,每个方格最多经过一次,问有多少种从起点坐标到终点坐标的方案。
输入格式
第一行为三个正整数 N,M,T,分别表示迷宫的长宽和障碍总数。
第二行为四个正整数 SX,SY,FX,FY。SX,SY 代表起点坐标,FX,FY 代表终点坐标。
接下来 T 行,每行两个正整数,表示障碍点的坐标。
输出格式
输出从起点坐标到终点坐标的方案总数。
本题给出的描述比较具象,我们很容易能联想到通过DFS去进行求解。他给了我们起点坐标和终点坐标,我们只需要将起点坐标作为第一层DFS函数的参数,去穷举每一条能到达终点的路线即可。同时我们还要考虑到障碍点的坐标不能访问的问题,需要进行绕行。具体思路可以看代码中的注解
参考代码如下:
#include <iostream>
using namespace std;
int N, M, T; //分别表示迷宫的长宽和障碍总数
int SX, SY, FX, FY; //S代表起点坐标,F代表终点坐标
int map[10][10]; //初始化地图,0代表可以走,1代表不能走
int ans;
void dfs(int x, int y) {
//注意,这里关于抵达终点的判断和能不能走的判断顺序一定得是先判断能不能走,再判断是否抵达终点
//因为若终点处设置了障碍,先判断是否抵达终点就会使结果直接+1,出现错误
if (map[x][y]) { //若该点不能走,直接回溯
return;
}
if (x == FX && y == FY) { //若到达终点
ans++;
return;
}
map[x][y] = 1; //访问该点
dfs(x - 1, y);
dfs(x, y + 1);
dfs(x + 1, y);
dfs(x, y - 1);
map[x][y] = 0; //回溯
return;
}
int main() {
scanf ("%d%d%d", &N, &M, &T);
scanf ("%d%d%d%d", &SX, &SY, &FX, &FY);
for (int i = 0; i < 10; i++) { //处理边界,避免越界
map[0][i] = 1;
map[i][0] = 1;
map[N + 1][i] = 1;
map[i][M + 1] = 1;
}
int barrx, barry; //记录障碍点的坐标
for (int i = 0; i < T; i++) {
scanf ("%d%d", &barrx, &barry);
map[barrx][barry] = 1; //在地图中设置障碍
}
dfs(SX, SY);
printf ("%d\n", ans);
return 0;
}
关于在代码中DFS函数两个退出条件的先后,笔者在这里解释一下。如前文所说,我们在DFS函数中需要设置回溯的条件,该条件一般为到达我们的目标或者某个地方不能继续深入。对于第一点,在这里指的就是当前搜索到的点的坐标是否是终点坐标;对于第二点,在本题指的是当前搜索到的点是否是障碍点,也就是是否可以访问。这两个判断条件的先后顺序一定是先判断该点是否能走,然后再判断是否到达终点。
我们试想一下,如果我们先判断该点是否为终点,那么这一层搜索就会直接令ans++,然后回溯。这里就会出现一个矛盾,假如题目给的数据在终点处设置了障碍,那么是不是就根本无法到达终点呢?最终的结果就一定是0条可行路线。
如果我们先判断该点是否可以访问(该点是否为障碍点),那么无论终点是否是障碍物,我们都可以得到正确的答案。
2.选数问题
题目描述
已知 n 个整数 1,2,⋯ ,x1,x2,⋯,xn,以及 11 个整数 k(k<n)。从 n 个整数中任选 k 个整数相加,可分别得到一系列的和。例如当 n=4,k=3,44 个整数分别为 3,7,12,19 时,可得全部的组合与它们的和为:
3+7+12=22
3+7+19=29
7+12+19=38
3+12+19=34
现在,要求你计算出和为素数共有多少种。
例如上例,只有一种的和为素数:3+7+19=29。
与第一道迷宫问题不同的是,这道题并没有那么具象。我们一眼看不出来起始点是什么,我们应该如何进行搜索,以及如何确定回溯条件。
遇到这种情况,我们就得从题目中提取出重要信息作为DFS的参数。那么什么是重要信息呢?根据题目描述,本题涉及了这样几个量:给定的数字、选定的数字个数、最终的总和、当前数字的下标。我们要做的,是将这几个量抽象成DFS函数的参数。至于如何进行抽象,要看个人如何理解自己的DFS函数。在这里给出我的一个定义:
void dfs(int m, int sum, int x)
在以上定义中,规定 m 表示当前选择的数字个数,sum 表示当前选择的数字总和, x 表示从第 x 个数字开始搜索,下面结合代码进行说明。
#include <iostream>
#include <math.h>
using namespace std;
int n, k; //n个整数中选k个整数
int num[25]; //num数组用于存数
long long ans; //ans用于存放答案
bool isprime(int x) {
if (x == 0 || x == 1) {
return false;
} else {
for (int i = 2; i <= sqrt(x); ++i) {
if (x % i == 0)
return false;
}
return true;
}
}
//m个数,和为sum,从第startx个开始搜
void dfs (int m, int sum, int startx) {
if (m == k) { //若已找到了k个数
if (isprime(sum)) //判断是否为质数
ans++;
return;
} else {
for (int i = startx; i < n; ++i)
dfs(m + 1, sum + num[i], i + 1);
return;
}
}
int main() {
scanf ("%d%d", &n, &k);
for (int i = 0; i < n; ++i)
scanf ("%d", &num[i]);
dfs(0, 0, 0);
printf ("%lld\n", ans);
return 0;
}
在以上代码中,我们首先通过num数组存放读入的数字,然后从第一个数字开始DFS。在DFS函数中,我们的退出条件是找到了 k 个数,也就是 m == k。若未达到这个条件,我们就要继续往下搜索。在函数中定义的for循环是为了保证数组中数据选择的升序顺序,比如说我们从1、2、3、4、5、6中选3个数,我们如何选呢?很明显,通过以上代码的操作,第一轮应该是这样:1 2 3;1 2 4;1 2 5;1 2 6……以此类推,保证选数次序是升序的(可以自己模拟一下DFS中for循环的过程)。完成搜索之后直接返回,输出答案即可。