双向BFS和启发式搜索的应用
题意简述
有12个旋钮,每个旋钮开始时处于状态 \(1\) ~ \(4\) ,每次操作可以往规定方向转动一个旋钮 (\(1\Rightarrow2\Rightarrow3\Rightarrow4\Rightarrow1\)) ,并且会触发一次连锁反应:处于某个状态的旋钮在旋转时会引起另一个旋钮发生相同方向的转动(另一个旋钮转动不会再触发连锁反应)。问将12个旋钮都置为 \(1\) 至少需要几次转动,并输出每次转动的旋钮编号。
单向BFS
  直接暴力地进行单向 \(BFS\) ,每次转动都有 \(12\) 种选择,时间复杂度是 \(O(12^{step})\) ,看数据范围,最高的步数可达 \(17\) 步,必定 \(TLE\) 。但是这样简单如果优化的比较好可以得 \(50\) ~ \(60\) 分(没吸氧气,吸了氧气反而更低了)。
  单向BFS评测记录
  超时的主要原因是搜索树过于庞大,而我们会发现本题起始状态和终止状态是明确的,这时我们就可以使用神奇的双向 \(BFS\) 来限制搜索树的生长。
双向BFS
  双向 \(BFS\) 非常适合在起始状态和终止状态明确的条件下使用,做法是从起点和终点同时进行单向 \(BFS\) ,让两边 \(BFS\) 搜索树的生长受到对面搜索树的限制,不要过于野蛮生长,偏离目标太远。自己画了一张很丑很丑的对比图,应该可以便于理解。

  可以看到双向 \(BFS\) 可以在某一状态发现相同时就停止了,通过回溯可以找到沿路选择的点。再看看本题的数据范围,最大的点正向和反向 \(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\) 个旋钮转动。
状态转移
首先正向和反向的 \(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,当前方向为 \(direction\) ,当前所有按钮状态为 \(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;
}
启发式搜索
双向 \(BFS\) 已经够快了,但是我们可以使用更快的启发式搜索。常用的启发式搜索有 \(IDA*\) 和 \(A*\) ,听说前者被卡了,我们就用 \(A*\) 吧。即使你可能不知道什么是 \(A*\) 算法(我做这题的时候就没听说过),也可以继续往下看。
在 \(A*\) 算法中,我们要利用当前状态的信息对状态进行评价,以此来决定下一次的操作,极大地限制了搜索树的生长。首先介绍一个特别的估价函数 \(F^*\) 来表示:\(F^*(x)=g^* (x)+h^*(x)\) 。其中 \(g^* (x)\) 表示从初始状态到当前状态所付出的最小代价(在本题中意义为操作步数),而 \(h^*(x)\) 是从当前状态到目标状态走最佳路径所付出的代价。在实际代码中我们使用的其实是 \(F(x)=g (x)+h(x)\) ,因为我们实际上是不知道这个加星后的函数的,但是我们可以通过一些限制,让不加星的函数也可以在一定范围内求解出正确答案;
- \(g(x)\) 是对 \(g^*(x)\) 的估计,且 \(g(x)>0\) ,在代码中我们记录的就是步数;
- \(h(x)\) 是 \(h^*(x)\) 的下界,即对任意状态均有 \(h(x)≤h^*(x)\)。在代码中我们定义为 \(12\) 个旋钮在不考虑牵连时都转到 \(1\) 要多少步,再除以 \(2\) ,这样就可以保证 \(h(x)\) 肯定会比实际要转的次数要少(一次操作恰好就可以让两个旋钮都向目标状态转一次,而实际上可能会让某个旋钮转过目标状态,从而要转更多次数),
\(h(x)\) 是一个比较玄学的东东,没有唯一的定义,不同的定义可能会导致程序执行效率和结果不同,这题中你还可以乘一个系数给他,能明显加快运行效率。经过笔者多次测试,发现给 \(h\) 乘上系数从 \(1.1\) ~ \(2.3\) 都能 \(AC\) 这道题,但是乘 \(2.4\) 时会 \(WA\) 掉一个点。变化趋势是这个系数越大,跑得越快,最大的点可以跑进 \(100ms\) 。这是因为系数越大越接近真实值 \(h^*(x)\),但是更大的系数不能保证一定可以得到最优解。
代码实现类似 \(Dijkstra\) 算法,定义一个结构体存状态和这个状态对应的估价函数值 \(F\) 。每次从小根堆中取出 \(F\) 最小的状态进行转移,存状态和转移状态的操作和上面双向 \(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;
    int flag = 1;
    while(!q.empty()&&flag){
        int state = q.top().state;
        q.pop();
        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;
                if(nextState == 0) { flag = 0;break;}  //到达目标状态
                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;
}
对于 \(A^*\) 算法我可能有些地方描述不够严谨,如果有错误的地方欢迎指出。做完这道题建议去做一下 P1379 八数码难题 ,可以同时用单向 \(BFS\) ,双向 \(BFS\) ,\(A^*\) 和 \(IDA^*\) 做这道题,如果每个方法都写一下一定受益良多。
双向BFS和启发式搜索的应用的更多相关文章
- POJ1915Knight Moves(单向BFS + 双向BFS)
		题目链接 单向bfs就是水题 #include <iostream> #include <cstring> #include <cstdio> #include & ... 
- HDU 3085 Nightmare II 双向bfs 难度:2
		http://acm.hdu.edu.cn/showproblem.php?pid=3085 出的很好的双向bfs,卡时间,普通的bfs会超时 题意方面: 1. 可停留 2. ghost无视墙壁 3. ... 
- POJ 3170 Knights of Ni (暴力,双向BFS)
		题意:一个人要从2先走到4再走到3,计算最少路径. 析:其实这个题很水的,就是要注意,在没有到4之前是不能经过3的,一点要注意.其他的就比较简单了,就是一个双向BFS,先从2搜到4,再从3到搜到4, ... 
- [转] 搜索之双向BFS
		转自:http://www.cppblog.com/Yuan/archive/2011/02/23/140553.aspx 如果目标也已知的话,用双向BFS能很大程度上提高速度. 单向时,是 b^le ... 
- 双向BFS
		转自“Yuan” 如果目标也已知的话,用双向BFS能很大提高速度 单向时,是 b^len的扩展. 双向的话,2*b^(len/2) 快了很多,特别是分支因子b较大时 至于实现上,网上有些做法是用两个 ... 
- HDU 3085 Nightmare Ⅱ (双向BFS)
		Nightmare Ⅱ Time Limit: 2000/1000 MS (Java/Others) Memory Limit: 32768/32768 K (Java/Others) Tota ... 
- HDU 3085 Nightmare Ⅱ 双向BFS
		题意:很好理解,然后注意几点,男的可以一秒走三步,也就是三步以内的都可以,鬼可以穿墙,但是人不可以,鬼是一次走两步 分析:我刚开始男女,鬼BFS三遍,然后最后处理答案,严重超时,然后上网看题解,发现是 ... 
- POJ 3126 Prime Path 解题报告(BFS & 双向BFS)
		题目大意:给定一个4位素数,一个目标4位素数.每次变换一位,保证变换后依然是素数,求变换到目标素数的最小步数. 解题报告:直接用最短路. 枚举1000-10000所有素数,如果素数A交换一位可以得到素 ... 
- Hdu1401-Solitaire(双向bfs)
		Solitaire is a game played on a chessboard 8x8. The rows and columns of the chessboard are numbered ... 
随机推荐
- 坚果云如何使用二次验证码/谷歌身份验证器/两步验证/虚拟MFA?
			一般步骤:登陆后点邮箱名——安全设置——开通两步验证,用二次验证码微信小程序绑定即可 具体步骤见链接 坚果云如何使用二次验证码/谷歌身份验证器/两步验证/虚拟MFA? 二次验证码小程序于谷歌身份验证 ... 
- Java应用服务器之tomcat session server msm搭建配置
			在上一篇博客中,我们介绍了tomcat自带的cluster组件配置session replication cluster,回顾请参考https://www.cnblogs.com/qiuhom-187 ... 
- Android集成Zxing
			1.在build文件中添加依赖 dependencies { //ZXing implementation 'com.google.zxing:core:3.3.3' implementation(' ... 
- PHP 实例 - AJAX 与 XML-AJAX XML 实例
			PHP 实例 - AJAX 与 XML AJAX 可用来与 XML 文件进行交互式通信. AJAX XML 实例 下面的实例将演示网页如何通过 AJAX 从 XML 文件读取信息: 实例 Sele ... 
- PHP pow() 函数
			实例 pow() 的实例: <?phpecho(pow(2,4) . "<br>");echo(pow(-2,4) . "<br>" ... 
- PHP xml_get_current_byte_index() 函数
			定义和用法 xml_get_current_byte_index() 函数获取 XML 解析器的当前字节索引.高佣联盟 www.cgewang.com 如果成功,该函数则返回当前字节索引.如果失败,则 ... 
- C/C++编程笔记:C语言贪吃蛇源代码控制台(二),分数和食物!
			接上文<C/C++编程笔记:C语言贪吃蛇源代码控制台(一),会动的那种哦!>如果你在学习C语言开发贪吃蛇的话,零基础建议从上一篇开始哦!接下来正式开始吧! 三.蛇的运动 上次我已经教大家画 ... 
- Java异步之《我call(),Future在哪里》
			我们大家都知道,在 Java 中创建线程主要有三种方式: 继承 Thread 类: 实现 Runnable 接口: 实现 Callable 接口. 而后两者的区别在于 Callable 接口中的 ca ... 
- 【BZOJ3307】雨天的尾巴 题解(树链剖分+树上差分)
			题目链接 题目大意:给定一颗含有$n$个结点的树,每次选择两个结点$x$和$y$,对从$x$到$y$的路径上发放一带$z$类型的物品.问完成所有操作后每个结点发放最多的时哪种物品. 普通的树链剖分貌似 ... 
- 01-java开发环境配置
			1 JDK.JRE.JVM的关系 JDK: java开发工具包 JRE: java运行时环境 JVM: java虚拟机 2 JDK下载 方式一:官网 方式二:该链接提供jdk1.6 ,jdk1.7 , ... 
