1. 程式人生 > >狀態的抽象:從狼羊白菜遊戲和倒油問題說起

狀態的抽象:從狼羊白菜遊戲和倒油問題說起

  版權申明:本文為博主窗戶(Colin Cai)原創,歡迎轉帖。如要轉貼,必須註明原文網址

  http://www.cnblogs.com/Colin-Cai/p/7507317.html 

  作者:窗戶

  QQ/微信:6679072

  E-mail:[email protected]

  數學中有一個重要概念,就是抽象。由數學開始發展的電腦科學,自然也離不開抽象。計算機語言、程式設計正規化都為抽象提供了工具,函式、回撥、泛型、運算元、類……

  以下從兩個問題開始,描述了一大類抽象。

 

問題

 

  這一篇文章我們先引入兩個問題。

  

  狼、羊、白菜問題

  一個農夫帶著一匹狼、一隻羊、一筐白菜這三樣東西,需要過一條河。河上有條船,農夫每次渡河要麼是自己一個人,要麼最多帶三樣東西中的其中一樣,多帶了船要沉。農夫當然是要把三樣東西都帶過河,但是有一個限制條件,如果農夫不在旁邊,狼是要吃羊的,羊是要吃白菜的,這是要絕對避免的。農夫怎麼一個過程,才能把狼、羊、白菜都運過河呢?

 

  倒油問題

  有兩個沒有刻度的杯子,一個杯子倒滿是9升,另一個杯子倒滿是4升。請問如何利用這兩個杯子倒出6升油?

 

思考

 

  這兩道題目說來應該歷史比較悠久。特別是第一道題目我印象非常深刻,在我小的時候就見過這個題目,當時我父親拿著我的小船玩具和三顆不一樣的扣子、藥丸給我講了這個問題的解答。

  我在這裡之所以把這兩個表面上看起來八竿子打不到一起的問題放在一起,自然是因為這兩個問題實際上具有某些方面的一致性。而我們今天要講的就是如何把這兩個問題抽化從而提取共性,從更一般的角度上去解決這兩個問題乃至更多的問題。

  一般來說,這樣的題目會出現在孩子的奧數甚至腦筋急轉彎裡。然而,我從來不認為一把鑰匙開一把鎖從而滿是套路的教育有什麼真正的用處。

 

狀態和原子

 

  所謂抽象,就是從各個問題中去掉不重要的成分,只保留與問題解答相關的最少資訊,然後再從多個問題中提取共性。問題中不重要的成分很多,比如狼羊白菜問題中羊的種類、白菜的重量、農夫划船的速度,倒油問題中杯子的材質、倒油的速度,等等,此類資訊都是與問題的解答沒什麼關係,都是不需要考慮的。

  然後就是提取共性。

  對於這兩個問題,數學建模首先做的第一個抽象就是狀態和原子。

  

  我們把這兩個問題都看成是狀態的轉換,而推動狀態轉換的是不可分割的原子操作。

  對於狼羊白菜問題,過程中不斷渡船改變的只是兩岸的物品,可以認為狀態是當前河兩岸的物品(包括狼、羊、白菜,以及農夫),使用字母代替更加方便一點,狼用W代替,羊用S代替,白菜用C代替,農夫用F代替,那麼最開始的狀態為FWSC||,如果第一步農夫帶著羊渡船,之後的狀態則為WC||FS,其中兩條豎線代表一條河應該比較形象化,當然,符號長什麼樣其實並不重要。

  然後我們把每次渡船看成原子操作,如果第一步農夫帶著羊渡船,就記作FS->

  對於倒油問題,我們把9升的杯子稱為A杯,把4升的杯子稱為B杯。反覆的倒來倒去改變的只是兩個杯子的油量,從而可以認為狀態是兩個杯子的油量組成的序偶。比如(5,4)就是A杯有5升油,B杯有4升油。

  原子操作則為每一次倒油,因為杯子沒有刻度,我們可以想到的是:

  倒滿A杯,記作A<-

  倒滿B杯,記作B<-

  倒空A杯,記作A->

  倒空B杯,記作B->

  再者,A杯和B杯也可以互相倒,分別記作A->BB->A,倒油直到一隻杯子倒空或者另外一隻杯子倒滿為止。

  

  另外,再重申一次,思想最重要,符號不重要! 

 

圖的遍歷

 

  有了上面狀態和原子的抽象,就有了圖的抽象,其中圖的頂點就是各個狀態,而圖的邊則為各個原子操作。

  而原問題就抽象為圖的路徑尋找問題,從而本質上還是圖的遍歷問題。

 

   

 

  上圖就是狼羊白菜問題的狀態圖,紅線經過的是全部過河的最短路徑。

  

  既然要講遍歷,還是以下面這個簡單一點的圖為例子比較好。

  

 

廣度遍歷

 

  為了找到達到目的狀態的最短路徑,可以選擇廣度遍歷。

  對於廣度遍歷,如果只是遍歷出圖的各個頂點,學過資料結構的應該都知道,只需要一個佇列,先把最初的頂點入列,之後每次頂點出列之前先把該頂點直接指向的沒有遍歷過的頂點入隊,如此重複直到佇列為空。當然,我們需要記錄已遍歷過的頂點。

  對於上面的圖,廣度遍歷佇列的變換可能如下(每次都是入列和出列一起,右邊一列記錄當前已經遍歷過的頂點):

  [1]                       {1}

  [2 3 4]                 {1,2,3,4}

  [3 4 5]                 {1,2,3,4,5}

  [4 5]                    {1,2,3,4,5}

  [5 6]                    {1,2,3,4,5,6}

  [6]                       {1,2,3,4,5,6}

  []                         {1,2,3,4,5,6}

  但是,我們不要忘了,我們目的是在搜尋到合適的頂點時得到路徑,而上述佇列裡沒有任何路徑的資訊,從而佇列裡的每個元素只放頂點是不合適的,還要把頂點的路徑也放在一起。比如上述如果要找到6並給出6的路徑,應該是

  [(1,)]                                    {1}

  [(2,1),(3,1),(4,1)]                 {1,2,3,4}

  [(3,1),(4,1),(5,1->2)]            {1,2,3,4,5}

  [(4,1),(5,1->2)]                    {1,2,3,4,5}

  [(5,1->2),(6,1->4)]               {1,2,3,4,5,6}

  如此,當找到6這個頂點的時候,會知道路徑是1->4->6

 

深度遍歷

 

  深度遍歷是另外一種遍歷方法。深度遍歷使用一個棧來記錄壓棧的路徑,壓棧是路徑往前加一個頂點,而退棧是路徑最前端減少一個節點。既然棧記錄的是路徑,而我們的目的是路徑,那麼我們至少不需要和廣度遍歷那樣,對於每個頂點都再記錄完整路徑,因為從棧底到這個頂點就是路徑。(但是我們實際需要的路徑可能是過程中的原子操作,所以實際需求的時候也可以再做一個棧,用來壓原子操作)

  大多數人會記得,深度遍歷依然需要標誌來記錄已經遍歷過的頂點。實際上,這個不是必須的。

  我們考慮一般情況的回溯,我們除了棧之外並沒有這樣的標誌來記錄以往遍歷過的頂點,也就是遍歷的過程中永遠只知道棧裡的頂點遍歷過而需要規避,不過有一個重要資訊,就是每次退棧回溯的時候我們會知道剛才退出的頂點,如果我們對於每個頂點,它所指向的所有的頂點存在一個排序,那麼回溯是可以終止的

  對於上面的這個圖,我們不記錄以往遍歷過的頂點回溯的過程如下:

  [1]
  [1 2]
  [1 2 3]
  [1 2 3 4]
  [1 2 3 4 6]
  [1 2 3 4] (6)
  [1 2 3] (4)
  [1 2 3 6]
  [1 2 3] (6)
  [1 2] (3)
  [1 2 5]
  [1 2] (5)
  [1] (2)
  [1 3]
  [1 3 4]
  [1 3 4 6]
  [1 3 4] (6)
  [1 3] (4)
  [1 3 6]
  [1 3] (6)
  [1] (3)
  [1 4]
  [1 4 6]
  [1 4] (6)
  [1] (4)

  上面雖然可以完成遍歷,但是頂點可能不只一次被遍歷。

  於是,我們還是記錄下節點訪問標記,那麼上圖遍歷則如下:

  [1]                     {1}
  [1 2]                  {1,2}
  [1 2 3]               {1,2,3}
  [1 2 3 4]            {1,2,3,4}
  [1 2 3 4 6]         {1,2,3,4,6}
  [1 2 3 4]            {1,2,3,4,6}
  [1 2 3]               {1,2,3,4,6}
  [1 2]                  {1,2,3,4,6}
  [1 2 5]               {1,2,3,4,6,5}
  [1 2]                  {1,2,3,4,6,5}
  [1]                     {1,2,3,4,6,5}

  當然,假如我們的目的是找到頂點6,那麼到[1 2 3 4 6]時,1->2->3->4->6就是路徑。

 

其他問題

 

  太多太多的問題其實都可以歸結到這篇文章所說的內容來建模,甚至於,prolog語言都是基於回溯這樣的模型設計的。

  當然,以下這樣的迷宮問題自然可以很好的對接於這篇文章的內容,不過這個似乎看上去太過於明顯了一點。

 

  

  

  我再舉一個更加複雜一點的遊戲——華容道。這個遊戲曾經出現在江蘇電視臺的《最強大腦》第五季中,以下是一個簡化版的。

 

  

 

  我們把9個位置的數字(如果是空則為0)序列當成狀態,比如上面的狀態為(0,8,7,6,5,4,3,2,1),下面的狀態為(1,2,3,4,5,6,7,8,0)。

  而每次移動則是原子操作,可以用所移動的數字來代表。

  有了狀態和原子的抽象,華容道問題就可以歸結於上述一樣的抽象,從而可以統一解決。

 

程式碼

 

  抽象的最終還是為了解決問題,程式的解決當然需要程式碼。

  如下連結檔案用Python做了本章的抽象,以及狼羊白菜、倒油、迷宮、華容道各自的實現: