郑州大学2024年寒假训练 Day4:DFS & 图DFS (A-J)

爆搜!让人又爱又恨的爆搜它来了!爆搜和大模拟是敲起来最掉头发的题了吧。

DFS(Deep First Search)也就是深度优先搜索,它是一种搜索方式。不过爆搜题基本上都是使用DFS的方式来搜索的,所以DFS基本上就被打上了爆搜的标签。图DFS顾名思义就是在图上进行深度优先搜索。

和BFS(Breath First Search)也就是广度优先搜索的不同之处在于,BFS在面对一个状态或者说局面的时候,是先把它所有的下一个状态都列出来,然后再按顺序一个一个看,一层一层搜索。DFS是随便找一个直接向下走,直到走到边界才回来。所以BFS可以找到深度最浅的边界(也就是最短路),而DFS则是一个一个寻找。在边界普遍很深的时候,BFS必须把所有情况都搜索到,还需要存储每一个搜索路径,而DFS可以通过提前判断这个搜索路径是否合理从而提前跳出不合理的搜索路径,同时只需要存储一个搜索路径即可。

由于DFS是一个一个来到达边界的,到达边界时它的搜索路径就是一个问题可行的解。因为大部分搜索都只找到一个可行的或者最优的就行了,所以搜索策略也很有讲究,好的搜索策略(或者说 剪枝策略)能更快找到可行解。其实人解决问题时大多数时候也相当于深度优先搜索,而且是有着相当优秀剪枝策略的深度优先搜索。比如做题时你觉得好像做过,就会模仿上次的做法,化简式子时优先向着一些公式的方向化简等等。

剪枝方法一般有以下几种:

  1. 优化搜索顺序
  2. 排除等效冗余
  3. 可行性剪枝
  4. 最优性剪枝
  5. 记忆化搜索

除开这些剪枝方式,还有一些高明的搜索方法,比如迭代加深搜索(先约定最大搜索深度,然后对这个深度以内进行搜索),启发式搜索(优先选择更有可能到达可行或最优解的方向搜索),双向DFS(从搜索的起点和终点分别搜索,有点像我们做数学题时分别从条件正推和从答案逆推,只要中间有交集就可以证明了)等等。

剪枝爆搜有很多经典题目,剪一天,剪到哭,比如:拼木棍生日蛋糕16*16数独八皇后


A

洛谷 P1036 [NOIP2002 普及组] 选数

思路:

由于每个数都给出了编号,第 i i i 个位置上是 x i x_i xi,所以显然对一对相同的数,位置不同的组合也算不同的。而两个组合中编号对应相同的话就是相同的组合了。

由于题目是要统计,所以一定需要把所有可能的情况都搜索到,所以用不到剪枝策略。不过为了防止重复搜索相同情况,需要排除等效冗余,具体来说,我们从前向后枚举每个位置,不走回头路,这样选一遍下来,选中位置的编号是递增的,这样就保证了不会搜索到相同的组合了。

不过搜索到达边界后需要判断素数,直接暴力开方枚举因数的话时间复杂度大概是 O ( C n k ∗ t ) O(C_n^k*\sqrt{t}) O(Cnkt ) C n k C_n^k Cnk 最坏情况下是 3 ∗ 1 0 7 3*10^7 3107 的, t \sqrt t t 最坏情况下是 1 0 4 10^4 104,估计会超时。所以需要用素数筛预先筛出 1 0 8 10^8 108 以内的所有素数,之后根据标记数组判断一个数是不是素数就行了。

code:

(保险,prime数组开的 1 0 7 10^7 107 大小, n n n 以内素数的个数大概有 n l n   n \dfrac n {ln\, n} lnnn 个)

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=1e7+5;

bool vis[maxn*10];
int prime[maxn],cnt;
void Eular(int n){
	vis[1]=true;
	for(int i=2;i<=n;i++){
		if(!vis[i])prime[++cnt]=i;
		for(int j=1,p=prime[1];i*p<=n && j<=cnt;p=prime[++j]){
			vis[i*p]=true;
			if(i%p==0)break;
		}
	}
}

int n,k,a[maxn],ans;
void dfs(int x,int num,int tot){
	if(num==k){
		if(!vis[tot])ans++;
		return;
	}
	if(x>n)return;
	
	dfs(x+1,num+1,tot+a[x]);
	dfs(x+1,num,tot);
}

int main(){
	Eular(1e8);
	cin>>n>>k;
	for(int i=1;i<=n;i++)
		cin>>a[i];
	dfs(1,0,0);
	cout<<ans;
	return 0;
}

B

P1219 [USACO1.5] 八皇后 Checker Challenge

思路:

数据量不够大,所以这个八皇后不用剪枝写起来还好。

一个比较明显的搜索策略是按行从小到大尝试放入皇后,放入皇后时判断能否放入,能放入就尝试向下搜索,如果这一行都不能放入皇后,那就返回上一行重新放。由于n很小,只有13,所以应该是不用剪枝(搜索的时间复杂度很难计算,剪枝之后更是成谜)。

为了加快判断一个位置能否放入皇后,可以设置列标记数组,和两个对角线标记数组,如果一个位置放入了皇后,就标记对应的列和两个对角线。在判断一个位置时,就可以方便地查询这个位置是否在列,对角线上有其他皇后了。

由于标记数组开的空间比较大,所以一般不放在dfs内进行传递,而是放在dfs外,进入下一个状态前将它修改为下一个状态的局面,返回后将局面还原回来。

因为要打印最终局面,设置a数组对状态路径进行记录。

其实上面按行从小到大来找就是通过规定搜索顺序从而排除了等效冗余,一种局面只会搜到一次。

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=30;

int n;
bool col[maxn],dia1[maxn],dia2[maxn];
int a[maxn],cnt;
void dfs(int line){//尝试第line行 
	if(line>n){
		++cnt;
		if(cnt<=3){
			for(int i=1;i<=n;i++)
				printf("%d ",a[i]);
			puts("");
		}
		return;
	}
	
	for(int i=1;i<=n;i++){
		if(col[i] || dia1[line+i] || dia2[n+line-i])continue;
		col[i]=dia1[line+i]=dia2[n+line-i]=true;
		a[line]=i;
		dfs(line+1);
		col[i]=dia1[line+i]=dia2[n+line-i]=false;
	}
}

int main(){
	cin>>n;
	dfs(1);
	printf("%d",cnt);
	return 0;
}

C

洛谷 P1008 [NOIP1998 普及组] 三连击

思路:

可以一个数位一个数位搜索,一共九个位置,前三个是第一个数,中间三个是第二个数,最后三个是第三个数,拿过的数就标记一下,到达边界后判断这种情况合不合法。

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=15;

int n;

int a[maxn];
bool vis[maxn];
bool check(){//检查局面是否成立 
	int x=a[1]*100+a[2]*10+a[3],y=a[4]*100+a[5]*10+a[6],z=a[7]*100+a[8]*10+a[9];
	return (x*3==z*1 && x*2==y*1);
}
void print(){
	for(int i=1;i<=n;i++){
		printf("%d",a[i]);
		if(i%3==0)printf(" ");
	}
	puts("");
}
void dfs(int x){//尝试放第x个位置 
	if(x>n){
		if(check())print();
		return;
	}
	
	for(int i=1;i<=n;i++){
		if(!vis[i]){
			a[x]=i;
			vis[i]=true;
			dfs(x+1);
			vis[i]=false;
		}
	}
}

int main(){
	n=9;
	dfs(1);
	return 0;
} 

D

洛谷 P1706 全排列问题

思路:

按字典序输出全排列。搜索的时候注意一下顺序,每层先从最小的没有用过的数开始搜。

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=15;

int n;

int a[maxn];
bool vis[maxn];
void dfs(int x){//尝试放第x个位置 
	if(x>n){
		for(int i=1;i<=n;i++)
			printf("%5d",a[i]);
		puts("");
		return;
	}
	
	for(int i=1;i<=n;i++){
		if(!vis[i]){
			a[x]=i;
			vis[i]=true;
			dfs(x+1);
			vis[i]=false;
		}
	}
}

int main(){
	cin>>n;
	dfs(1);
	return 0;
} 

E

洛谷 P1618 三连击(升级版)

思路:

和C题思路一样,只不过判断条件要修改一下即可。

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=15;

int n,A,B,C;

int a[maxn];
bool vis[maxn],flag;
bool check(){//检查局面是否成立 
	int x=a[1]*100+a[2]*10+a[3],y=a[4]*100+a[5]*10+a[6],z=a[7]*100+a[8]*10+a[9];
	return (x*C==z*A && x*B==y*A);
}
void print(){
	for(int i=1;i<=n;i++){
		printf("%d",a[i]);
		if(i%3==0)printf(" ");
	}
	puts("");
}
void dfs(int x){//尝试放第x个位置 
	if(x>n){
		if(check()){
			print();
			flag=true;
		}
		return;
	}
	
	for(int i=1;i<=n;i++){
		if(!vis[i]){
			a[x]=i;
			vis[i]=true;
			dfs(x+1);
			vis[i]=false;
		}
	}
}

int main(){
	n=9;
	cin>>A>>B>>C;
	dfs(1);
	if(!flag)printf("No!!!");
	return 0;
} 

F

洛谷 P1120 小木棍

思路:

拼木棍!260ms加上n=65的极限数据造就了这么一个剪枝大题。需要考虑到很多剪枝策略才能把时间降到260ms以内。

在写剪枝之前最好先把普通的爆搜版本写出来,不然爆搜和一些剪枝策略同时写很有可能写出bug,然后调到怀疑人生。

因为拼出来的木棍需要用上所有的小木棍,所以拼出来的总长度就是小木棍长度之和 t o t tot tot,至少可以拼出一个长木棍。因为拼出来的每个木棍的长度是一样的,所以拼出来的木棍的长度应该是总长度的约数,而要找最小长度,可以先把所有所有约数处理出来并排序然后从小到大枚举并检查是否可行。

普通的爆搜策略比较好想,假如现在要求每个木棍的长度为 l e n len len,有两种搜索方法,一种是枚举小木棒,然后尝试拼到第 x x x 个木棒上,一种是枚举木棒,尝试拼入第 x x x 个小木棒。由于后者容易考虑到更多重复情况,不好优化,所以选择前者来搜索。

假设现在在拼第 x x x 个木棒,剩下 r e s t rest rest 长度要拼,那么枚举可行的没有被选上的木棒拼入,向下搜索。直到搜索到要拼第 t o t / l e n + 1 tot/len+1 tot/len+1 根木棍说明已经拼好了 t o t / l e n tot/len tot/len 根木棍,这时说明这个长度有解。直接输出并退出程序。所以一个简单的爆搜就写好了。

#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn=105;
const int maxm=5005;

int n,a[maxn],tot;
int Length[maxm],counter;

int len;
bool vis[maxn];
void add(int x,int rest){//尝试拼第x根木棍 一根木棍长为len 
	if(!rest){
		add(x+1,len);
		return;
	}
	if(x*len>tot){
		printf("%d",len);
		exit(0);
	}
	
	
	for(int i=1;i<=n;i++){
		if(!vis[i] && a[i]<=rest){
			vis[i]=true;
			add(x,rest-a[i]);
			vis[i]=false;
		}
	}
}

int main(){
	cin>>n;
	for(int i=1;i<=n;i++)
		cin>>a[i],tot+=a[i];
	sort(a+1,a+n+1,greater<int>());
	
	for(int i=1;i*i<=tot;i++)
		if(tot%i==0){
			Length[++counter]=i;
			if(i*i!=tot)Length[++counter]=tot/i;
		}
	sort(Length+1,Length+counter+1);
	
	for(int i=1;i<=counter;i++){
		len=Length[i];//可能的长度
		add(1,len);
	}
	
	return 0;
}

不过显然会超时。尝试剪枝,想想看我们在拼木棒的时候碰到什么情况就会放弃继续向下拼木棍。

  1. 优化搜索顺序1:由于每个小木棒都要拼进去,所以不妨先拼不那么灵活的长度比较长的小木棍。如果有个木棒怎么都拼不进去,那也省的继续向后拼了。
  2. 排除等效冗余1:由于我们不在乎拼在一个木棒上的所有小木棒的大小顺序,比如拼一个长度为8的木棒,3+5和5+3是没有区别的。那么不妨规定我们拼一个木棒的小木棒长度是单调不增的。这样拼成一个木棒的一种选取方案就确定了,不会反复搜索。
  3. 排除等效冗余2:因为我们根本不在乎木棒的编号,如果一个长度的木棒拼在某个木棒上怎么都拼不上,那么就没必要尝试其他长度相同的木棒了,因为一定拼不上。
  4. 可行性剪枝1:能拼出来的木棒长度一定大于等于最长的小木棒。否则它一定拼不进去。
  5. 可行性剪枝2:已经拼好前 t o t / l e n − 1 tot/len-1 tot/len1 根木棒后,第 t o t / l e n tot/len tot/len 根木棒一定能拼起来,因为剩下的小木棒总长度就是 l e n len len,不需要继续搜索,直接返回。
  6. 可行性剪枝3:拼一个木棒时,第一个放入的小木棒一定能拼接成功。否则这个小木棒作为第一个拼入的都不可行,它在后面一定不能拼入任何一个木棒中。
  7. 可行性剪枝4:如果有一根小木棒正好长为 r e s t rest rest,直接拼入这根木棍,如果不成功则后面一定不会成功,直接返回。因为拼入rest长度要么使用这根小木棍,要么用多个更小的木棒来凑,后者灵活度更高。拼 r e s t rest rest 都不行,浪费更灵活的小木棒拼好后,后面一定也拼不起来。

其中1,2,6,7是相当强力的剪枝手段,它可以在搜索还在很浅的层次时就剪枝,从而剪掉大量的不合理情况,大大节省了时间。剪枝3策略在面对大量重复数据时也相当强力。在查找时直接顺序查找也会浪费一些时间,可以使用更快的二分查找或者指针等。

由于我们求到的第一个可行解就是最优解,所以没有最优性剪枝,硬要说的话,从小到大枚举长度也算最优性剪枝。由于不好存储状态,也不太会遇到相同状态,所以也没有记忆化搜索其实有种说法就是反向的记忆化搜索就是DP,DP就是爆搜,从边界暴力地枚举每种状态,向答案状态递推

#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn=105;
const int maxm=5005;

int n,a[maxn],tot;
int Length[maxm],counter;

int len,nxt[maxn];//一根木棍长为len 
bool vis[maxn];
void add(int x,int lst,int rest){//尝试拼第x根木棍 上一个木棍编号 剩余没拼长度 
	if(rest==0){
		add(x+1,0,len);
		return;
	}
	if(x*len==tot){//剪枝5
		printf("%d",len);
		exit(0);
	}
	//lower_bound(a+lst+1,a+n+1,rest,[](int element, int value)->bool{return element > value;})-a
	//从[lst+1,n+1)区间二分查找第一个小于等于rest的元素
	for(int i=lower_bound(a+lst+1,a+n+1,rest,[](int element, int value)->bool{return element > value;})-a 
	//剪枝2:前面的木棒已经尝试拼接过了,从lst后面直接开始 
	;i<=n;i=(vis[i])?i+1:nxt[i]){//剪枝3:同种长度不成功,从nxt开始 
		if(!vis[i]){
			vis[i]=true;
			add(x,i,rest-a[i]);
			vis[i]=false;
			if(rest==len)return;//剪枝6:a[i]作为第一根拼的木棒还会失败,之后就不可能成功 
			if(a[i]==rest)return;//剪枝7:长度正好为rest的木棍失败,之后一定不可能成功 
		}
	}
}

int main(){
	cin>>n;
	for(int i=1;i<=n;i++)
		cin>>a[i],tot+=a[i];
	sort(a+1,a+n+1,greater<int>());//剪枝1
	
	for(int i=n,lst=n+1;i>=1;i--){
		if(a[i]!=a[i+1])lst=i+1;
		nxt[i]=lst;//直接指向下一个不同长度的木棍,跳过中间相同长度的木棍
	}
	
	for(int i=1;i*i<=tot;i++)
		if(tot%i==0){
			Length[++counter]=i;
			if(i*i!=tot)Length[++counter]=tot/i;
		}
	sort(Length+1,Length+counter+1);
	
	//剪枝4:从最长的小木棍开始枚举长度
	for(int i=lower_bound(Length+1,Length+counter+1,a[1])-Length;i<=counter;i++){
		len=Length[i];//可能的长度
		add(1,0,len);
	}
	
	return 0;
}

G

题意:

N N N 个点和 N − 1 N-1 N1 条边,它们构成一颗树。从树根 1 1 1 开始DFS,优先向编号小的节点遍历,问DFS经过的路径。

思路:

因为要优先向编号小的节点遍历,而且就 N − 1 N-1 N1 条边,是稀疏图,直接用邻接表存图,读入结束后排一下序,之后dfs即可。

code:

#include <iostream>
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int maxn=2e5+5;

int n;

vector<int> g[maxn];
void add(int u,int v){
	g[u].push_back(v);
	g[v].push_back(u);
}

void dfs(int u,int rt){
	printf("%d ",u);
	for(auto v:g[u]){
		if(v==rt)continue;
		dfs(v,u);
		printf("%d ",u);
	}
	return;
}

int main(){
	cin>>n;
	for(int i=1,u,v;i<n;i++){
		cin>>u>>v;
		add(u,v);
	}
	for(int i=1;i<=n;i++)
		sort(g[i].begin(),g[i].end());
	
	dfs(1,-1);
	
	return 0;
}

H

洛谷 P1604 B进制星球

思路:

不知道这个题怎么和dfs沾上关系,就是一个B进制的加法器。某一位要相加的话,有三个来源的数,两个操作数这一位上的数和前一位运算的进位,加起来后如果超出B就进位,余数放在结果的这一位上,从低位到高位计算一遍即可。

处理的时候因为写法上是从高位到低位书写,所以用reverse函数把两个操作数翻转一下,结果算出来之后再翻转回来就好了,存储从低位到高位也方便进位。高精也可以这么写。

可以使用map把 0 ~ 35 与字符的 0 ~ 9 和 A ~ Z 建立映射,方便数字和字符的转化。

code:

#include <iostream>
#include <cstdio>
#include <cstring>
#include <map>
#include <algorithm>
using namespace std;
const int maxn=2005;

int n;
string a,b,c;
map<char,int> m1;
map<int,char> m2;

int main(){
	for(int i=0;i<=9;i++){
		m1['0'+i]=i;
		m2[i]='0'+i;
	}
	for(int i=0;i<26;i++){
		m1['A'+i]=10+i;
		m2[10+i]='A'+i;
	}
	
	cin>>n>>a>>b;
	if(a.length()<b.length())swap(a,b);
	reverse(a.begin(),a.end());
	reverse(b.begin(),b.end());
	int lst=0;//上一位进位 
	for(int i=0,t;i<a.length();i++){
		t=m1[a[i]]+((i<b.length())?m1[b[i]]:0)+lst;
		lst=t/n;
		t%=n;
		c+=m2[t];
	}
	if(lst)c+=m2[lst];
	reverse(c.begin(),c.end());
	cout<<c;
	return 0;
}

I

洛谷 P2661 [NOIP2015 提高组] 信息传递

思路:

一个人接收到了自己的生日信息其实相当于从他的位置出发走一圈回到了自己的位置,这就构成了一个环,同时对环上的所有人都是如此,进行的轮数就是环长。这个题其实说白了就是求最小环长。

考虑图上dfs,如果一次dfs时,走到了这次dfs自己走过的点,那么说明就有环,记录一下这个环长。直到把所有点都dfs一下,不过这样肯定会超时,考虑优化。

因为如果一个点我们之前已经dfs过了的话,它的“下游”肯定已经dfs过了,我们就没必要再向下dfs了,直接返回。不过碰到之前dfs经过的点不代表会构成环,只有一次dfs时碰到经过的点会构成环。因此对每个节点进行“染色”,一次dfs时分配一个新的颜色,如果碰到了相同颜色的点说明碰到了本次dfs经过的点,否则就是之前经过的点。

为了计算环长,设置一个时间戳数组idx,记录这个点是本次dfs第几个访问到的点,环长就是两个时间戳相减+1。

code:

#include <iostream>
#include <cstdio>
using namespace std;
const int maxn=2e5+5;

int n,nxt[maxn];
int vis[maxn],idx[maxn],c;

int ans=1e9;
void dfs(int u,int num,int color){
	if(vis[nxt[u]]){
		if(vis[nxt[u]]==color)
			ans=min(ans,num-idx[nxt[u]]+1);
		return;
	}
	else {
		vis[u]=color;
		idx[u]=num;
		dfs(nxt[u],num+1,color);
		return;
	}
}

int main(){
	cin>>n;
	for(int i=1;i<=n;i++)
		cin>>nxt[i];
	
	for(int i=1;i<=n;i++)
		if(!vis[i])
			dfs(i,1,++c);
	
	cout<<ans;
	return 0;
}

J

思路:

不知道这个和dfs有啥关系,就是暴力枚举。

四位数总共就8999个,而每次条件只有100个,就算一个一个枚举四位数然后判断是否符合条件都不会超时,所以直接暴力枚举就行了。

code:

#include <iostream>
#include <cstdio>
#include <map>
#include <cmath>
using namespace std;
const int maxn=105; 

int n;
struct message{
	int x;
	pair<int,int> m;//猜对几个 几个正确位置
}a[maxn];
int cnt;

pair<int,int> comp(int x,int y){
	int a=0,b=0;
	map<int,int> s1,s2;
	while(x && y){
		if(x%10==y%10)b++;
		s1[x%10]++;
		x/=10;
		s2[y%10]++;
		y/=10;
	}
	for(auto t:s1)
		if(s2.find(t.first)!=s2.end())
			a+=min(s1[t.first],s2[t.first]);
	
	return make_pair(a,b);
}

int main(){
	while(cin>>n,n){
		cnt=0;
		for(int i=1,x,y,z;i<=n;i++){
			cin>>x>>y>>z;
			a[i].x=x;
			a[i].m=make_pair(y,z);
		} 
		
		int cnt=0,lst;
		for(int i=1000;i<=9999;i++){
			bool f=true;
			for(int j=1;j<=n;j++){
				auto x=comp(a[j].x,i);
				if(comp(a[j].x,i)!=a[j].m){
					f=false;
					break;
				}
			}
			if(f){
				cnt++;
				lst=i;
				if(cnt>1)break;
			}
		}
		if(cnt==1)printf("%d\n",lst);
		else printf("Not sure\n");
	}
} 
  • 11
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值