[Scala] 連線猜數字 Actor 模擬版。

還記得連線猜數字是我在暨大六年來,老俞上 Java 程式設計時一定要教到的東西,在我們那屆甚至是回家作業。如果我沒記錯,老俞在這個例子中,想要強調的是電腦的解題方式和人腦有時會有很大的差別。

雖然那時作業我有寫出來,之後研所當程設助教,好像也有教過學弟妺這題,但有一點我須承認:這個程式對我而言並不簡單。想通之後我可以了解整個解題的邏輯(並不難),但我很難去掌握要如何處理一些細節上的問題,特別是當老俞的標準版本裡甚至出現了四層迴圈時,我就開始頭痛了……

而早上嚐試了用 Scala 以及 Functional Programming 的方式寫比對幾 A 幾 B 後,我就開始想,如果用 Function Programming 的方式來解這題,是不是會比較漂亮,比較簡單呢?!

所以,這篇文章就是試著實作這個作業,不過為了一些原因,我並沒有把他做成連線版的,而是單機跑的 Multi-Thread 版本,一個 Thread 當 Server,一個 Thread 當 Client。

以下是這個作業(?)的規格:

  1. 一個 Server Thread 來模擬猜數字的伺服器
  2. 一個 Client Thread 來模擬猜數字的客戶端
  3. 客戶端要嚐試在最少次數內猜中 Server 端上的數字
  4. 客戶端可以丟一個類似 Guess("3215") 的訊息給 Server 端
  5. Server 端則回傳 Status("3215", 2, 1) // 3215 的結果為 2A1B 給 Client 端
  6. 伺服端和客戶端均在猜中正確答案時中止

在這個範例裡,我們會用 Scala 的 Actor 來模擬伺服器的行為,基本上 Actor 就類似伺服器--監聽訊息並做出反應。

首先,我們定義一些訊息物件,你可以把他想像成是網路上的封包,並且 import 一些之後需要用到的東西。

import scala.actors.Actor
import scala.actors.Actor._

import scala.util.Random

/**
 *  猜數字伺服器回傳的結果
 *
 *  @param  x   使用者輸入的答案
 *  @param  a   猜數字遊戲裡的A(數字對,位置也對)
 *  @param  b   猜數字遊戲裡的B(數字對,但位置錯)
 */
case class Status(x: String, a: Int, b: Int)

/**
 *  要送給猜數字伺器的猜測
 *
 *  @param  x   使用者猜的數字
 */
case class Guess(x: String)

/**
 *  用這個訊息告訴猜數字 AI 開始猜數字
 */
case object StartGuess

接著,定義以下兩個猜數字的輔助函式:

/**
 *  猜數字的輔助函式
 */
trait GuessNumberFunctions
{
    // 產生猜數字遊戲裡所有可能答案的組合,共 5040 種
    //
    // 產生方式:
    //
    //  1. 先產生 0 到 9999 所有數字
    //  2. 將這些數字補零至四位數並轉成字串
    //  3. 過濾掉所有出現重覆數字的字串
    //
    def generateAllPossible = {

        //  重覆的定義:
        //
        //  將字串轉成字元集合後,如果大小不為四,就是有重覆的字元
        //
        //  例: "1234" -> Set(1, 2, 3, 4) -> 不重覆 (return false)
        //       "1224" -> Set(1, 2, 4)    -> 有重覆 (return true)
        def hasRepeatNumber (x: String) = x.toSet.size != 4
        def convertToPaddingString(x: Int) = "%04d" format (x)

        (0 to 9999).                     // 先產生 0 到 9999 的所有整數
            map(convertToPaddingString). // 將這些整數全轉成補零成四位數的字串
            filterNot(hasRepeatNumber)   // 濾出所有不含重覆數字的部份
    }

    /**
     *  比對兩個猜數字字串後,回覆幾 A 幾 B
     *
     *  詳細的說明請參考[前一篇文章][1]的講解。
     *
     *  [1]: http://brianhsu.moe/blog/archives/1787
     *
     *  @param  number1 第一個數字
     *  @param  number2 第二個數字
     */
    def compare(number1: String, number2: String) = {

        def pairIsSame(x: (Char, Char)) = x._1 == x._2

        val sumA = (number1 zip number2) filter (pairIsSame)

        val (remainNumber1, remainNumber2) =
            (number1 zip number2).
            filterNot (sumA contains _).
            unzip

        val sumB = remainNumber1 intersect remainNumber2

        (sumA.length, sumB.length)
    }
}

伺服器的部份很簡單:不斷監聽封包,如果傳進來的是猜測就進行比對並回傳結果,不然就略過該訊息。

/**
 *  猜數字伺服器
 *
 *  這裡使用 Scala 的 Actor 來模擬老俞的作業裡的猜數字伺服器,
 *  而會使用這個方法的原因有兩個:
 *
 *    1. 練習 Scala 的 Actor 的概念和寫法
 *    2. 用這種方式實作比較簡單,不用處理 Socket
 *
 *  這個伺服器啟動後會做下面的事情:
 *
 *    1. 亂數產生一個猜數字正確解答
 *    2. 持續監聽別人丟過來的訊息
 *        - 當這個訊息是一個猜數字的猜測訊息時(Guess)物件,
 *          伺服器就會將其與正確答案比對,並回傳給猜數字客戶端
 *
 *        - 如果是其他訊息就略過並印出
 *
 *  這個伺服器會在以下情況結束:
 *
 *    - 客戶端猜中了正確數字
 *
 */
class GuessNumberServer extends Actor with GuessNumberFunctions
{
    // 亂數選擇正確答案
    val answer =
        Random.shuffle((0 to 9).toList) // 產生一個 List(0, 1, 2..., 9) 然後打亂之後
              .take(4)                  // 取前四個數字,例:List(3,4,1,5)
              .mkString                 // 再結合成字串,例:"3415"

    // 印出正確答案
    println ("[debug] answer:" + answer)

    // 持續 (loop) 地監聽事件 (react)
    def act () = loop {
        react {

            // 當進來的是一個猜數字的訊息(可以把他當做是封包之類的)
            case Guess(x)  => 

                // 將傳進來的猜測與正確答比對
                val (statusA, statusB) = compare (answer, x)

                // 印出比對後是『幾 A 幾 B』
                println ("%s => %dA%dB" format(x, statusA, statusB))

                // 回傳給對方此次猜測的結果
                // x       => 這次他猜的數字
                // statusA => 數字對,位置也對的數量
                // statusB => 數字對,位置錯的數量
                sender ! Status(x, statusA, statusB)

                // 當 4A 時遊戲結束
                if (statusA == 4) Actor.exit()

            // 收到其他我不懂的訊息
            case otherwise =>
                println ("Server 收到不明訊息:" + otherwise)
        }
    }
}

接下來是重頭戲的部份,猜數字的 AI,其實概念很簡單:

  • 先把所有可能的解列出
  • 隨便挑一個來猜
  • 如果對方告訴你答案是 2A1B
  • 那把你的解答列表中,和你的答案相比是 2A1B 的東西全部找出來,正解一定在裡頭!而且此時可能解的範圍已經縮小了
  • 從你在上一步中產生的新的可能解範圍裡,再挑一個出來猜
  • 重覆以上的動作,直到猜中 4A 為止
/**
 *  猜數字 AI
 *
 *  猜數字的 AI 其實很簡單,但套句老俞的說法:『不要用人腦的思維來想這
 *  個問題!電腦和人腦的優點是不一樣的!』
 *
 *  記得:
 *   1. 電腦的處理速度很快
 *   2. 電腦的記憶量非常大
 *
 *  所以從電腦的角度來想,你可以用消去法來解猜數字的問題:
 *
 *   1. 把所有可能的解列出(一開始會有 5040 個)
 *   2. 當你猜一個數字,對方告訴你是 2A0B 的時候
 *   3. 把你所有可能解裡,和你猜的數字比對為 2A0B 的部份列出(假設此時已縮減到 1000 個可能解)
 *   4. 因為正解和你的解比對是 2A0B,所以正解一定在這 1000 個可能解裡
 *   5. 從這 1000 個可能解中,隨便找一個猜
 *   6. 4A 的話結束,不然的話依照 2 ~ 5 的方式繼續進行
 *
 *  這個 AI Client 做的事情也一模一樣,他可以處理以下的訊息:
 *
 *   - StartGame 
 *       開始向 Server 丟一個數字
 *
 *   - Status(myGuess, 4, 0)
 *       4A,所有數字和位置都對,遊戲結束
 *
 *   - Status(myGuess, a, b)
 *       過濾出可能解裡,和 myGuess 比對後為 aAbB 的解答之後繼續猜
 *
 *  @param  server  要連線到哪個 Server 猜數字
 */
class GuessNumberClient(server: GuessNumberServer) extends Actor with GuessNumberFunctions
{
    // 目前的可能解,一開始是全部的數字
    var possibleAnswer = generateAllPossible

    // 檢測 number1 和 number2 的結果是不是 nAnB
    def isSameStatus (number1: String, number2: String, nA: Int, nB: Int) = {
        compare(number1, number2) == (nA, nB)
    }

    // 持續 (loop) 地監聽 (react) 事件
    def act () = loop {
        react {

            // 如果開始玩猜數字遊戲
            case StartGuess => 

                // 從第一個數字猜
                println ("開始猜數字")
                server ! Guess(possibleAnswer.head)

            // 如果伺服器告訴我們猜中了,就印出答案並結束
            case Status(myGuess, 4, 0) => 
                println ("答案是:" + answer)
                Actor.exit()

            // 如果沒中的話,利用消去法,濾出剩下的所有組合理,
            // 同樣也是 nAnB 的部份
            case Status(myGuess, a, b) =>
                // 濾出剩下的可能答案
                possibleAnswer = possibleAnswer.filter(isSameStatus(_, myGuess, a, b))
                println ("現在的可能答案數:" + possibleAnswer.length)

                // 拿剩下的答案中的第一個繼續猜
                server ! Guess(possibleAnswer.head)

            // 如果收到不明訊息
            case otherwise =>
                println ("Client 收到不明訊息:" + otherwise)

        }
    }
}

最後是主程式的部份,很單純的就是建立 Client / Server 物件,然後開始猜數字。

object Main
{

    def main (args: Array[String]) {
        val server = new GuessNumberServer
        val client = new GuessNumberClient(server)

        server.start()  // 啟動 Server
        client.start()  // 啟動 AI Client

        client ! StartGuess // 開始玩猜數字!
    }
}

最後附上執行結果,從執行結果可以看出來,用這種方式猜測,大概 5 ~ 6 次之內就可以猜出正確的解答。

brianhsu@NBGentoo ~/GuessNumber $ scala Main
[debug] answer:0951
開始猜數字
0123 => 1A1B
現在的可能答案數:720
0245 => 1A1B
現在的可能答案數:110
0356 => 2A0B
現在的可能答案數:7
0374 => 1A0B
現在的可能答案數:2
0851 => 3A0B
現在的可能答案數:1
0951 => 4A0B
答案是:0951
brianhsu@NBGentoo ~/GuessNumber $ scala Main
[debug] answer:4183
開始猜數字
0123 => 2A0B
現在的可能答案數:180
0145 => 1A1B
現在的可能答案數:48
0426 => 0A1B
現在的可能答案數:5
4173 => 3A0B
現在的可能答案數:2
4183 => 4A0B
答案是:4183

以上,是不是覺得 Scala 和 Functional Programming 很神奇有趣呢?快點一起來玩 Scala 吧~:p

註一:如果你算一下,你會發現註解比程式碼還長,可見 Scala 在短短的幾行裡幫你做了多少事。XDD

註二:老俞的版本在這邊,和這個版本最大的差別在如何處理幾 A 幾 B,以及他是網路程式。

註三:不知道你有沒有發現,這隻程式裡除了監聽事件的 loop 外沒有迴圈。^_^

回響