阅读之前
请确认您能够理解函数及其递归调用。
快速入门
深度优先搜索(DFS,即Depth First Search)属于图算法的一种,也被氢切的称为回溯算法,本质还是打暴力。其过程简要来说是对每一个可能的分支路径深入到不能再深入为止,而且每个节点只能访问一次。在非图论的应用中,DFS常用于设计回溯递归算法,本次我们主要围绕DFS的基本应用即回溯搜索为中心展开讲解。
考虑全排列问题,即按照字典序输出自然数 到
所有不重复的排列的个数与排列的方式,即
的全排列,要求所产生的任一数字序列中不允许出现重复的数字。要求编写程序遍历所有可能的情况并输出。我们先来看一个简单的情况
,此时用脑子很容易想到一共有6种排列,分别是
。
我们是如何想到这6个数字的呢?首先,我们以1作为第1位,之后选择2作为第2位,剩下一个3作为第3位,得到123;之后我们回到第一位为1的情况,再选择刚才没选的3作为第2位,最后选择剩下的2作为第3位,得到132。这样1作为第1位的情况就全部遍历完成,我们回到最开始,再选择2作为第1位,之后选择1作为第2位,以此往复,直到把6种情况全部遍历完毕。
我们以此为以据设计算法,首先考虑基本的连续选择过程,由于对于全排列而言,每个数字只能使用一次,我们使用一个数组vis
来存储每一个数组是否已被使用,当选择下一个数字时,我们检查这个数字是否已经被使用,如果没被使用,则选择这个数字并更新状态,如过已经被使用,那么尝试选择下一个数字。
最重要的在于“回溯”过程,我们在上面提到过,当第一次选择完成,选择完成一次回溯,重新跳回到第2位的选择。由于第3位选择的3已经被释放,我们应该将vis
数组中的3位置修改为false。由于2已经被选择,接着继续尝试向下选择3,此时3未被访问过,接下来将3作为第2为,生成132。
从上面的分析我们得出,每次最终回溯前我们的三位数字都完成了取值,可以被记为一个答案,我们可以在调用时增加一个参数
layer
,表示目前搜索的层数,当层数大于3时,代表3位数字均已搜索完成,此时我们就得到一个新的答案。用一个全局参数ans
记录:
#include <iostream>
using namespace std;
void dfs(int layer);
int n,ans;
int vis[14] = {};
int main()
{
cin >> n;
dfs(1);
cout << ans << endl;
return 0;
}
void dfs(int layer)
{
if (layer > n)
ans++;
for (int i = 1; i <= n; ++i)
{
if (!vis[i])
{
vis[i] = true;
dfs(layer + 1);
vis[i] = false;
}
}
}
我们来简单的解读一下这份递归代码中的核心部分——dfs函数。
-
生成答案。当
layer > n
时,代表产生了一种新答案,此时累加答案。
if (layer > n)
ans++;
- 遍历搜索树的一层。对于每深入的一位,我们都从起点开始遍历,覆盖每一种情况。
for (int i = 1; i <= n; ++i)
- 产生情况。对每次要取的数字,判断它是否被访问过。如果是,则不进入内部的处理,循环继续寻找下一位。如果它没有被访问过,进入更深的一层递归,同时将该数设为已访问。
if (!vis[i])
{
vis[i] = true;
dfs(layer + 1);
}
-
回溯。当遍历至终点,更深层的
dfs
函数退出。需要注意的是,相对于上一层dfs
函数而言,更深层的dfs
函数占用了vis[i]
,此时要从浅层的dfs
,通过下一个数字来访问深层的dfs
,也就是回溯过程,将已经被占用的数字重新置否,将被占用的vis[i]
设为未占用。
vis[i] = false;
至此完成整个深度优先搜索过程。
接下来我们考虑如何输出答案的排列。我们可以使用一个新数组arrange
来记录该数字每一位的情况,在每一次成功占用数字时,我们将arrange[layer]
(也就是第layer层占用的数字)设置为i,表示第layer层使用了数字i。当每次完成递归也就是layer > n
时,我们输出这个数组的全部元素即可,为了美观我们位每个元素保留5个场宽。
#include <iostream>
#include <iomanip>
using namespace std;
void dfs(int layer);
int n;
int arrange[14] = {}, vis[14] = {};
int main()
{
cin >> n;
dfs(1);
return 0;
}
void dfs(int layer)
{
if (layer > n)
{
for (int i = 1; i <= n; ++i)
cout << setw(5) << arrange[i];
cout << endl;
}
for (int i = 1; i <= n; ++i)
{
if (!vis[i])
{
vis[i] = true;
arrange[layer] = i;
dfs(layer + 1);
vis[i] = false;
}
}
}
再次梳理执行思路如下:
- 对于每一层
layer
:- 如果
layer > n
,打印当前排列arrange[1...n]
- 否则,对每个数字
i
(1到n):- 如果
vis[i]
为假:- 设置
vis[i]
为真 - 将
i
放入arrange[layer]
- 递归调用
dfs(layer + 1)
- 递归返回后,设置
vis[i]
回假
- 设置
- 如果
- 如果
这里无需重置arrange[layer]
,结论是显然的,这个数组中的元素会在每次数字占用时被覆盖。
排列问题
深度优先搜索在排列问题中非常常见。上文我们正是通过一个基本的全排列问题引入了DFS。下面我们考虑“三连击”问题:将 共
个数分成三组,分别组成三个三位数,且使这三个三位数的比例是
,试求出所有满足条件的三个三位数,若无解,输出
No!!!
。输入,;输出若干行,每行
个数字。按照每行第一个数字升序排列。
初看这题可能毫无头绪,上文提到过,DFS本质就是打暴力,想想这个题能不能暴力求解?这个题的暴力思路是明显的,我们枚举1-9这9个数字的全排列,然后把它们分成三组,那么这个题就可以被转换为全排列问题。事实上,我们可以把每次递归,都可以不加限制的对搜索树的一层进行枚举的情况都称作全排列。
现在转回到这个问题上来,很容易构建它的搜索树:
按照模板构建深度优先搜索,
首先,准备数组vis
记录所有访问过的元素,数组arrange
记录每一位的情况。
-
生成答案。当
layer > 9
时,代表产生了一种新答案,此时我们按照arrange
的每三位进行分割,形成三个数字。之后,由于题目要求这三个三位数的比例是,我们通过一个语句
num1 * C == num3 * A && num1 * B == num2 * A
来验证,如果符合要求,则输出。
if (layer > 9)
{
auto num1 = arrange[1] * 100 + arrange[2] * 10 + arrange[3];
auto num2 = arrange[4] * 100 + arrange[5] * 10 + arrange[6];
auto num3 = arrange[7] * 100 + arrange[8] * 10 + arrange[9];
if (num1 * C == num3 * A && num1 * B == num2 * A)
{
flag = true;
cout << num1 << " " << num2 << " " << num3 << endl;
}
}
-
遍历搜索树的一层。该树任意一层有9种情况,代表数字
。
for (int i = 1; i < 10; ++i)
- 产生情况。我们遍历每一个数字,如该数字未被使用,我们记录该数字并更新状态为已使用,并加以此状态进入下一层递归。
if (!vis[i])
{
vis[i] = true;
arrange[layer] = i;
dfs(layer + 1);
}
-
回溯。将已经被占用的数字重新置否,将被占用的
vis[i]
设为未占用。
vis[i] = false;
-
特判。此题需要注意的是,若无解,输出
No!!!
。我们定义一个标志flag
。在第1步中,一旦产生合法的答案,flag
就会被更新为true
。到最后时,我们看一下flag
的状态,如果仍为false
,说明没有任何解,需要输出No!!!
。
if (!flag)
cout << "No!!!" << endl;
return 0;
本题完整代码如下:
#include <iostream>
using namespace std;
int A, B, C;
int vis[10] = {0},arrange[10] = {-1};
bool flag = false;
void dfs(int layer);
int main()
{
cin >> A >> B >> C;
dfs(1);
if (!flag)
cout << "No!!!" << endl;
return 0;
}
void dfs(int layer)
{
if (layer > 9)
{
auto num1 = arrange[1] * 100 + arrange[2] * 10 + arrange[3];
auto num2 = arrange[4] * 100 + arrange[5] * 10 + arrange[6];
auto num3 = arrange[7] * 100 + arrange[8] * 10 + arrange[9];
if (num1 * C == num3 * A && num1 * B == num2 * A)
{
flag = true;
cout << num1 << " " << num2 << " " << num3 << endl;
}
}
for (int i = 1; i < 10; ++i)
{
if (!vis[i])
{
vis[i] = true;
arrange[layer] = i;
dfs(layer + 1);
vis[i] = false;
}
}
}
子集问题
我们来看一道有趣的配料问题:Perket 是一种流行的美食。为了做好 Perket,厨师必须谨慎选择食材,以在保持传统风味的同时尽可能获得最全面的味道。你有 种可支配的配料。对于每一种配料,我们知道它们各自的酸度
和苦度
。当我们添加配料时,总的酸度为每一种配料的酸度总乘积;总的苦度为每一种配料的苦度的总和。众所周知,美食应该做到口感适中,所以我们希望选取配料,以使得酸度和苦度的绝对差最小。另外,我们必须添加至少一种配料,因为没有任何食物以水为配料的。输入第一行一个整数
,表示可供选用的食材种类数。接下来
行,每行
个整数
和
,表示第
种食材的酸度和苦度。输出一行一个整数,表示可能的总酸度和总苦度的最小绝对差。
这个问题说到底还是这些酸度和苦度的全排列。但这问题相比起上一个有两个明显的不同点,首先,酸度和苦度并不能任意排,我们可以使用一个结构体配对存储,然后对其全排:
struct ingredient
{
int s;
int b;
}arr[11];
另一个最大的不同就是这题的“全排列”不是说一定要到最后一层的所有情况,也就是说,给了我们种材料,我们不是一定要加
种,而是可以加1种,可以加2种...可以加
种,当然也可以加
种,这种情况相当于求了集合
的所有子集。我们要找出最优解,可以画图看一下。
这时我们就需要对深搜板子做一点小小的改动,本来时直到
layer > n
时才进行答案的处理,在这里我们进入每一次dfs
函数都进行答案处理即可,下面构建深搜。
-
生成答案。每次进入深搜时,我们都要尝试此时的答案,即可能的总酸度和总苦度的最小绝对差
。并且比较
D
与目前最小答案ans
的大小,如果,那么更新
ans
为D
。需要特别注意的是,刚进入时,vis
数组全为false,此时未进入求值,而我们给答案赋值时,酸度的初值为1,苦度的初值为0。,也就是说求出来的这个最小差为1,我们需要记录一下是否进入了计算
的过程,如果没有进入,那么就不进行赋值。否则进行正常赋值。
if (layer > n) return;
int sour = 1, bitter = 0;
bool changed = false;
for (int i = 0; i < n; ++i)
{
if (vis[i])
{
sour *= arr[i].s;
bitter += arr[i].b;
changed = true;
}
}
if (changed)
ans = min(ans, abs(sour - bitter));
-
遍历搜索树的一层。任意一层有
种情况。代表选择
。
for (int i = 0; i < n; ++i)
-
产生情况。我们遍历
arr
中的每一种配料,并使用vis
进行记录映射,如该调料未被使用,记录该数字并更新状态为已使用并加以此状态进入下一层递归。
if (!vis[i])
{
vis[i] = true;
dfs(layer + 1);
}
-
回溯。将已经被占用的数字重新置否,将被占用的
vis[i]
设为未占用。
vis[i] = false;
合并以上思路,便可以使用以下简单的代码解决该问题:
#include<iostream>
using namespace std;
struct ingredient
{
int s;
int b;
}arr[11];
bool vis[11];
int n,ans = 2147483647;
void dfs(int layer);
int main()
{
cin >> n;
for (int i = 0; i < n; ++i)
cin >> arr[i].s >> arr[i].b;
dfs(0);
cout << ans << endl;
return 0;
}
void dfs(int layer)
{
if (layer > n) return;
int sour = 1, bitter = 0;
bool changed = false;
for (int i = 0; i < n; ++i)
{
if (vis[i])
{
sour *= arr[i].s;
bitter += arr[i].b;
changed = true;
}
}
if (changed)
ans = min(ans, abs(sour - bitter));
for (int i = 0; i < n; ++i)
{
if (!vis[i])
{
vis[i] = true;
dfs(layer + 1);
vis[i] = false;
}
}
}
组合问题
组合与排列最大的区别就是组合不包含重复的排列。举个例子,对于数字中抽取三个数字,而言,全排列可以包含
等等,对于组合而言,这三种排列都是数字
的组合,算作同一种组合。那么如何寻找组合呢?事实上,组合和排列的区别在于,组合需要对重复的排列去重。
首先我们观察一个简单的情况:不加详细考虑的先认为以1为首位的所有组合已经枚举完成,下面以2作为首位枚举,显然是一个重复的组合(
已被枚举)那么移动第2位的开始位置至第2个元素2,由于元素2已经在首位被使用,这种情况被忽略,接下来第2位移动到3,如果第3位从第1个元素开始,会得到
,与
重复,那么移动第3位的开始位置至第2个元素2,由于2已经被使用,接下来第2位移动到3,又由于3已经被使用,接下来第2位移动到4,产生新的组合
,之后继续以上过程得到新的组合。
从上面的分析中,我们得出一个求组合的通用思路,就是在每一层的遍历中,不在从1开始枚举以产生重复的组合(即排列),由于组合里每一个数都要大于前一个数,就从比当前数字大的地方
i+1
开始遍历到最大值。
解决组合问题:排列与组合是常用的数学方法,其中组合就是从 个元素中抽出
个元素(不分顺序且
),我们可以简单地将
个元素理解为自然数
,从中任取
个数。现要求你输出所有组合。例如
,所有组合为:
。输入一行两个自然数
。输出所有的组合,每一个组合占一行且其中的元素按由小到大的顺序排列,每个元素占三个字符的位置,所有的组合也按字典顺序。
我们尽可能地通过修改板子地方式实现,我们修改第2步,遍历搜索树的一层。对于每深入的一位,我们都从比上一位大的地方开始遍历,覆盖每一种情况。
for (int i = start; i <= n; ++i)
之后修改第3步,产生情况。对每次要取的数字,判断它是否被访问过。如果是,则不进入内部的处理,循环继续寻找下一位。如果它没有被访问过,进入更深的一层递归,同时将该数设为已访问。同时向dfs
函数中传入当前遍历到的位置,并+1作为下一层的起始值。
if (!vis[i])
{
vis[i] = true;
dfs(layer + 1,i + 1);
}
最后修改函数签名,增加一个参数start接收这个位置即可。
void dfs(int layer,int start);
完整代码如下所示:
#include<iostream>
#include <iomanip>
using namespace std;
int n, r;
int vis[22], combine[22];
void dfs(int layer, int start);
int main()
{
cin >> n >> r;
dfs(1,1);
return 0;
}
void dfs(int layer,int start)
{
if (layer > r)
{
for (int i = 1; i < r + 1; ++i)
cout << setw(3) << combine[i];
cout << endl;
return;
}
for (int i = start; i < n + 1; ++i)
{
if (!vis[i])
{
vis[i] = true;
combine[layer] = i;
dfs(layer + 1, i + 1);
vis[i] = false;
}
}
}
坐标问题
深度优先搜索在二维数组(坐标系)中应用广泛。一方面,可以解决回溯搜索问题;另一方面,衍生出的Flood Filling一类算法可以解决连通性问题。
“过河卒”是一个经典的算法问题:棋盘上 点有一个过河卒,需要走到目标
点。卒行走的规则:可以向下、或者向右。同时在棋盘上
点有一个对方的马,该马所在的点和所有跳跃一步可达的点称为对方马的控制点。因此称之为“马拦过河卒”。棋盘用坐标表示,
点
、
点
,同样马的位置坐标是需要给出的。
现在要求你计算出卒从 点能够到达
点的路径的条数,假设马的位置是固定不动的,并不是卒走一步马走一步。输入一行四个正整数,分别表示
点坐标和马的坐标。输出一个整数,表示所有的路径条数。
现在问题从一维上升到了二维,我们依然还是先构建搜索树,搜索树有了,思路也就有了。但这时我们构建搜索树时可能遇到难以逾越的天堑:我们无从获知每一次选择的情况是什么。这时候我们可以重新思考搜索树的实质。每一层的选择实质上是相对于上一层,下一层可以如何选择。在本题中,对于上一层的坐标,下一次可以向下移动,此时坐标变为
,也可以向右移动,此时坐标变为
。也就是说我们的搜索树每层有2种情况。
那么这棵树有多少层呢?我们无从获知。但是这棵树的层数真的有这么重要吗?回想树层数的作用,不过是用于确定终止条件而已,而这个题的终止条件并不是选择多少个格子,而是到达终点,所以这棵树的层数根本就不重要。
完成了思考,接下来,我们古法炮制dfs代码。
-
生成答案。当此时搜索的坐标达到终点的坐标时,将答案累计1。我们存储马及其控制点的坐标至数组
forbid
中,如果传入的坐标在forbid
中,函数立即返回;并且,如果坐标超出了坐标系的界限,函数也立即返回。
if (x < 0 || y < 0 || x > dest[0] || y > dest[1])
return;
for (int i = 0; i < 8; ++i)
if (forbid[x][y])
return;
if (x == dest[0] && y == dest[1])
{
ans++;
return;
}
-
遍历搜索树的一层。任意一层有
种情况。也就是两个
dfs
函数的调用。 -
产生情况。如果当前位置未访问,就访问接下来的两个临近位置,并且完成对
vis
数组的更新如上,不再赘述。
if (!vis[x][y])
{
vis[x][y] = true;
dfs(x + 1, y);
dfs(x, y + 1);
}
-
回溯。将已经被扫过的数字接触占用,将被占用的
vis[i]
设为未占用。
vis[x][y] = false;
整理上述思路,容易构建深搜如下:
#include<iostream>
#include <iomanip>
using namespace std;
int dest[2],ans = 0;
bool vis[21][21] = {0},forbid[21][21] = {0};
void dfs(int x, int y);
int main()
{
int hx, hy;
cin >> dest[0] >> dest[1];
cin >> hx >> hy;
int dx[] = { -2,-1,1,2,2,1,-1,-2 };
int dy[] = { 1,2,2,1,-1,-2,-2,-1 };
forbid[hx][hy] = true;
for (int i = 0; i < 8; ++i)
{
if (hx + dx[i] <= dest[0] && hy + dy[i] <= dest[1])
forbid[hx + dx[i]][hy + dy[i]] = true;
}
dfs(0, 0);
cout << ans << endl;
return 0;
}
void dfs(int x,int y)
{
if (x < 0 || y < 0 || x > dest[0] || y > dest[1])
return;
for (int i = 0; i < 8; ++i)
if (forbid[x][y])
return;
if (x == dest[0] && y == dest[1])
{
ans++;
return;
}
if (!vis[x][y])
{
vis[x][y] = true;
dfs(x + 1, y);
dfs(x, y + 1);
vis[x][y] = false;
}
},
巩固提高
坐标系中的问题不一定非得要深搜每一个格子,例如“八皇后”问题:
一个如下的 的跳棋棋盘,有六个棋子被放置在棋盘上,使得每行、每列有且只有一个,每条对角线(包括两条主对角线的所有平行线)上至多有一个棋子。
上面的布局可以用序列
不难发现,每一行/每一列都是只能放置一个棋子的。我们可以以行构建搜索树。每一行中,我们都有(在示例中为6)种放置方法,很容易构建搜索树。
之后进行深搜即可。这里的深搜终止条件是同一列或者对角线已经被其它棋子占用。我们可以用三个数组col[n]
、diagp[n]
和diagn[n]
存储同一列、斜率为正和负的对角线的占用情况。列可以用直接用坐标表示,如何表示一条对角线呢?观察可以发现,两条对角线的其中一条
为定值,另外一条
为定值。根据这些推断直接套上dfs板子即可,不再赘述:
#include<iostream>
using namespace std;
void dfs(int layer);
int col[30], diagp[30], diagn[30], status[15],ans = 0, n = 0;
int main()
{
cin >> n;
dfs(1);
cout << ans << endl;
return 0;
}
void setval(int l, int i, int val)
{
status[l] = i;
col[i] = val;
diagp[l + i] = val;
diagn[l - i + n] = val;
}
void dfs(int layer)
{
if (layer > n)
{
ans++;
if (ans <= 3)
{
for (int i = 1; i <= n; ++i)
cout << status[i] << " ";
cout << endl;
}
}
for (int i = 1; i <= n; ++i)
{
if (col[i] || diagp[layer + i] || diagn[layer - i + n]) continue;
setval(layer, i, 1);
dfs(layer + 1);
setval(layer, i, 0);
}
}