帶你學習BFS最小步數模型

時間最考驗人發表於2022-01-21

最小步數模型

一、簡介

最小步數模型和最短路模型的區別?

最短路模型:某一個點到另一個點的最短距離(座標與座標之間)

最小步數模型:不再是點(座標),而是狀態到另一個狀態的轉變

BFS難點所在(最短路問題):

  1. 儲存的資料結構:佇列

    狀態如何儲存到佇列裡邊(以什麼形式)?

  2. 狀態怎麼表示,怎麼轉移?

3. dist

如何記錄每一個狀態的距離

技巧:在最小步數模型中狀態和狀態的距離通常用雜湊表來進行儲存(存在key-value的對映關係!),如mapunordered_map

思路:將初始狀態加入佇列,然後去搜尋擴充套件,直到搜尋到目標狀態為止。

注:

​ 在搜尋過程中可能由狀態的切換,如一維座標切換到二維座標,字串切換到二標座標形式的狀態等等!

二、練習

1. 八數碼

【題目連結】845. 八數碼 - AcWing題庫

在一個 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

現在,給你一個初始網格,請你求出得到正確排列至少需要進行多少次交換。

輸入格式

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

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

1 2 3 
x 4 6 
7 5 8 

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

輸出格式

輸出佔一行,包含一個整數,表示最少交換次數。

如果不存在解決方案,則輸出 −1−1。

輸入樣例:

2  3  4  1  5  x  7  6  8

輸出樣例

19

1、題目的目標

image

求最小步數 -> 用BFS

2、移動情況

image

移動方式:

轉以後:a = x + dx[i], b = y + dy[i].

思想:把每一個狀態看作圖論的一個節點(節點a到節點b的距離為1——狀態能轉移)——> 起點到終點最少需要多少步

從初始狀況移動到目標情況 —> 求最短路

3、問題

第一點:狀態如何儲存到佇列裡?

第二點:如何記錄每一個狀態的“距離”(即需要移動的次數)?

第三點:佇列怎麼定義(佇列儲存的是狀態,狀態可以用字串表示),dist陣列怎麼定義(每一個狀態到起始狀態的距離——兩個關鍵字:狀態(字串)、距離(int))——key-value?——雜湊表

4、解決方案

將 “3*3矩陣” 轉化為 “字串”

如:

image

所以:

佇列可以用 queue<string>
//直接存轉化後的字串
dist陣列用 unordered_map<string, int>
//將字串和數字聯絡在一起,字串表示狀態,數字表示距離

5、矩陣與字串的轉換方式

image

【參考程式碼】

#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>
#include<unordered_map>

using namespace std;

//狀態轉移
int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};

int bfs(string strat)
{
    //定義目標狀態、
    string end = "12345678x";
    //定義佇列和dist陣列
    queue<string> q;
    unordered_map<string, int> d;
    
    //初始化佇列和dist陣列
    q.push(strat);
    d[strat] = 0;
    
    while(q.size())
    {
        // 獲取隊頭元素,彈出佇列
        auto t = q.front();
        q.pop();
        //記錄當前狀態的距離,如果為最終狀態則返回距離結果
        int distance = d[t];
        if(t == end) return d[t];
        
        //查詢x在一位陣列中的下標,進行狀態轉換
        int k = t.find('x');
        int x = k / 3, y = k % 3;
        
        //擴充套件隊頭元素(鄰接節點入隊)
        for(int i = 0; i < 4; i ++)
        {
            //轉移後的x的座標(鄰接節點)
            int a = x + dx[i], b = y + dy[i];
            //沒有越界
            if(a >= 0 && b >= 0 && a < 3 && b < 3)
            {
                //轉移x
                swap(t[k], t[a * 3 + b]);
                //如果當前狀態是第一次遍歷,記錄距離,入隊
                if(!d.count(t))
                {
                    d[t] = distance + 1;
                    q.push(t);
                }
                //還原狀態,為下一種轉換情況做準備!
                swap(t[k], t[a * 3 + b]);
            }
        }
    }
    
    //無法轉換到目標狀態,返回-1
    return -1;
    
}

int main()
{
    
    string strat;
    // 輸入起始狀態
    for (int i = 0; i < 9; i ++ )
    {
        char c;
        cin >> c;
        strat += c;
    }
    
    cout << bfs(strat);
    
}

2.魔板

【題目連結】1107. 魔板 - AcWing題庫

Rubik 先生在發明了風靡全球的魔方之後,又發明了它的二維版本——魔板。

這是一張有 8 個大小相同的格子的魔板:

1 2 3 4
8 7 6 5

我們知道魔板的每一個方格都有一種顏色。

這 8 種顏色用前 8 個正整數來表示。

可以用顏色的序列來表示一種魔板狀態,規定從魔板的左上角開始,沿順時針方向依次取出整數,構成一個顏色序列。

對於上圖的魔板狀態,我們用序列 (1,2,3,4,5,6,7,8) 來表示,這是基本狀態。

這裡提供三種基本操作,分別用大寫字母 A,B,C 來表示(可以通過這些操作改變魔板的狀態):

A:交換上下兩行;
B:將最右邊的一列插入到最左邊;
C:魔板中央對的4個數作順時針旋轉。

下面是對基本狀態進行操作的示範:

A:

8 7 6 5
1 2 3 4

B:

4 1 2 3
5 8 7 6

C:

1 7 2 4
8 6 3 5

對於每種可能的狀態,這三種基本操作都可以使用。

你要程式設計計算用最少的基本操作完成基本狀態到特殊狀態的轉換,輸出基本操作序列。

注意:資料保證一定有解。

輸入格式

輸入僅一行,包括 8 個整數,用空格分開,表示目標狀態。

輸出格式

輸出檔案的第一行包括一個整數,表示最短操作序列的長度。

如果操作序列的長度大於0,則在第二行輸出字典序最小的操作序列。

資料範圍

輸入資料中的所有數字均為 1 到 88 之間的整數。

輸入樣例:

2 6 8 4 5 7 3 1

輸出樣例:

7
BCABCCB

思路:

  • 將初始狀態加入佇列,然後去搜尋擴充套件,直到搜尋到目標狀態為止。每一次擴充套件ABC三種操作後,如果該狀態沒被遍歷過,更新最短距離並加入佇列。

  • 由於要儲存路徑,因此我們開一個pre陣列,記錄當前狀態的前驅狀態,最終由終點逆推回去即可獲得整個路徑(上一篇部落格的迷宮問題也是儲存路徑),在記錄前驅狀態的同時也把操作方式記錄下來

  • 狀態的儲存:看程式碼解釋

  • 狀態的切換:字串與一個2行4列的一個二維表的相互切換,以這個二維表為橋樑具體實現A、B、C三種操作

我們只需要按照先進行操作A,再B後C的操作順序,最終得到的結果就會滿足字典序最小。

【程式碼實現】

#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>
#include <unordered_map>

using namespace std;

char g[2][4];//狀態圖(狀態圖作為操作狀態轉換的一個橋樑)
// key-value
unordered_map<string, int> dist;// 記錄到當前狀態的最小步數
unordered_map<string, pair<char, string>> pre;// 儲存當前狀態的前驅,和操作方式
queue<string>q;

//在操作前 先把字串變化到 2行4列的圖表狀態,方便我們操作的實現
void set(string state)
{
    for(int i = 0; i < 4; i ++) g[0][i] = state[i];
    for(int i = 7, j = 0; j < 4; i --, j ++) g[1][j] = state[i];
}
//在set得到的圖基礎上 在進行了ABC操作後得到新的狀態圖 將改圖轉換為狀態字串,最為操作後得到的字串結果返回
string get()
{
    string res;
    for(int i = 0; i < 4; i ++) res += g[0][i];
    for(int j = 3; j >= 0; j --) res += g[1][j];  
    return res;
}


string move0(string state)//操作A:交換上下兩行
{
    set(state);
    for(int i = 0; i < 4; i ++) swap(g[0][i], g[1][i]);
    return get();
}

string move1(string state)//操作B:將最右邊的一列插入到最左邊
{
    set(state);
    //(先將最後一列存下來,將前3列後移一列,最後一列移到最左邊)
    char v0 = g[0][3], v1 = g[1][3];
    for(int i = 3; i >= 0; i --)
    {
        g[0][i] = g[0][i - 1];
        g[1][i] = g[1][i - 1];
    }
    g[0][0] = v0, g[1][0] = v1;
    return get();
}

string move2(string state)//操作C:魔板中央對的4個數作順時針旋轉
{
    set(state);
    char v = g[0][1];
    g[0][1] = g[1][1];
    g[1][1] = g[1][2];
    g[1][2] = g[0][2];
    g[0][2] = v;
    return get();
    
}

int bfs(string start, string end)
{
    if(start == end) return 0;//如果一開始的狀態即為目標狀態

    q.push(start);
    dist[start] = 0;
    
    //擴充套件隊頭元素
    while(q.size())
    {
        auto t = q.front();
        q.pop();
        
        //每種狀態有三種操作方式,每次操作後得到對應的字串結果
        string m[3];
        m[0] = move0(t);
        m[1] = move1(t);
        m[2] = move2(t);
        
        //遍歷三種狀態方式,看看選哪個(按照先A後B再C操作得到的結果滿足字典序從小到大)
        for(int i = 0; i < 3; i ++)
        {
            if(!dist.count(m[i]))//如果這個狀態沒有被遍歷過
            {
                q.push(m[i]);
                dist[m[i]] = dist[t] + 1;//更新步數
                pre[m[i]] = {'A' + i ,t};//將操作方式,是該鄰接點的前驅記錄下來
                if(m[i] == end) return dist[end];// 如果在遍歷中發現已經到達目標狀態直接返回結果
            }
        }
        
    }
    
    return -1;
}

int main()
{
    string start, end;
    start = "12345678";
    for(int i = 0; i < 8; i ++)
    {
        int x;
        cin >> x;
        end += (x + '0');
    }
    // cout << end << endl;
    
    int step = bfs(start, end);
    cout << step << endl;
    
    //通過終點的前驅逐一反推獲取操作的方式
    string res;
    while (end != start)
    {
        res += pre[end].first;//操作
        end = pre[end].second;//狀態字串
    }

    reverse(res.begin(), res.end());//由於是逆推求前驅,因此結果要翻轉

    if (step > 0) cout << res << endl;
    
    return 0;
}

三、總結

最小步數模型的難點在於狀態的表示、切換還有儲存,理清它們之間的邏輯關係程式碼就好實現了!

技巧:一維陣列與二維陣列座標的轉換

設[一維陣列]下標為index(從0開始),二維陣列長度為m * n,則:

一維陣列轉換為二維陣列

x = index / n 
y = index % n

二維陣列轉換為一維陣列

index = x + y * n

技巧:在最小步數模型中狀態和狀態的距離通常用雜湊表來進行儲存(存在key-value的對映關係!),如mapunordered_map

技巧:BFS儲存路徑問題,通常通過設定一個前驅pre陣列來記錄當前節點或者狀態的前驅,最後再逆推找出路徑

學習內容參考:
acwing演算法基礎課、提高課

注:如果文章有任何錯誤或不足,請各位大佬盡情指出,評論留言留下您寶貴的建議!如果這篇文章對你有些許幫助,希望可愛親切的您點個贊推薦一手,非常感謝啦

相關文章