1. 程式人生 > >妖怪與和尚過河問題解法完全攻略(C++完整程式碼實現)

妖怪與和尚過河問題解法完全攻略(C++完整程式碼實現)

如圖 1 所示。有三個和尚和三個妖怪(也可翻譯為傳教士和食人妖)要利用唯一一條小船過河,這條小船一次只能載兩個人,同時,無論是在河的兩岸還是在船上,只要妖怪的數量大於和尚的數量,妖怪們就會將和尚吃掉。現在需要選擇一種過河的安排,保證和尚和妖怪都能過河且和尚不能被妖怪吃掉。


圖 1 妖怪與和尚過河遊戲
這其實是一個很簡單的遊戲,過河的策略就是無論何時都要保證在河的任意一側和尚數盤多於妖怪。先來看一種過河的方法:
  • 兩個妖怪先過河,一個妖怪返回;
  • 再兩個妖怪過河,一個妖怪返回;
  • 兩個和尚過河,一個妖怪和一個和尚返回;
  • 兩個和尚過河,一個妖怪返回;
  • 兩個妖怪過河,一個妖怪返回;
  • 兩個妖怪過河。

這個遊戲的答案不止一個,到底有幾個答案呢?寫個演算法來找找吧。

問題與求解思路

題目的初始條件是三個和尚和三個妖怪在河的一邊,和它們在一起的還有一條小船。過河後的情況應該是三個和尚和三個妖怪安全地過到河的對岸,雖然沒有明確提到船的狀態,但是船也應該跟著到了對岸,否則豈不鬧鬼了?

我們看這個問題裡的三個關鍵因素,就是和尚、妖怪和小船,當然,還有它們的位置。假如我們要讓計算機理解這個問題,除了對這三個事物進行描述,還要定義它們的位置資訊。如果把任意時刻妖怪、和尚和小船的位置資訊合在一起看作一個“狀態”,則要解決這個問題只需要找到一條從初始狀態變換到終止狀態的路徑即可。我們可以嘗試使用窮舉方法,遍歷所有由妖怪、和尚和小船的位罝構成的狀態空間,尋找一條或多條從初始狀態到最終狀態的轉換路徑。

從初始狀態開始,通過構造特定的搜尋演算法,對狀態空間中的所有狀態進行窮舉,就得到一棵以初始狀態為根的狀態樹。如果狀態樹上某個葉子節點是題目要求的最終狀態,則從根節點到此葉子節點之間的所有狀態節點就是一個過河問題的解決過程。

建立數學模型

本章介紹的演算法是從一個根狀態開始對狀態空間進行搜尋,其結果也是一棵狀態搜尋樹。解決本問題的演算法關鍵是建立狀態和動作的數學模型,並找到一種持續驅動動作產生的搜尋方法。

本問題並不複雜,因此建立數學模型的工作就“退化”成建立描述問題的資料結構。本問題的狀態模型不僅要能夠描述靜止狀態,還要能夠描述並記錄狀態轉換動作,尤其是對狀態轉換的描述,因為這會影響到狀態樹搜尋演算法的設計。

除此之外,當搜尋演算法找到一個最終狀態時,需要輸出從開始狀態到最終狀態的動作序列,這也需要狀態模型能夠和動作模型結合在一起。下面一起來看看本問題的狀態模型以及狀態樹的設計。

狀態的數學模型與狀態樹

觀察一下本問題的狀態,看起來好像是3個和尚、3和妖怪加上一隻船一共7個屬性,佢是仔細研宄就會發現,3個和尚之間和3個妖怪之間沒有差異,也沒有順序關係,因此在考慮數學模型的時候不需要賦予它們太多的屬性,只要用數量表示它們就可以了。

對於和尚和妖怪的狀態,分別用兩個值表示它們在河兩岸的數量,這樣只需4個屬性就可以表示,分別是河左岸和尚數量、河左岸妖怪數量、河對岸和尚數量和河對岸妖怪數量。

每當有妖怪或和尚隨船的移動發生變化時,只需要修改和尚和妖怪在河兩岸的數量即可完成狀態的轉換。除了和尚和妖怪的數量,還有一個關鍵因素也會影響到狀態的變化,那就是小船的位罝。小船的位罝是個非常重要的狀態屬性,不僅決定了狀態的差異,還會影響後序動作的選擇。

最後的狀態模型中,和尚與妖怪的狀態就是數值,船有兩個列舉狀態,在河左岸(LOCAL)和在河對岸(REMOTE)。我們用一個五元組來表示某個時刻的過河狀態:

[本地和尚數,本地妖怪數,對岸和尚數,對岸妖怪數,船的位罝]

用五元組表示的初始狀態就是 [3,3,0,0, LOCAL],問題解決的過河狀態是 [0, 0,3,3,REMOTE]。

和尚、妖怪和小船的狀態模型定義的資料結構如下所示。
struct Itemstate
{
    int local_monster;
    int local_monk;
    int remote_monster;
    int remote_monk;
    B0AT_L0CATI0N boat;/*LOCAL or REMOTE*/
    ...
};
狀態模型確定以後,整個狀態空間的樹形模型也就確定了。接下來就要確定和尚與妖怪過河的動作模型,過河動作是驅動狀態變化的關鍵。

過河動作的數學模型

河兩岸的和尚與妖怪的數量發生變化的直接原因是小船的位置關係發生變化,因為船上至少要有一個和尚或妖怪,所以只要船的位罝發生變化,必然會引起狀態的變化。

過河動作是促使船的位罝發生變化的原因,也是連線兩個狀態的轉換關係。這個轉換關係包含兩部分內容,一部分是船的位置變化,另一部分是船上的妖怪或和尚的數量,這個數量會引起兩岸的和尚和妖怪的數量發生變化。

過河動作的數學模型需要明確定義兩個內容,即動作引起船的位置變化情況和此動作移動的和尚或妖怪的數量。

過河動作的具體資料結構定義如下:
typedef struct tagActionEffection
{
    ACTION_NAME act;
    BOAT_LOCATION boat_to; //船移動的方向
    int move_monster;//此次移動的妖怪數量
    int move_monk;//此次移動的和尚數量
}ACTION_EFFECTION;
ACTION_NAME 是一個比較有意思的屬性,其實是對動作的一個命名。通過對問題的觀察,我們發現過河問題的所有過河動作其實是一個有限的動作集合。

看一下 ACTION_EFFECTION 的定義,根據題目的要求,無論船是從左岸到對岸,還是從對岸返回到左岸,船上裝載的妖怪和和尚的情況只能是以下五種:一個妖怪、一個和尚、兩個妖怪、兩個和尚以及一個妖怪加一個和尚。結合船移動的方向,一共只有 10 種過河動作可供選擇,分別是:
  1. 一個妖怪過河;
  2. 兩個妖怪過河;
  3. 一個和尚過河;
  4. 兩個和尚過河;
  5. 一個妖怪和一個和尚過河;
  6. —個妖怪返回;
  7. 兩個妖怪返回;
  8. 一個和尚返回;
  9. 兩個和尚返回;
  10. 一個妖怪和一個和尚返回;

於是,ACTION_NAME 的定義如下:
typedef enum tagActionName {
    ONE_MONSTER_GO = 0,
    TWO_MONSTER_GO,
    ONE_MONK_GO,
    TWO_MONK_GO,
    ONE_MONSTER_ONE_MONK_GO,
    ONE_MONSTER_BACK,
    TWO_MONSTER_BACK,
    ONE_MONK_BACK,
    TWO_MONK_BACK,
    ONE_MONSTER_ONE_MONK_BACK,
    INVALID_ACTION_NAME,
}ACTION_NAME;
請注意,如果 ACTION_NAME 不同,其對應的 boat_to、move_monster 和 move_monk 三個屬性也不相同。這個問題有 10 種不同的動作,如果對這10種動作不能用一個抽象的記錄進行一致性處理,那麼我們的演算法程式碼就不可避免地出現長長的if...else語句或switch...case語句。

程式碼中長的 if...else 或 switch...case 語句正是各種問題的起源,我們要儘量避免出現這種情況。怎麼做一致性處理?這是演算法設計中常用的技巧之一,總結起來就是兩點:
  1. 首先對要處理的資料進行歸納處理,確定共性的部分和差異的部分;
  2. 然後對差異部分進行量化處理,將邏輯的差異轉化成計算機能一致性處理的差異,比如數字的大小變化、字串的長短變化,等等。

在本例中,動作名稱和小船的位罝是共性的部分,計算機己經不用區分動作的實際型別就可以進行一致處理。和尚和妖怪的移動方法隨動作型別不同而變化,無法統一處理,但是可以轉化成數字的加減法處理。舉個例子,一個和尚和一個妖怪過河的動作,實際效果就是河左岸的和尚數量和妖怪數量各減一,河對岸的和尚數量和妖怪數量各加一。整理起來,所有的動作可歸納為以下動作列表:
ACTION_EFFECTION actEffect[]=
{
    { ONE_MONSTER_GO, REMOTE, -1, 0 },
    { TWO_MONSTER_GO, REMOTE, -2, 0 },
    { ONE_MONK_GO, REMOTE, 0, -1 },
    { TWO_MONK_GO, REMOTE, 0, -2 },
    { ONE_MONSTER_ONE_MONK_GO, REMOTE, -1, -1 },
    { ONE_MONSTER_BACK, LOCAL, 1, Q },
    { TWO_MONSTER_BACK, LOCAL, 2, Q },
    { ONE_MONK_BACK, LOCAL, 0, 1 },
    { TWO_MONK_BACK, LOCAL, 0, 2 },
    { ONE_MONSTER_ONE_MONK_BACK, LOCAL, 1, 1 }
};

搜尋演算法

本章介紹的演算法仍然採用深度優先遍歷演算法,每次遍歷只暫時儲存當前搜尋的分支的所有狀態,之前搜尋過的分支上的狀態是不儲存的,只在必要的時候輸出結果。

因此,演算法不需要完整的樹狀資料結構儲存整個狀態樹(也沒有必要這麼做),只需要一個佇列能晳時儲存當前搜尋分支上的所有狀態即可。這個佇列初始時只有一個初始狀態,隨著搜尋的進行逐步增加,當搜尋演算法完成後,佇列中應該仍然只有一個初始狀態。狀態樹的搜尋過程就是狀態樹的生成過程,因此狀態樹一開始並不完整,只有一個初始狀態的根節點,當搜尋(也就是遍歷)操作完成時,狀態樹才完整。

一個靜止狀態結合不同的過河動作會遷移到不同的狀態。剛剛已經分析過了,每個狀態所能採用的過河動作只能是ActionName標識的10種動作中的一種(當然並不是每種動作都適用於此狀態),有了這個動作範圍,搜尋狀態樹的窮舉演算法就非常簡單了,只需將當前狀態分別與這10種動作進行組合,就可以得到狀態樹上這個狀態所有可能的新狀態,對新狀態繼續應用各種過河動作,再得到新狀態,直到出現最終狀態,得到一個過河過程。圖 2 就是一個過河結果的狀態轉換過程。

圖 2 —個過河結果的狀態轉換過程

狀態樹的遍歷

狀態樹的遍歷暗含了一個狀態生成的過程,就是促使狀態樹上的一個狀態向下一個狀態轉換的驅動過程,這是一個很重要的部分,如果不能正確地驅動狀態變化,就不能實現狀態樹的遍歷(搜尋)。

前面提到的動作模型,就是驅動狀態變化的關鍵因子。演算法的動作模型一共定義了10種動作,每種動作結合當前狀態就可以產生一個新的狀態,就可以推動狀態產生變化。當然,並不是所有的動作都能適用於當前狀態,比如,假設當前狀態是隻有兩個妖怪在河左岸,則“一個和尚過河”“兩個和尚過河”和“一個和尚和一個妖怪過河”這三種動作就不適用於當前狀態。

狀態樹遍歷的關鍵就是處理過河動作列表 actEffeet,依次處理一遍這個列表中的每個動作就實現了狀態樹的搜尋,因為使用了表結構,程式碼變得非常簡單:
/*嘗試用種動作分別與當前狀態組合*/
for(int i = 0; i < sizeof(actEffect) / sizeof(actEffect[0]); i++)
{
    ProcessStateOnNewAction(states, current, actEffect[i]);
}

剪枝和重複狀態判斷

前面己經提到過,並不是所有的動作都適用於當前狀態,那麼,如何判斷一個動作是否適用於當前狀態?

首先,當前狀態中船的位置很關鍵,如果船的位置在河對岸,那麼所有的過河動作就都不適用。其次是移動的妖怪或和尚的數敏是否與當前狀態相適應,比如河左岸沒有和尚,那麼所有需要移動和尚的動作就都不適用。根據以上分析,我們可以給出判斷動作合法性的演算法:
bool Itemstate::CanTakeAction(ACTION_EFFECTlON& ae) const {
    if(boat == ae.boat_to)
        return false;
    if((local_monster + ae.move_monster) < 0|| (local_monster + ae.move_monster) > monster_count)
        return false;
    if((local_monk + ae.move_monk) < 0|| (localjnonk + ae.move_monk) > monk_count)
        return false;
    return true;
}
應用這個判斷,可以省去很多不必要的狀態變化,避免出現一些不符合題目要求的錯誤狀態,比如河左岸有 -1 個和尚,河對岸有 4 個和尚這種情況。

本演算法採用深度優先原則搜尋狀態樹,就會遇到重複出現的狀態導致狀態環路的問題。比如某一時刻採用的動作是“一個和尚和一個妖怪過河”,到了河對岸形成新的狀態,如果新狀態採用的動作是“一個和尚和一個妖怪返回”,則最後的狀態就變成了過河之前的狀態,這兩個狀態加上這兩個動作就會形成狀態環路,搜尋路徑上存在狀態環路的後果就是搜尋演算法可能會陷入死迴圈。

除此之外,如果對一個狀態樹分支上的某個狀態經過搜尋,其結果己經知道,則在另一個狀態樹分支上搜索時再遇到這個狀態時,可以直接給出結果,或跳過搜尋,以便提高搜尋演算法的效率。在這個過程中因重複出現被放棄或跳過的狀態,可以理解為另一種形式的“剪枝”,可以使一次深度優先遍歷很快收斂到初始狀態。

因此,本演算法採用雙端佇列來組織搜尋過程中的己處理狀態。

演算法實現

演算法的核心依然是遞迴搜尋,從初始狀態開始呼叫 SearchState() 函式。函式每次從狀態佇列尾部取出當前要處理的狀態,首先判斷是否是最終的過河狀態,如果是則輸出一組過河方案,如果不是,則嘗試用動作列表中的動作與當前狀態結合,看看是否能生成合法的新狀態。
void SearchState(std::deque<ItemState>& states)
{
    Itemstate current = states.back(); /*每次都從當前狀態開始*/
    if(current.IsFinalState())
    {
        PrintResult(states);
        return;
    }
    /*嘗試用10種動作分別與當前狀態組合*/
    for(int i = 0; i < sizeof(actEffect) / sizeof(actEffect[0]); i++)
    {
        SearchStateOnNewAction(states, current, actEffect[i]);
    }
}
搜尋的遞迴關係是通過 SearchStateOnNewAction() 函式體現的,這個函式首先判斷當前狀態和制定的過河動作是否能生成一個新狀態,如果能得到一個合法的新狀態,則繼續處理這個新狀態。
void SearchStateOnNewAction(std::deque<ItemState>& states,ItemState& current, ACTI0N_EFFECTI0N& ae)
{
    Itemstate next;
    if(MakeActionNewState(current> ae, next))
    {
        if(next.IsValidState() && !IsProcessedState(states> next))
        {
            states.push_back(next);
            SearchState(states);
            states.pop back();
        }
    }
}
MakeActionNewState() 函式是一個很有意思的函式,它就是這個演算法設計的通過過河動作屬性列表對所有動作進行一致性處理的體現,通過對屬性的直接加或減計算,避免了長 if...else 語句或 switch...case 程式碼。
bool MakeActionNewState(const ItemState& curState, ACTION_EFFECTION& ae, ItemState& newState)
{
    if(curState.CanTakeAction(ae))
    {
        newState = curState;
        newState.local_monster += ae.move_monster;
        newState.localjnonk += ae.move_monk;
        newState.remote_monster -= ae.move_monster;
        newState.remote_monk -= ae.move_monk;
        newState.boat = ae.boat_to;
        newState.curAct = ae.act;
        return true;
    }
    return false;
}