双向BFS和启发式搜索的应用
题意简述
有12个旋钮,每个旋钮开始时处于状态 \(1\) ~ \(4\) ,每次操作可以往规定方向转动一个旋钮 (\(1\Rightarrow2\Rightarrow3\Rightarrow4\Rightarrow1\)) ,并且会触发一次连锁反应:处于某个状态的旋钮在旋转时会引起另一个旋钮发生相同方向的转动(另一个旋钮转动不会再触发连锁反应)。问将12个旋钮都置为 \(1\) 至少需要几次转动,并输出每次转动的旋钮编号。
单向BFS
直接暴力地进行单向 \(\mathrm{BFS}\) ,每次转动都有 \(12\) 种选择,时间复杂度是 \(O(12^{\mathrm{step}})\) ,看数据范围,最高的步数可达 \(17\) 步,必定 \(\mathrm{TLE}\) 。但是这样简单如果优化的比较好可以得 \(50\) ~ \(60\) 分(没吸氧气,吸了氧气反而更低了)。
单向BFS评测记录
超时的主要原因是搜索树过于庞大,而我们会发现本题起始状态和终止状态是明确的,这时我们就可以使用神奇的双向 \(\mathrm{BFS}\) 来限制搜索树的生长。
双向BFS
双向 \(\mathrm{BFS}\) 非常适合在起始状态和终止状态明确的条件下使用,做法是从起点和终点同时进行单向 \(\mathrm{BFS}\) ,让两边 \(\mathrm{BFS}\) 搜索树的生长受到对面搜索树的限制,不要过于野蛮生长,偏离目标太远。自己画了一张很丑很丑的对比图,应该可以便于理解。
可以看到双向 \(\mathrm{BFS}\) 可以在某一状态发现相同时就停止了,通过回溯可以找到沿路选择的点。再看看本题的数据范围,最大的点正向和反向 \(\mathrm{BFS}\) 最多是 \(9\) 步, \(12^9\) 是 \(5\times10^8\) 的量级,勉强可以在一秒冲过去。事实上我最大的点用时在 \(200ms\) ~ \(300ms\) 之间,还是很稳的。
最好的一次双向BFS记录
状态存储
可以把两个二进制位当做一个四进制位,把每个旋钮状态减一后就刚好可以存下了,即1对应0,2对应1,以此类推。先讲一下读入处理。
int button,Start = 0; For(i,0,11){ button = read(); //读入第i+1个旋钮状态 Start |= (button - 1) << (i << 1); //记录初始状态 For(j,0,3) nxt[i][j] = read()-1; }
我代码中的旋钮编号和状态全部进行了减一处理(后面描述时我都会说+1),方便位运算操作。注意记录初始状态时要将 \(i*2\) (即左移一位),因为我们把两个二进制位当做一个四进制位了,后面也有这样的乘2处理。再用一个数组 \(nxt\) 记录第 \(i+1\) 个旋钮在 \(j+1\) 状态下进行旋转时,会带动第 \(nxt[i][j]+1\) 个旋钮转动。
状态转移
首先正向和反向的 \(\mathrm{BFS}\) 的转移方式是不一样的。设当前转到的是第 \(i+1\) 个旋钮,它现在处于 \(j+1\) 状态。
- 正向:将第 \(i+1\) 个旋钮按规定方向转动一次,同时带动第 \(nxt[i][j]+1\) 个旋钮转动。旋转后状态可以用\((j+1)\&3\) 表示(这样可以实现旋钮位于4状态,即 \(j=3\) 时,旋转后变成1 ,即 \(j = 0\) 的操作)。
- 反向:将第 \(i+1\) 个旋钮按规定的相反方向转动一次,如果其转动后的状态为 \(k+1\) ,则带动第 \(nxt[i][k]+1\) 个旋钮也以相反方向转动。逆向旋转后状态可以用\((j+3)\&3\) 表示。
我们把正向方向定义为1,反向方向定义为2,当前方向为 \(\mathrm{direction}\) ,当前所有按钮状态为 \(\mathrm{state}\) ,有:
int si,sNext,nx,nextState; For(i,0,11) { if (direction == 1) { //正向 si = (state >> (i << 1)) & 3; //1、获取第i+1个旋钮状态(0~3) nx = nxt[i][si]; //2、获取牵连旋钮编号 sNext = (state >> (nx << 1)) & 3; //3、获取牵连旋钮状态,方式同1 nextState = state ^ (si << (i << 1)) ^ (((si + 1) & 3) << (i << 1)); //4、修改状态为第i+1个旋钮旋转后的状态 nextState ^= (sNext << (nx << 1)) ^ (((sNext + 1) & 3) << (nx << 1)); //5、修改状态为牵连旋钮旋转后的状态 } else { //反向 si = (state >> (i << 1)) & 3; nx = nxt[i][(si + 3) & 3]; //获取第i+1个旋钮逆向旋转后的牵连旋钮编号 sNext = (state >> (nx << 1)) & 3; nextState = state ^ (si << (i << 1)) ^ (((si + 3) & 3) << (i << 1)); //修改状态为第i+1个旋钮逆向旋转后的状态 nextState ^= (sNext << (nx << 1)) ^ (((sNext + 3) & 3) << (nx << 1));//修改状态为牵连旋钮逆向旋转后的状态 } }
\(Code:\)
#include <bits/stdc++.h> using namespace std; #define For(i,sta,en) for(int i = sta;i <= en;i++) inline int read(){ int sum = 0,fu = 1;char ch = getchar(); while(!isdigit(ch)) { if(ch == '-') fu = -1;ch = getchar();} while(isdigit(ch)) { sum = (sum<<1)+(sum<<3)+(ch^48);ch =getchar();} return sum * fu; } const int N = 1<<24; bool vis[N]; int nxt[14][6],fa[N],choice[N],v[N],flag,m1,m2,mid,ans1[30],ans2[30]; queue<int>q; int main(){ int button,Start = 0; For(i,0,11){ button = read(); //读入第i+1个旋钮状态 Start |= (button - 1) << (i << 1); //记录初始状态 For(j,0,3) nxt[i][j] = read()-1; } vis[Start] = vis[0] = 1; //是否访问过 v[Start] = 1; v[0] = 2; //区分方向 q.push(Start); q.push(0); while(!q.empty() && !flag){ int state = q.front(),direction = v[state]; q.pop(); int si,sNext,nx,nextState; For(i,0,11){ if(direction == 1){ //正向 si = (state >> (i << 1))&3; //1、获取第i+1个旋钮状态(0~3) nx = nxt[i][si]; //2、获取牵连旋钮编号 sNext = (state >> (nx << 1)) & 3; //3、获取牵连旋钮状态,方式同1 nextState = state ^ (si << (i << 1)) ^ (((si + 1) & 3) << (i << 1)); //4、修改状态为第i+1个旋钮旋转后的状态 nextState ^= (sNext << (nx << 1)) ^ (((sNext + 1) & 3) << (nx << 1)); //5、修改状态为牵连旋钮旋转后的状态 } else{ //反向 si = (state >> (i << 1))&3; nx = nxt[i][(si+3)&3]; //获取第i+1个旋钮逆向旋转后的牵连旋钮编号 sNext = (state >> (nx << 1)) & 3; nextState = state ^ (si << (i << 1)) ^ (((si + 3) & 3) << (i << 1)); //修改状态为第i+1个旋钮逆向旋转后的状态 nextState ^= (sNext << (nx << 1)) ^ (((sNext + 3) & 3) << (nx << 1));//修改状态为牵连旋钮逆向旋转后的状态 } //如果这个状态在之前访问过 if(vis[nextState]){ if(v[nextState] == direction) continue; //同方向的直接跳过,之前到达的时候肯定不劣于现在 /* * 不同方向说明已经找到答案了 * m1 记录正向与逆向的连接点 * m2 记录逆向与正向的连接点 * mid 记录从state状态转移到nextState状态选择的旋钮编号 */ m1 = direction == 1 ? state : nextState; mid = i+1; m2 = direction == 1 ? nextState : state; flag = 1;break; } vis[nextState] = 1; v[nextState] = direction; //继承方向 fa[nextState] = state; //用于回溯操作 choice[nextState] = i + 1; //记录本次操作 q.push(nextState); } } int cnt1 = 0,state = m1,cnt2 = 0; //正向回溯 while(state != Start){ ans1[++cnt1] = choice[state]; state = fa[state]; } //逆向回溯 state = m2; while(state != 0){ ans2[++cnt2] = choice[state]; state = fa[state]; } //总步数,还要加上中间那一步mid操作 printf("%d\n",cnt1+cnt2+1); for(int i = cnt1; i; i--) printf("%d ", ans1[i]); printf("%d ",mid); For(i,1,cnt2) printf("%d ", ans2[i]); return 0; }
启发式搜索
双向 \(\mathrm{BFS}\) 已经够快了,但是我们可以使用更快的启发式搜索。常用的启发式搜索有 \(\mathrm{IDA^*}\) 和 \(\mathrm{A^*}\) ,听说前者被卡了,我们就用 \(\mathrm{A^*}\) 吧。这里先介绍这个算法的实现,然后给出正确性证明。
在 \(\mathrm{A^*}\) 算法中,我们要利用当前状态的信息对状态进行评价,以此来决定下一次的操作,极大地限制了搜索树的生长。这里用函数 \(F^*\) 来表示 \(x\) 状态的代价:\(F^*(x)= g(x)+h^*(x)\) 。其中 \(g (x)\) 表示从初始状态到当前状态所付出的最小代价(在本题中意义为操作步数),而 \(h^*(x)\) 是从当前状态到目标状态走最佳路径所付出的代价。在实际代码中我们使用的其实是 \(F(x)=g (x)+h(x)\) ,因为我们实际上是不知道这个 \(h^*(x)\) 的,但是我们可以用 \(h(x)\) 去估计它,要保证 \(h(x)\) 是 \(h^*(x)\) 的下界,即对任意状态均有 \(h(x)≤h^*(x)\) ,这个估计能保证搜索正确性。
在代码中我们用以下方式估计 \(h^*(x)\) : \(12\) 个旋钮在不考虑牵连时都转到 \(1\) 要多少步,再除以 \(2\) ,这样就可以保证得到的 \(h(x)\) 肯定会比实际要转的次数 \(h^*(x)\) 要少(一次操作恰好就可以让两个旋钮都向目标状态转一次,而实际上可能会让某个旋钮转过目标状态,从而要转更多次数)。
\(h(x)\) 是一个比较玄学的东东,没有唯一的定义,不同的定义可能会导致程序执行效率和结果不同,这题中你还可以乘一个系数给他,能明显加快运行效率。经过笔者多次测试,发现给 \(h\) 乘上系数从 \(1.1\) ~ \(2.3\) 都能 \(\mathrm{AC}\) 这道题,但是乘 \(2.4\) 时会 \(\mathrm{WA}\) 掉一个点。变化趋势是这个系数越大,跑得越快,最大的点可以跑进 \(100ms\) 。这是因为系数越大越接近真实值 \(h^*(x)\),但是更大的系数不能保证 \(h(x)≤h^*(x)\)。
代码实现类似 \(\mathrm{Dijkstra}\) 算法,定义一个结构体存状态和这个状态对应的估价函数值 \(F\) 。每次从小根堆中取出 \(F\) 最小的状态进行转移,存状态和转移状态的操作和上面双向 \(\mathrm{BFS}\) 相同,这里直接给出代码,证明放后面。
\(Code:\)
#include <bits/stdc++.h> using namespace std; #define For(a,sta,en) for(int a = sta;a <= en;a++) inline int read(){ int sum = 0,fu = 1;char ch = getchar(); while(!isdigit(ch)) { if(ch == '-') fu = -1;ch = getchar();} while(isdigit(ch)) { sum = (sum<<1)+(sum<<3)+(ch^48);ch =getchar();} return sum * fu; } const int N = 1<<24; int g[N],nxt[14][6],fa[N],ans[30],choice[N]; struct node{ int state; //状态 double F; //状态对应估价函数值 node(int s):state(s){ //构造函数,冒号后面部分相当于 state = s; double h = 0; F = 0; For(i,0,11) if((s>>(i<<1))&3) h += 4 - ((s >> (i << 1)) & 3); //计算不处在状态1的旋钮的对应的h值 F = h / 2 + g[s]; //可以在h/2前乘一个玄学系数 } bool operator<(const node &y) const{ return F > y.F; //估价函数值小的放前面 } }; priority_queue<node>q; int main(){ int button,Start = 0; For(i,0,11){ button = read(); //读入第i+1个旋钮状态 Start |= (button - 1) << (i << 1); //记录初始状态 For(j,0,3) nxt[i][j] = read()-1; } q.push(node(Start)); //调用构造函数,顺便计算出估价函数值 g[Start] = 0; while(!q.empty()){ int state = q.top().state; q.pop(); if(state == 0) break; int si,sNxt,nx,nextState; For(i,0,11){ si = (state>>(i<<1))&3; nx = nxt[i][si]; sNxt = (state>>(nx<<1))&3; nextState = state ^ (si << (i << 1)) ^ (((si + 1) & 3) << (i << 1)) ^ (sNxt << (nx << 1)) ^ (((sNxt + 1) & 3) << (nx << 1)); //如果没有访问过就可以转移新状态了 if(!g[nextState]){ g[nextState] = g[state] + 1; fa[nextState] = state; //用于回溯 choice[nextState] = i + 1; q.push(node(nextState)); } } } int cnt = 0,state = 0; while(state != Start){ ans[++cnt] = choice[state]; state = fa[state]; } printf("%d\n",cnt); for(int i = cnt;i;i--) printf("%d ",ans[i]); return 0; }
证明正确性,只需证明 \(\mathrm{A^*}\) 在选择到目标节点 \(t\) 时(即在优先队列 \(top\) 时),目标节点的最优解已找到。
令 \(x\) 为任意扩展到的节点(优先队列中的元素),有 \(F(t)\le F(x)\) 。
- 由于 \(h(x)\le h^*(x)\) ,所以 \(F(t)\le F(x) \le F^*(x)\) 。
- 根据 \(F^*\) 的定义,\({F^*(x)}\) 中必存在一个值为到目标节点的最优解,记为 \(F^*(s)\) ,仍有 \(F(t)\le F^*(s)\)。
- 因为 \(t\) 为目标节点,所以 \(h(t) = 0 ,F(t)=g(t)+h(t)=g(t)\le F^*(s)\)
- \(F(t)=g(t)\) 是一个可行解,必有 \(g(t)\ge F^*(s)\)
- 故 \(F(t)=g(t)=F^*(s)\) , 即 \(F(t)\) 为最优解,证毕。
做完这道题建议去做一下 P1379 八数码难题 ,可以同时用单向 \(\mathrm{BFS}\) ,双向 \(\mathrm{BFS}\) ,\(\mathrm{A^*}\) 和 \(\mathrm{IDA^*}\) 做这道题,如果每个方法都写一下一定受益良多🐤。
本文作者:ailanxier
本文链接:https://www.cnblogs.com/ailanxier/p/13501529.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步