【算法】深度优先搜索(dfs)

突然发现机房里有很多人不会暴搜(dfs),所以写一篇他们能听得懂的博客(大概?)

PS:万能 yuechi ———— 大法师怎么能不会呢?!

若有错误,请 dalao 指出。

前置

我知道即使很多人都知道 dfs 是用递归来实现的,但免不了还是叨叨几句:

  • 要有边界(不然你要递归到猴年马月……)

  • 剪枝(即使是暴搜也不至于从头莽到尾)

  • 别犯 sb 错误(debug 到心累,最后发现边界写错了 = =)

大概流程

严格来说,dfs 其实也是有一套固定的流程,毕竟

万物皆可板(bushi)

  1. 定义现在的状态(即搜索到了哪一个位置)

  2. 枚举可能的情况(如一个数可能是 \([0,9]\)

  3. 标记枚举到的情况已被用了(如一个数已经是偶数了,那下一个数就不能是偶数(这个视情况而定))

  4. 判断有无到达边界(如果到达就输出,没到就继续搜(用递归))

  5. 回溯(难点,下面举例来讲)

放几个例题来讲解一下

例题一

很多算法都是建立在 dfs 上的,先放一个裸题。

题目描述

一个的 \(n \times n\) 的跳棋棋盘,有 \(n\) 个棋子被放置在棋盘上,使得每行、每列有且只有一个,每条对角线(包括两条主对角线的所有平行线)上至多有一个棋子。

数据范围:\(n \in [6, 13]\)

分析

八皇后的题目我相信大家也不陌生,积护所有 dfs 入门的人都做过,但我还是来分析一下吧。

看过数据范围,就能确认眼神:一道 dfs 能做的题。

首先,很容易就能知道: \(n\) 个棋子一定是在不同行,不同列的,这是可以构成限制的。

要求每条对角线上只能有一个棋子,这不仅是限制,也是该题的难点所在,如果要优化可以从这里入手。

既然是搜(暴搜),那么就可以从第一行开始,到达最后一行结束(边界)。

代码实现

从第一行开始枚举行数,同时也枚举列数,并且记录下棋子放下的位置导致出现的限制。

变量 意义
a 存储答案
b1 判断一个位置是否能放棋子
b2 判断这个数有无被用(貌似没用)
t 搜到的当前的行数
函数 意义
fread 快读
bj 标记位置不能用
hy 标记位置能用
print 搜完输出答案
search dfs
/**
*
author:Eiffel_A
*/
#include <iostream>
#include <iomanip>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <map>
#include <queue>
#define MAXN 100001
#define Mod 998244353
//-------------定义变量-------------
int n, s = 0;
int a[14], b1[14][14], b2[14];
//------------定义结构体------------

//-------------定义函数-------------
int fread() {
	int x = 0, f = 0; char ch = getchar();
	while (!isdigit(ch)) f |= (ch == '-'), ch = getchar();
	while (isdigit(ch)) x = x * 10 + (ch ^ 48), ch = getchar();
	return f ? -x : x;
}
void bj(int x, int y) { // 一个棋子放下后将对角线标记为不可用
	for (int j = 1; j <= n; ++j) {
		if (x + j >= 1 && x + j <= n && y + j >= 1 && y + j <= n && b1[x + j][y + j] == 0)
			b1[x + j][y + j] = x;
		if (x + j >= 1 && x + j <= n && y - j >= 1 && y - j <= n && b1[x + j][y - j] == 0)
			b1[x + j][y - j] = x;
	}
}
void hy(int x, int y) { // 将棋子回溯到未放下时将对角线标记为可用
	for (int j = 1; j <= n; ++j) {
		if (x + j >= 1 && x + j <= n && y + j >= 1 && y + j <= n && b1[x + j][y + j] == x)
			b1[x + j][y + j] = 0;
		if (x + j >= 1 && x + j <= n && y - j >= 1 && y - j <= n && b1[x + j][y - j] == x)
			b1[x + j][y - j] = 0;
	}
}
void print() { // 到达边界后输出
	s++;
	if (s <= 3) {
		for (int i = 1; i <= n; ++i)
			printf("%d ",a[i]);
		printf("\n");
	}
}
int search(int t) { // dfs
	for (int i = 1; i <= n; ++i) // 枚举列数
		if (!b2[i] && !b1[t][i]) { // 如果这一列还没有棋子且不在任何一条已放棋子的对角线上
			a[t] = i; b2[i] = t; // 记录棋子位置,标记这列已用
			bj(t, i); // 标记对角线已用
			if (t == n) print(); // 如果到了最后一行,就输出
			else search(t + 1); // 否则继续搜下一行
			b2[i] = 0; // 回溯,这列还没用
			hy(t, i); // 回溯,这个对角线还没用
		}
}
//--------------主函数--------------
int main() {
	n = fread();
	search(1);
	printf("%d", s);
	return 0;
}

请不要在意我难看的马蜂和奇怪的变量名……

解释回溯

让我们想象一下:

当判断是否搜到边界时,

如果到了,则输出,然后回到 dfs 函数里;

但这时候仅仅只是找到了一种可行的摆放方法,还有许多方法还没开始搜,

所以我们要假装这个位置没有放过棋子,即退回放这个棋子之前,这样才能将这一列空出来,以便在其他行在这一列放棋子,找到更多的情况。

若没到边界,则又会进入新的一行,一直到到达边界为止,剩下的就与上一种情况一致了。

如果到现在还是没懂的话,那我举个栗子:

假如你正在走迷宫:emmmm 这个(我手画的……)

你走到了终点:这样(橡皮开路)

但是你的要求是找出所有能到达终点的路,仅仅只有一条是不够的,

所以你得退回去:


(当然也可以退到其他地方)

这样你就可以找另一条道路:

所以回溯大概就是这么一个过程~~

dfs (\(t\)) 每一层 dfs 可以用变量 \(t\) 来标记,可以把 \(t\) 看做是下标(反正我这么理解)

如果 \(t == 1\) 就说明这一层 dfs 是在 \(1\) 这个点的,以此类推。

这样回溯就会很好理解啦~~

优化

这个代码是我刚刚学 dfs 时写的,只不过又被我扒了出来改了改马蜂罢了……

如果你像我这份代码这样判断一条对角线有无占用,那么当你把代码交上去后,你就会惊喜地发现:

你 T 啦~~

大概是反复调用标记和回溯函数的问题……

所以要优化的说~~

然后经过我深(cha)思(kan)熟(ti)虑(jie)后发现了一个好方法:

我们可以再开一个 \(c\) 数组和一个 \(d\) 数组,然后把 \(b1\)\(b2\) 数组去掉,改成 \(b\) 数组 。

众所周知,如果一个点的坐标是 \((x,y)\) 且独一无二,那么 \(x + y\)\(x - y + n\)\(n\) 是总行数)就是独一无二的。

这样就可以表达出对角线啦~~~

int search(int t) {
	for (int i = 1; i <= n; ++i)
		if (!b[i] && !c[t + i] && !d[t - i + n]) {
			a[t] = i; b[i] = 1;
			c[t + i] = 1; d[t - i + n] = 1;
			if (t == n) print();
			else search(t + 1);
			b[i] = 0;
			c[t + i] = 0; d[t - i + n] = 0;
		}	
}

例题二

题目描述

将整数 \(n\) 分成 \(k\) 份,且每份不能为空,任意两个方案不相同(不考虑顺序)。

例如:\(n=7\)\(k=3\),下面三种分法被认为是相同的。

\(1,1,5\)\(1,5,1\)\(5,1,1\)

问有多少种不同的分法。

数据范围:\(n\in (6,200]\)\(k\in [2,6]\)

分析

几乎与上一题一样,无非只是把条件和枚举的东西变了一下而已 = =

PS:下面的代码是错的,而且还删了几个头文件(貌似 pd 函数写错了,不过这不重要)

代码实现

#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<cstring>
//------------定义结构体------------ 

//-------------定义变量-------------
int n, k, s, v = 0, w = 0;
int a[10], b[10]; 
map<int,int> hh;
//-------------定义函数------------- 
bool pd() { // 错的
	w = 0; memcpy(b, a, sizeof(a));
	sort(b, b + 10);
	for (int j = 9; j >= 10 - k; --j)
		w *= 10, w += b[j];
	if (!hh[w]) v++, hh[w] = 1;
}
int search(int t) {
	if (t == k && s) a[t] = s, pd(); // 判断到边界后是否满足条件,若满足,则输出
	else {
		for (int i = a[t - 1]; i <= n; ++i) { // 保证下一个数大于等于上一个数,防止重复
			if (!i) continue; // 如果 i 为零,则不算入答案
			if (i < s) { // 保证各数字之和不大于 n
				a[t] = i; // 记录 i
				s -= a[t]; // 减去加数
				search(t + 1); // 继续搜
				s += a[t]; // 回溯,假装没用过这个加数
			}
		}
	}
}
//--------------主函数-------------- 
int main() {
	cin >> n >> k;
	s = n;
	search(1);
	printf("%d", v);
	return 0;
}

依旧是很久以前写的代码,被我扒拉出来改改马蜂贴了上来……

Q:为什么把错的代码放了上来?
A:是因为我 懒得改 只想让你们了解思路就行了

优化

经查实,如果你按照这个思路(即 pd 函数写对)交了上去

你会惊喜得发现:

你又 T 啦~~

这时候就又需要优化剪枝了,我们可以这样想:

  • 既然是求 \(k\) 个数,又知道这 \(k\) 个数的和,那么只需要求 \(k - 1\) 个数,最后一个数减出来就好辣。

  • 直接减出来了数,就不用判断所有数加起来是否等于 \(n\)

  • 只需要判断减出来的数是否大于之前的数(判重)。

这下正解代码就出来啦~~

int search(int t) {
	if (t == k && s >= a[t - 1]) ++v;	
	if (t != k)
		for (int i = a[t - 1]; i <= n; ++i) {
			if (!i) continue;
			if (i < s) {
				a[t] = i;
				s -= a[t];
				search(t + 1);
				s += a[t];
			}
		}
}

后言

我相信两道例题已经足够讲明白了,就不举第三个例子了 (其实只是我不想写了而已)

祝所有人 noip2020 rp++

练习题

  1. 洛谷 八皇后 Checker Challenge(例题一)

  2. 洛谷 数的划分(例题二)

  3. 一堆 慢慢刷

posted @ 2020-12-03 11:37  _Scaley  阅读(156)  评论(0编辑  收藏  举报