抽象深度优先搜索

抽象深度优先搜索

  1. 抽象深度优先搜索
    6.1. 抽象形式的 dfs0
    前面用到的 dfs 算法都是比较容易想象出搜索过程的,接下来我们看一些不那么容易想象搜索过程的
    dfs 过程,这些问题我们称为抽象形式的 dfs。
    来看一个非常简单的问题:
    给定 个整数,要求选出 个数,使得选出来的 个数的和为 。
    我们依然借助 dfs 来解决这个问题。对于每一个数,枚举选或者不选两种情况,我们可以用 dfs 思想来
    完成这样的枚举过程。
    我们在搜索的过程中,用 来记录当前选择的数值总和, 来记录选择的数的个数, 表示当前
    正在枚举第几个数是否选择。
    在第一层 dfs 的时候,我们可以枚举是否选第一个数,如果选第一个数则让 加上第一个数且 加
    一,dfs 进入到下一层;否则 dfs 直接进入到下一层。当然,这里我们还需要借助全局变量、参数或修
    改数组中元素的值等方式来标识出当前的层数,为了减少篇幅,在下文中就直接忽略掉了。
    在第二层,对第二个数做同样的处理,dfs 的过程中记录已经选取的数的个数,如果已经选取了 个
    数,判断 值是否等于 。对于每一层,我们都有两个选择——选和不选。不同的选择,都会使
    得搜索进入完全不同的分支继续搜索。
    下图是这个搜索过程对应的 搜索树,搜索树上的每一个结点都是一个 状态,一个状态包含两个值
    和 ,也就是一个状态对应当前的数值总和,以及选的数的个数。
    在这里插入图片描述

6.2. 搜索树和状态
这一节我们对搜索树进行深入的理解,如果对搜索树和状态有很好的理解,对后面的广度优先搜索和
动态规划的学习都有很大的帮助。
前面说过,dfs 看起来是运行在图上的搜索算法,而前一节给大家展示的 dfs 过程,我们没有看到图的
存在,这就是抽象形式的 dfs 的特点。
我们可以根据搜索状态构建一张抽象的图,图上的一个顶点就是一个状态,而图上的边就是状态之间
的转移关系(进一步搜索或回溯)。虽然 dfs 是在这张抽象的图上进行的,但我们不必把这张图真正
地建立出来。
我们可以认为,一次 dfs 实际上就是在搜索树上完成了一次深度优先搜索。而在上节中的搜索树里的
每一个状态,记录了两个值——和值和个数。对于每个数,我们都有两个选择——选和不选。不同的
选择,都会使得搜索进入完全不同的分支继续搜索。而每个状态对应的 子树,都是这个状态通过搜索
可能达到的状态。
在搜索树上 dfs 的过程如下:
在这里插入图片描述
【小练习】方案个数
从 中选若干个数,使得和值为 。
有 ________种选取方案。
【实践操作】K 个数的和(一)

#include <iostream>
using namespace std;
int n, k, sum, ans;
int a[40];
int main() {
// 输入数据
cin >> n >> k >> sum;
for (int i = 0; i < n; i++) {
cin >> a[i];
}
return 0;
}

这一节课我们来解决从 个数中选 个数的和为 这个问题。
初始的输入都已经写好了。我们先写下 dfs 的框架,dfs 需要传入三个参数,一个 i 表示当前正在选取
第几个数,一个 cnt 表示选取了几个数,一个 s 表示选取的数的和值。
在 main 函数上方写下
1, 2, 3, 4, 5 9

n k sum
void dfs(int i, int cnt, int s) {
}

如果不选取第 个数,那么 cnt 和 s 都不会有变化。
如果选取第 个数, cnt 加上 , s 加上 a[i] 。
对于不同的选择我们进行不同的分支搜索。
在 dfs 函数的开头写下

dfs(i + 1, cnt, s);
dfs(i + 1, cnt + 1, s + a[i]);

这一步我们来处理边界条件,边界条件其实很简单,当 i == n 的时候,我们已经对所有的数都做出了
选择,这时候我们来判断选出来的数的个数是都等于 ,和值是否是等于 。
在 dfs 开头插入

if (i == n) {
if (cnt == k && s == sum) {
ans++;
}
return;
}

在 main 函数里面调用 dfs 函数,初始的时候参数 i 、 cnt 、 s 都应该初始化成为 。记得初始
化 ans 为 。
在 main 函数里面的空行写下

ans = 0;
dfs(0, 0, 0);
cout << ans << endl;

这一节已经完成,点击运行,然后输入数据
5 3 9
1 2 3 4 5
6.3. 改变搜索策略
对于前面 k 个数的和的求法,我们除了可以用上面的 dfs 方法以后,还有一种搜索策略。
i
i 1
k sum
0
0

之前的方法是每次去抉择是否选择第 个数,现在我们的策略是从剩下的数中选择一个数。比如有
个数 ,如果选择了 ,那么剩下 四个数;如果选择了 ,那么剩下 四
个数,还可以选择 …;选择 …;选择 …。
代码实现起来很简单,我们标记每个数的是否被选择了。我们用 表示选出来的数的和, 表示选
出来的数的个数。

int n, k, sum, ans = 0, a[110];
bool xuan[110]; // 标记是否选择了
void dfs(int s, int cnt) {
if (cnt == k) {
if (s == sum) {
ans++;
}
}
for (int i = 0; i < n; i++) {
if (!xuan[i]) {
xuan[i] = true;
dfs(s + a[i], cnt + 1);
xuan[i] = false;
}
}
}

【实践操作】K 个数的和(二)

#include <iostream>
using namespace std;
int n, k, sum, ans;
int a[40];
int main() {
// 输入数据
cin >> n >> k >> sum;
for (int i = 0; i < n; i++) {
cin >> a[i];
}
return 0;
}

这一节课我们用另外的搜索策略来解决从 个数中选 个数的和为 这个问题。
初始的输入都已经写好了。我们先写下 dfs 的框架,dfs 需要传入两个参数,一个 s 表示选取了几个
数,一个 cnt 表示选取的数的和值。
在 main 函数上方写下
i 5
1, 2, 3, 4, 5 1 2, 3, 4, 5 2 1, 3, 4, 5
3 4 5
s cnt

n k sum

void dfs(int s, int cnt) {
}

如果选择的数的个数正好等于 并且了,并且 也正好等于 ,那么说明我们找到了一种合法的
方案。
在 dfs 函数里面写下

if (s == sum && cnt == k) {
ans++;
}

每次搜索,我们枚举剩下的数,然后尝试从剩下的数中选取一个,对于每个数我们都要尝试一下。
在 dfs 函数里面接着写到

for (int i = 0; i < n; i++) {
}

我们用一个数组 xuan[40] 来标记每个数是否已经选择了,现在 int a[40] 这句话下面写下
bool xuan[40];
如果一个数 a[i] 没有被选择,也就是如果 xuan[i] == false ,我们就可以尝试选择这个数。
在 for 循环里面写下
if (!xuan[i]) {
}
在选择这个数之前,我们先标记这个数已经选择了,等对这个数的处理完成以后,我们再取消标记。
这也是回溯。
在 if 判断里面写下
xuan[i] = 1;
dfs(s + a[i], cnt + 1);
xuan[i] = 0;
最后,我们调用 dfs 函数来求解,别忘了初始化 ans = 0 哦。
在 main 函数里面写下
k s sum

ans = 0;
dfs(0, 0);
cout << ans << endl;

这一节已经完成,点击运行,然后输入数据
5 3 9
1 2 3 4 5
你会发现输出答案并不是之前的 而是 ,这是为什么呢?代码写错了吗?
这是因为这样搜索,最后方案实际上乘上了一个 。比如一个组合 、这样的方法,会把
、 、 、 、 、 当成不同的方法,而之前的搜索方法只会搜到
。后面我们会讲到如何优化。
【例题1】等边三角形
蒜头君手上有一些小木棍,它们长短不一,蒜头君想用这些木棍拼出一个等边三角形,并且每根木棍
都要用到。 例如,蒜头君手上有长度为 , , , 的 根木棍,他可以让长度为 , 的木棍组成
一条边,另外 跟分别组成 条边,拼成一个边长为 的等边三角形。蒜头君希望你提前告诉他能不
能拼出来,免得白费功夫。
输入格式
首先输入一个整数 ,表示木棍数量,接下来输入 根木棍的长度

输出格式
如果蒜头君能拼出等边三角形,输出 “yes” ,否则输出 “no” 。
样例输入1
5
1 2 3 4 5
样例输出1
yes
样例输入2
4
1 1 1 1
样例输出2
2 12
k! 2, 3, 5
2, 3, 5 2, 5, 3 3, 2, 5 3, 5, 2 5, 3, 2 5, 2, 3
2, 3, 5
1 2 3 3 4 1 2
2 2 3
n(3 ≤ n ≤ 10) n
pi(1 ≤ pi ≤ 10000)

no

6.4. N 皇后问题

皇后问题是一个经典的问题,在一个 的棋盘上放置 个皇后,每行刚好放置一个并使其
不能互相攻击(同一行、同一列、同一斜线上的皇后都会自动攻击)。
在这里插入图片描述
上图就是一个合法的 皇后的解。 皇后问题是指:计算一共有多少种合法的方法放置 个皇后。
很显然,我们依然会用 dfs 来求解 皇后问题,我们的搜索策略如下。
从第 行开始,我们依次给每一行放置一个皇后,对于一个确定的行,我们只需要去枚举放置的列即
可,在保证放置合法的情况下,一直搜索下去。
下图是 时搜索树的局部形态(这个搜索树是按照逐列放置的):
在这里插入图片描述

上图中每个状态下的 表示的是该状态的冲突次数(在多少列或斜线发生冲突),只有
当冲突次数为 时才是合法状态。
确定了搜索策略,现在我们还有一个问题没有解决,那么就是如何判断是否发生了冲突。因为我们是
逐行放置的,所以行内是不会冲突的,对于列也很好处理,如果某列被暂用了,只需要用一个数组标
记即可。
而最难处理的是同一斜线了。一种方法是我们可以直接暴力枚举这个皇后对应的两条对角线上的点,
判断是否有冲突。但是这种方法,既然蛮烦,效率也比较低。下面我们介绍一种巧妙的方法。
−2, −1, 0
0

有一个规律,这条对角线上的位置的行加列的值相同,都是 。
而这样的对角线上的位置的坐标的行减去列的值也是相同的,都是 。
而我们发现,对于每条对角线,和值或者差值都是不一样的,而我们正好可以用这一点来标记一条对
角线是否被占用,使得标记对角线就像标记列一样简单了。
【实践操作】求解八皇后问题
今天我们来学习一下搜索经典问题中的八皇后问题。
八皇后问题,是一个古老而著名的问题,是搜索算法的典型案例。该问题是国际西洋棋棋手马克斯ꞏ贝
瑟尔于 年提出:在 格的国际象棋上摆放八个皇后,使其不能互相攻击,即任意两个皇后
都不能处于同一行、同一列或同一斜线上,问有多少种摆法。
我想大家对于搜索已经有所了解了,看到这个问题已经有了一定的思路。这个问题很简单,就是对于
每一行的八个位置一个一个进行尝试,如果可以放下,就填下一行。直至填到最后一行就是一个可行
的方案。
在这里插入图片描述
4
0 + 4 = 1 + 3 = 2 + 2 = 3 + 1 = 4 + 0 = 4
在这里插入图片描述
−2
1848 8 × 8

首先我们来构造一个简单的 dfs 函数,里面传入了一个参数表示对第 行进行尝试。
在 main 函数上面写下

void dfs(int r) {
}

这一步我们先把需要用的标记数组定义好,我们定义三个数组。
在 int ans = 0 下面写下

bool col[10], x1[20], x2[20];

分别用来标记列和两条对角线
然后我们就可以对第 行的每一列的进行一一尝试。在这里我们需要开一个数组,记录我们把第 行
的皇后放到了第几列。
首先在 dfs 函数中写下

for (int i = 0; i < 8; i++) {
}

接下来我们就可以判断第 行第 列的位置是否可以放下这个皇后。如果可以我们就可填写下一行,
否则就换一列继续填写。函数 check 用来判断是否能放置。
而这里有一个重要的过程,首先是标记列 col[i] = true ,然后标记第一条对角线 x1[r + i] = true ,
然后标记第二条对角线 x2[r ‐ i + n] = true ,这里加上 是因为 r ‐ i 可能为负数,我们加上一个
偏移量对于标记的结果没有影响。
注意回溯的时候需要取消标记哦。
在 for 循环里面写下

if (check(r, i)) {
col[i] = x1[r + i] = x2[r ‐ i + 8] = true;
dfs(r + 1);
col[i] = x1[r + i] = x2[r ‐ i + 8] = false;
}

这个时候我们实现 check 函数。 check 很简单,只需要对应的列和两条对角线没有被占用就可以放置。
在 dfs 函数上面写下
r
r r
r i
8

bool check(int r, int i) {
return !col[i] && !x1[r + i] && !x2[r ‐ i + 8];
}

这个时候我们的八皇后代码就基本写好了,但是总觉得少了点什么呢?
对了就是递归出口!这个也是我想向大家强调的,写递归的时候一定要注意递归出口,也就是边界条
件。
递归出口:如果已经填完前 行的话,那么就代表我们已经填写好了八皇后的一种可行方案。就可以
给总方案书加一了。
在 dfs 开头面写

if (r == 8) {
ans++;
return;
}

我们在 main 中调用可以了。
在 main 函数开头写下

dfs(0);
cout << ans << endl;

现在你可运行你的程序了,可以看看八皇后问题到底有多少种解法。
6.5. 搜索剪枝
我们前面已经学习到搜索过程最后会生成一颗搜索树。
剪枝,顾名思义,就是通过一些判断,砍掉搜索树上不必要的子树。有时候,我们会发现某个结点对
应的子树的状态都不是我们要的结果,那么我们其实没必要对这个分支进行搜索,砍掉这个子树,就
是剪枝。

6.5.1. 可行性剪枝

8
在这里插入图片描述
如上图,对于求 个数的和,当 的时候,如果已经选了 个数,再往后选多的数是没有意义
的。所以我们可以直接减去这个搜索分支,对应上图中的剪刀减去的那个子树。
又比如,如果所有的数都是正数,如果一旦发现当前的和值都已经大于 了,那么之后不管怎么
选和值都不可能回到 了,我们也可以直接终止这个分支的搜索。
我们在搜索过程中,一旦发现如果某些状态无论如何都不能找到最终的解,就可以将其“剪枝”了。
6.5.2. 最优性剪枝
对于求最优解的一类问题,通常可以用最优性剪枝,比如在求解迷宫最短路的时候,如果发现当前的
步数已经超过了当前最优解,那从当前状态开始的搜索都是多余的,因为这样搜索下去永远都搜不到
更优的解。通过这样的剪枝,可以省去大量冗余的计算。
此外,在搜索是否有可行解的过程中,一旦找到了一组可行解,后面所有的搜索都不必再进行了,这
算是最优性剪枝的一个特例。
6.5.3. 重复性剪枝
对于某一些特定的搜索方式,一个方案可能会被搜索很多次,这样是没必要的。比如对于求解 个数
的的第二种搜索方法, 这个选取方法能被搜索到 次,这是没必要的,因为我们只关注选出来
的数的和,而根本不会关注选出来的数的顺序,所以这里可以用重复性剪枝。
我们规定选出来的数的位置是递增的,在搜索的时候,用一个参数来记录上一次选取的数的位置,那
么此次选择我们从这个数之后开始选取,这样最后选出来的方案就不会重复了。
当然,搜索的效率也要比我们给出的第一种算法更少,想一想为什么?
k k = 2 2
sum
sum
k

void dfs(int s, int cnt, int pos) {
...
...
for (int i = pos; i <= n; i++) {
if (!xuan[i]) {
xuan[i] = true;
dfs(s + a[i], cnt + 1, i + 1); // i + 1 表示从上一次选取的位置后面开始选
xuan[i] = false;
}
}
}

如果在用 dfs 解决问题的时候遇到超时错误,就需要开动你的大脑,尝试对搜索过程进行各种剪枝
啦。
【实践操作】可行性剪枝

#include <iostream>
using namespace std;
int n, k, sum, ans;
int a[40];
void dfs(int i, int cnt, int s) {
if (i == n) {
if (cnt == k && s == sum) {
ans++;
}
return;
}
dfs(i + 1, cnt, s);
dfs(i + 1, cnt + 1, s + a[i]);
}
int main() {
n = 30;
k = 8;
sum = 200;
for (int i = 0; i < 30; i++) {
a[i] = i + 1;
}
ans = 0;
dfs(0, 0, 0);
cout << ans << endl;
return 0;
}

现在你看的的代码框的代码就是我们之前写的第一种搜索方法,对于每个数一一枚举选和不选。不过
里面的数据不再是输入的,而是有一组特殊的数据。

从 这 个数中选 个数出来,使得和值为 。
你可以先尝试直接点击 运行,你会发现这个程序运行的时间可能比你预期的时间要长很多。
这一步我们尝试加一下可行性剪枝。首先是如果 cnt > k 了,那么肯定不是我们需要的答案了。
在 dfs 函数的开头写下
if (cnt > k) {
return;
}
这时候再点击 运行,加上一个剪枝以后,程序运行的时间明显快了很多。
我们还可以加上一个剪枝,因为所有的数都是正整,如果 s > sum 的话,后面的搜索也是不可能的。
所以,在刚才加上的剪枝后面继续写到
if (s > sum) {
return;
}
再次点击 运行,你会发现现在的答案是秒出的。
【实践操作】重复性剪枝
0⋯30 30 8 200

#include <iostream>
using namespace std;
int n, k, sum, ans;
int a[40];
bool xuan[40];
void dfs(int s, int cnt) {
if (s == sum && cnt == k) {
ans++;
}
for (int i = 0; i < n; i++) {
if (!xuan[i]) {
xuan[i] = 1;
dfs(s + a[i], cnt + 1);
xuan[i] = 0;
}
}
}
int main() {
n = 30;
k = 8;
sum = 200;
for (int i = 0; i < 30; i++) {
a[i] = i + 1;
}
ans = 0;
dfs(0, 0);
cout << ans << endl;
return 0;
}

现在你看的的代码框的代码就是我们之前写的第二种搜索方法,每次从剩下的数中选取一个数。
从 这 个数中选 个数出来,使得和值为 。
你可以先尝试直接点击 运行,你会发现这次这个程序根本不能运行得出结果。
这一步,我们先为这个搜索加上可行性剪枝。
在 dfs 函数开头写下

if (s > sum || cnt > k) {
return;
}

这一步我们加入最优性剪枝叶,首先我们需要更改 dfs 函数传入的参数,多传入一个参数 pos ,
把 dfs 更改成为如下

void dfs(int s, int cnt, int pos) {
// 里面的内容不需要变动
}

现在从剩下的数中选取一个数的时候, i 就不需要从 开始了,我们可以直接从 pos 位置开始。
如果选择了数 ,那么下一次搜索就要从 开始了。
把枚举选取哪个数的部分改成如下,其他部分不要有改动

for (int i = pos; i < n; i++) {
if (!xuan[i]) {
xuan[i] = 1;
dfs(s + a[i], cnt + 1, i + 1);
xuan[i] = 0;
}
}

最后,因为添加了一个参数,我们需要更改调用 dfs 的方式,多传入一个参数。
把 main 函数里面的 dfs(0, 0) 更改为 dfs(0, 0, 0) 。
再次点击 运行,你会发现现在的答案是秒出的。
【例题2】迷宫最短路(最优性剪枝)
这一节需要你完全自己动手实现一个迷宫上的最短路问题,这个应该很简单,毕竟前面已经学习过。
重要的是,想过通过本题,你还需要自己想办法加上最优性剪枝。
保证迷宫至少存在一种可行的路径。
输入格式
输入一行两个整数 ,分别表示迷宫的行数和列数。
接下来输入 行,每行一个长度为 的字符串。
其中字符 ‘S’ 表示起点,字符 ‘T’ 表示终点,字符 ‘*’ 表示墙壁,字符 ‘.’ 表示平地。你需要
从 ‘S’ 出发走到 ‘T’ ,每次只能向上下左右相邻的位置移动,并且不能走出地图,也不能走进墙壁。
输出格式
输出 ‘S’ 走到 ‘T’ 的最少步数。
0
i i + 1
n, m(1 ≤ n, m ≤ 10)
n m

样例输入
3 4
S**.

***T
样例输出
5
【附】课后习题提示
如果你发现课后习题不知道如何下手的时候,可以回头看看这里。

找数字

用 dfs 求解,一个技巧,在一个数字 后面加一位 ,那么新的数为 ,比如,数
后面加上 ,新的数为 。

全排列

用第二种搜索方法,从剩下的数中选一个数。

数独

类似于类似八皇后问题,对每个位置搜索填什么数,标记方法为标记某行某个数字是否出现,标记某
列某个数字是否出现,标记某个小方格某个数字是否出现。
对于方格 ,起对应的小方格为编号为 。

a b a = 10 ∗ a + b 12
3 10 ∗ 12 + 3 = 123
(x, y)
3∗ 3 +x
3
y
在这里插入图片描述
正方形
对于正方形的每一条边,我们能事先计算出长度。一条边一条边的进行搜索,当搜索到一条边满足长
度要求的时候,重新从剩下的木棍中再搜索出一条边,直到搜索出四条边。像三角形那样同时搜索
条边会超时的。记得需要用重复性剪枝。
另外可以提前判断一下,如果所有木棍的和不能被 整除,那么肯定不可能。
金字塔数独
如果写了数独,那么金字塔数独只需要在数独的的基础上多记录一个分数而已。
4
4

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值