1. 程式人生 > >暴力求解-路徑尋找-八數碼問題

暴力求解-路徑尋找-八數碼問題

在此之前介紹過圖的遍歷,很多問題可以歸結為圖的遍歷,但這些問題中的圖卻不是事先給定的、從程式讀入的,而是由程式動態生成的,稱為隱式圖。本節和前面介紹的回溯法不同,回溯法一般是要找一個(或者所有)滿足約束的解(或者某種意義下的最優解),而狀態空間搜尋一般是要找到一個從初始狀態到終止狀態的路徑

八數碼問題。編號為1-8的8個正方形滑塊被擺成3行3列(有一個格子留空),如圖所示。每次可以把與空格相鄰的滑塊(有公共邊才算相鄰)移到空格中,而它原來的位置就成為了新的空格。給定初始局面和目標局面(用0表示空格),你的任務是計算出最少的移動步數。如果無法到達目標局面,則輸出-1。

圖片

樣例輸入:

2 8 3 1 6 4 7 0 5

1 2 3 8 0 4 7 6 5

樣例輸出:

5

執行結果:

不難把八數碼問題歸結為圖上的最短路問題,圖中的結點就是9個格子中的滑塊編號(從上到下,從左到右把它們放到一個包含9個元素的陣列中)。無權圖上的最短路問題可以用BFS求解。

首先是輔助巨集的定義:

typedef int Status[9]; //定義狀態型別
const int maxstatus=1000000;
Status st[maxstatus],goal; //狀態陣列,所有狀態儲存在這裡
int dist[maxstatus];
const int dx[4]={-1,1,0,0};
const int dy[4]={0,0,-1,1};

核心程式碼:

int bfs()
{
    //BFS,返回目標狀態在st陣列下標
    init_lookup_table(); //初始化查詢表
    int front=1,rear=2,i,x,y,z,newx,newy,newz; //不用下標0,因為0被當做不存在
    while(front<rear)
    {
       Status &s=st[front]; //引用簡化程式碼
       if(memcmp(goal,s,sizeof(s))==0) //找到目標狀態 成功返回
           return front;
       for(z=0;z<9;z++) //找0的位置
          if(!s[z])
             break;
       x=z/3; //獲取行列編號
       y=z%3;
       for(i=0;i<4;i++)
       {
          newx=x+dx[i]; //新的結點座標
          newy=y+dy[i];
          newz=newx*3+newy;
          if(legal(newx,newy)) //如果移動合法
          {
              Status &t=st[rear];
              memcpy(&t,&s,sizeof(s)); //拓展新結點
              t[newz]=s[z];
              t[z]=s[newz];
              dist[rear]=dist[front]+1; //更新新結點的距離值
              if(try_to_insert(rear)) //如果成功插入查詢表,修改隊尾指標
                  rear++;
          }
       }
       front++; //拓展完畢後再修改隊首指標
    }
    return 0; //失敗
}

注意,此處用到了cstring中的memcmp和memcpy完成整塊記憶體的比較和複製,比用迴圈比較和迴圈賦值快。

主程式很容易實現:

int main()
{
    int i;
    for(i=0;i<9;i++)
        scanf("%d",&st[1][i]); //起始狀態
    for(i=0;i<9;i++)
        scanf("%d",&goal[i]); //目標狀態
    int ans=bfs();
    if(ans>0)
        printf("%d\n",dist[ans]);
    else
        printf("-1\n");
}

注意,應在呼叫bfs函式之前設定好st[1]和goal。上面的程式碼幾乎是完整的,唯一沒有涉及的是init_lookup_table()和try_to_insert(rear)的實現。為什麼會有這兩項呢?還記得BFS中的判重操作麼?在DFS中可以檢查idx來判斷結點是否已經訪問過:在求最短路的BFS中用d值是否為-1來判斷結點是否訪問過,不管用哪種方法,作用是相同的:比米娜同一個結點訪問多次。樹的BFS不需要判重,因為根本不會重複:但對於圖來說,如果不判重,時間和空間都將產生極大的浪費。

如何判重呢?難道要宣告一個9維陣列vis 然後執行 if(vis[s[0][s[1]][s[2]]...[s[8]])?無論程式好不好看,9維陣列的每維都要包含9個元素,一

共有9^9=387420489項,太多了,陣列開不下。實際的節點數並沒有這麼多,0-8的排列總共只有9!=362880個,為什麼9維陣列開不下呢?原因在於,這樣的用法存在大量的浪費-陣列中有很多項都沒有被用到,但卻佔據了空間。

一種方法是:把排列變成整數,然後只開一個一維陣列,也就是說,設計一套排列的編碼和解碼函式,把0-8的全排列和0-362879的整數一一對應起來。原理很巧妙,時間效率也很高,但編碼解碼法的適用範圍並不大:如果隱式圖的總結點數非常大,編碼也會很大,陣列還是開不下。

還有一種方法是用STL集合t。把狀態轉化為9為十進位制整數,就可以用set<int>判重了。使用STL集合的程式碼最簡單,但時間效率也最低(若此時不用-O2優化則速度劣勢更加明顯)。

我們來重點介紹一下使用雜湊(Hash)技術。雜湊表的執行效率高,適用範圍也很廣。

簡單的說,就是要把結點變成整數,但不必是一一對應,換句話說,只需要設計一個所謂的雜湊函式h(x),然後將任意結點x對映到某個給定範圍[0,M-1]的整數介面,其中M是程式設計師根據可用記憶體大小自選的。在理想情況下,只需開一個大小為M的陣列就能完成判重,但此時往往會有不同結點的雜湊值相同,因此需要把雜湊值相同的的狀態組織成立連結串列。

const int hashsize=1000003;
int next[maxstatus],head[hashsize]; //距離陣列

bool legal(int x,int y) //是否合法
{
    return x>=0&&x<3&&y>=0&&y<3;
}

void init_lookup_table()
{
    memset(head,0,sizeof(head));
}

int hash(Status s)
{
    //確保hash函式值是不超過hash表大小的非負整數
    int i,v=0;
    for(i=0;i<9;i++)
      v=v*10+s[i];
    return v%hashsize;
}

bool try_to_insert(int rear)
{
    //是否重複
    //其中head為連結串列表頭,連結串列中儲存的是相同Hash值的元素在st中的位置。
    int u,h;
    h=hash(st[rear]);
    u=head[h]; //從表頭開始查詢連結串列
    while(u)
    {
        if(memcmp(st[u],st[rear],sizeof(st[rear]))==0) //找到了,查詢失敗
            return false;
        u=next[u]; //順著連結串列繼續找
    }
    next[rear]=head[h]; //插入到連結串列中
    head[h]=rear;
    return true;
}

除了BFS中的結點判重外,還可以用到其他需要快速查詢的地方。不過需要注意的是:雜湊表中,對效率起到關鍵作用的是雜湊函式。如果雜湊函式選取得當,幾乎不會有結點的雜湊值相同,且此時連結串列查詢的速度也較快;但如果衝突嚴重,整個雜湊表會退化成少數幾條長長的連結串列看,查詢的速度將非常緩慢。

某些特定的STL實現中還有hash_set,它正式基於前面的雜湊表,但它並不是標準C++的一部分,因此不是所有情況下都可用。