AcWing 179 八數碼

題目描述:

在一個3×3的網格中,1~8這8個數字和一個“X”恰好不重不漏地分佈在這3×3的網格中。

例如:

1 2 3
X 4 6
7 5 8

在遊戲過程中,可以把“X”與其上、下、左、右四個方向之一的數字交換(如果存在)。

我們的目的是通過交換,使得網格變爲如下排列(稱爲正確排列):

1 2 3
4 5 6
7 8 X

例如,示例中圖形就可以通過讓“X”先後與右、下、右三個方向的數字交換成功得到正確排列。

交換過程如下:

1 2 3   1 2 3   1 2 3   1 2 3
X 4 6   4 X 6   4 5 6   4 5 6
7 5 8   7 5 8   7 X 8   7 8 X

把“X”與上下左右方向數字交換的行動記錄爲“u”、“d”、“l”、“r”。

現在,給你一個初始網格,請你通過最少的移動次數,得到正確排列。

輸入格式

輸入佔一行,將3×3的初始網格描繪出來。

例如,如果初始網格如下所示:
1 2 3

x 4 6

7 5 8

則輸入爲:1 2 3 x 4 6 7 5 8

輸出格式

輸出佔一行,包含一個字符串,表示得到正確排列的完整行動記錄。如果答案不唯一,輸出任意一種合法方案即可。

如果不存在解決方案,則輸出”unsolvable”。

輸入樣例:

2  3  4  1  5  x  7  6  8 

輸出樣例

ullddrurdllurdruldr

分析:

八數碼問題是典型的求從一種狀態轉化到另一種狀態的最短步數模型,可以使用BFS實現,本題要輸出合法方案,之前寫過只輸出最小步數的八數碼問題的BFS的解法的題解AcWing 845 八數碼,當然,這裏將使用更高效的啓發式搜索算法A*去求解,人們經常把BFS算法、dijkstra算法以及A*算法放在一起進行比較,也可以看出A*算法的難度與前兩種算法的難度差不多,但是第一次接觸要想很快理解A*算法,也是比較困難的。網上有不少什麼關於A*算法詳解的文章,號稱最好最全,看後完全懂了,且不說是否講的全面,反正我是沒耐心看下去,這些文章基本與通俗易懂這個詞無關,唯一看見一篇寫A*的覺得很適合入門的文章就是A*,那個傳說中的算法了。下面我儘可能按照自己的理解比較精簡的去說下自己印象裏的A*算法。

我們知道,BFS相對於DFS的優勢是狀態多的時候不會爆棧,解決最短路問題效率也比較高。在上一題子串變換中,因爲需要搜索狀態的層數太多,我們不得已才使用了雙向BFS去優化,BFS的劣勢在於狀態擴展得太快,如果一種狀態可以擴展成x種狀態,則第n層就要擴展x^n種狀態,狀態數成指數式趨勢增加,空間和時間都會激增。如果有一種算法能夠在搜索時只去擴展其中一部分狀態,卻又能很快的找到終點狀態的最短路徑,就可以大幅提高搜索的效率了。BFS算法是一層層的擴展裏起點距離是d的狀態,只要從起點經過d步可以轉化到的狀態,都會毫無遺漏的被BFS擴展到,dijkstra算法也是每次選擇還沒加入點集中的離起點最近的頂點去擴展,爲了說明方便,我就用一維的模型去描述搜索的過程了。

如上圖所示,BFS和dijkstra都是按離起點的路徑由近到遠逐漸擴展狀態的,或者說,我們對終點的位置一無所知,只能左顧右盼,不停的跳躍式搜索,直至搜到終點。但實際上往往我們對終點的信息並不是一無所知,而且恰恰相反,我們是知道終點是什麼纔去搜索的。比如起點是0,終點是100,BFS搜索狀態的順序往往類似於-1,1,-2,2,-3,3,...,-100,100,我們既然知道100在0的右邊,爲何還要去搜索左邊呢?直接1,2,3,...,100不就行了。在這個簡單的一維模型中利用上終點的信息我們都減少了一半狀態的搜索,在實際問題中能夠砍掉的狀態遠不止原狀態的一半。總結下這個簡單例子帶給我們的啓發,我們之前的搜索算法只關注當前搜索狀態到起點的距離,只利用了起點的信息,如果有一種算法能夠把終點的信息也考慮進來,那麼效率將大幅提高。

現在假設我們在一個迷宮的起點,我們想找到到達迷宮終點的最短路徑,並且嘗試過的位置儘可能的少,顯然BFS需要搜索的冗餘狀態太多了。假設我們知道的信息是當前位置到起點和終點的距離,設當前位置是u,g(u)表示u到起點的距離,d(u)表示u到終點的距離,我們需要轉移到下一個位置上。現在有三個候選位置:

v1: g(v1) = 1,d[v1] = 9;

v2: g(v2) = 4,d[v2] = 4;

v3: g(v3) = 8,d[v3] = 2。

我們會優先考慮擴展到哪個狀態呢?BFS和dijkstra的思路肯定是擴展到離起點最近的v1點,但是可以發現1 + 9並不是最短路徑,所以v1點不會是最短路徑上的點,這次擴展並沒有必要。如果你想盡快到達終點,肯定優先考慮擴展到裏終點最近的點v3,因爲再有2步就到達終點了,到達終點時候發現8 + 2依舊不是最短路徑長度,不滿足我們要以最短路徑到達終點的條件,這次擴展也是徒勞的,所以我們最終會選擇擴展到v2點,儘管它裏起點和終點都不是最近,但是4 + 4是最小的,選擇v2既能夠保證選擇的路徑是最短的,又能夠保證不去擴展明顯不必要的狀態。這個例子給我們的啓發是,在知道狀態到起點和終點的距離的前提下,我們需要優先選擇狀態中裏起點和終點距離和最小的狀態。但是往往我們對終點的信息知道的並沒有那麼清楚,比如要求最短路徑的長度,如果我們一開始就知道起點到終點的距離,那就沒必要進行搜索了,我們知道的可能只是部分信息,利用這些信息可以估計當前狀態到終點狀態大致的距離,我們將計算某個狀態到目標狀態所需代價的估計值的函數稱爲估價函數。

現在可以開始講解A*算法了,因爲已經引出了A*算法的核心估價函數。除了BFS和DFS外,還有一種使用較多的搜索叫PFS,優先擴展優先級最高的狀態,比如dijkstra就可以視爲一種PFS算法,優先級被設置爲離起點最近的距離。A*算法只不過需要修改dijkstra算法在優先級設置上的定義,其它過程完全一致。A*算法流程如下:

將初始狀態加入優先級隊列,當優先級隊列非空時,執行以下操作:

取出隊頭元素,(優先級隊列中優先級被設置爲g[u] + f[u]越小優先級越高,其中g[u]表示u到起點的距離,f[u]表示u到終點的估計距離),將隊頭元素能夠擴展到的狀態加入到優先級隊列中。

當目標狀態第一次出隊時,結束搜索,當前目標狀態離起點的距離就是最短距離。

知道了上面這麼多,A*算法算是已經入門一半了,還有一半可能大多數文章一筆帶過,但是個人覺得還是相當重要的,如果我不說這麼多,直接說A*算法就是將優先級改爲g[u] + f[u]最小的dijkstra算法,相信大多數人找到如何去實現,但是不明白其中緣由的。下面繼續分析A*算法的核心估價函數的選取。在百科中,我們不難看到有以下描述:

我們以d(n)表達狀態n到目標狀態的距離,那麼f(n)的選取大致有如下三種情況:

  1. 如果f(n)< d(n)到目標狀態的實際距離,這種情況下,搜索的點數多,搜索範圍大,效率低。但能得到最優解。
  2. 如果f(n)=d(n),即距離估計f(n)等於最短距離,那麼搜索將嚴格沿着最短路徑進行, 此時的搜索效率是最高的。
  3. 如果 f(n)>d(n),搜索的點數少,搜索範圍小,效率高,但不能保證得到最優解。

估價函數選取的一個重要標準就是f(n) <= d(n),即估計距離一定要小於實際距離,不然搜索得到的不一定是最優解。我覺得很多文章的缺陷在於沒有把上面三點的原因說出來,儘管原因是顯然的,但是我還是在這裏卡了不少時間。

如上圖所示,B,C節點到終點的距離均被高估了,要求A到D的最短路徑長度,首先將初始狀態A加入到隊列,出隊,加入相鄰的兩個狀態B、C,g(B) + f(B) = 1 + 6 = 7,g(C) + f(C) = 1 + 10 = 11,此時優先級隊列的隊頭是B,故將B出隊,加入相鄰的狀態D,此時g(D) + f(D) = 6 + 0 = 6,優先級隊列中有CD兩個狀態,並且D位於隊頭,將D出隊,由於D是終點,則終止算法,輸出最短距離6。我們分析,當B、C到終點的估計距離比實際距離大時,求出的最短路徑長度是6,而不是我們的最優解4,這就是估價函數的值爲什麼不能大於實際值的原因,可能會導致搜不到最優解。當f(n) = d(n)時,正如開頭舉的例子,可以特別迅速的搜到最優解,當f(n) = 0時,優先級隊列的優先級完全取決於g(n),這就是dijkstra,效率較低,也就是說,估價函數值不能大於實際值,且與實際值越接近效率越高。下面再以一個實際例子證明爲什麼估價函數值小於真實值時,終點狀態第一次出隊時一定是最優解。

現在B、C到終點的距離均被低估了,並且B低估的更多,還是先加入A,然後A出隊加入BC,g(B) + f(B) = 1 + 1 = 2,g(C) + f(C) = 1 + 2 = 3,所以B在隊頭,B出隊,將相鄰狀態D入隊,g(D) + f(D) = 6 + 0 = 6,隊頭元素是C,將C的相鄰狀態D入隊,由於D是第二次入隊,我用D1表示區分開,g(D1) + f(D1) = 4 + 0 = 4,D1在隊頭,所以先出隊,此時D1的最短距離恰好是最優解4。通過這個例子可以看出兩點,第一點,每個狀態可能多次入隊,但是終點第一次出隊時一定是最優解。第二點就是爲何估價函數小於真實值時一定能搜到最優解。正如例子所示,終點狀態第一次入隊求得的距離不是最短距離,設最短距離是d,而這次入隊求得的最短距離是d1,則d1 > d,又終點狀態第一次入隊時隊列中一定含有最優路徑中的點(比如上圖中的C點),並且g(C) + f(C) <= g(C) + d(C) = d > d1,所以C點必然先於終點出隊,並將真正的終點狀態D1入隊,正因爲最短路徑上點的估價函數被低估,纔會導致還沒被更新到最優解的終點入隊後不可能優先級最高,不會提前出隊。這就是關於估價函數三個說明的解釋。

關於估價函數,還有兩點要補充。第一點是知道了估價函數不能超過真實值,那麼要如何確定估價函數,在開頭給出的參考文章中已經詳細說明了,一般定義估價函數爲曼哈頓距離(行距離+列距離)或者歐氏距離(座標之間的距離)。第二點是BFS、dijkstra和A*的區別。BFS每個頂點只會入隊一次,所以已經入隊的頂點不會再入隊;dijkstra算法中每個狀態可能被更新多次,但是已經出過隊的狀態離起點的距離已經是最短的了,不會再去更新其他狀態了,所以可以在出隊時剪枝。A*中由於優先級就是經過當前點起點到終點的估計距離,所以某個點第一次出隊時離起點的距離不一定是最小的,還可能繼續被更新,所以不能設置訪問數組,已經出過隊的節點再次出隊時依舊要繼續去更新狀態。比如兩條路徑A-B-C-D和A-E-D,D不是終點,任意相鄰兩點間路徑長度均是1,ABCD的估價函數值均是0,E的估價函數值是100,所以當A*沿着ABCD擴展到D時,儘管此刻D離起點A的距離不是最近的,但是由於到D的最短路要經過E,E的估價函數太大,導致經過ABC到達D的路徑更新的D先出隊去更新後面的狀態,當塊更新到終點時,發現走錯了纔會回來重新從E擴展到D。正因爲除了起點終點外的點可能多次被擴展,所以不能用訪問數組去剪枝。

關於A*算法已經說的太多了,下面就簡要的說下八數碼問題如何用A*算法解決。首先將x橫着移動,不改變序列中逆序對的數目,豎着移動,將導致與x相鄰的兩個元素與x的相對位置反轉了,所以有要麼這三個元素見是逆序對數目從0變成2,要麼從2變成0橫着從1變成1,也就是說,移動x並不會導致逆序對總數奇偶性的變化,所以當逆序對數目爲奇數時,就可以判定爲無解。

本題用A*具體的實現過程見代碼,已經加了足夠多的註釋:

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <string>
#include <queue>
#include <cmath>
#include <unordered_map>
using namespace std;
string en = "12345678x";
typedef pair<int,string>    PIS;
priority_queue<PIS,vector<PIS>,greater<PIS> > q;//優先級隊列,{g[u]+f[u],u}
unordered_map<string,int> d;//到起點距離
unordered_map<string,pair<string,char> > pre;//存儲上一步操作
int dx[] = {-1,1,0,0};
int dy[] = {0,0,-1,1};
char op[] = {'u','d','l','r'};
int f(string st){//估值函數,哈密頓距離
    int res = 0;
    for(int i = 0;i < st.size();i++){
        if(st[i] != 'x'){//其他位置歸位,x自然歸位了
            int dis = st[i] - '1';
            int x = dis / 3,y = dis % 3;
            res += abs(x - i / 3) + abs(y - i % 3);
        }
    }
    return res;
}
void bfs(string st){
    d[st] = 0;
    q.push({f(st),st});
    while(q.size()){
        string t = q.top().second;//出隊後只會用到狀態本身
        q.pop();
        if(t == en)    break;
        int loc,dis = d[t] + 1;
        for(int i = 0;i < t.size();i++){//尋找x的位置
            if(t[i] == 'x'){
                loc = i;
                break;
            }
        }
        string s = t;
        for(int i = 0;i < 4;i++){
            int nx = loc / 3 + dx[i],ny = loc % 3 + dy[i];
            if(nx < 0 || nx >= 3 || ny < 0 || ny >= 3)  continue;
            swap(t[loc],t[nx * 3 + ny]);
            if(!d.count(t) || d[t] > dis){//未被擴展過或者擴展後最短距離變小才擴展
                d[t] = dis;
                pre[t] = {s,op[i]};
                q.push({d[t] + f(t),t});
            }
            swap(t[loc],t[nx * 3 + ny]);//恢復狀態
        }
    }
}
int main(){
    string c,st,seq,res;
    while(cin>>c){
        st += c;
        if(c != "x")    seq += c;
    }
    int cnt = 0;
    for(int i = 0;i < seq.size();i++){
        for(int j = i + 1;j < seq.size();j++){
            if(seq[i] > seq[j]) cnt++;
        }
    }
    if(cnt & 1) puts("unsolvable");
    else{
        bfs(st);
        while(st != en){
            res += pre[en].second;
            en = pre[en].first;
        }
        cout<<string(res.rbegin(),res.rend())<<endl;
    }
    return 0;
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章