雙向BFS和啓發式搜索的應用

題目連接 P5507 機關node

題意簡述

  有12個旋鈕,每一個旋鈕開始時處於狀態 \(1\) ~ \(4\) ,每次操做能夠往規定方向轉動一個旋鈕 (\(1\Rightarrow2\Rightarrow3\Rightarrow4\Rightarrow1\)) ,而且會觸發一次連鎖反應:處於某個狀態的旋鈕在旋轉時會引發另外一個旋鈕發生相同方向的轉動(另外一個旋鈕轉動不會再觸發連鎖反應)。問將12個旋鈕都置爲 \(1\) 至少須要幾回轉動,並輸出每次轉動的旋鈕編號。c++

單向BFS

  直接暴力地進行單向 \(BFS\) ,每次轉動都有 \(12\) 種選擇,時間複雜度是 \(O(12^{step})\) ,看數據範圍,最高的步數可達 \(17\) 步,一定 \(TLE\) 。可是這樣簡單若是優化的比較好能夠得 \(50\) ~ \(60\) 分(沒吸氧氣,吸了氧氣反而更低了)。
  單向BFS評測記錄
  超時的主要緣由是搜索樹過於龐大,而咱們會發現本題起始狀態和終止狀態是明確的,這時咱們就可使用神奇的雙向 \(BFS\) 來限制搜索樹的生長。git

雙向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;   //一、獲取第i+1個旋鈕狀態(0~3)
          nx = nxt[i][si];                       //二、獲取牽連旋鈕編號
          sNext = (state >> (nx << 1)) & 3;      //三、獲取牽連旋鈕狀態,方式同1
          nextState = state ^ (si << (i << 1)) ^ (((si + 1) & 3) << (i << 1)); //四、修改狀態爲第i+1個旋鈕旋轉後的狀態
          nextState ^= (sNext << (nx << 1)) ^ (((sNext + 1) & 3) << (nx << 1)); //五、修改狀態爲牽連旋鈕旋轉後的狀態
      } 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:\)spa

#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;   //一、獲取第i+1個旋鈕狀態(0~3)
                nx = nxt[i][si];                       //二、獲取牽連旋鈕編號
                sNext = (state >> (nx << 1)) & 3;      //三、獲取牽連旋鈕狀態,方式同1
                nextState = state ^ (si << (i << 1)) ^ (((si + 1) & 3) << (i << 1)); //四、修改狀態爲第i+1個旋鈕旋轉後的狀態
                nextState ^= (sNext << (nx << 1)) ^ (((sNext + 1) & 3) << (nx << 1)); //五、修改狀態爲牽連旋鈕旋轉後的狀態
            } 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*\) 算法(我作這題的時候就沒據說過),也能夠繼續往下看。code

  在 \(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^*\) 作這道題,若是每一個方法都寫一下必定受益良多。

相關文章
相關標籤/搜索