搜索学习笔记+杂题 (基础二 dfs/bfs的拓展)
搜索杂题:
博客中讲述的题的题单:戳我
二、dfs/bfs的各种变式
1、深搜
深搜以指数级的时间复杂度闻名,稍不注意时间就会爆炸,所以一般会用到剪枝的技巧(这个技巧基本上是因题而异,需要平时的刷题与积累)。深搜同样也是一种可变性极高的算法(其实都可以不叫做一种算法,深搜已经是一种做题的思想,很多题都可以凭借深搜的思想来解决),实现方法多种多样,本篇文章给出基础的搜索变式。
深搜题目的特点:有分支,多种情况;数据一般较小;题面一般较复杂,需要自己手推样例模拟搜索过程。
(1)记忆化搜索
其实深搜还有一个非常有用的做法——记忆化搜索,其实就是将本来为void类型的dfs转化成int类型,每一次搜索都将答案记录下来,由于深搜很多时候都会搜到同样一个情况,所以将每个答案只搜索一遍记录下答案,下一次搜索到这个位置的时候返回已经搜索过的答案就可以节约大量的时间(其实是一种时间换空间的典型做法)。
记忆化搜索在某些角度来看其实就是一种特殊的动态规划,是不过常见的动态规划常以递推的式子出现。(本篇博文其实是讲题的)
习题:
P1434 [SHOI2002] 滑雪
最最经典的记忆化搜索题目,由于我们需要找到最长的下降路径,再看范围是\(100*100\)的四连通图,点数最大有\(10000\)个,而每个点还有四个方向,要是真的直接暴力深搜时间复杂度够跑一辈子,而这道题虽然是图上问题,用广搜处理也不好处理,考虑记忆化搜索。
可以记忆化搜索必须有以下几个特点:看得出来可以暴力深搜只不过时间复杂度不高、总情况不多,在空间限制范围之内可以存得下、答案是有最优解的/答案是可以前后相加的,可以保证各个部分的最优解合起来是最后的最优解,所以后面的选择会影响前面的答案的题,记忆化搜索的不支持的。(其实就和动态规划一样)
观察这道题,暴力深搜明显是可以的,但是时间复杂度会超;设数组\(dp[x][y]\),表示从\((x,y)\)出发可以滑行的最长路径空间\(O(n^2)\)可以接受,最后一点,从起点出发走最远的路然后再加上一节已经计算出来最大值,得到的答案一定是最大值。
记忆化搜索在大多数情况还是有点板子的,理解了暴力深搜的原理,需要记住记忆化搜索的基本框架,结合题目要求做(记忆化搜索还是比较考验综合代码能力的,因为有时候的记忆方法可能还会用到位运算等技巧)。下面以这道题为例介绍一下框架。
代码:
#include<iostream>
#include<cmath>
#include<cstring>
#include<algorithm>
#include<queue>
#include<cstdio>
#define int long long
using namespace std;
inline int max(int x,int y){return x>y?x:y;}
inline int min(int x,int y){return x>y?y:x;}
const int M=105;
int n,m;
int mapp[M][M];
int vis[M][M];
int fx[5]={0,1,-1,0,0};
int fy[5]={0,0,0,1,-1};
inline int dfs(int x,int y)//类型名一定是int类型,你才能传回从某一个点出发的最优答案
{
if(vis[x][y]) return vis[x][y];//这个点已经记录了答案,那么直接返回这个答案
vis[x][y]=1;//即使自己哪都去不了,也是1的长度
for(int i=1;i<=4;i++)
{
int sx=fx[i]+x,sy=fy[i]+y;//从一个点出发向四个方向
if(sx<1||sx>n||sy<1||sy>m||mapp[sx][sy]>=mapp[x][y]) continue;//超出边界或要到的地方比目前所在的地方高
dfs(sx,sy);//搜索扩散的点
vis[x][y]=max(vis[sx][sy]+1,vis[x][y]);//由于已经搜索了(sx,sy),那么从(sx,sy)出发的答案也就统计好了,+1是因为从所在点到(sx,sy)不是还有1的路程嘛。
//搜索出(x,y)可以到达的每一个点,记录最大路程,这样就把vis[x][y]求出来了
}
return vis[x][y];//这个点已经找完了,返回这个点出发的最大答案
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
cin>>mapp[i][j];
}
}//朴素的输入
int ans=0;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
ans=max(ans,dfs(i,j));//枚举,从每一个点出发,找出最长的路程
}
}
cout<<ans<<"\n";
return 0;
}
P5635 【CSGRound1】天下第一
同样的,先分析题目,首先数据\(x,y\le10^4\),而且还是多组测试,暴力搜索明显是可以做的,依次向下递归,但直接暴力深搜肯定时间复杂度过不去(暴力深搜一般处理范围也就在25以内)。但观察题面,虽然有多组询问,但是模数一样,这就代表了对于任意的\((x,y)\)只有唯一的解与之对应,这样我们就可以将搜索出来的\((x,y)\)的解存起来了。
但是记忆化搜索的第二个要求是空间要足够大,至少可以将所有情况的答案存下,数据\(x,y<=10^4\),数组大小就是\(10000*10000*4\)字节,明显超空间了。但c++有多种变量类型,我们分析,对于任意一组\((x,y)\),最后只有可能先手赢、后手赢和平局,也就是三种情况,用\(int\)存确实奢侈了,但\(bool\)又存不下,但还有一种变量类型可以选择——\(short\),每个\(short\)占2字节,存储范围是\(-32768-32768\)。
这样,这道题就满足了记忆化搜索的要求。首先暴力深搜dfs(int x,int y),当\(x\)先为0时,\(y\)先为0时,后手获胜,都不行就是平局,每进入一个状态,先判断这个状态搜索过没有,搜索过就直接返回已经存好的答案,否则向下搜索,看谁先赢,最后将答案保存至记忆化数组中再返回。
关键代码:
定义值为vis[x][y]值为-1就是平局,1就是先手赢,2就是后手赢。
inline int dfs(int x,int y)
{
//cout<<x<<" "<<y<<"\n";
if(vis[x][y]==-1) return -1;
if(vis[x][y]) return vis[x][y];//有答案了,直接返回
vis[x][y]=-1;//先标为平局
if(!x) return vis[x][y]=1;//先手赢啦
if(!y) return vis[x][y]=2;//后手赢啦
int num=(x+y)%mod;
return vis[x][y]=dfs(num,(num%mod+y)%mod);//模拟题目中每一次的变化要求
}
P1535 [USACO08MAR] Cow Travelling S
比较经典的记忆化搜索,记忆化搜索一种是寻找最优解,还有计算方案数也是记搜的主要用途。
对于\(dp[x][y]\)就是从\((x,y)\)出发到达终点的方案数,这个方案数我们可以借助\((x,y)\)向四周扩散的四个点累加得到,类似于递推,从终点一步一步推到起点,但这道题多了一维,就是时间,因为不同的时间到达同一个点的方案肯定是不同的,所以dp数组就是\(dp[x][y][time]\)。
同时这道题还有一个剪枝的小技巧,如果当前所剩的时间小于当前所在位置到终点的曼哈顿距离,那么很明显,这种情况下,无论如何都无法在目标时间之前到达终点,直接返回方案数为0。
关键代码:
inline int dfs(int x,int y,int time)
{
if(time>t||abs(x-ex)+abs(y-ey)>t-time) return dp[x][y][time]=0;//时间超了或不可能到达了,方案数为0
if(dp[x][y][time]!=-1) return dp[x][y][time];//一开始应该将dp数组赋为-1,因为有方案数为0的情况
if(time==t)//目标时间
{
if(x==ex&&y==ey) return dp[x][y][time]=1;//在终点
else return dp[x][y][time]=0;//不在终点
}
int ans=0;//累加答案
for(int i=1;i<=4;i++)
{
int sx=x+fx[i],sy=y+fy[i];
if(sx<1||sx>n||sy<1||sy>m||mapp[sx][sy]) continue;
ans+=dfs(sx,sy,time+1);//累加向四周的点的方案数,那么得到的和就是在time这个时间,(x,y)这个点到达终点的方案数
}
return dp[x][y][time]=ans;//赋上答案,记忆化并返回答案
}
P7074 [CSP-J2020] 方格取数
这题算是记忆化搜索中较为繁琐的一道题了,按照前两道题的思路,\(1 \le n,m \le 10^3\),可以存下,而且暴力深搜是可以做的,设\(dp[x][y]\)就是从\((x,y)\)出发可以走的最远距离,但是答案之间是可以互相拼接然后累加的嘛。
好像不行,我们可以举一个很显然的列子。
3 3
1 2 3
3 2 1
4 5 3
观察这一张网格图,左下角的\(4\)的最优走法是\(4-3-1-2-3-1-2-5-3\),那么\(mapp[3][1]=24\),我们从\((1,1)\)出发,遍历到\((3,1)\),无论怎样,我们会惊奇的发现答案会超过整张地图的权值和,求其原因是这道题没有限制方向,上下左右都可以走,并且没有像滑雪一题中有高度差的限制。
所以我们只能被迫增加一维\(dp[x][y][pos]\),最后一维就代表了方向,从上下左右到达\((x,y)\)后\((x,y)\)走到终点的最大值。(其实只用三个方位就可以了)
处理起来有点麻烦,但是如果这道题做出来了,那么记忆化搜索就入门了。
关键代码:
inline int dfs(int x,int y,short u)
{
if(x>m||y>n||x<1||y<1)
{
return -1e9;
}
if(y==n)
{
return f[x][y][2];
}
int s=-1e9;
if(x>1&&u!=0)
{
if(f[x][y][1]==-1e9)
{
f[x][y][1]=dfs(x-1,y,1)+a[x][y];
}
s=max(s,f[x][y][1]);
}
if(x<m&&u!=1)
{
if(f[x][y][0]==-1e9)
{
f[x][y][0]=dfs(x+1,y,0)+a[x][y];
}
s=max(s,f[x][y][0]);
}
if(y<n)
{
if(f[x][y][2]==-1e9)
{
f[x][y][2]=dfs(x,y+1,2)+a[x][y];
}
s=max(s,f[x][y][2]);
}
return s;
}
(2)剪枝
深搜的时间复杂度是指数级别的,但是借助神奇的剪枝可以使深搜的时间复杂度大大优化(优化多少是个玄学问题),剪枝的技巧也是需要综合素养以及刷题提升的。
P1215 [USACO1.4] 母亲的牛奶 Mother's Milk
呀哈,一道简简单单没啥技巧的剪枝题,很显然,我们可以暴力深搜,每一次都有6种可能(反正就是互相倒嘛),但是我们惊讶的发现,我们找不到边界条件来返回,如果没有边界条件,深搜就会一直向下搜。
但是我们可以看见其实总共就只有单个桶,而且每个桶的大小也不大, 那极限数据之下也只会有\(200*200*200\)种情况,这个空间时间都可以接受。所以设一个\(vis[x][y][z]\)记录一下这个状态搜过没有,搜过就return,每次搜索的时候判断一下\(x\)是否为\(0\),为\(0\)就统计z的大小(可以全部存起来排序后去重,也可以使用桶排,反正值域就\(200\))。
关键代码:
inline void dfs(int a,int b,int c)
{
if(vis[a][b][c]) return ;//搜过了
if(!a)
{
ans[++cnt]=c;//我用的比较暴力,存下全部最后排序+去重
}
vis[a][b][c]=1;//标记这个状态已经搜过了
//a向b中倒
dfs(a-min(a,t2-b),b+min(a,t2-b),c);
//b向c中倒
dfs(a,b-min(b,t3-c),c+min(b,t3-c));
//a向c中倒
dfs(a-min(a,t3-c),b,c+min(a,t3-c));
//c向a中倒
dfs(a+min(t1-a,c),b,c-min(t1-a,c));
//c向b中倒
dfs(a,b+min(t2-b,c),c-min(t2-b,c));
//b向a中倒
dfs(a+min(t1-a,b),b-min(t1-a,b),c);
}//按题意模拟
P2327 [SCOI2005] 扫雷
实质上也是一道剪枝,在知道了扫雷的规则之后,又只有两列,所以a数组里的数值最大也就3。根据暴力深搜,每一个格子都有两种情况,有地雷和没有地雷,我们拿一个c数组来展现第i个格子上是否有地雷。
那这道题怎么剪枝呢(其实比较痴呆),就是在\(i\)做出决策进入\(i+1\)时,判断\(a[i-1]\)是否合法,因为\(a[i-1]\)对应的就是\(a[i-2],a[i-1],a[i]\),决策完\(a[i]\)后,自然就可以判断\(a[i-1]\)是否合法,合法就继续向下搜,否则及时止损马上返回。注意边界的处理。
关键代码:
inline void dfs(int x)
{
if(c[x-3]+c[x-2]+c[x-1]!=a[x-2]&&x-3>=1) return;//判断
if(x==n+1)//搜到最后了
{
if(c[n-1]+c[n]==a[n]) ans++;//a[n]就只是对应c[n-1]与c[n]
return;
}
if((a[x]==1&&c[x-1]!=1)||a[x]==2||a[x]==3)
{
c[x]=1;
dfs(x+1);
c[x]=0;
}
if(a[x]==0||a[x]==1||(a[x]==2&&c[x-1]==1))
dfs(x+1);//贪心,排除掉一些完全都不可能的情况
}
P2040 打开所有的灯
也是非常的良心,没有加强数据卡某些较为暴力的搜索做法。打开所有的灯,由于一个点你按两次就和没按一样,所以每个点最多就按一次,那么对于每个点有两种可能——按这个点于不按这个点。
深搜去遍历,用一个\(ans\)变量记录下每一种可能的方案最小需要的按击次数。
由于\(ans\)一直记录的是最小值,我们可以非常自然的想到如果当前所需的按击次数大于\(ans\),那就直接返回,因为这样的方案肯定对于答案是没有任何贡献的。
每一次搜索都\(check\)一下原来的\(mapp\)数组,看起是否全部为1,如果是那就更新答案并返回,否则说明还需要继续搜索。
关键代码:
inline void update(int x,int y)
{
mapp[x][y]^=1;
for(int i=1;i<=4;i++)
{
int sx=x+fx[i],sy=y+fy[i];
if(sx<1||sx>3||sy<1||sy>3) continue;
mapp[sx][sy]^=1;//一个位运算的小性质 0^1=1,1^1=0,就可以实现0,1互换了
}
}
inline void dfs(int u)
{
if(u>=ans) return ;//当前方案数大于等于ans,对答案没有贡献,返回
if(check())//如果全部是打开状态了
{
ans=min(ans,u);//更新答案
return ;
}
for(int i=1;i<=3;i++)
{
for(int j=1;j<=3;j++)
{
update(i,j);//更改mapp数组
dfs(u+1);
update(i,j);//回溯
}
}
}
P2919 [USACO08NOV] Guarding the Farm S
其实严格意义上这道并不是剪枝,而是一种贪心的思想。但贪心在某些题目中确实可以省略许多无用的情况。
题目对小山丘的定义:若地图中一个元素所邻接的所有元素都小于等于这个元素的高度(或它邻接的是地图的边界),则该元素和其周围所有按照这样顺序排列的元素的集合称为一个小山丘,并且是八连通图。
那么很显然,越高的地方越可以使一个小山丘的集合最大,这样就能让整个地图存在的小山丘数量更小。于是我们可以拿一个结构体将\(x,y,h\)坐标和高度排序,以高度为第一关键字从大到小排序。将每个点扫一次,如果这个点还没有被遍历过(没有被打上标记),并且判断附近8联通的点都没有自己高,那么我们让它成为一个新的小山丘的最高点,然后从这个点向四周扩散,将满足要求点打上标记,答案就是我们取了多少个点去扩散。
这样我们可以同时保证地图上的每一个点都被划在某一个小山丘内,并且小山丘的数量最小。减少了大量搜索的次数(被标记过的点就不用去搜了)。
其实这道题按贪心的思路套上广搜是一样的。
关键代码:
深搜广搜都很简单
struct N{
int x,y,w;
};N p[M*M];//结构体
inline bool cmp(N a,N b)
{
return a.w>b.w;//从大到小排
}//结构体排序(主要是结构体排序要简单一点,并且方便)
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
cin>>mapp[i][j];
p[++cnt].x=i,p[cnt].y=j,p[cnt].w=mapp[i][j];
}
}
sort(p+1,p+cnt+1,cmp);
int x,y,flag,ans=0;
for(int i=1;i<=cnt;i++)
{
x=p[i].x,y=p[i].y,flag=1;
if(vis[x][y]) continue;//没有被遍历过
for(int j=1;j<=8;j++)
{
int sx=x+fx[j],sy=y+fy[j];
if(sx<1||sx>n||sy<1||sy>m) continue;
if(mapp[sx][sy]>mapp[x][y])
{
flag=0;
}
}
if(flag)//周围8个格子都没有自己高
{
dfs(x,y);
ans++;
}
}
P3052 [USACO12MAR] Cows in a Skyscraper G
非常极端的一道贪心+暴力搜索
给出 \(n\) 个物品,体积为 \(w _ 1, w _ 2, \cdots, w _ n\),现把其分成若干组,要求每组总体积小于等于 \(W\),问最小分组数量。
\(n\le 18,1\le c_i\le W\le 10^8\)。
数据范围还是较大的,时间复杂度最劣是阶乘级别的,明显没法接受。回到基础的暴力深搜,可以建立一个桶数组然后对于每一个还没有选取的物品,判断这个物品可不可以放在之前已经存了物品的桶的剩余空间中,对于每一种可能的情况继续向下搜索,搜索边界就是物品数。
学习P2040中删去对答案没有贡献的方案,一旦方案数大于等于了已经搜索过的最少方案数那就直接放回,对答案完全没有贡献了。
接着,回到题目,数据保证了桶的大小大于每一个物品的大小,所以对于n个物品最多也就开\(n\)个桶,将\(ans\)的初始值赋为\(n\),那如果前三个物品只占了\(2\)个桶,那么最多我们需要几个桶?后面的物品最极端就开\(n-3\)个桶,所以当前局面下最多开\(n-1\)个桶。于是我们可以记录前面x个物品用了多少个桶,我们就可以知晓最多需要的桶数,这样就又大大的优化了我们的算法复杂度(虽然有点玄学,没法严格的证明)。
关键代码:
//a数组是物品的大小,b数组是每个桶剩余的空间,一开始要将b数组赋为m
inline void dfs(int u,int res)//第几个物品与当前方案所需的桶
{
if(u>n)//边界条件
{
ans=min(ans,res);
return ;//记录答案并返回
}
if(res>=ans) return ;//当前方案对答案已经没有贡献
for(int i=1;i<=u-tot;i++)//前面相较u少用了tot个桶,所以现在就只有u-tot个桶(剪枝的关键)
{
if(b[i]<a[u]) continue;//这个桶剩余空间放不下
if(b[i]==m) res++;//如果新开了一个桶
else tot++;//否则相当于又少用了一个桶
b[i]-=a[u];//更新剩余空间
dfs(u+1,res);//向下搜索
b[i]+=a[u];
if(b[i]==m) res--;
else tot--;//回溯
}
}
(3)较为繁琐复杂的dfs
这种dfs一般需要一定的码力+不错的剪枝能力,因为这种题数据一般不会特别毒瘤,但也需要找到一定的性质,进行时间复杂度的优化,而且题目的要求要读完,各种限制在搜索函数中都要体现出来,不要混成一团。题目的特点:限制很多,数据规模和暴力深搜差不了太多,题面长且感觉像是在模拟(所以需要较好的归纳模拟能力)。
P1784 数独
给出一个残缺的数独,让我们给出一种合法的填涂方案(你说的对,这也是舞蹈链的板子题,但舞蹈链是啥)。
还是比较良心的,有hack数据没有加(放在题目的下方让大家自行测试),所以我们只需要拿三个二维数组\(h,l,g\),分别将每一行,每一列,每一个9宫格之内出现过的数字记录下来,然后一行行的遍历(很多有限制的题都是一行行的遍历,应为这样显得比较有次序,并且可以更好地维护)。
由于记录了每一行每一列每一个九宫格之内存在的数字,所以对于每一个点,判断1-9每个数字是否合法,合法的在数组中记录已经出现,并且在在a数组记录在当前坐标写下的数,然后向后搜,到达边界条件\(x=9,y=9\)时输出在深搜过程中已经填好的a数组,然后exit(0)就行了。
关键代码:
inline void dfs(int x,int y)
{
if(a[x][y]!=0)
{
if(x==9&&y==9)
print();//去输出
if(y==9) dfs(x+1,1);
else dfs(x,y+1);//一行一行的遍历
}
else//是没有给出数字的位置,需要自己搜索找出应填的数字
{
for(int i=1;i<=9;i++)
{
if(h[x][i]&&l[y][i]&&g[(x-1)/3*3+(y-1)/3+1][i] )//都没有出现过
{
a[x][y]=i;
h[x][i]=0;
l[y][i]=0;
g[(x-1)/3*3+(y-1)/3+1][i]=0;//标记已经出现
if(x==9&&y==9) print();
if(y==9) dfs(x+1,1);
else dfs(x,y+1);
a[x][y]=0;
h[x][i]=1;
l[y][i]=1;//回溯
g[(x-1)/3*3+(y-1)/3+1][i]=1;//前面一维表示这是哪一个九宫格
}
}
}
}
P1406 方格填数
这道题开始就有模拟的味道了。给出\(n*n\)个数字,让我们填入\(n*n\)的正方形矩阵中,要求每行每列以及两条对角线上的数之和都相同。保证数据给出的范围有解,其中\(n\le4\)。
数据范围非常的友善,所以考虑直接模拟搜索。输入中给出了\(n*n\)个数,那么这些数的总和就知道了,由于总共n行,每行之和相同,n行就包含了所有的数字,所以我们可以算出每行每列每条对角线数字之和,\(sum/n\),直接输出这个值。
然后\(vis\)数组存每一个数字有没有用过,然后\(mapp\)数组记录方案。题目中要求方案数的字典序最小,那么一开始就将所有给出的数字从小到大排序,这样可以保证我们搜出来的第一个可行解字典序一定是最小的。
由于最多有\(16\)个点,所以还是要有必要的优化。一行行按顺序的搜索,搜完每一行我们就检查这一行是否满足要求,同样搜完每一列,每一条对角线就检查这一列是否满足要求,边界条件就是每个数都填好了,这时候,\(mapp\)数组内存储的方案一定是最小字典序并且合法的方案,输出后exit(0)就行。
关键代码:
inline int check(int x,int id)//要检查的是啥
{
int res=0;
if(id==1)//一行
{
for(int i=1;i<=n;i++)
{
res+=mapp[x][i];
}
}
else if(id==2)//一列
{
for(int i=1;i<=n;i++)
{
res+=mapp[i][x];
}
}
else if(id==3)//左下,右上对角线
{
for(int x=1,y=n;x<=n;x++,y--)
{
res+=mapp[x][y];
}
}
else//左上,右下对角线
{
for(int x=1,y=1;x<=n;x++,y++)
{
res+=mapp[x][y];
}
}
if(res!=sum/n) return 0;//如果和不等于sum/n,那么返回0,表示不合法。
else return 1;
}
inline void dfs(int x,int y)
{
//cout<<x<<" "<<y<<"\n";
if(x!=1&&y==1)//检查一行
{
//cout<<x<<" "<<y<<"\n";
if(!check(x-1,1)) return ;
//cout<<"OK\n";
}
if(x==n&&y!=1)//检查一列
{
//cout<<x<<" "<<y<<"\n";
if(!check(y-1,2)) return ;
//cout<<"OK\n";
}
if(x==n&&y==2)//检查左下-右上对角线
{
//cout<<x<<" "<<y<<"\n";
if(!check(0,3)) return ;
//cout<<"OK\n";
}
if(x>n)//检查左上-右下对角线
{
//cout<<x<<" "<<y<<"\n";
if(!check(0,4)) return ;
//cout<<"OK\n";
if(!check(n,2)) return ;
}
if(x>n)
{
for(int i=1;i<=n;i++)
{
for(int j=1;j<=n;j++)
{
cout<<mapp[i][j]<<" ";
}
cout<<"\n";
}
exit(0);
}
for(int i=1;i<=n*n;i++)
{
if(!vis[i])
{
vis[i]=1;
if(y>=n)//这行搜完了,该下一行
{
mapp[x][y]=a[i];
dfs(x+1,1);
}
else//下一列
{
mapp[x][y]=a[i];
dfs(x,y+1);
}
vis[i]=0;
}
}
}
初始状态:
dfs(1,1)
P1274 魔术数字游戏
限制变多了,但是和上一道题其实思路差不多,先将给出坐标的那个点赋为\(1\),暴力深搜出每一个方案,但在某一个限制的区域搜完之后立即检查是否合法,不合法就可以即使返回,避免不必要的搜索。
其他按题意以模拟即可,注意一下各个限制影响的区域应该是在多久就搜完了,还有就是这道题要将所有可能的情况按字典序搜出来,所以你搜每一个点的时候就按\(2-n\)的顺序搜,这样可以保证字典序的单调上升,由于输出所有的方案,在搜到第一种合法解之后不能直接exit(0),而是应该return ,找到下一组和法解。(最后各个方案之间还要加一行空行)
关键代码:
//题目中的限制id依次就是1-6。
inline int check(int x,int y,int id)//不同的限制不同的判法
{
int res=0;
if(id==1)
{
res=mapp[1][1]+mapp[1][4]+mapp[4][1]+mapp[4][4];
}
else if(id==2)
{
if(y==1) x--,y=n;
else if(y==3) y--;
res=mapp[x][y]+mapp[x-1][y]+mapp[x][y-1]+mapp[x-1][y-1];
}
else if(id==3)
{
y--;
res=mapp[x][y]+mapp[x-1][y]+mapp[x][y-1]+mapp[x-1][y-1];
}
else if(id==4)
{
for(int i=1;i<=4;i++)
{
res+=mapp[x][i];
}
}
else if(id==5)
{
for(int i=1;i<=4;i++)
{
res+=mapp[i][x];
}
}
else if(id==6)
{
for(int x=1,y=n;x<=n;x++,y--)
{
res+=mapp[x][y];
}
}
else
{
for(int x=1,y=1;x<=n;x++,y++)
{
res+=mapp[x][y];
}
}
if(res==34) return 1;
return 0;
}
inline void dfs(int x,int y)
{
if((x==2&&y==3)||(x==3&&y==1)||(x==4&&y==3))
{
if(!check(x,y,2)) return ;
}
if(x==3&&y==4)
{
if(!check(x,y,3)) return ;
}
if(x!=1&&y==1)
{
if(!check(x-1,y,4)) return ;
}
if(x==4&&y!=1)
{
if(!check(y-1,y-1,5)) return ;
}
if(x==4&&y==2)
{
if(!check(0,0,6)) return ;
}//自己想一下各种限制所要求的格子在多久就全部搜完了
if(x==5)
{
if(!check(0,0,1)) return ;
if(!check(x,y,2)) return ;
if(!check(n,n,5)) return ;
if(!check(0,0,7)) return ;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=n;j++)
{
cout<<mapp[i][j]<<" ";
}
cout<<"\n";
}
cout<<"\n";//题目描述漏了,注意一下
return ;//输出所有可能的解
}
if(mapp[x][y]==1)
{
if(y==n)
{
dfs(x+1,1);
}
else
{
dfs(x,y+1);
}
}//一行一行有顺序的搜
else
{
for(int i=2;i<=16;i++)
{
if(!vis[i])//没用过
{
vis[i]=1;//标记
if(y==n)
{
mapp[x][y]=i;
dfs(x+1,1);
}
else
{
mapp[x][y]=i;
dfs(x,y+1);
}
vis[i]=0;//回溯
}
}
}
}
初始状态:
dfs(1,1);
P1378 油滴扩展
可能会用到小学学的圆的知识。
首先先把给定矩形的四个角算出来。暴力搜索第u次放哪一个点,边界条件也就是全部放完了,拿一个\(ans\)数组存下最大覆盖面积,最后用给定的矩形面积减去最大的覆盖面积。
由于涉及到圆,虽说最后要四舍五入输出,但是为了保证精度,还是拿都变了存。对于每一个油滴,我们肯定要判断它最大可以延伸的半径(它碰到其他油滴的边界与给定矩形的边界就不再扩张了),所以最大可以延伸的半径就是点与已经放置的点的距离-那个点扩散的半径+与边界距离的最小值。
使用一个\(r\)数组记录一下已经放置的点它扩散的半径大小。
关键代码:
inline double work(int i)
{
double s1=min(abs(x[i]-x_1),abs(x[i]-x_2));
double s2=min(abs(y[i]-y_1),abs(y[i]-y_2));
double ans=min(s1,s2);//与边界距离的最小值
for(int j=1;j<=n;j++)
{
if(i!=j&&vis[j])
{
double d=sqrt((x[i]-x[j])*(x[i]-x[j])+(y[i]-y[j])*(y[i]-y[j]));
ans=min(ans,max(d-r[j],0.0));//半径总不能是负的吧(如果负的其实就代表已经放置的油滴覆盖了这个点)
}//与其他油滴边界的最小值
}
return ans;
}
inline void dfs(int u,double sum)
{
if(u>n)
{
maxx=max(maxx,sum);//记录下最大的答案
return ;
}
for(int i=1;i<=n;i++)
{
if(!vis[i])
{
r[i]=work(i);//记录这个点扩散的半径大小
vis[i]=1;
dfs(u+1,sum+r[i]*r[i]*pi);//下一个放那个点 加上这个点对答案的贡献
vis[i]=0;//回溯
}
}
}
P1236 算24点
有点毒瘤,但幸好是SPJ,可以乱搞,暴力搜索每两个之间放哪一种符号,然后检验这种搭配是否可行。
其实就是一个模拟+枚举。
有点难调,给出全部代码。
关键代码:
#include<iostream>
#include<cmath>
#include<cstring>
#include<algorithm>
#include<queue>
#include<cstdio>
#define int long long
using namespace std;
inline int max(int x,int y){return x>y?x:y;}
inline int min(int x,int y){return x>y?y:x;}
const int M=11;
using namespace std;
int a[5][M],ans[M][3],ky[M];
bool flg;
char op(int k)
{
if(k==1) return '+';
if(k==2) return '-';
if(k==3) return '*';
if(k==4) return '/';
}
inline void print(int dep)
{
if(dep==1) return ;
printf("%d%c%d=%d\n",ans[dep][1],op(ky[dep]),ans[dep][2],a[dep-1][1]);
print(dep-1);
}
inline int calc(int x,int y,int k)
{
if(k==1) return x+y;
if(k==2) return x-y;
if(k==3) return x*y;
return x/y;
}
inline void dfs(int dep)
{
if(flg) return ;
if(dep==1)
{
if(a[1][1]==24)
{
flg=1;
print(4);
}
return ;
}
for(int i=1;i<=dep;i++)
{
for(int j=1;j<=dep;j++)
{
if(i!=j)
{
ans[dep][1]=max(a[dep][i],a[dep][j]),ans[dep][2]=min(a[dep][i],a[dep][j]);
for(int k=1;k<=4;k++)
{
if((k==2&&a[dep][i]<=a[dep][j])||(k==4&&a[dep][j]!=0&&a[dep][i]%a[dep][j]!=0)) continue;
int tp=1;
a[dep-1][tp]=calc(a[dep][i],a[dep][j],k);
for(int g=1;g<=dep;g++)
{
if(g!=i&&g!=j)
{
a[dep-1][++tp]=a[dep][g];
}
}
ky[dep]=k;
dfs(dep-1);
}
}
}
}
}
signed main()
{
for(int i=1;i<=4;i++)
{
cin>>a[4][i];
}
dfs(4);
if(!flg)
{
cout<<"No answer!\n";
return 0;//没有一种方案满足
}
return 0;
}
(4)其他常见的dfs优化
\(dfs\)的优化除了记忆化、贪心、剪枝之外还有一些比较小众的优化方法,这些优化的题目相对前面几种较为稀少,要是出题人就只考这一个\(trick\)的话,那么很有可能会变成一道\(Ad-hoc\)题(还是比较考验转化能力以及平时的积累)。
这里介绍一种比较巧妙,运用也比较多的优化方法——位运算&状态压缩,位运算0和1做为计算机的基础有许多特别的性质(不会的网上应该介绍的比较齐全,或许哪一天我没事也会写?),而位运算在大多数时候都与状态压缩有关,状态压缩其实运用十分广泛(特别是在记忆化搜索&动态规划中,甚至专门有一个板块就是状压DP),其实位运算本质就是在模拟电脑的01信号传递信息,使内存要求大大降低。
P1562 还是 N 皇后
状态压缩+搜索的入门题。唯一与朴素八皇后的区别就是题目中限制了有些地方是不可以放置皇后的。这个很好办,我们只需要在暴力搜索的时候特判一下这些地方不放置皇后。成功的取得了70pts的高分(管你咋优化都不行,这是时间复杂度之间的差距,常数优化几乎弥补不了)。
我们观察深搜中比较耽误时间的地方,深搜本身就不说了(毕竟本来就已经是指数级别的了),那么最耽误时间的就是枚举每一个可行的点,每一行我们都要遍历每一列,然后判断是否可行,然后再向下搜索,还是有点太耽误时间了。
那位运算可以怎么优化呢,我们可以边搜的时候边处理出哪一列没法放置(如同朴素的搜索),我们设可以放置的地方为\(0\),不可以放置的地方为\(1\),使用去反运算(~),每一位上的数变化,那么现在可以放置的地方为\(1\),不可以放置的地方为0,位运算中,有一个叫\(lowbit\)的运算,可以在\(O(1)\)时间内找出某个数二进制下最后一位1所在的位置,所以我们就可以通过位运算快速的找到可以放置的列。
由于比较难写(注意位运算的运算优先级极低,所以在写位运算的时候多多的使用小括号保证运算的顺序),下面给出完整的代码:
#include<iostream>
#include<cmath>
#include<cstring>
#include<algorithm>
#include<queue>
#include<cstdio>
#define int long long
using namespace std;
inline int max(int x,int y){return x>y?x:y;}
inline int min(int x,int y){return x>y?y:x;}
const int M=35;
int n,ans,k;
int l[M];
inline int lowbit(int x){return x&-x;}//lowbit运算
inline void dfs(int x,int dx,int dy,int u)
{
if(x==k)
{
++ans;//到达边界,说明找到了一种方案
return ;
}
int a=(~(x|dx|dy|l[u]));//把所有的限制与(|)起来,就是总的限制,
int pos=k&a;
while(pos)
{
int p=lowbit(pos);
dfs(x+p,(dx+p)<<1,(dy+p)>>1,u+1);//传递到下一层,更新限制
pos-=p;//减去之后相当于这一位的1就没有了,找下一个1直到减到0,这样就可以找出pos存在的所有的1。
}
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);cout.tie(0);
cin>>n;
k=(1<<n)-1;
char opt;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=n;j++)
{
cin>>opt;
if(opt=='.') l[i]|=(1<<(n-j));//将不能存在的先赋为1
}
//cout<<l[i]<<"\n";
}
dfs(0,0,0,1);
cout<<ans<<"\n";
return 0;
}
/*
5
**.**
*****
*****
*****
*****
*/
2、广搜
广搜变式起来也就最短路,01BFS,双向搜索几种。理解了朴素的广搜之后其实还是比dfs的变式简单的。(最短路谔谔,其实上一个章节就讲了,就是bfs+松弛操作)
(1)朴素+一些不同要求的bfs
P2960 [USACO09OCT] Invasion of the Milkweed G
其实也是一道板子题,放到这只是因为出题人有点想法,将一道板子题活生生考成了细节理解题,注意本题坐标系的转化,可能并不是按照大多数题目的坐标系。
关键代码:
你的输入可能需要一点特殊的转化。
for(int i=n;i>=1;i--)
{
for(int j=1;j<=m;j++)
{
cin>>opt;
mapp[i][j]=(opt=='*');
}
}
P2385 [USACO07FEB] Bronze Lilypad Pond B
广搜的题目不想出成板子就只能疯狂的叠加各种限制&操作。输入时标记起点终点,与处理好你的\(mapp\),这样在广搜的时候可能会轻松一点。
关键代码:
queue<pair<int,int> > q;
inline void bfs()
{
q.push(make_pair(bx,by));
dis[bx][by]=0;
vis[bx][by]=1;
while(!q.empty())
{
int x=q.front().first,y=q.front().second;
q.pop();
vis[x][y]=0;
for(int i=1;i<=8;i++)
{
int sx=x+fx[i],sy=y+fy[i];//处理好方向数组
if(sx<1||sx>n||sy<1||sy>m||mapp[sx][sy]) continue;//将岩石与水的mapp值标成1,其余为0
if(dis[sx][sy]>dis[x][y]+1)
{
dis[sx][sy]=dis[x][y]+1;
if(!vis[sx][sy])
{
vis[sx][sy]=1;
q.push(make_pair(sx,sy));
}
}
}
}
}//这不是一纯板子??
P2895 [USACO08FEB] Meteor Shower S
多了时间的限制,那你就先将每个点的时间数组处理好咯,根据题意与输入,可以轻松的处理出贝茜至少在多久之前就应该到达某个点以及哪些点会被轰炸(一个格子可能被炸多次,肯定是以第一次来计算),然后跑一遍广搜板子(多了的限制就是如果你到达某点的时间大于你处理出来至少多久之前到达的值的话,应该\(continue\),因为这种情况下是没法转移的)。
跑完\(bfs\)之后,判断每一个没有被轰炸的点的\(dis\)值是否有小于\(inf\)的(你一开始肯定将\(dis\)赋为\(0x3f\)了,如果全都没有被更新,那就只能说明贝茜被轰上天了,输出-1),否则取所有没有被轰炸的点的\(dis\)值的\(min\),也就是贝茜最少需要多少时间才能到达一个安全的格子,输出即可。
关键代码
inline void bfs()
{
while(!kkk.empty())
{
now=kkk.front();
kkk.pop();
for(int i=0;i<4;i++){
int fx=now.x+dx[i],fy=now.y+dy[i];
if(fx<1||fy<1||vis[fx][fy]||now.t+1>=p[fx][fy])
continue;
vis[fx][fy]=1;
kkk.push({fx,fy,now.t+1});
if(p[fx][fy]==2000)
{
cout<<now.t+1;
return;
}
}
}
cout<<-1;//无解
}//很久之前的代码了
P1825 [USACO11OPEN] Corn Maze S
一开始是用深搜做的,喜获18pts,因为题面中有一个限制:如果一头奶牛处在这个装置的起点或者终点,这头奶牛就必须使用这个装置,所以你到达一个装置的节点,你不想被传送,那么代价就要+2。
跑图的话还是用广搜了。
自己写哒,懒得给代码了。
P1767 家族
是用bfs求连通块个数,上一个章节应该讲过,可以使用染色法。知道用染色法这道题差不多就没了。
唯一让它评绿的资本应该就是稍稍有点恶心的输入&每一行的个数并不一定相同。这个很好处理,我们可以使用\(getchar()\)读入一行,然后处理每一行有多少节点,就可以限制边界了。
关键代码:
signed main()
{
//ios::sync_with_stdio(false);
//cin.tie(0);cout.tie(0);
cin>>n;
string s;
getline(cin,s);//UB问题,反正就是要先读一行
for(int i=0;i<n;i++)
{
getline(cin,s);
l[i]=s.size();
for(int j=0;j<l[i];j++)
{
if(s[j]>='a'&&s[j]<='z') mapp[i][j]=1;//标记是否可走
}
}
for(int i=0;i<n;i++)
{
for(int j=0;j<l[i];j++)
{
if(!vis[i][j]&&mapp[i][j])//没有被遍历过&是家族
{
bfs(i,j);//将可以扩展的全部连通块染上色
++ans;//统计答案
}
}
}
cout<<ans<<"\n";
return 0;
}
P1126 机器人搬重物
有点大模拟的感觉,需要注意各种细节与操作方法,要有强大的内心与持久的毅力(调不死人的)。
要注意的是机器人是有直径的,一个格子机器人是过不去的,我紫菜,我直接贺了。您加油(ง •_•)ง,就当本节练习题了。
(2)双向搜索
在已知终点和起点的情况下,从终点和起点同时搜索,看是否可以搜索到同一种状态,可以大大的降低时间复杂度(深搜有个与这个名字很像的——meet in the middle),主要内容会在下一章涉及(可能会咕咕)。
P5195 [USACO05DEC] Knights of Ni S
严格来说其实这道题完全就不是双向搜索,只是和双向搜索有点类似而已。
由于贝茜需要采集到灌木,并且要将灌木交给骑士,灌木还有多个(只让你拿一个就行了)。bfs只能处理某一个点到其他点的距离,还不支持中间经过一个中间点,然后在到达终点的操作。所以我们可以将路程分为两半:贝茜到达灌木的距离与骑士到达灌木的距离,两个距离相加明显就是贝茜所需要走的全距离。
那么我们跑两边bfs:从贝茜为起点与以骑士为起点(注意第一次的时候贝茜不可以经过骑士),对于每一个灌木,将跑出来的两张图对应的距离相加就是采集这一从灌木所需要走的距离了,记录最小值。
关键代码:
//用个id写起来方便一点。id=0代表从贝茜出发,id=1代表从骑士出发
inline void bfs(int x,int y,int id)
{
queue<pair<int,int> > q;
q.push(make_pair(x,y));
dis[x][y][id]=0,vis[x][y]=1;
while(!q.empty())
{
int x=q.front().first,y=q.front().second;
q.pop();
vis[x][y]=0;
for(int i=1;i<=4;i++)
{
int sx=x+fx[i],sy=y+fy[i];
if(sx<1||sx>n||sy<1||sy>m) continue;
if(!id)
{
if(mapp[sx][sy]==1||mapp[sx][sy]==3) continue;//从贝茜出发不可以经过骑士
}
else
{
if(mapp[sx][sy]==1) continue;
}
if(dis[sx][sy][id]>dis[x][y][id]+1)
{
dis[sx][sy][id]=dis[x][y][id]+1;
q.push(make_pair(sx,sy));
}
}
}
}//板子
主函数里:
memset(dis,0x3f,sizeof(dis));//开始赋极值
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
cin>>mapp[i][j];
if(mapp[i][j]==2) bx=i,by=j;
else if(mapp[i][j]==3) ex=i,ey=j;
}
}
bfs(bx,by,0);
bfs(ex,ey,1);
int minn=1e9;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
if(mapp[i][j]==4)//枚举每一丛灌木
{
minn=min(minn,dis[i][j][0]+dis[i][j][1]);//两端相加,找出最小值
}
}
}
cout<<minn<<"\n";
(3)01BFS
其实01BFS应该在最短路之后学习,但是其实知道了最短路的原理就可以了(贪心的对每次对边权最小的边进行更新)
这种算法可以在 \(O(m)\) 的时间对于\(01\)权图即边权只有0或1的这种特殊图求最短路径。需要注意的是这种算法本质上和$BFS \(+ 优先队列一致,都是将边进行排序,然后进行\)BFS$。但因为只有\(01\)两种边权,运用 \(priority_queue\)进行插入运用的时间还是过大,所以我们只需将边权为\(0\)的边插入队头,边权为\(1\)的边插入队尾,就可以用 \(O(1)\)的时间替代\(O(log_{2}n)\)的优先队列插入,使得复杂度显著降低。
我看到有大佬进行了一定程度的推广,如果一张图只有两种边权,将小边权插入队首,大边权插入插入队尾,同样可以做到排序的操作,替代优先队列。(可能不太正确,但这种题本来就少)
更一步的01BFS在下一节中介绍。
P4667 [BalticOI 2011 Day1] Switch the Lamp On
就是一道板子题(其实应该出现在下一节的,有比它还板的题),题目中给出了各个边,我们可以将边转化成点(所以这里的n,m都要加上一,判范围的时候别判错了)。
那么对于每一个点有四个方向,左上,左下,右上,右下。我们一一分析,设当前在\((x,y)\)。我们预处理一个\(mapp\)数组如果当前电路是'/'那么\(mapp\)的值是1,否则就为0,\(mapp\)就代表了以\((x,y)\)为起点的电路方向。
去右下,值是\(mapp[x][y]\)决定的,当\(mapp[x][y]\)为\(0\)时,边权为\(0\),mapp为\(1\)时,边权为\(1\),\(w=mapp[x][y]\)。(想象一下如果是''就是直通右下,边权为\(0\),而如果是'/'的话,需要将这个电路扭过来,边权就是1)。
同理去右上,值是\(mapp[x-1][y]\)决定的,\(mapp[x-1][y]\)为\(1\)时,边权为\(0\),\(mapp[x-1][y]\)为\(0\)时,边权为\(0\),\(w=mapp[x-1][y]^1\)。
去左下,值是\(mapp[x][y-1]\)决定的,\(mapp[x][y-1]\)为\(1\)时,边权为\(0\),\(mapp[x][y-1]\)为\(0\)时,边权为\(0\),\(w=mapp[x][y-1]^1\)。
去右上,值是\(mapp[x-1][y-1]\)决定的,当\(mapp[x-1][y-1]\)为\(0\)时,边权为\(0\),\(mapp[x-1][y-1]\)为1时,边权为\(1\),\(w=mapp[x-1][y-1]\)。
将边权为\(0\)的插入队列前方,边权为\(1\)的插入队列后方。
关键代码:
#include<iostream>
#include<cmath>
#include<cstring>
#include<algorithm>
#include<queue>
#include<cstdio>
#define int long long
using namespace std;
inline int max(int x,int y){return x>y?x:y;}
inline int min(int x,int y){return x>y?y:x;}
const int M=505;
int n,m;
int mapp[M][M],dis[M][M],vis[M][M];
int fx[5]={0,1,-1,1,-1};
int fy[5]={0,1,1,-1,-1};
inline void bfs()
{
deque<pair<int,int> >q;//STL 头文件#include<queue>包含
q.push_front(make_pair(1,1));
dis[1][1]=0,vis[1][1]=1;
while(!q.empty())
{
int x=q.front().first,y=q.front().second;
q.pop_front();
vis[x][y]=0;
for(int i=1;i<=4;i++)
{
int sx=x+fx[i],sy=y+fy[i];
if(sx<1||sx>n||sy<1||sy>m) continue;
int w=0;
if(i==1) w=mapp[x][y];
if(i==2) w=mapp[x-1][y]^1;
if(i==3) w=mapp[x][y-1]^1;
if(i==4) w=mapp[x-1][y-1];//同上文介绍
if(dis[x][y]+w<dis[sx][sy])
{
dis[sx][sy]=dis[x][y]+w;//更新+插入队列
if(!vis[sx][sy])
{
vis[sx][sy]=1;
if(!w)
{
q.push_front(make_pair(sx,sy));//边权为0,push_front
}
else
{
q.push_back(make_pair(sx,sy));//边权为1,push_back
}
}
}
}
}
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);cout.tie(0);
memset(dis,0x3f,sizeof(dis));//赋为极值
cin>>n>>m;
char opt;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
cin>>opt;
if(opt=='/') mapp[i][j]=1;
}
}
n++,m++; //边转点,边界都扩大1
bfs();
/*for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
if(dis[i][j]>=1e9) cout<<"* ";
else cout<<dis[i][j]<<" ";
}
cout<<"\n";
}*/
if(dis[n][m]>1e9)
{
cout<<"NO SOLUTION\n";//到达不了
}
else cout<<dis[n][m]<<"\n";
return 0;
}