双向BFS和启发式搜索的应用

题目链接 P5507 机关

题意简述

  有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\) 搜索树的生长受到对面搜索树的限制,不要过于野蛮生长,偏离目标太远。自己画了一张很丑很丑的对比图,应该可以便于理解。
img
  可以看到双向 \(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^*\) 做这道题,如果每个方法都写一下一定受益良多。