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


題目鏈接 P5507 機關

題意簡述

  有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}\) 搜索樹的生長受到對面搜索樹的限制,不要過於野蠻生長,偏離目標太遠。自己畫了一張很丑很丑的對比圖,應該可以便於理解。
img
  可以看到雙向 \(\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^*}\) 做這道題,如果每個方法都寫一下一定受益良多🐤。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM